diff --git a/README.md b/README.md index 8acac4b5..f33628b7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ rate_group|Yes|LMW|Apply or remove a star rating from grouped images rename-tags|Yes|LMW|Change a tag name select_untagged|Yes|LMW|Enable selection of untagged images slideshowMusic|No|L|Play music during a slideshow +transfer_hierarchy|Yes|LMW|Image move/copy preserving directory hierarchy video_ffmpeg|No|LMW|Export video from darktable ### Example Scripts diff --git a/contrib/transfer_hierarchy.lua b/contrib/transfer_hierarchy.lua new file mode 100755 index 00000000..c2ba9cb9 --- /dev/null +++ b/contrib/transfer_hierarchy.lua @@ -0,0 +1,358 @@ +--[[ + TRANSFER HIERARCHY + Allows the moving or copying of images from one directory + tree to another, while preserving the existing hierarchy. + + AUTHOR + August Schwerdfeger (august@schwerdfeger.name) + + ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT + None. + + USAGE + darktable's native operations for moving and copying images in + batches allow only one directory to be specified as the destination + for each batch. Those wanting to move or copy images from a _hierarchy_ + of directories within darktable while preserving the directory structure, + must take the laborious step of performing the operation one individual + directory at a time. + + This module allows the intact moving and copying of whole directory trees. + It was designed for the specific use case of rapidly transferring images + from a customary source (e.g., a staging directory on the local disk) + to a customary destination (e.g., a directory on a NAS device). + + Instructions for operation: + + 1. Select the set of images you want to copy. + + 2. Click the "calculate" button. This will calculate the + lowest directory in the hierarchy that contains every selected + file (i.e., the common prefix of all the images' pathnames), and + write its path into the "existing root" text box. + + 3. If (a) you have specified the "customary source root" and "customary + destination root" preferences, and (b) the selected images are all + contained under the directory specified as the customary source + root, then the "root of destination" text box will also be + automatically filled out. + + For example, suppose that you have specified '/home/user/Staging' + as your customary source root and '/mnt/storage' as your customary + destination root. If all selected images fell under the directory + '/home/user/Staging/2020/Roll0001', the "root of destination" would + be automatically filled out with '/mnt/storage/2020/Roll0001'. + + But if all selected images fall under a directory outside the + specified customary source root (e.g., '/opt/other'), the "root + of destination" text box must be filled out manually. + + It is also possible to edit the "root of destination" further once + it has been automatically filled out. + + 4. Click the "move" or "copy" button. + + Before moving or copying any images, the module will first + replicate the necessary directory hierarchy by creating all + destination directories that do not already exist; should a + directory creation attempt fail, the operation will be + aborted, but any directories already created will not be + removed. + + During the actual move/copy operation, the module transfers an + image by taking its path and replacing the string in the "existing + root" text box with that in the "root of destination" text box + (e.g., '/home/user/Staging/2020/Roll0001/DSC_0001.jpg' would be + transferred to '/mnt/storage/2020/Roll0001/DSC_0001.jpg'). + + LICENSE + LGPLv2+ +]] + + +-- Header material: BEGIN + +local darktable = require("darktable") +local dtutils = require("lib/dtutils") +local dtutils_file = require("lib/dtutils.file") +local dtutils_system = require("lib/dtutils.system") + +local LIB_ID = "transfer_hierarchy" +dtutils.check_min_api_version("5.0.0", LIB_ID) + +local MKDIR_COMMAND = darktable.configuration.running_os == "windows" and "mkdir " or "mkdir -p " +local PATH_SEPARATOR = darktable.configuration.running_os == "windows" and "\\\\" or "/" +local PATH_SEGMENT_REGEX = "(" .. PATH_SEPARATOR .. "?)([^" .. PATH_SEPARATOR .. "]+)" + +unpack = unpack or table.unpack +gmatch = string.gfind or string.gmatch + +darktable.gettext.bindtextdomain(LIB_ID, darktable.configuration.config_dir .. PATH_SEPARATOR .. "lua" .. PATH_SEPARATOR .. "locale" .. PATH_SEPARATOR) + +local function _(msgid) + return darktable.gettext.dgettext(LIB_ID, msgid) +end + +-- Header material: END + + + +-- Helper functions: BEGIN + +local function pathExists(path) + local success, err, errno = os.rename(path, path) + if not success then + if errno == 13 then + return true + end + end + return success, err +end + +local function pathIsDirectory(path) + return pathExists(path..PATH_SEPARATOR) +end + +local function createDirectory(path) + local errorlevel = dtutils_system.external_command(MKDIR_COMMAND .. dtutils_file.sanitize_filename(path)) + if errorlevel == 0 and pathIsDirectory(path) then + return path + else + return nil + end +end + +-- Helper functions: END + + +-- Widgets and business logic: BEGIN + +local sourceTextBox = darktable.new_widget("entry") { + tooltip = _("Lowest directory containing all selected images"), + editable = false + } +sourceTextBox.reset_callback = function() sourceTextBox.text = "" end + +local destinationTextBox = darktable.new_widget("entry") { + text = "" +} +destinationTextBox.reset_callback = function() destinationTextBox.text = "" end + + + + + + + + + +local function findRootPath(films) + local commonSegments = nil + local prefix = "" + for film, _ in pairs(films) do + local path = film.path + if commonSegments == nil then + commonSegments = {} + local firstMatchIndex = string.find(path, PATH_SEGMENT_REGEX) + if firstMatchIndex ~= nil then + prefix = string.sub(path, 1, firstMatchIndex-1) + end + string.gsub(path, PATH_SEGMENT_REGEX, function(w, x) + if w ~= "" then table.insert(commonSegments, w) end + table.insert(commonSegments, x) + end) + else + local matcher = gmatch(path, PATH_SEGMENT_REGEX) + local i = 1 + while i < #commonSegments do + match, match2 = matcher() + if match == nil then + while i <= #commonSegments do + table.remove(commonSegments, #commonSegments) + end + break + elseif match ~= "" then + if commonSegments[i] ~= match then + while i <= #commonSegments do + table.remove(commonSegments, #commonSegments) + end + break + else + i = i+1 + end + end + if match2 == nil or commonSegments[i] ~= match2 then + while i <= #commonSegments do + table.remove(commonSegments, #commonSegments) + end + break + else + i = i+1 + end + end + end + end + if commonSegments == nil then + return prefix + end + if commonSegments[#commonSegments] == PATH_SEPARATOR then + table.remove(commonSegments, #commonSegments) + end + rv = prefix .. table.concat(commonSegments) + return rv +end + +local function calculateRoot() + films = {} + for _,img in ipairs(darktable.gui.action_images) do + films[img.film] = true + end + return findRootPath(films), films +end + +local function doCalculate() + local rootPath = calculateRoot() + if rootPath ~= nil then + sourceTextBox.text = rootPath + local sourceBase = darktable.preferences.read(LIB_ID, "source_base", "directory") + local destBase = darktable.preferences.read(LIB_ID, "destination_base", "directory") + if sourceBase ~= nil and sourceBase ~= "" and + destBase ~= nil and destBase ~= "" and + string.sub(rootPath, 1, #sourceBase) == sourceBase then + destinationTextBox.text = destBase .. string.sub(rootPath, #sourceBase+1) + end + end +end + +local function stopTransfer(transferJob) + transferJob.valid = false +end + +local function doTransfer(transferFunc) + rootPath, films = calculateRoot() + if rootPath ~= sourceTextBox.text then + darktable.print(_("transfer hierarchy: ERROR: existing root is out of sync -- click 'calculate' to update")) + return + end + if destinationTextBox.text == "" then + darktable.print(_("transfer hierarchy: ERROR: destination not specified")) + return + end + local sourceBase = sourceTextBox.text + local destBase = destinationTextBox.text + local destFilms = {} + for film, _ in pairs(films) do + films[film] = destBase .. string.sub(film.path, #sourceBase+1) + if not pathExists(films[film]) then + if createDirectory(films[film]) == nil then + darktable.print(_("transfer hierarchy: ERROR: could not create directory: " .. films[film])) + return + end + end + if not pathIsDirectory(films[film]) then + darktable.print(_("transfer hierarchy: ERROR: not a directory: " .. films[film])) + return + end + destFilms[film] = darktable.films.new(films[film]) + if destFilms[film] == nil then + darktable.print(_("transfer hierarchy: ERROR: could not create film: " .. film.path)) + end + end + + local srcFilms = {} + for _,img in ipairs(darktable.gui.action_images) do + srcFilms[img] = img.film + end + + local job = darktable.gui.create_job(string.format(_("transfer hierarchy") .. " (%d image" .. (#(darktable.gui.action_images) == 1 and "" or "s") .. ")", #(darktable.gui.action_images)), true, stopTransfer) + job.percent = 0.0 + local pctIncrement = 1.0 / #(darktable.gui.action_images) + for _,img in ipairs(darktable.gui.action_images) do + if job.valid and img.film == srcFilms[img] then + destFilm = destFilms[img.film] + transferFunc(img, destFilm) + job.percent = job.percent + pctIncrement + end + end + job.valid = false + local filterRules = darktable.gui.libs.collect.filter() + darktable.gui.libs.collect.filter(filterRules) +end + +local function doMove() + doTransfer(darktable.database.move_image) +end + +local function doCopy() + doTransfer(darktable.database.copy_image) +end + + + + + + +local transfer_widget = darktable.new_widget("box") { + orientation = "vertical", + darktable.new_widget("button") { + label = _("calculate"), + clicked_callback = doCalculate + }, + darktable.new_widget("label") { + label = _("existing root"), + halign = "start" + }, + sourceTextBox, + darktable.new_widget("label") { + label = _("root of destination"), + halign = "start" + }, + destinationTextBox, + darktable.new_widget("button") { + label = _("move"), + tooltip = "Move all selected images", + clicked_callback = doMove + }, + darktable.new_widget("button") { + label = _("copy"), + tooltip = _("Copy all selected images"), + clicked_callback = doCopy + } +} + +-- Widgets and business logic: END + + + + + + +-- Preferences: BEGIN + +darktable.preferences.register( + LIB_ID, + "source_base", + "string", + "[transfer hierarchy] Customary source root", + "", + "") + +darktable.preferences.register( + LIB_ID, + "destination_base", + "string", + "[transfer hierarchy] Customary destination root", + "", + "") + +-- Preferences: END + + + + + + +darktable.register_lib(LIB_ID, + "transfer hierarchy", true, true, { + [darktable.gui.views.lighttable] = { "DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 700 } + }, transfer_widget, nil, nil)