Files
lua-scripts-w-api/contrib/transfer_hierarchy.lua
Bill Ferguson 5da64a7da0 [API 7.0.0 compatibility] Made changes to take advantage of the latest API changes
including the ability to start and stop most scripts.
2021-06-19 23:34:14 -04:00

401 lines
12 KiB
Lua
Executable File

--[[
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("7.0.0", LIB_ID)
-- return data structure for script_manager
local script_data = {}
script_data.destroy = nil -- function to destory the script
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
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 th = {}
th.module_installed = false
th.event_registered = false
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
local function install_module()
if not th.module_installed then
darktable.register_lib(LIB_ID,
"transfer hierarchy", true, true, {
[darktable.gui.views.lighttable] = { "DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 700 }
}, th.transfer_widget, nil, nil)
th.module_installed = true
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 function destroy()
darktable.gui.libs[LIB_ID].visible = false
end
local function restart()
darktable.gui.libs[LIB_ID].visible = true
end
th.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
if darktable.gui.current_view().id == "lighttable" then
install_module()
else
if not th.event_registered then
darktable.register_event(
LIB_ID, "view-changed",
function(event, old_view, new_view)
if new_view.name == "lighttable" and old_view.name == "darkroom" then
install_module()
end
end
)
th.event_registered = true
end
end
script_data.destroy = destroy
script_data.restart = restart
script_data.destroy_method = "hide"
script_data.show = restart
return script_data