-
Notifications
You must be signed in to change notification settings - Fork 137
New script: 'transfer_hierarchy'. #234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did a copy from a collection to a new collection. After the copy I expected to end up at the destination, but the existing root just got refreshed and set to the top of the collection. Would it be better to end up at the destination after a move or copy?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keeping the view on the source was done on account of there being no single "destination" directory for the images, so the only possible collection change would be to a collect-by-film-roll on the destination root, which in my case is an already-well-populated archive. Finding the freshly moved or copied images among all the images there is like finding a needle in a haystack.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense