--[[ DESCRIPTION ext_editor.lua - edit images with external editors This script provides helpers to edit image files with programs external to darktable. It adds: - a new target storage "collection". Image exported will be reimported to collection for further edit with external programs - a new lighttable module "external editors", to select a program from a list of up to - 9 external editors and run it on a selected image (adjust this limit by changing MAX_EDITORS) - a set of lua preferences in order to configure name and path of up to 9 external editors - a set of lua shortcuts in order to quick launch the external editors USAGE * require this script from main lua file -- setup -- * in "preferences/lua options" configure name and path/command of external programs * note that if a program name is left empty, that and all following entries will be ignored * in "preferences/shortcuts/lua" configure shortcuts for external programs (optional) * whenever programs preferences are changed, in lighttable/external editors, press "update list" -- use -- * in the export dialog choose "collection" and select the format and bit depth for the exported image * press "export" * the exported image will be imported into collection and grouped with the original image * select an image for editing with en external program, and: * in lighttable/external editors, select program and press "edit" * edit the image with the external editor, overwite the file, quit the external program * the selected image will be updated or * in lighttable/external editors, select program and press "edit a copy" * edit the image with the external editor, overwite the file, quit the external program * a copy of the selected image will be created and updated or * in lighttable select target storage "collection" * enter in darkroom * to create an export or a copy press CRTL+E * use the shortcut to edit the current image with the corresponding external editor * overwite the file, quit the external program * the darkroom view will be updated * warning: mouseover on lighttable/filmstrip will prevail on current image * this is the default DT behavior, not a bug of this script CAVEATS * MAC compatibility not tested TODO * send multiple images to the same program, maybe BUGS, COMMENTS, SUGGESTIONS * send to Marco Carrarini, marco.carrarini@gmail.com CHANGES * 20191224 - initial version * 20191227 - added button "update list", better error handling, fixed bug with groups/tags in "edit" ]] local dt = require "darktable" local du = require "lib/dtutils" local df = require "lib/dtutils.file" local dtsys = require "lib/dtutils.system" -- module name local MODULE_NAME = "ext_editor" -- check API version du.check_min_api_version("5.0.2", MODULE_NAME) -- darktable 3.0 -- OS compatibility local PS = dt.configuration.running_os == "windows" and "\\" or "/" -- translation local gettext = dt.gettext gettext.bindtextdomain(MODULE_NAME, dt.configuration.config_dir..PS.."lua"..PS.."locale"..PS) local function _(msgid) return gettext.dgettext(MODULE_NAME, msgid) end -- maximum number of external programs, can be increased to necessity local MAX_EDITORS = 9 -- number of valid entries in the list of external programs local n_entries -- allowed file extensions for external editors local allowed_file_types = {"JPG", "jpg", "JPEG", "jpeg", "TIF", "tif", "TIFF", "tiff", "EXR", "exr", "PNG", "png"} -- last used editor initialization if not dt.preferences.read(MODULE_NAME, "initialized", "bool") then dt.preferences.write(MODULE_NAME, "lastchoice", "integer", 0) dt.preferences.write(MODULE_NAME, "initialized", "bool", true) end local lastchoice = 0 -- update lists of program names and paths, as well as combobox --------------- local function UpdateProgramList(combobox, button_edit, button_edit_copy, update_button_pressed) -- initialize lists program_names = {} program_paths = {} -- build lists from preferences local name local last = false n_entries = 0 for i = 1, MAX_EDITORS do name = dt.preferences.read(MODULE_NAME, "program_name_"..i, "string") if (name == "" or name == nil) then last = true end if last then if combobox[n_entries + 1] then combobox[n_entries + 1] = nil end -- remove extra combobox entries else combobox[i] = i..": "..name program_names[i] = name if dt.configuration.running_os == "macos" then program_paths[i] = "open -W -a "..dt.preferences.read(MODULE_NAME, "program_path_"..i, "string") else program_paths[i] = df.sanitize_filename(dt.preferences.read(MODULE_NAME, "program_path_"..i, "string")) end n_entries = i end end lastchoice = dt.preferences.read(MODULE_NAME, "lastchoice", "integer") if lastchoice == 0 and n_entries > 0 then lastchoice = 1 end if lastchoice > n_entries then lastchoice = n_entries end dt.preferences.write(MODULE_NAME, "lastchoice", "integer", lastchoice) -- widgets enabled if there is at least one program configured combobox.selected = lastchoice local active = n_entries > 0 combobox.sensitive = active button_edit.sensitive = active button_edit_copy.sensitive = active if update_button_pressed then dt.print(n_entries.._(" editors configured")) end end -- callback for buttons "edit" and "edit a copy" ------------------------------ local function OpenWith(images, choice, copy) -- check choice is valid, return if not if choice > n_entries then dt.print(_("not a valid choice")) return end -- check if one image is selected, return if not if #images ~= 1 then dt.print(_("please select one image")) return end local bin = program_paths[choice] local friendly_name = program_names[choice] -- check if external program executable exists, return if not if not df.check_if_bin_exists(bin) then dt.print(friendly_name.._(" not found")) return end -- image to be edited local image i, image = next(images) local name = image.path..PS..image.filename -- check if image format is allowed local file_ext = df.get_filetype (image.filename) local allowed = false for i,v in pairs(allowed_file_types) do if v == file_ext then allowed = true break end end if not allowed then dt.print(_("file type not allowed")) return end -- save image tags, rating and color local tags = {} for i, tag in ipairs(dt.tags.get_tags(image)) do if not (string.sub(tag.name, 1, 9) == "darktable") then table.insert(tags, tag) end end local rating = image.rating local red = image.red local blue = image.blue local green = image.green local yellow = image.yellow local purple = image.purple -- new image local new_name = name local new_image = image if copy then -- create unique filename while df.check_if_file_exists(df.sanitize_filename(new_name)) do new_name = df.filename_increment(new_name) -- limit to 50 more exports of the original export if string.match(df.get_basename(new_name), "_%d%d$") == "_50" then break end end -- physical copy, check result, return if error local copy_success = df.file_copy(name, new_name) if not copy_success then dt.print(_("error copying file ")..name) return end end -- launch the external editor, check result, return if error local run_cmd = bin.." "..df.sanitize_filename(new_name) dt.print(_("launching ")..friendly_name.."...") local result = dtsys.external_command(run_cmd) if result ~= 0 then dt.print(_("error launching ")..friendly_name) return end if copy then -- import in database and group new_image = dt.database.import(new_name) new_image:group_with(image) else -- refresh the image view -- note that only image:drop_cache() is not enough to refresh view in darkroom mode -- therefore image must be deleted and reimported to force refresh -- find the grouping status local image_leader = image.group_leader local group_members = image:get_group_members() local new_leader local index = nil local found = false -- membership status, three different cases if image_leader == image then if #group_members > 1 then -- case 1: image is leader in a group with more members while not found do index, new_leader = next(group_members, index) if new_leader ~= image_leader then found = true end end new_leader:make_group_leader() image:delete() if image.local_copy then image:drop_cache() end -- to fix fail to allocate cache error new_image = dt.database.import(name) new_image:group_with(new_leader) new_image:make_group_leader() else -- case 2: image is the only member in group image:delete() if image.local_copy then image:drop_cache() end -- to fix fail to allocate cache error new_image = dt.database.import(name) new_image:group_with() end else -- case 3: image is in a group but is not leader image:delete() if image.local_copy then image:drop_cache() end -- to fix fail to allocate cache error new_image = dt.database.import(name) new_image:group_with(image_leader) end -- refresh darkroom view if dt.gui.current_view() == dt.gui.views.darkroom then dt.gui.views.darkroom.display_image(new_image) end end -- restore image tags, rating and color, must be put after refresh darkroom view for i, tag in ipairs(tags) do dt.tags.attach(tag, new_image) end new_image.rating = rating new_image.red = red new_image.blue = blue new_image.green = green new_image.yellow = yellow new_image.purple = purple -- select the new image local selection = {} table.insert(selection, new_image) dt.gui.selection (selection) end -- callback function for shortcuts -------------------------------------------- local function program_shortcut(event, shortcut) OpenWith(dt.gui.action_images, tonumber(string.sub(shortcut, -2)), false) end -- export images and reimport in collection ----------------------------------- local function export2collection(storage, image_table, extra_data) local new_name, new_image, result for image, temp_name in pairs(image_table) do -- images are first exported in temp folder then moved to collection folder -- create unique filename new_name = image.path..PS..df.get_filename(temp_name) while df.check_if_file_exists(df.sanitize_filename(new_name)) do new_name = df.filename_increment(new_name) -- limit to 50 more exports of the original export if string.match(df.get_basename(new_name), "_%d%d$") == "_50" then break end end -- move image to collection folder, check result, return if error move_success = df.file_move(temp_name, new_name) if not move_success then dt.print(_("error moving file ")..temp_name) return end -- import in database and group new_image = dt.database.import(new_name) new_image:group_with(image.group_leader) end dt.print (_("finished exporting")) end -- register new storage ------------------------------------------------------- -- note that placing this declaration later makes the export selected module -- not to remember the choice "collection" when restarting DT, don't know why dt.register_storage("exp2coll", _("collection"), null, export2collection) -- combobox, with variable number of entries ---------------------------------- local combobox = dt.new_widget("combobox") { label = _("choose program"), tooltip = _("select the external editor from the list"), changed_callback = function(self) dt.preferences.write(MODULE_NAME, "lastchoice", "integer", self.selected) end, "" } -- button edit ---------------------------------------------------------------- local button_edit = dt.new_widget("button") { label = _("edit"), tooltip = _("open the selected image in external editor"), --sensitive = false, clicked_callback = function() OpenWith(dt.gui.action_images, combobox.selected, false) end } -- button edit a copy --------------------------------------------------------- local button_edit_copy = dt.new_widget("button") { label = _("edit a copy"), tooltip = _("create a copy of the selected image and open it in external editor"), clicked_callback = function() OpenWith(dt.gui.action_images, combobox.selected, true) end } -- button update list --------------------------------------------------------- local button_update_list = dt.new_widget("button") { label = _("update list"), tooltip = _("update list of programs if lua preferences are changed"), clicked_callback = function() UpdateProgramList(combobox, button_edit, button_edit_copy, true) end } -- box for the buttons -------------------------------------------------------- -- it doesn't seem there is a way to make the buttons equal in size local box1 = dt.new_widget("box") { orientation = "horizontal", button_edit, button_edit_copy, button_update_list } -- register new module "external editors" in lighttable ------------------------ dt.register_lib( MODULE_NAME, _("external editors"), true, -- expandable false, -- resetable {[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}}, dt.new_widget("box") { orientation = "vertical", combobox, box1 }, nil, -- view_enter nil -- view_leave ) -- initialize list of programs and widgets ------------------------------------ UpdateProgramList(combobox, button_edit, button_edit_copy, false) -- register the new preferences ----------------------------------------------- for i = MAX_EDITORS, 1, -1 do if dt.configuration.running_os == "macos" then dt.preferences.register(MODULE_NAME, "program_path_"..i, "string", _("executable for external editor ")..i, _("select executable for external editor") , _("(None)")) else dt.preferences.register(MODULE_NAME, "program_path_"..i, "file", _("executable for external editor ")..i, _("select executable for external editor") , _("(None)")) end dt.preferences.register(MODULE_NAME, "program_name_"..i, "string", _("name of external editor ")..i, _("friendly name of external editor"), "") end -- register the new shortcuts ------------------------------------------------- for i = 1, MAX_EDITORS do dt.register_event("shortcut", program_shortcut, _("edit with program ")..string.format("%02d", i)) end -- end of script -------------------------------------------------------------- -- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua -- kate: hl Lua;