|
| 1 | +--[[ |
| 2 | + TRANSFER HIERARCHY |
| 3 | + Allows the moving or copying of images from one directory |
| 4 | + tree to another, while preserving the existing hierarchy. |
| 5 | +
|
| 6 | + AUTHOR |
| 7 | + August Schwerdfeger ([email protected]) |
| 8 | +
|
| 9 | + ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT |
| 10 | + None. |
| 11 | +
|
| 12 | + USAGE |
| 13 | + darktable's native operations for moving and copying images in |
| 14 | + batches allow only one directory to be specified as the destination |
| 15 | + for each batch. Those wanting to move or copy images from a _hierarchy_ |
| 16 | + of directories within darktable while preserving the directory structure, |
| 17 | + must take the laborious step of performing the operation one individual |
| 18 | + directory at a time. |
| 19 | +
|
| 20 | + This module allows the intact moving and copying of whole directory trees. |
| 21 | + It was designed for the specific use case of rapidly transferring images |
| 22 | + from a customary source (e.g., a staging directory on the local disk) |
| 23 | + to a customary destination (e.g., a directory on a NAS device). |
| 24 | +
|
| 25 | + Instructions for operation: |
| 26 | +
|
| 27 | + 1. Select the set of images you want to copy. |
| 28 | +
|
| 29 | + 2. Click the "calculate" button. This will calculate the |
| 30 | + lowest directory in the hierarchy that contains every selected |
| 31 | + file (i.e., the common prefix of all the images' pathnames), and |
| 32 | + write its path into the "existing root" text box. |
| 33 | +
|
| 34 | + 3. If (a) you have specified the "customary source root" and "customary |
| 35 | + destination root" preferences, and (b) the selected images are all |
| 36 | + contained under the directory specified as the customary source |
| 37 | + root, then the "root of destination" text box will also be |
| 38 | + automatically filled out. |
| 39 | +
|
| 40 | + For example, suppose that you have specified '/home/user/Staging' |
| 41 | + as your customary source root and '/mnt/storage' as your customary |
| 42 | + destination root. If all selected images fell under the directory |
| 43 | + '/home/user/Staging/2020/Roll0001', the "root of destination" would |
| 44 | + be automatically filled out with '/mnt/storage/2020/Roll0001'. |
| 45 | +
|
| 46 | + But if all selected images fall under a directory outside the |
| 47 | + specified customary source root (e.g., '/opt/other'), the "root |
| 48 | + of destination" text box must be filled out manually. |
| 49 | +
|
| 50 | + It is also possible to edit the "root of destination" further once |
| 51 | + it has been automatically filled out. |
| 52 | +
|
| 53 | + 4. Click the "move" or "copy" button. |
| 54 | +
|
| 55 | + Before moving or copying any images, the module will first |
| 56 | + replicate the necessary directory hierarchy by creating all |
| 57 | + destination directories that do not already exist; should a |
| 58 | + directory creation attempt fail, the operation will be |
| 59 | + aborted, but any directories already created will not be |
| 60 | + removed. |
| 61 | +
|
| 62 | + During the actual move/copy operation, the module transfers an |
| 63 | + image by taking its path and replacing the string in the "existing |
| 64 | + root" text box with that in the "root of destination" text box |
| 65 | + (e.g., '/home/user/Staging/2020/Roll0001/DSC_0001.jpg' would be |
| 66 | + transferred to '/mnt/storage/2020/Roll0001/DSC_0001.jpg'). |
| 67 | +
|
| 68 | + LICENSE |
| 69 | + LGPLv2+ |
| 70 | +]] |
| 71 | + |
| 72 | + |
| 73 | +-- Header material: BEGIN |
| 74 | + |
| 75 | +local darktable = require("darktable") |
| 76 | +local dtutils = require("lib/dtutils") |
| 77 | +local dtutils_file = require("lib/dtutils.file") |
| 78 | +local dtutils_system = require("lib/dtutils.system") |
| 79 | + |
| 80 | +local LIB_ID = "transfer_hierarchy" |
| 81 | +dtutils.check_min_api_version("5.0.0", LIB_ID) |
| 82 | + |
| 83 | +local MKDIR_COMMAND = darktable.configuration.running_os == "windows" and "mkdir " or "mkdir -p " |
| 84 | +local PATH_SEPARATOR = darktable.configuration.running_os == "windows" and "\\\\" or "/" |
| 85 | +local PATH_SEGMENT_REGEX = "(" .. PATH_SEPARATOR .. "?)([^" .. PATH_SEPARATOR .. "]+)" |
| 86 | + |
| 87 | +unpack = unpack or table.unpack |
| 88 | +gmatch = string.gfind or string.gmatch |
| 89 | + |
| 90 | +darktable.gettext.bindtextdomain(LIB_ID, darktable.configuration.config_dir .. PATH_SEPARATOR .. "lua" .. PATH_SEPARATOR .. "locale" .. PATH_SEPARATOR) |
| 91 | + |
| 92 | +local function _(msgid) |
| 93 | + return darktable.gettext.dgettext(LIB_ID, msgid) |
| 94 | +end |
| 95 | + |
| 96 | +-- Header material: END |
| 97 | + |
| 98 | + |
| 99 | + |
| 100 | +-- Helper functions: BEGIN |
| 101 | + |
| 102 | +local function pathExists(path) |
| 103 | + local success, err, errno = os.rename(path, path) |
| 104 | + if not success then |
| 105 | + if errno == 13 then |
| 106 | + return true |
| 107 | + end |
| 108 | + end |
| 109 | + return success, err |
| 110 | +end |
| 111 | + |
| 112 | +local function pathIsDirectory(path) |
| 113 | + return pathExists(path..PATH_SEPARATOR) |
| 114 | +end |
| 115 | + |
| 116 | +local function createDirectory(path) |
| 117 | + local errorlevel = dtutils_system.external_command(MKDIR_COMMAND .. dtutils_file.sanitize_filename(path)) |
| 118 | + if errorlevel == 0 and pathIsDirectory(path) then |
| 119 | + return path |
| 120 | + else |
| 121 | + return nil |
| 122 | + end |
| 123 | +end |
| 124 | + |
| 125 | +-- Helper functions: END |
| 126 | + |
| 127 | + |
| 128 | +-- Widgets and business logic: BEGIN |
| 129 | + |
| 130 | +local sourceTextBox = darktable.new_widget("entry") { |
| 131 | + tooltip = _("Lowest directory containing all selected images"), |
| 132 | + editable = false |
| 133 | + } |
| 134 | +sourceTextBox.reset_callback = function() sourceTextBox.text = "" end |
| 135 | + |
| 136 | +local destinationTextBox = darktable.new_widget("entry") { |
| 137 | + text = "" |
| 138 | +} |
| 139 | +destinationTextBox.reset_callback = function() destinationTextBox.text = "" end |
| 140 | + |
| 141 | + |
| 142 | + |
| 143 | + |
| 144 | + |
| 145 | + |
| 146 | + |
| 147 | + |
| 148 | + |
| 149 | +local function findRootPath(films) |
| 150 | + local commonSegments = nil |
| 151 | + local prefix = "" |
| 152 | + for film, _ in pairs(films) do |
| 153 | + local path = film.path |
| 154 | + if commonSegments == nil then |
| 155 | + commonSegments = {} |
| 156 | + local firstMatchIndex = string.find(path, PATH_SEGMENT_REGEX) |
| 157 | + if firstMatchIndex ~= nil then |
| 158 | + prefix = string.sub(path, 1, firstMatchIndex-1) |
| 159 | + end |
| 160 | + string.gsub(path, PATH_SEGMENT_REGEX, function(w, x) |
| 161 | + if w ~= "" then table.insert(commonSegments, w) end |
| 162 | + table.insert(commonSegments, x) |
| 163 | + end) |
| 164 | + else |
| 165 | + local matcher = gmatch(path, PATH_SEGMENT_REGEX) |
| 166 | + local i = 1 |
| 167 | + while i < #commonSegments do |
| 168 | + match, match2 = matcher() |
| 169 | + if match == nil then |
| 170 | + while i <= #commonSegments do |
| 171 | + table.remove(commonSegments, #commonSegments) |
| 172 | + end |
| 173 | + break |
| 174 | + elseif match ~= "" then |
| 175 | + if commonSegments[i] ~= match then |
| 176 | + while i <= #commonSegments do |
| 177 | + table.remove(commonSegments, #commonSegments) |
| 178 | + end |
| 179 | + break |
| 180 | + else |
| 181 | + i = i+1 |
| 182 | + end |
| 183 | + end |
| 184 | + if match2 == nil or commonSegments[i] ~= match2 then |
| 185 | + while i <= #commonSegments do |
| 186 | + table.remove(commonSegments, #commonSegments) |
| 187 | + end |
| 188 | + break |
| 189 | + else |
| 190 | + i = i+1 |
| 191 | + end |
| 192 | + end |
| 193 | + end |
| 194 | + end |
| 195 | + if commonSegments == nil then |
| 196 | + return prefix |
| 197 | + end |
| 198 | + if commonSegments[#commonSegments] == PATH_SEPARATOR then |
| 199 | + table.remove(commonSegments, #commonSegments) |
| 200 | + end |
| 201 | + rv = prefix .. table.concat(commonSegments) |
| 202 | + return rv |
| 203 | +end |
| 204 | + |
| 205 | +local function calculateRoot() |
| 206 | + films = {} |
| 207 | + for _,img in ipairs(darktable.gui.action_images) do |
| 208 | + films[img.film] = true |
| 209 | + end |
| 210 | + return findRootPath(films), films |
| 211 | +end |
| 212 | + |
| 213 | +local function doCalculate() |
| 214 | + local rootPath = calculateRoot() |
| 215 | + if rootPath ~= nil then |
| 216 | + sourceTextBox.text = rootPath |
| 217 | + local sourceBase = darktable.preferences.read(LIB_ID, "source_base", "directory") |
| 218 | + local destBase = darktable.preferences.read(LIB_ID, "destination_base", "directory") |
| 219 | + if sourceBase ~= nil and sourceBase ~= "" and |
| 220 | + destBase ~= nil and destBase ~= "" and |
| 221 | + string.sub(rootPath, 1, #sourceBase) == sourceBase then |
| 222 | + destinationTextBox.text = destBase .. string.sub(rootPath, #sourceBase+1) |
| 223 | + end |
| 224 | + end |
| 225 | +end |
| 226 | + |
| 227 | +local function stopTransfer(transferJob) |
| 228 | + transferJob.valid = false |
| 229 | +end |
| 230 | + |
| 231 | +local function doTransfer(transferFunc) |
| 232 | + rootPath, films = calculateRoot() |
| 233 | + if rootPath ~= sourceTextBox.text then |
| 234 | + darktable.print(_("transfer hierarchy: ERROR: existing root is out of sync -- click 'calculate' to update")) |
| 235 | + return |
| 236 | + end |
| 237 | + if destinationTextBox.text == "" then |
| 238 | + darktable.print(_("transfer hierarchy: ERROR: destination not specified")) |
| 239 | + return |
| 240 | + end |
| 241 | + local sourceBase = sourceTextBox.text |
| 242 | + local destBase = destinationTextBox.text |
| 243 | + local destFilms = {} |
| 244 | + for film, _ in pairs(films) do |
| 245 | + films[film] = destBase .. string.sub(film.path, #sourceBase+1) |
| 246 | + if not pathExists(films[film]) then |
| 247 | + if createDirectory(films[film]) == nil then |
| 248 | + darktable.print(_("transfer hierarchy: ERROR: could not create directory: " .. films[film])) |
| 249 | + return |
| 250 | + end |
| 251 | + end |
| 252 | + if not pathIsDirectory(films[film]) then |
| 253 | + darktable.print(_("transfer hierarchy: ERROR: not a directory: " .. films[film])) |
| 254 | + return |
| 255 | + end |
| 256 | + destFilms[film] = darktable.films.new(films[film]) |
| 257 | + if destFilms[film] == nil then |
| 258 | + darktable.print(_("transfer hierarchy: ERROR: could not create film: " .. film.path)) |
| 259 | + end |
| 260 | + end |
| 261 | + |
| 262 | + local srcFilms = {} |
| 263 | + for _,img in ipairs(darktable.gui.action_images) do |
| 264 | + srcFilms[img] = img.film |
| 265 | + end |
| 266 | + |
| 267 | + 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) |
| 268 | + job.percent = 0.0 |
| 269 | + local pctIncrement = 1.0 / #(darktable.gui.action_images) |
| 270 | + for _,img in ipairs(darktable.gui.action_images) do |
| 271 | + if job.valid and img.film == srcFilms[img] then |
| 272 | + destFilm = destFilms[img.film] |
| 273 | + transferFunc(img, destFilm) |
| 274 | + job.percent = job.percent + pctIncrement |
| 275 | + end |
| 276 | + end |
| 277 | + job.valid = false |
| 278 | + local filterRules = darktable.gui.libs.collect.filter() |
| 279 | + darktable.gui.libs.collect.filter(filterRules) |
| 280 | +end |
| 281 | + |
| 282 | +local function doMove() |
| 283 | + doTransfer(darktable.database.move_image) |
| 284 | +end |
| 285 | + |
| 286 | +local function doCopy() |
| 287 | + doTransfer(darktable.database.copy_image) |
| 288 | +end |
| 289 | + |
| 290 | + |
| 291 | + |
| 292 | + |
| 293 | + |
| 294 | + |
| 295 | +local transfer_widget = darktable.new_widget("box") { |
| 296 | + orientation = "vertical", |
| 297 | + darktable.new_widget("button") { |
| 298 | + label = _("calculate"), |
| 299 | + clicked_callback = doCalculate |
| 300 | + }, |
| 301 | + darktable.new_widget("label") { |
| 302 | + label = _("existing root"), |
| 303 | + halign = "start" |
| 304 | + }, |
| 305 | + sourceTextBox, |
| 306 | + darktable.new_widget("label") { |
| 307 | + label = _("root of destination"), |
| 308 | + halign = "start" |
| 309 | + }, |
| 310 | + destinationTextBox, |
| 311 | + darktable.new_widget("button") { |
| 312 | + label = _("move"), |
| 313 | + tooltip = "Move all selected images", |
| 314 | + clicked_callback = doMove |
| 315 | + }, |
| 316 | + darktable.new_widget("button") { |
| 317 | + label = _("copy"), |
| 318 | + tooltip = _("Copy all selected images"), |
| 319 | + clicked_callback = doCopy |
| 320 | + } |
| 321 | +} |
| 322 | + |
| 323 | +-- Widgets and business logic: END |
| 324 | + |
| 325 | + |
| 326 | + |
| 327 | + |
| 328 | + |
| 329 | + |
| 330 | +-- Preferences: BEGIN |
| 331 | + |
| 332 | +darktable.preferences.register( |
| 333 | + LIB_ID, |
| 334 | + "source_base", |
| 335 | + "string", |
| 336 | + "[transfer hierarchy] Customary source root", |
| 337 | + "", |
| 338 | + "") |
| 339 | + |
| 340 | +darktable.preferences.register( |
| 341 | + LIB_ID, |
| 342 | + "destination_base", |
| 343 | + "string", |
| 344 | + "[transfer hierarchy] Customary destination root", |
| 345 | + "", |
| 346 | + "") |
| 347 | + |
| 348 | +-- Preferences: END |
| 349 | + |
| 350 | + |
| 351 | + |
| 352 | + |
| 353 | + |
| 354 | + |
| 355 | +darktable.register_lib(LIB_ID, |
| 356 | + "transfer hierarchy", true, true, { |
| 357 | + [darktable.gui.views.lighttable] = { "DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 700 } |
| 358 | + }, transfer_widget, nil, nil) |
0 commit comments