Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 246 additions & 40 deletions contrib/rename_images.lua
Original file line number Diff line number Diff line change
@@ -1,56 +1,52 @@
--[[

rename.lua - rename image file(s)
rename.lua - rename image file(s)

Copyright (C) 2020, 2021 Bill Ferguson <wpferguson@gmail.com>.
Copyright (C) 2020, 2021 Bill Ferguson <wpferguson@gmail.com>.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
]]
--[[
rename - rename an image file or files
rename - rename an image file or files

This shortcut resets the GPS information to that contained within
the image file. If no GPS info is in the image file, the GPS data
is cleared.
USAGE
* enable the script in the script manager
* select an image or images
* enter a renaming pattern
* test the pattern by clicking "preview filename"
* click "rename" to rename the files

USAGE
* require this script from your luarc file or start it from script_manager
* select an image or images
* enter a renaming pattern
* click the button to rename the files
BUGS, COMMENTS, SUGGESTIONS
* Report to https://github.com/darktable-org/lua-scripts/issues

BUGS, COMMENTS, SUGGESTIONS
* Send to Bill Ferguson, wpferguson@gmail.com
CHANGES

CHANGES

TODO
* Add pattern builder
* Add new name preview
TODO
* Add pattern builder
]]

local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string"

du.check_min_api_version("7.0.0", "rename_images")
du.check_min_api_version("7.0.0", "rename_images")

local gettext = dt.gettext.gettext

local function _(msgid)
return gettext(msgid)
return gettext(msgid)
end

-- namespace variable
Expand All @@ -67,7 +63,7 @@ local script_data = {}
script_data.metadata = {
name = _("rename images"),
purpose = _("rename an image file or files"),
author = "Bill Ferguson <wpferguson@gmail.com>",
author = "Bill Ferguson <wpferguson@gmail.com>, Yannic Meyer",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename_images"
}

Expand Down Expand Up @@ -96,6 +92,18 @@ local function stop_job(job)
job.valid = false
end

-- make sure filename ends with the given extension (without leading dot);
-- does nothing if extension is empty
local function ensure_extension(filename, extension)
if extension ~= "" then
local suffix = "." .. extension
if string.lower(string.sub(filename, -string.len(suffix))) ~= string.lower(suffix) then
filename = filename .. suffix
end
end
return filename
end

local function install_module()
if not rename.module_installed then
dt.register_lib(
Expand All @@ -107,6 +115,11 @@ local function install_module()
dt.new_widget("box"){
orientation = "vertical",
rename.widgets.pattern,
rename.widgets.change_file_extension,
rename.widgets.extension_pattern,
rename.widgets.allow_moving_files,
rename.widgets.preview_button,
rename.widgets.preview_label,
rename.widgets.button,
},
nil,
Expand All @@ -128,11 +141,110 @@ end
-- M A I N
-- - - - - - - - - - - - - - - - - - - - - - - -

-- characters that are forbidden (or at least highly problematic) in
-- filenames across common operating systems; always replaced, regardless
-- of the "allow moving files" setting
local function sanitize_forbidden_characters(name)
return (name:gsub('[<>:"|?*%c]', "-"))
end

-- replace path separators with "-" unless allow_separators is true; the
-- underlying split_filepath() (used by df.get_path/df.get_filename) treats
-- both "/" and "\" as separators regardless of the current OS, so both are
-- always allowed together here too, rather than just the OS's native one
local function sanitize_path_separators(name, allow_separators)
if allow_separators then
return name
end
return (name:gsub("[/\\]", "-"))
end

-- runs variable substitution for one image and returns the result, or -1
-- if the substitution failed (see ds.substitute_list). The result always
-- has forbidden filename characters replaced (sanitize_forbidden_characters),
-- and has path separators replaced too unless allow_separators is true.
-- allow_separators is only ever passed for the main rename pattern, gated
-- behind the "allow moving files" checkbox - never for the extension
-- pattern, where a stray separator could never be useful and would just
-- corrupt the final filename
local function substitute_pattern(image, sequence, pattern, allow_separators)
ds.build_substitute_list(image, sequence, pattern, USER, PICTURES, HOME, DESKTOP)
local new_name = ds.substitute_list(pattern)
ds.clear_substitute_list()
if new_name == -1 then
return -1
end
new_name = sanitize_path_separators(new_name, allow_separators)
new_name = sanitize_forbidden_characters(new_name)
return new_name
end

-- work out which extension to use for a given image, based on the
-- "change file extension" checkbox and (if checked) the extension pattern
local function determine_extension(image, sequence)
if not rename.widgets.change_file_extension.value then
return df.get_filetype(image.filename)
end

local extension_pattern = rename.widgets.extension_pattern.text
local extension = ""
if string.len(extension_pattern) > 0 then
local substituted = substitute_pattern(image, sequence, extension_pattern)
if substituted ~= -1 then
extension = substituted
end
end

-- collapse runs of consecutive dots into a single dot, so that e.g.
-- "......." is treated the same as a single "." (falls through to the
-- empty-extension check below, which falls back to the original extension)
extension = string.gsub(extension, "%.+", ".")

if string.sub(extension, 1, 1) == "." then
extension = string.sub(extension, 2)
end

if extension == "" then
return df.get_filetype(image.filename)
end

return extension
end

-- returns the pattern's folder component with any leading separators
-- stripped, so a leading "/" or "\" can never point to the filesystem
-- root (or, for an AppImage, the process's working directory) - the
-- folder is always resolved relative to the image's current location
local function resolve_relative_path(new_name)
local path = string.sub(df.get_path(new_name), 1, -2)
return (string.gsub(path, "^[/\\]+", ""))
end

-- work out the destination folder for a renamed image, always relative
-- to the image's current folder (see resolve_relative_path)
local function resolve_path(new_name, image)
local relative_path = resolve_relative_path(new_name)
if string.len(relative_path) == 0 then
return image.path
end
return image.path .. PS .. relative_path
end

-- save the pattern and extension-pattern fields so they survive a restart;
-- the checkbox persists itself immediately on click (see its clicked_callback)
local function persist_pattern_prefs()
dt.preferences.write(MODULE_NAME, "pattern", "string", rename.widgets.pattern.text)
dt.preferences.write(MODULE_NAME, "extension_pattern", "string", rename.widgets.extension_pattern.text)
end

-- renames/moves every image in `images` according to the current pattern,
-- extension, and "allow moving files" settings; runs as a cancellable job
-- and updates the lighttable selection to the renamed images once done
local function do_rename(images)
if #images > 0 then
local first_image = images[1]
local pattern = rename.widgets.pattern.text
dt.preferences.write(MODULE_NAME, "pattern", "string", pattern)
persist_pattern_prefs()
dt.print_log("pattern is " .. pattern)
if string.len(pattern) > 0 then
local datetime = os.date("*t")
Expand All @@ -141,20 +253,15 @@ local function do_rename(images)
for i, image in ipairs(images) do
if job.valid then
job.percent = i / #images
ds.build_substitute_list(image, i, pattern, USER, PICTURES, HOME, DESKTOP)
local new_name = ds.substitute_list(pattern)
local new_name = substitute_pattern(image, i, pattern, rename.widgets.allow_moving_files.value)
if new_name == -1 then
dt.print(_("unable to do variable substitution, exiting..."))
stop_job(job)
return
end
ds.clear_substitute_list()
local args = {}
local path = string.sub(df.get_path(new_name), 1, -2)
if string.len(path) == 0 then
path = image.path
end
local filename = df.get_filename(new_name)
local path = resolve_path(new_name, image)
local filename = ensure_extension(df.get_filename(new_name), determine_extension(image, i))
local filmname = image.path
if path ~= image.path then
if not df.check_if_file_exists(df.sanitize_filename(path)) then
Expand Down Expand Up @@ -185,6 +292,35 @@ local function do_rename(images)
end
end

-- mirrors do_rename's computation for the first selected image, but only
-- updates the preview label instead of actually touching any files - keep
-- this in sync with do_rename whenever that function's logic changes
local function do_preview(images)
if #images > 0 then
local image = images[1]
local pattern = rename.widgets.pattern.text
persist_pattern_prefs()
if string.len(pattern) > 0 then
local new_name = substitute_pattern(image, 1, pattern, rename.widgets.allow_moving_files.value)
if new_name == -1 then
rename.widgets.preview_label.label = _("unable to do variable substitution")
else
local relative_path = resolve_relative_path(new_name)
local filename = ensure_extension(df.get_filename(new_name), determine_extension(image, 1))
if string.len(relative_path) > 0 then
rename.widgets.preview_label.label = relative_path .. PS .. filename
else
rename.widgets.preview_label.label = filename
end
end
else -- pattern length
rename.widgets.preview_label.label = _("please enter the new name or pattern")
end
else -- image count
rename.widgets.preview_label.label = _("please select an image first")
end
end


-- - - - - - - - - - - - - - - - - - - - - - - -
-- W I D G E T S
Expand All @@ -205,6 +341,76 @@ if pattern_pref then
rename.widgets.pattern.text = pattern_pref
end

rename.widgets.allow_moving_files = dt.new_widget("check_button"){
label = _("allow moving files"),
value = false,
tooltip = _("allows for moving files during renaming by using slash/backslash to construct a filepath") .. "\n" ..
_("useful for e.g. sorting files by their date") .. "\n" ..
_("caution: use with extra care"),
clicked_callback = function(self)
dt.preferences.write(MODULE_NAME, "allow_moving_files", "bool", self.value)
dt.preferences.write(MODULE_NAME, "allow_moving_files_saved", "bool", true)
end
}

if dt.preferences.read(MODULE_NAME, "allow_moving_files_saved", "bool") then
rename.widgets.allow_moving_files.value = dt.preferences.read(MODULE_NAME, "allow_moving_files", "bool")
end

rename.widgets.extension_pattern = dt.new_widget("entry"){
tooltip = _("pattern or literal text for the new extension") .. "\n" ..
_("leave empty to keep the existing extension"),
text = "$(FILE.EXTENSION)",
visible = false,
reset_callback = function(self)
self.text = "$(FILE.EXTENSION)"
self.visible = false
dt.preferences.write(MODULE_NAME, "extension_pattern", "string", self.text)
end
}

-- defined AFTER extension_pattern: setting "value" to something other than
-- its GTK default fires clicked_callback immediately during construction,
-- which references rename.widgets.extension_pattern; kept for safety even
-- though the default here is now "false" (matching GTK's own default)
rename.widgets.change_file_extension = dt.new_widget("check_button"){
label = _("change file extension"),
value = false,
clicked_callback = function(self)
rename.widgets.extension_pattern.visible = self.value
dt.preferences.write(MODULE_NAME, "change_file_extension", "bool", self.value)
dt.preferences.write(MODULE_NAME, "change_file_extension_saved", "bool", true)
end
}

-- only override the widget defaults if the checkbox was explicitly
-- saved before; dt.preferences.read(..., "bool") returns false rather
-- than nil for a preference that was never set, so a sentinel flag is
-- needed to tell "never saved" apart from "saved as false"
if dt.preferences.read(MODULE_NAME, "change_file_extension_saved", "bool") then
rename.widgets.change_file_extension.value = dt.preferences.read(MODULE_NAME, "change_file_extension", "bool")
rename.widgets.extension_pattern.visible = rename.widgets.change_file_extension.value
end

local extension_pattern_pref = dt.preferences.read(MODULE_NAME, "extension_pattern", "string")
if extension_pattern_pref and extension_pattern_pref ~= "" then
rename.widgets.extension_pattern.text = extension_pattern_pref
end

rename.widgets.preview_button = dt.new_widget("button"){
label = _("preview filename"),
clicked_callback = function(this)
do_preview(dt.gui.action_images)
end
}

rename.widgets.preview_label = dt.new_widget("label"){
label = "",
selectable = true,
halign = "center",
tooltip = _("shows the filename (and destination subfolder, if any) the rename button would produce"),
}

rename.widgets.button = dt.new_widget("button"){
label = _("rename"),
clicked_callback = function(this)
Expand Down