Skip to content

Commit 1ec9cfb

Browse files
authored
Merge pull request darktable-org#234 from schwerdf/master
New script: 'transfer_hierarchy'.
2 parents 48d1c62 + 0501758 commit 1ec9cfb

File tree

2 files changed

+359
-0
lines changed

2 files changed

+359
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ rate_group|Yes|LMW|Apply or remove a star rating from grouped images
5959
rename-tags|Yes|LMW|Change a tag name
6060
select_untagged|Yes|LMW|Enable selection of untagged images
6161
slideshowMusic|No|L|Play music during a slideshow
62+
transfer_hierarchy|Yes|LMW|Image move/copy preserving directory hierarchy
6263
video_ffmpeg|No|LMW|Export video from darktable
6364

6465
### Example Scripts

contrib/transfer_hierarchy.lua

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
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

Comments
 (0)