1 Commits

82 changed files with 196 additions and 18258 deletions

View File

@@ -79,7 +79,6 @@ passport_guide_germany|Yes|LMW|Add passport cropping guide for German passports
[slideshowMusic](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow
[transfer_hierarchy](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy
[video_ffmpeg](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable
website_gallery_export|No|LMW|Export a website gallery for selected images
### Example Scripts

View File

@@ -1,486 +0,0 @@
-- Route handlers. Each function receives a request table and returns:
-- status (int), body (string), content_type (string|nil), extra_headers (table|nil)
local json = require "json"
local auth = require "auth"
local db = require "db"
local preview = require "preview"
local config = require "config"
local modules = require "modules"
local socket = require "socket"
-- ── Refresh queue (IPC with the darktable bridge plugin) ──────────────────────
-- The bridge plugin running inside darktable polls this file and calls
-- image:drop_cache() + image:generate_cache() for each queued image ID.
--
-- We append atomically via a .tmp + rename idiom to avoid partial reads:
-- 1. Write IDs to <queue>.tmp.<pid>
-- 2. Append that file's contents to the main queue (or rename if queue absent)
-- In practice, for low-frequency refresh calls a simple append is fine because
-- the bridge reads and renames the queue file atomically.
local function enqueue_refresh(image_id)
local qf = config.refresh_queue
local f, ferr = io.open(qf, "a")
if not f then
return false, "cannot open refresh queue " .. qf .. ": " .. (ferr or "?")
end
f:write(tostring(image_id) .. "\n")
f:close()
return true
end
local function enqueue_export(image_id)
local qf = config.export_queue
local f, ferr = io.open(qf, "a")
if not f then
return false, "cannot open export queue " .. qf .. ": " .. (ferr or "?")
end
f:write(tostring(image_id) .. "\n")
f:close()
return true
end
local function export_path(image_id)
return config.export_dir .. "/" .. tostring(image_id) .. ".jpg"
end
local M = {}
-- ── Helpers ───────────────────────────────────────────────────────────────────
local function ok(data, ct)
return 200, json.encode(data), ct or "application/json; charset=utf-8"
end
local function err(status, code, detail)
local body = json.encode({ error = code, detail = detail })
return status, body, "application/json; charset=utf-8"
end
-- Parse application/x-www-form-urlencoded or application/json body.
local function parse_body(req)
local ct = (req.headers["content-type"] or ""):lower()
if ct:find("application/json", 1, true) then
return json.decode(req.body or "{}")
end
-- form-encoded
local fields = {}
for k, v in (req.body or ""):gmatch("([^&=]+)=([^&]*)") do
k = k:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
v = v:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
fields[k] = v
end
return fields
end
-- Authenticate the request and check an optional scope.
-- Returns: payload on success.
-- Returns: nil, status, body, ct on failure (caller does "return s,b,ct").
local function require_auth(req, scope)
local payload, aerr = auth.authenticate(req)
if not payload then
return nil, err(401, "unauthorized", aerr)
end
if scope and not auth.require_scope(payload, scope) then
return nil, err(403, "forbidden", "scope '" .. scope .. "' required")
end
return payload
end
-- ── Public endpoints ──────────────────────────────────────────────────────────
-- GET /health — liveness check, no auth required
function M.health(req)
return ok({ status = "ok", timestamp = os.time() })
end
-- ── OAuth 2.0 ─────────────────────────────────────────────────────────────────
-- POST /oauth/token
-- Accepts: application/x-www-form-urlencoded or application/json
-- Required fields: grant_type=client_credentials, client_id, client_secret
-- Optional: scope (space-separated, must be subset of client's scopes)
--
-- Returns: { access_token, token_type, expires_in, scope }
function M.oauth_token(req)
local fields = parse_body(req)
if not fields then
return err(400, "invalid_request", "could not parse request body")
end
if fields.grant_type ~= "client_credentials" then
return err(400, "unsupported_grant_type",
"only 'client_credentials' is supported")
end
-- Also accept Basic Auth header for client credentials
local client_id, client_secret = fields.client_id, fields.client_secret
if not client_id then
local basic = (req.headers["authorization"] or ""):match("^[Bb]asic%s+(.+)$")
if basic then
local decoded = require("base64").decode(basic)
client_id, client_secret = decoded:match("^([^:]+):(.+)$")
end
end
if not client_id or not client_secret then
return err(400, "invalid_request", "client_id and client_secret are required")
end
local ok_flag, client_scopes = auth.validate_client(client_id, client_secret)
if not ok_flag then
return err(401, "invalid_client", client_scopes)
end
-- Restrict to requested scopes if provided
local granted = client_scopes
if fields.scope and fields.scope ~= "" then
local requested = {}
for s in fields.scope:gmatch("%S+") do requested[s] = true end
granted = {}
for _, s in ipairs(client_scopes) do
if requested[s] then granted[#granted+1] = s end
end
if #granted == 0 then
return err(400, "invalid_scope", "none of the requested scopes are available")
end
end
local token = auth.issue_token(client_id, granted)
return ok({
access_token = token,
token_type = "Bearer",
expires_in = config.token_ttl,
scope = table.concat(granted, " "),
})
end
-- ── Images ────────────────────────────────────────────────────────────────────
-- GET /api/v1/images
-- Query params: rating_min, rating_max, rating, film_id, from, to,
-- color_label, is_raw, is_hdr, search, tag,
-- limit (1-500, default 50), offset, sort_by, sort_dir
function M.list_images(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local result = db.list_images(req.params)
return ok(result)
end
-- GET /api/v1/images/:id
function M.get_image(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
return ok(img)
end
-- ── Previews ──────────────────────────────────────────────────────────────────
-- GET /api/v1/images/:id/preview[?size=thumb|small|medium|large|xlarge|0-5]
-- Returns JPEG data directly (Content-Type: image/jpeg).
function M.get_preview(req)
local payload, s, b, ct = require_auth(req, "previews:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local data, ct, redirect = preview.get(id, img.path, img.filename, req.params.size)
if data then
return 200, data, ct
end
if redirect then
-- Attempt to serve the local file directly rather than issuing a redirect,
-- since file:// URLs are not useful to HTTP clients.
local file_data = preview.read_local(redirect)
if file_data then
local ext = (img.filename or ""):match("%.([^%.]+)$") or ""
local mime = ext:lower():match("^png$") and "image/png" or "image/jpeg"
return 200, file_data, mime
end
end
-- No cached preview exists. Enqueue a full-pipeline thumbnail generation
-- so the bridge renders one; the client should retry after a few seconds.
enqueue_refresh(id)
return 202, json.encode({
status = "generating",
image_id = id,
retry_after = 4,
message = "thumbnail is being generated; retry in a few seconds",
}), "application/json; charset=utf-8", { ["Retry-After"] = "4" }
end
-- ── Tags ──────────────────────────────────────────────────────────────────────
-- GET /api/v1/tags[?search=&limit=&offset=]
function M.list_tags(req)
local payload, s, b, ct = require_auth(req, "tags:read")
if not payload then return s, b, ct end
local tags = db.list_tags(req.params)
return ok({ items = tags })
end
-- ── Film rolls ────────────────────────────────────────────────────────────────
-- GET /api/v1/films[?search=&limit=&offset=]
function M.list_films(req)
local payload, s, b, ct = require_auth(req, "films:read")
if not payload then return s, b, ct end
local films = db.list_films(req.params)
return ok({ items = films })
end
-- ── Cache refresh ─────────────────────────────────────────────────────────────
-- POST /api/v1/images/:id/refresh
-- Enqueues the image for thumbnail regeneration via the darktable bridge.
-- The bridge calls image:drop_cache() + image:generate_cache(true, …), which
-- re-renders through darktable's full processing pipeline so that development
-- settings (tone curves, exposure, colour grading, etc.) are reflected.
-- Requires scope: cache:refresh
function M.refresh_cache(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local queued, qerr = enqueue_refresh(id)
if not queued then
return err(500, "queue_error", qerr)
end
return 202, json.encode({
status = "accepted",
image_id = id,
filename = img.filename,
queue = config.refresh_queue,
message = "cache refresh enqueued; darktable bridge will re-render thumbnails shortly",
}), "application/json; charset=utf-8"
end
-- ── Development modules ───────────────────────────────────────────────────────
-- GET /api/v1/images/:id/modules
-- Returns every operation in the history stack (active entry per operation).
-- Response includes decoded params for known modules; base64 blob for others.
-- Requires scope: images:read
function M.list_modules(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local mods = db.list_modules(id)
return ok({ image_id = id, items = mods })
end
-- GET /api/v1/images/:id/modules/:op
-- Returns the active history entry for one operation.
-- Requires scope: images:read
function M.get_module(req)
local payload, s, b, ct = require_auth(req, "images:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
local op = req.path_params.op
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local mod = db.get_module(id, op)
if not mod then
return err(404, "module_not_found",
"operation '" .. op .. "' not in history for image " .. id)
end
return ok(mod)
end
-- PATCH /api/v1/images/:id/modules/:op
-- Body (JSON):
-- {
-- "enabled": true|false, -- optional: change enabled state
-- "params": { field: value, … } -- optional: named params for known modules
-- "params_b64": "<base64>" -- alternative: raw binary for unknown modules
-- }
-- Query param: ?preview=thumb|small|medium|large|xlarge
-- If given, waits up to 30 s for the thumbnail to update and returns the JPEG.
-- Otherwise returns 204 No Content.
-- Requires scope: cache:refresh
function M.patch_module(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
local op = req.path_params.op
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local mod = db.get_module(id, op)
if not mod then
return err(404, "module_not_found",
"operation '" .. op .. "' not in history for image " .. id)
end
-- Parse request body
local body = parse_body(req)
if not body then
return err(400, "invalid_body", "could not parse JSON body")
end
local new_enabled = body.enabled -- may be nil (leave unchanged)
local new_blob = nil
if body.params_b64 then
-- Caller provided raw base64 params (for unknown modules)
local ok_dec, decoded = pcall(require("base64").decode, body.params_b64)
if not ok_dec then
return err(400, "invalid_params_b64", "base64 decode failed")
end
new_blob = decoded
elseif body.params then
if mod.params then
local existing_blob = db.get_module_blob(id, op)
if not existing_blob then
return err(500, "db_error", "could not read existing params blob")
end
new_blob = modules.encode(op, mod.module_version, existing_blob, body.params)
if not new_blob then
return err(400, "unsupported_module",
"named-param editing is not supported for operation '" .. op ..
"'; use params_b64 with a raw binary blob instead")
end
else
-- Unknown module, caller must supply params_b64
return err(400, "params_required",
"operation '" .. op .. "' requires params_b64 (binary blob) for editing")
end
end
-- Persist to history table
local ok_write, werr = db.set_module_params(id, op, new_enabled, new_blob)
if not ok_write then
return err(500, "db_write_error", werr)
end
-- Enqueue a thumbnail refresh so the bridge re-renders with the new params.
enqueue_refresh(id)
-- Optional synchronous preview response
local preview_size = req.params.preview
if preview_size and preview_size ~= "" then
-- Read the thumbnail before the refresh so we can detect when it changes.
local old_data = preview.get(id, img.path, img.filename, preview_size)
local deadline = os.time() + 30
local new_data
while os.time() < deadline do
socket.select(nil, nil, 0.5)
local candidate = preview.get(id, img.path, img.filename, preview_size)
if candidate and candidate ~= old_data then
new_data = candidate
break
end
end
if new_data then
return 200, new_data, "image/jpeg"
end
-- Timeout — return what we have (may be stale) with a warning header
local fallback = preview.get(id, img.path, img.filename, preview_size)
if fallback then
return 200, fallback, "image/jpeg",
{ ["X-Preview-Warning"] = "regeneration-timeout; thumbnail may be stale" }
end
return err(504, "preview_timeout",
"thumbnail regeneration timed out after 30 s; retry the preview endpoint")
end
return 204, "", nil
end
-- ── Full-resolution export ────────────────────────────────────────────────────
-- POST /api/v1/images/:id/export
-- Removes any stale export file, enqueues the bridge job, returns 202.
-- The client should poll GET /full until it receives a 200.
-- Requires scope: cache:refresh
function M.trigger_export(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
-- Remove stale export so the client can detect when the fresh one lands.
os.remove(export_path(id))
local queued, qerr = enqueue_export(id)
if not queued then return err(500, "queue_error", qerr) end
return 202, json.encode({
status = "accepted",
image_id = id,
filename = img.filename,
message = "export enqueued; poll GET /api/v1/images/" .. id .. "/full",
}), "application/json; charset=utf-8"
end
-- GET /api/v1/images/:id/full
-- Serves the full-resolution JPEG previously exported via POST /export.
-- If no export exists yet, enqueues one and returns 202 so the client can retry.
-- Requires scope: previews:read
function M.get_full(req)
local payload, s, b, ct = require_auth(req, "previews:read")
if not payload then return s, b, ct end
local id = tonumber(req.path_params.id)
if not id then return err(400, "invalid_id", "id must be an integer") end
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local path = export_path(id)
local f = io.open(path, "rb")
if f then
local data = f:read("*a"); f:close()
local stem = (img.filename or "export"):match("^(.+)%.[^.]+$") or (img.filename or "export")
local disposition = string.format('attachment; filename="%s.jpg"', stem)
return 200, data, "image/jpeg", { ["Content-Disposition"] = disposition }
end
-- Not yet exported — enqueue and tell the client to retry.
local queued, qerr = enqueue_export(id)
if not queued then
return err(500, "queue_error", qerr)
end
return 202, json.encode({
status = "queued",
image_id = id,
filename = img.filename,
retry_after = 5,
message = "export enqueued; retry in a few seconds",
}), "application/json; charset=utf-8", { ["Retry-After"] = "5" }
end
return M

View File

@@ -1,96 +0,0 @@
-- OAuth 2.0 Client Credentials grant + HS256 JWT
-- Requires: LuaJIT FFI with libcrypto (OpenSSL)
local ffi = require "ffi"
local base64 = require "base64"
local json = require "json"
local config = require "config"
-- ── OpenSSL HMAC-SHA256 via FFI ───────────────────────────────────────────────
ffi.cdef[[
void *EVP_sha256(void);
unsigned char *HMAC(const void *evp_md, const void *key, int key_len,
const unsigned char *data, size_t data_len,
unsigned char *md, unsigned int *md_len);
]]
-- libcrypto ships as part of OpenSSL; path varies by distro but the soname is standard
local crypto = ffi.load("crypto")
local function hmac_sha256(key, data)
local digest = ffi.new("unsigned char[32]")
local dlen = ffi.new("unsigned int[1]")
crypto.HMAC(crypto.EVP_sha256(), key, #key, data, #data, digest, dlen)
return ffi.string(digest, 32)
end
-- ── JWT helpers ───────────────────────────────────────────────────────────────
local HEADER_B64 = base64.urlencode(json.encode({ alg = "HS256", typ = "JWT" }))
local function jwt_sign(payload_tbl)
local payload = base64.urlencode(json.encode(payload_tbl))
local signing = HEADER_B64 .. "." .. payload
local sig = base64.urlencode(hmac_sha256(config.jwt_secret, signing))
return signing .. "." .. sig
end
local function jwt_verify(token)
local h, p, s = token:match("^([^.]+)%.([^.]+)%.([^.]+)$")
if not h then return nil, "malformed token" end
local expected = base64.urlencode(hmac_sha256(config.jwt_secret, h .. "." .. p))
if s ~= expected then return nil, "invalid signature" end
local payload, err = json.decode(base64.urldecode(p))
if not payload then return nil, "invalid payload: " .. (err or "?") end
if os.time() > (payload.exp or 0) then return nil, "token expired" end
return payload
end
-- ── Public API ────────────────────────────────────────────────────────────────
local M = {}
-- Validate client_id / client_secret pair; returns true + scopes or false + error.
function M.validate_client(client_id, client_secret)
local client = config.clients[client_id]
if not client then return false, "unknown client" end
if client.secret ~= client_secret then return false, "invalid client secret" end
return true, client.scopes
end
-- Issue an access token for a validated client.
function M.issue_token(client_id, scopes)
local now = os.time()
return jwt_sign({
iss = "darktable-api",
sub = client_id,
scope = table.concat(scopes, " "),
iat = now,
exp = now + config.token_ttl,
})
end
-- Extract and verify a Bearer token from a request.
-- Returns payload table on success, or nil + error string on failure.
function M.authenticate(req)
local auth = (req.headers or {})["authorization"] or ""
local token = auth:match("^[Bb]earer%s+(.+)$")
if not token then return nil, "missing Bearer token" end
return jwt_verify(token)
end
-- Check that the authenticated payload contains a required scope.
function M.require_scope(payload, scope)
if not payload then return false end
local scopes = payload.scope or ""
for s in scopes:gmatch("%S+") do
if s == scope then return true end
end
return false
end
return M

View File

@@ -1,52 +0,0 @@
-- Base64 and Base64url encode/decode (pure Lua, no dependencies)
local M = {}
local CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local LOOKUP = {}
for i = 1, #CHARS do LOOKUP[CHARS:sub(i,i)] = i - 1 end
function M.encode(s)
local out = {}
for i = 1, #s, 3 do
local a, b, c = s:byte(i, i + 2)
b, c = b or 0, c or 0
local n = a * 65536 + b * 256 + c
out[#out+1] = CHARS:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
out[#out+1] = CHARS:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
out[#out+1] = CHARS:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
out[#out+1] = CHARS:sub( n % 64 + 1, n % 64 + 1)
end
local rem = #s % 3
if rem == 1 then out[#out-1], out[#out] = "=", "=" end
if rem == 2 then out[#out] = "=" end
return table.concat(out)
end
function M.decode(s)
s = s:gsub("[^A-Za-z0-9+/=]", "")
local out = {}
for i = 1, #s, 4 do
local a = LOOKUP[s:sub(i, i )] or 0
local b = LOOKUP[s:sub(i+1, i+1)] or 0
local c = LOOKUP[s:sub(i+2, i+2)] or 0
local d = LOOKUP[s:sub(i+3, i+3)] or 0
local n = a * 262144 + b * 4096 + c * 64 + d
out[#out+1] = string.char(math.floor(n / 65536))
if s:sub(i+2, i+2) ~= "=" then out[#out+1] = string.char(math.floor(n / 256) % 256) end
if s:sub(i+3, i+3) ~= "=" then out[#out+1] = string.char(n % 256) end
end
return table.concat(out)
end
-- URL-safe Base64 (RFC 4648 §5): replace +/ with -_ and strip padding
function M.urlencode(s)
return M.encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "")
end
function M.urldecode(s)
s = s:gsub("-", "+"):gsub("_", "/")
local pad = (4 - #s % 4) % 4
return M.decode(s .. string.rep("=", pad))
end
return M

View File

@@ -1,33 +0,0 @@
meta {
name: Health Check
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("status is ok", function() {
expect(res.body.status).to.equal("ok");
});
test("has timestamp", function() {
expect(res.body.timestamp).to.be.a("number");
expect(res.body.timestamp).to.be.above(0);
});
}
docs {
## Health Check
Liveness probe — no authentication required.
Returns `{"status":"ok","timestamp":<unix>}`.
}

View File

@@ -1,33 +0,0 @@
meta {
name: Get Token (Bad Secret)
type: http
seq: 2
}
post {
url: {{baseUrl}}/oauth/token
body: form-urlencoded
auth: none
}
body:form-urlencoded {
grant_type: client_credentials
client_id: {{clientId}}
client_secret: wrong-secret
}
tests {
test("returns 401", function() {
expect(res.status).to.equal(401);
});
test("error is invalid_client", function() {
expect(res.body.error).to.equal("invalid_client");
});
}
docs {
## Negative test — wrong client secret
Verifies the server rejects bad credentials with 401.
}

View File

@@ -1,64 +0,0 @@
meta {
name: Get Token
type: http
seq: 1
}
post {
url: {{baseUrl}}/oauth/token
body: form-urlencoded
auth: none
}
body:form-urlencoded {
grant_type: client_credentials
client_id: {{clientId}}
client_secret: {{clientSecret}}
}
script:post-response {
if (res.status === 200) {
bru.setVar("accessToken", res.body.access_token);
console.log("Token stored. Expires in", res.body.expires_in, "seconds.");
console.log("Scopes:", res.body.scope);
} else {
console.error("Token request failed:", res.status, res.body);
}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has access_token", function() {
expect(res.body.access_token).to.be.a("string");
expect(res.body.access_token.length).to.be.above(0);
});
test("token_type is Bearer", function() {
expect(res.body.token_type).to.equal("Bearer");
});
test("scope includes images:read", function() {
expect(res.body.scope).to.include("images:read");
});
test("scope includes cache:refresh", function() {
expect(res.body.scope).to.include("cache:refresh");
});
test("expires_in is 3600", function() {
expect(res.body.expires_in).to.equal(3600);
});
}
docs {
## OAuth 2.0 — Client Credentials Grant
Exchanges `client_id` + `client_secret` for a short-lived Bearer JWT.
The post-response script stores the token in `{{accessToken}}` so all
other requests pick it up automatically.
Run this request first before calling any protected endpoint.
}

View File

@@ -1,6 +0,0 @@
{
"version": "1",
"name": "Darktable API",
"type": "collection",
"ignore": []
}

View File

@@ -1,12 +0,0 @@
vars {
baseUrl: http://127.0.0.1:7645
clientId: darktable-client
clientSecret: changeme
accessToken:
imageId: 1
}
vars:secret [
clientSecret,
accessToken
]

View File

@@ -1,12 +0,0 @@
vars {
baseUrl: http://192.168.1.134:7645
clientId: darktable-client
clientSecret: changeme
accessToken:
imageId: 1
}
vars:secret [
clientSecret,
accessToken
]

View File

@@ -1,54 +0,0 @@
meta {
name: List Films
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/films
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
limit: 50
offset: 0
~search: 2018
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
});
test("each film has id, folder, image_count", function() {
res.body.items.forEach(function(film) {
expect(film.id).to.be.a("number");
expect(film.folder).to.be.a("string");
expect(film.image_count).to.be.a("number");
});
});
test("image counts are positive", function() {
res.body.items.forEach(function(film) {
expect(film.image_count).to.be.above(0);
});
});
}
docs {
## List Films
Returns all film rolls (import folders) with their image counts, sorted by folder path.
Use `?search=` to filter by folder path substring.
Use `?limit` and `?offset` for pagination (max 2000 per page).
}

View File

@@ -1,52 +0,0 @@
meta {
name: Export Image
type: http
seq: 6
}
post {
url: {{baseUrl}}/api/v1/images/{{imageId}}/export
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 202 Accepted", function() {
expect(res.status).to.equal(202);
});
test("status is accepted", function() {
expect(res.body.status).to.equal("accepted");
});
test("image_id matches request", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has filename", function() {
expect(res.body.filename).to.be.a("string");
});
test("has export path", function() {
expect(res.body.export).to.be.a("string");
});
}
docs {
## Export Image (Full Resolution)
Enqueues a full-resolution JPEG export processed through darktable's complete
development pipeline — tone curves, exposure, colour grading, noise reduction,
and all other active modules are applied.
Returns **202 Accepted** immediately. The darktable bridge plugin writes the
result to the path shown in `export` (default: `/tmp/dt-api-exports/<id>.jpg`).
Poll **GET /api/v1/images/:id/full** to retrieve the image once ready.
Requires scope: `cache:refresh`
}

View File

@@ -1,57 +0,0 @@
meta {
name: Get Full Image
type: http
seq: 7
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/full
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200 or 202", function() {
expect([200, 202]).to.include(res.status);
});
test("if 200, content-type is image/jpeg", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.equal("image/jpeg");
}
});
test("if 200, body is non-empty", function() {
if (res.status === 200) {
expect(res.body.length).to.be.above(0);
}
});
test("if 202, export was queued", function() {
if (res.status === 202) {
expect(res.body.status).to.equal("queued");
expect(res.body.retry_after).to.be.a("number");
}
});
}
docs {
## Get Full-Resolution Image
Returns the full-resolution JPEG developed through darktable's complete
processing pipeline (all active development modules applied).
### Workflow
1. POST `/api/v1/images/:id/export` — enqueue the export (returns 202)
2. Wait ~2–10 s (depending on complexity and hardware)
3. GET `/api/v1/images/:id/full` — download the result (200 with JPEG body)
If no export exists yet this endpoint auto-enqueues one and returns **202**
with a `Retry-After: 5` header. Keep retrying until you receive **200**.
Requires scope: `previews:read`
}

View File

@@ -1,31 +0,0 @@
meta {
name: Get Image (Not Found)
type: http
seq: 3
}
get {
url: {{baseUrl}}/api/v1/images/999999
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 404", function() {
expect(res.status).to.equal(404);
});
test("error is not_found", function() {
expect(res.body.error).to.equal("not_found");
});
}
docs {
## Get Image — Not Found
Verifies a non-existent image ID returns 404.
}

View File

@@ -1,72 +0,0 @@
meta {
name: Get Image
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
script:post-response {
if (res.status === 200) {
// Store this image's ID for use by preview / refresh requests
bru.setVar("imageId", res.body.id);
}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has correct id", function() {
expect(res.body.id).to.equal(Number(bru.getVar("imageId")));
});
test("has filename", function() {
expect(res.body.filename).to.be.a("string");
expect(res.body.filename.length).to.be.above(0);
});
test("has path", function() {
expect(res.body.path).to.be.a("string");
});
test("has rating in valid range", function() {
expect(res.body.rating).to.be.at.least(-1);
expect(res.body.rating).to.be.at.most(5);
});
test("has tags array", function() {
expect(res.body.tags).to.be.an("array");
});
test("has color_labels array", function() {
expect(res.body.color_labels).to.be.an("array");
});
test("has metadata object", function() {
expect(res.body.metadata).to.be.an("object");
});
test("has boolean flags", function() {
expect(res.body.is_raw).to.be.a("boolean");
expect(res.body.is_hdr).to.be.a("boolean");
expect(res.body.is_ldr).to.be.a("boolean");
});
}
docs {
## Get Image
Returns full detail for a single image including tags, color labels, and metadata.
Set `{{imageId}}` in the environment (or pick up from "List Images" run).
}

View File

@@ -1,67 +0,0 @@
meta {
name: Get Preview
type: http
seq: 4
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/preview
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
size: small
~size: medium
~size: large
~size: thumb
~size: xlarge
}
tests {
test("returns 200 or 404", function() {
expect([200, 404]).to.include(res.status);
});
test("if 200, content-type is image/jpeg or image/png", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.match(/image\/(jpeg|png)/);
}
});
test("if 200, body is non-empty", function() {
if (res.status === 200) {
expect(res.body.length).to.be.above(0);
}
});
test("if 404, no_preview error returned", function() {
if (res.status === 404) {
expect(res.body.error).to.equal("no_preview");
}
});
}
docs {
## Get Preview
Returns a JPEG thumbnail from darktable's mipmap cache.
### Size options
| Value | Approx. long edge |
|---|---|
| `thumb` | ~135 px |
| `small` | ~270 px |
| `medium` | ~540 px (default) |
| `large` | ~1080 px |
| `xlarge` | ~2160 px |
| `0`–`5` | Raw mip level |
Only one `size` param is active at a time — uncomment the desired one above.
Falls back to lower mip levels automatically if the requested size isn't cached.
Returns 404 if no cached thumbnail exists at any level.
}

View File

@@ -1,27 +0,0 @@
meta {
name: List Images (No Auth)
type: http
seq: 6
}
get {
url: {{baseUrl}}/api/v1/images
body: none
auth: none
}
tests {
test("returns 401 without token", function() {
expect(res.status).to.equal(401);
});
test("error is unauthorized", function() {
expect(res.body.error).to.equal("unauthorized");
});
}
docs {
## List Images — No Auth
Verifies that protected endpoints return 401 when no Bearer token is supplied.
}

View File

@@ -1,87 +0,0 @@
meta {
name: List Images
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/images
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
limit: 10
offset: 0
sort_by: datetime_taken
sort_dir: desc
~rating_min: 1
~rating_max: 5
~rating: 3
~film_id: 1
~from: 2018-01-01
~to: 2018-12-31
~color_label: red
~is_raw: true
~is_hdr: false
~search: 500mm
~tag: realestate
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
});
test("has total count", function() {
expect(res.body.total).to.be.a("number");
expect(res.body.total).to.be.above(0);
});
test("has limit and offset", function() {
expect(res.body.limit).to.equal(10);
expect(res.body.offset).to.equal(0);
});
test("each image has required fields", function() {
res.body.items.forEach(function(img) {
expect(img.id).to.be.a("number");
expect(img.filename).to.be.a("string");
expect(img.path).to.be.a("string");
expect(img.rating).to.be.a("number");
});
});
}
docs {
## List Images
Returns a paginated list of images from the darktable catalog.
### Active query params (remove `~` prefix to enable)
| Param | Type | Description |
|---|---|---|
| `limit` | int | Results per page (max 500, default 50) |
| `offset` | int | Pagination offset |
| `sort_by` | string | `id`, `filename`, `datetime_taken`, `rating`, `focal_length`, `iso` |
| `sort_dir` | string | `asc` or `desc` |
| `rating_min` | int | Minimum star rating (−1=rejected, 0=unrated, 1–5=stars) |
| `rating_max` | int | Maximum star rating |
| `rating` | int | Exact rating |
| `film_id` | int | Filter by film roll ID |
| `from` | date | Earliest capture date (YYYY-MM-DD) |
| `to` | date | Latest capture date (YYYY-MM-DD) |
| `color_label` | string | `red`, `yellow`, `green`, `blue`, `purple` |
| `is_raw` | bool | `true` / `false` |
| `is_hdr` | bool | `true` / `false` |
| `search` | string | Free-text search in filename, maker, model, lens |
| `tag` | string | Filter by exact tag name |
}

View File

@@ -1,49 +0,0 @@
meta {
name: Refresh Cache
type: http
seq: 5
}
post {
url: {{baseUrl}}/api/v1/images/{{imageId}}/refresh
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 202 Accepted", function() {
expect(res.status).to.equal(202);
});
test("status is accepted", function() {
expect(res.body.status).to.equal("accepted");
});
test("image_id matches request", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has filename", function() {
expect(res.body.filename).to.be.a("string");
});
test("has queue path", function() {
expect(res.body.queue).to.be.a("string");
});
}
docs {
## Refresh Cache
Enqueues the image for thumbnail regeneration inside the running darktable instance.
Returns **202 Accepted** immediately. The darktable bridge plugin
(`darktable-api-bridge.lua`) polls `/tmp/dt-api-refresh.queue` every second
and calls `image:drop_cache()` + `image:generate_cache()` when it finds this ID.
Requires scope: `cache:refresh`
}

View File

@@ -1,31 +0,0 @@
meta {
name: Get Module (Not Found)
type: http
seq: 4
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/nonexistent_module
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 404", function() {
expect(res.status).to.equal(404);
});
test("error is module_not_found", function() {
expect(res.body.error).to.equal("module_not_found");
});
}
docs {
## Get Module — Not Found
Verifies 404 is returned for an operation that does not exist in the image history.
}

View File

@@ -1,55 +0,0 @@
meta {
name: Get Module
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("operation is filmicrgb", function() {
expect(res.body.operation).to.equal("filmicrgb");
});
test("has enabled flag", function() {
expect(res.body.enabled).to.be.a("boolean");
});
test("has decoded params", function() {
expect(res.body.params).to.be.an("object");
expect(res.body.params.grey_point_source).to.be.a("number");
expect(res.body.params.black_point_source).to.be.a("number");
expect(res.body.params.white_point_source).to.be.a("number");
expect(res.body.params.contrast).to.be.a("number");
expect(res.body.params.saturation).to.be.a("number");
});
// Save for the Patch Module test
bru.setVar("filmicGreyPointSource", res.body.params.grey_point_source);
}
docs {
## Get Module
Returns the active history entry for the `filmicrgb` module.
Change the URL path segment to inspect a different module, e.g.:
- `.../modules/exposure`
- `.../modules/temperature`
- `.../modules/highlights`
- `.../modules/channelmixerrgb`
Returns 404 if the operation has never been added to this image's history.
}

View File

@@ -1,79 +0,0 @@
meta {
name: List Modules
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has image_id", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
expect(res.body.items.length).to.be.above(0);
});
test("each item has required fields", function() {
res.body.items.forEach(function(m) {
expect(m.operation).to.be.a("string");
expect(m.enabled).to.be.a("boolean");
expect(m.module_version).to.be.a("number");
expect(m.history_num).to.be.a("number");
});
});
test("known modules have decoded params", function() {
var known = res.body.items.filter(function(m) {
return ["exposure","temperature","filmicrgb","highlights",
"channelmixerrgb","flip","gamma","rawprepare","demosaic"].indexOf(m.operation) >= 0;
});
known.forEach(function(m) {
expect(m.params).to.not.be.null;
expect(typeof m.params).to.equal("object");
});
});
test("unknown modules have params_b64", function() {
var unknown = res.body.items.filter(function(m) { return m.params === undefined || m.params === null; });
unknown.forEach(function(m) {
expect(m.params_b64).to.be.a("string");
});
});
// Save the first module op for subsequent tests
bru.setVar("moduleOp", res.body.items[0].operation);
}
docs {
## List Modules
Returns all active development modules (history stack entries) for the image.
Each item includes:
- `operation` – module name (e.g. `"exposure"`, `"filmicrgb"`)
- `enabled` – whether the module is currently active in the pipeline
- `module_version` – params struct version stored in the database
- `multi_name` – user label for multi-instance modules (empty for single-instance)
- `history_num` – position in the history stack (higher = more recent)
- `params` – decoded named parameters (for supported modules)
- `params_b64` – base64-encoded raw binary blob (for unsupported modules)
### Supported modules (params decoded)
`exposure`, `temperature`, `filmicrgb`, `channelmixerrgb`, `highlights`,
`flip`, `gamma`, `rawprepare`, `demosaic`
}

View File

@@ -1,89 +0,0 @@
meta {
name: Patch Module
type: http
seq: 3
}
patch {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
body: json
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
~preview: medium
}
body:json {
{
"enabled": true,
"params": {
"grey_point_source": 18.45,
"white_point_source": 4.0,
"black_point_source": -8.0,
"contrast": 1.0
}
}
}
tests {
test("returns 204 (no preview) or 200 (with preview)", function() {
expect([200, 204]).to.include(res.status);
});
test("if 200, content-type is image/jpeg", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.equal("image/jpeg");
}
});
}
docs {
## Patch Module
Update one or more parameters on a development module and optionally get a
preview image back showing the result.
### Body fields
- `enabled` (boolean, optional) – enable or disable the module
- `params` (object, optional) – named parameter overrides for known modules
- `params_b64` (string, optional) – raw base64 binary blob for unknown modules
(mutually exclusive with `params`)
Only fields present in `params` are changed; all others keep their current values.
### Preview query param
Add `?preview=small|medium|large|xlarge` to receive a JPEG thumbnail (200)
instead of empty 204. The server waits up to 30 s for the thumbnail to
regenerate before returning. Without this param the endpoint returns
**204 No Content** immediately (the bridge regenerates async in ~1-3 s).
### filmicrgb key parameters
| Field | Description | Default |
|---|---|---|
| `grey_point_source` | middle-grey exposure % | 18.45 |
| `black_point_source` | black relative exposure EV | -8.0 |
| `white_point_source` | white relative exposure EV | 4.0 |
| `contrast` | S-curve contrast | 1.0 |
| `saturation` | extreme-luminance saturation | 0.0 |
| `balance` | shadows ↔ highlights balance | 0.0 |
| `auto_hardness` | auto-adjust curve hardness | true |
### exposure key parameters
| Field | Description |
|---|---|
| `exposure` | exposure compensation in EV |
| `black` | black level correction |
| `mode` | 0=manual, 1=deflicker |
### temperature key parameters
| Field | Description |
|---|---|
| `red` | red channel multiplier |
| `green` | green channel multiplier |
| `blue` | blue channel multiplier |
}

View File

@@ -1,54 +0,0 @@
meta {
name: List Tags
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/tags
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
limit: 50
offset: 0
~search: darktable
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
});
test("each tag has id, name, image_count", function() {
res.body.items.forEach(function(tag) {
expect(tag.id).to.be.a("number");
expect(tag.name).to.be.a("string");
expect(tag.image_count).to.be.a("number");
});
});
test("tags are sorted alphabetically", function() {
var names = res.body.items.map(function(t) { return t.name; });
var sorted = names.slice().sort();
expect(names).to.deep.equal(sorted);
});
}
docs {
## List Tags
Returns all tags in the catalog with their image counts, sorted alphabetically.
Use `?search=keyword` to filter by tag name prefix/substring.
Use `?limit` and `?offset` for pagination (max 2000 per page).
}

View File

@@ -1,38 +0,0 @@
local M = {}
local home = os.getenv("HOME") or "/root"
M.host = os.getenv("DT_API_HOST") or "127.0.0.1"
M.port = tonumber(os.getenv("DT_API_PORT")) or 7645
-- Darktable paths
M.library_db = os.getenv("DT_LIBRARY") or (home .. "/.config/darktable/library.db")
M.data_db = os.getenv("DT_DATA_DB") or (home .. "/.config/darktable/data.db")
M.cache_dir = os.getenv("DT_CACHE_DIR") or (home .. "/.cache/darktable")
-- JWT / OAuth
M.jwt_secret = os.getenv("DT_JWT_SECRET") or "change-this-secret-in-production"
M.token_ttl = tonumber(os.getenv("DT_TOKEN_TTL")) or 3600
-- OAuth 2.0 clients table: client_id -> { secret, scopes[] }
-- Additional clients can be registered by adding entries here or setting
-- DT_CLIENT_ID / DT_CLIENT_SECRET environment variables.
M.clients = {}
local default_id = os.getenv("DT_CLIENT_ID") or "darktable-client"
local default_secret = os.getenv("DT_CLIENT_SECRET") or "changeme"
M.clients[default_id] = {
secret = default_secret,
scopes = { "images:read", "previews:read", "tags:read", "films:read", "cache:refresh" },
}
-- Queue file the bridge plugin watches for cache-refresh requests.
M.refresh_queue = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
-- Queue file the bridge plugin watches for full-resolution export requests.
M.export_queue = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
-- Directory where the bridge writes full-resolution JPEG exports.
-- Served by GET /api/v1/images/:id/full.
M.export_dir = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
return M

View File

@@ -1,302 +0,0 @@
--[[
darktable-api-bridge.lua
Companion plugin for the darktable REST API server.
Runs inside a live darktable instance and watches two queue files written by
the standalone LuaJIT server.
Refresh queue (DT_REFRESH_QUEUE, default /tmp/dt-api-refresh.queue)
One image ID per line. The bridge calls image:drop_cache() +
image:generate_cache(true, …) so that darktable re-renders thumbnails
through its full processing pipeline, reflecting tone curves, exposure,
colour grading, and all other development settings.
Export queue (DT_EXPORT_QUEUE, default /tmp/dt-api-export.queue)
One image ID per line. The bridge exports a full-resolution JPEG via
darktable's rendering pipeline to DT_EXPORT_DIR/<id>.jpg.
Install: add the following line to ~/.config/darktable/luarc
require "contrib/darktable-api/darktable-api-bridge"
Or let the startup script (start-with-api.sh) do it automatically.
--]]
local dt = require "darktable"
local du = require "lib/dtutils"
du.check_min_api_version("7.0.0", "darktable-api-bridge")
local MODULE = "darktable-api-bridge"
-- ── Configuration ─────────────────────────────────────────────────────────────
-- Must match config.refresh_queue / export_queue / export_dir in config.lua.
local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
local EXPORT_QUEUE = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
local EXPORT_DIR = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
local POLL_MS = 1000 -- poll interval in milliseconds
-- Mipmap range to regenerate: 0 = smallest thumbnail, 4 ≈ 1080 px.
local MIP_MIN = 0
local MIP_MAX = 4
-- JPEG quality used for full-resolution exports.
local EXPORT_QUALITY = tonumber(os.getenv("DT_EXPORT_QUALITY")) or 95
-- ── Logging ───────────────────────────────────────────────────────────────────
local function log(msg)
dt.print_log("[" .. MODULE .. "] " .. msg)
end
local function notify(msg)
dt.print("[" .. MODULE .. "] " .. msg)
log(msg)
end
-- ── Image lookup ──────────────────────────────────────────────────────────────
-- Build a fast id→image map from dt.database.
-- dt.database is a table-like object; iterating it is O(n) but fast enough
-- for typical library sizes (thousands of images).
local function build_id_map()
local map = {}
for _, img in ipairs(dt.database) do
map[img.id] = img
end
return map
end
-- ── Queue file handling ───────────────────────────────────────────────────────
-- Atomically claim the queue file by renaming it, then read all IDs from it.
-- Returns a set table { [id] = true } of unique image IDs, or {} if none.
local function drain_queue()
local processing = QUEUE_FILE .. ".processing"
-- Rename is atomic on POSIX filesystems: the server's append-only writes
-- will start a fresh QUEUE_FILE after we claim this one.
local ok = os.rename(QUEUE_FILE, processing)
if not ok then
return {} -- queue file doesn't exist yet
end
local ids = {}
local f = io.open(processing, "r")
if f then
for line in f:lines() do
local id = tonumber(line)
if id then ids[id] = true end
end
f:close()
os.remove(processing)
end
return ids
end
-- ── Mipmap directory ─────────────────────────────────────────────────────────
-- Darktable's mipmap_cache->cachedir is not directly accessible from Lua, so
-- we replicate the naming: SHA1 hex of realpath(library.db) + ".d".
-- We compute it once and cache it here.
local _mipmap_cachedir
local function mipmap_cachedir()
if _mipmap_cachedir then return _mipmap_cachedir end
local cache_dir = os.getenv("DT_CACHE_DIR")
if not cache_dir then
local home = os.getenv("HOME") or "~"
cache_dir = home .. "/.cache/darktable"
end
-- There is exactly one mipmaps-<sha1>.d directory per library.
-- Find it by globbing rather than recomputing the SHA1.
local h = io.popen(string.format("ls -d %q/mipmaps-*.d 2>/dev/null | head -1", cache_dir))
local found = h and h:read("*l"); if h then h:close() end
if found and #found > 0 then
-- Strip trailing ".d" to get the base prefix used in file paths.
_mipmap_cachedir = found:match("^(.+)%.d$") or found
log("mipmap cachedir: " .. _mipmap_cachedir .. ".d")
return _mipmap_cachedir
end
log("WARNING: could not find mipmap cache directory under " .. cache_dir)
return nil
end
-- Delete all mipmap disk files for an image so generate_cache regenerates them.
local function delete_mipmap_files(imgid)
local base = mipmap_cachedir()
if not base then return end
for level = 0, 8 do
local path = string.format("%s.d/%d/%d.jpg", base, level, imgid)
os.remove(path)
end
end
-- ── Cache regeneration ────────────────────────────────────────────────────────
local function refresh_image(img)
local label = string.format("id=%d %s/%s", img.id, img.path, img.filename)
log("refreshing " .. label)
-- Remove disk mipmap files first so generate_cache doesn't skip any level.
-- drop_cache() only clears the in-memory entry; existing disk files would
-- cause generate_cache to skip those levels (it guards with dt_util_test_image_file).
delete_mipmap_files(img.id)
img:drop_cache()
-- create_dirs=true ensures the per-level directories exist.
img:generate_cache(true, MIP_MIN, MIP_MAX)
log("done " .. label)
end
local function export_image(img, dest_path)
local label = string.format("id=%d → %s", img.id, dest_path)
log("exporting " .. label)
local fmt = dt.new_format("jpeg")
fmt.quality = EXPORT_QUALITY
-- max_width/max_height = 0 means no scaling: full resolution.
fmt.max_width = 0
fmt.max_height = 0
local export_err = fmt:write_image(img, dest_path)
if export_err then
error("export failed: " .. tostring(export_err))
end
log("export done " .. label)
end
-- Atomically claim the export queue and return a set of unique image IDs.
local function drain_export_queue()
local processing = EXPORT_QUEUE .. ".processing"
local ok = os.rename(EXPORT_QUEUE, processing)
if not ok then return {} end
local ids = {}
local f = io.open(processing, "r")
if f then
for line in f:lines() do
local id = tonumber(line)
if id then ids[id] = true end
end
f:close()
os.remove(processing)
end
return ids
end
-- Process all pending refresh IDs in one pass.
local function process_queue()
local ids = drain_queue()
local count = 0
for id, _ in pairs(ids) do count = count + 1 end
if count == 0 then return end
log(string.format("processing %d queued refresh request(s)", count))
local id_map = build_id_map()
for id, _ in pairs(ids) do
local img = id_map[id]
if img then
local ok, err = pcall(refresh_image, img)
if not ok then
log("error refreshing id=" .. id .. ": " .. tostring(err))
end
else
log("image id=" .. id .. " not found in database (may have been removed)")
end
end
-- Nudge the lighttable to redraw so updated thumbnails appear immediately.
dt.gui.views.lighttable.is_culling_mode = dt.gui.views.lighttable.is_culling_mode
end
-- Process all pending export IDs in one pass.
local function process_export_queue()
local ids = drain_export_queue()
local count = 0
for id, _ in pairs(ids) do count = count + 1 end
if count == 0 then return end
-- Ensure export directory exists.
os.execute('mkdir -p "' .. EXPORT_DIR .. '"')
log(string.format("processing %d queued export request(s)", count))
local id_map = build_id_map()
for id, _ in pairs(ids) do
local img = id_map[id]
if img then
local dest = EXPORT_DIR .. "/" .. tostring(id) .. ".jpg"
local ok, err = pcall(export_image, img, dest)
if not ok then
log("error exporting id=" .. id .. ": " .. tostring(err))
end
else
log("export: image id=" .. id .. " not found in database")
end
end
end
-- ── Background polling loop ───────────────────────────────────────────────────
-- dt.control.dispatch runs the function asynchronously in darktable's worker
-- thread pool, leaving the UI thread free. We loop inside with sleep() so
-- one dispatch call drives the whole bridge.
local running = true
local function poll_loop()
notify(string.format(
"started; polling queues every %d ms\n refresh: %s\n export: %s → %s",
POLL_MS, QUEUE_FILE, EXPORT_QUEUE, EXPORT_DIR))
while running do
local ok, err = pcall(process_queue)
if not ok then log("process_queue error: " .. tostring(err)) end
local ok2, err2 = pcall(process_export_queue)
if not ok2 then log("process_export_queue error: " .. tostring(err2)) end
dt.control.sleep(POLL_MS)
end
notify("stopped")
end
-- ── Plugin lifecycle ──────────────────────────────────────────────────────────
local script_data = {}
script_data.metadata = {
name = "darktable API bridge",
purpose = "Watch the REST API refresh queue and regenerate image thumbnails",
author = "darktable-api",
help = "https://github.com/darktable-org/lua-scripts",
}
local function destroy()
running = false
-- Give the poll loop one final chance to exit cleanly.
dt.control.sleep(POLL_MS + 200)
dt.destroy_event(MODULE, "shortcut")
end
script_data.destroy = destroy
-- Register a manual-trigger shortcut so the user can force an immediate poll
-- without waiting for the next timer tick.
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
log("manual poll triggered via shortcut")
local ok, err = pcall(process_queue)
if not ok then notify("error during manual poll: " .. tostring(err)) end
local ok2, err2 = pcall(process_export_queue)
if not ok2 then notify("export error during manual poll: " .. tostring(err2)) end
end,
"poll refresh queue now"
)
-- Start the background loop.
dt.control.dispatch(poll_loop)
return script_data

View File

@@ -1,525 +0,0 @@
-- Darktable SQLite catalog access (read-only for image queries; read-write for
-- module-parameter edits via the history table).
-- Schema inspected from an actual darktable library (recent version):
--
-- images(id, group_id, film_id, filename, width, height, aspect_ratio,
-- maker_id, model_id, lens_id, camera_id,
-- exposure, aperture, iso, focal_length, focus_distance,
-- datetime_taken INTEGER (Unix epoch),
-- flags, orientation, longitude, latitude, altitude,
-- version, max_version, write_timestamp, change_timestamp, ...)
-- makers(id, name) models(id, name) lens(id, name)
-- film_rolls(id, access_timestamp, folder)
-- tagged_images(imgid, tagid, position)
-- color_labels(imgid, color) -- color: 0=red 1=yellow 2=green 3=blue 4=purple
-- data.tags(id, name, ...) -- in data.db
-- meta_data(id, key, value) -- key references data.meta_data.key
--
-- images.flags & 0x07 → rating (0=unrated, 1-5=stars, 6=rejected)
-- images.flags & 64 → DT_IMAGE_RAW
-- images.flags & 128 → DT_IMAGE_HDR
-- images.flags & 32 → DT_IMAGE_LDR
local sqlite3 = require "lsqlite3"
local bit = require "bit" -- LuaJIT built-in; Lua 5.1 bitwise
local config = require "config"
local band = bit.band
local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility
local M = {}
-- ── Connections ───────────────────────────────────────────────────────────────
local _db -- read-only, lazy
local _db_rw -- read-write, opened on first write
local function get_db()
if _db then return _db end
_db = sqlite3.open(config.library_db, sqlite3.OPEN_READONLY)
if not _db then
error("Cannot open darktable library: " .. config.library_db)
end
local data_db = config.data_db:gsub("'", "''")
_db:exec(string.format("ATTACH DATABASE '%s' AS data", data_db))
return _db
end
local function get_db_rw()
if _db_rw then return _db_rw end
_db_rw = sqlite3.open(config.library_db) -- read-write
if not _db_rw then
error("Cannot open darktable library for writing: " .. config.library_db)
end
-- WAL mode: allow concurrent reads from the darktable process.
_db_rw:exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
return _db_rw
end
-- ── Query helpers ─────────────────────────────────────────────────────────────
local function query(sql, params)
local d = get_db()
local stmt = d:prepare(sql)
if not stmt then
error("SQL error: " .. (d:errmsg() or "?") .. "\nSQL: " .. sql)
end
if params and #params > 0 then
stmt:bind_values(unpack(params))
end
local rows = {}
for row in stmt:nrows() do
rows[#rows+1] = row
end
stmt:finalize()
return rows
end
local function query_one(sql, params)
return query(sql, params)[1]
end
-- ── Schema helpers ────────────────────────────────────────────────────────────
-- Rating is packed into the low 3 bits of flags: 0=unrated, 1-5=stars, 6=rejected
local function decode_rating(flags)
local r = band(flags or 0, 0x07)
return r == 6 and -1 or r
end
-- datetime_taken is stored as GLib microseconds since 0001-01-01 00:00:00 UTC.
-- Unix epoch (1970-01-01) = 62135596800 seconds after 0001-01-01.
local GLIB_EPOCH_OFFSET = 62135596800 -- seconds
local function glib_to_unix(glib_us)
if not glib_us or glib_us == 0 then return nil end
return math.floor(glib_us / 1000000) - GLIB_EPOCH_OFFSET
end
local function fmt_datetime(glib_us)
local unix_ts = glib_to_unix(glib_us)
if not unix_ts then return nil end
return os.date("!%Y-%m-%dT%H:%M:%SZ", unix_ts)
end
local LABEL_NAMES = { [0]="red", [1]="yellow", [2]="green", [3]="blue", [4]="purple" }
local function row_to_image(row)
if not row then return nil end
return {
id = row.id,
film_id = row.film_id,
group_id = row.group_id,
filename = row.filename,
path = row.folder,
width = row.width,
height = row.height,
aspect_ratio = row.aspect_ratio,
maker = row.maker,
model = row.model,
lens = row.lens,
exposure = row.exposure,
aperture = row.aperture,
iso = row.iso,
focal_length = row.focal_length,
datetime_taken = fmt_datetime(row.datetime_taken),
rating = decode_rating(row.flags),
is_raw = band(row.flags or 0, 64) ~= 0,
is_hdr = band(row.flags or 0, 128) ~= 0,
is_ldr = band(row.flags or 0, 32) ~= 0,
latitude = row.latitude,
longitude = row.longitude,
altitude = row.altitude,
version = row.version,
write_timestamp = row.write_timestamp,
change_timestamp = row.change_timestamp,
}
end
-- Standard SELECT columns + JOINs used by both list_images and get_image.
local IMAGE_SELECT = [[
SELECT i.id, i.group_id, i.film_id, i.filename,
i.width, i.height, i.aspect_ratio,
mk.name AS maker, mo.name AS model, ln.name AS lens,
i.exposure, i.aperture, i.iso, i.focal_length,
i.datetime_taken, i.flags, i.version,
i.longitude, i.latitude, i.altitude,
i.write_timestamp, i.change_timestamp,
f.folder
FROM images i
JOIN film_rolls f ON f.id = i.film_id
LEFT JOIN makers mk ON mk.id = i.maker_id
LEFT JOIN models mo ON mo.id = i.model_id
LEFT JOIN lens ln ON ln.id = i.lens_id
]]
-- ── Filter builder ────────────────────────────────────────────────────────────
local COLOR_MAP = { red=0, yellow=1, green=2, blue=3, purple=4 }
local VALID_SORT = {
id=true, filename=true, datetime_taken=true, rating=true,
focal_length=true, iso=true,
}
-- Convert a YYYY-MM-DD string to a GLib microsecond timestamp for DB comparison.
local function date_to_glib_us(date_str, end_of_day)
local y, m, d = date_str:match("(%d%d%d%d)[%-:](%d%d)[%-:](%d%d)")
if not y then return nil end
local unix_ts = os.time({
year=tonumber(y), month=tonumber(m), day=tonumber(d),
hour = end_of_day and 23 or 0,
min = end_of_day and 59 or 0,
sec = end_of_day and 59 or 0,
})
return (unix_ts + GLIB_EPOCH_OFFSET) * 1000000
end
local function build_filter(filters)
local conds = {}
local params = {}
-- Star rating (packed in flags & 0x07; 6 = rejected)
if filters.rating_min then
local r = tonumber(filters.rating_min) or 0
table.insert(conds, "(i.flags & 7) >= ?")
table.insert(params, r == -1 and 6 or r)
end
if filters.rating_max then
local r = tonumber(filters.rating_max) or 5
table.insert(conds, "(i.flags & 7) <= ?")
table.insert(params, r == -1 and 6 or r)
end
if filters.rating then
local r = tonumber(filters.rating)
if r then
table.insert(conds, "(i.flags & 7) = ?")
table.insert(params, r == -1 and 6 or r)
end
end
-- Film roll / folder
if filters.film_id then
table.insert(conds, "i.film_id = ?")
table.insert(params, tonumber(filters.film_id))
end
-- Date range (datetime_taken is GLib microseconds since 0001-01-01)
if filters.from then
local glib_us = date_to_glib_us(filters.from, false)
if glib_us then
table.insert(conds, "i.datetime_taken >= ?")
table.insert(params, glib_us)
end
end
if filters.to then
local glib_us = date_to_glib_us(filters.to, true)
if glib_us then
table.insert(conds, "i.datetime_taken <= ?")
table.insert(params, glib_us)
end
end
-- Color label
if filters.color_label then
local cid = COLOR_MAP[filters.color_label:lower()]
if cid ~= nil then
table.insert(conds, "EXISTS (SELECT 1 FROM color_labels cl WHERE cl.imgid=i.id AND cl.color=?)")
table.insert(params, cid)
end
end
-- RAW / HDR flags (SQLite supports & bitwise)
if filters.is_raw == "true" then table.insert(conds, "(i.flags & 64) != 0") end
if filters.is_raw == "false" then table.insert(conds, "(i.flags & 64) = 0") end
if filters.is_hdr == "true" then table.insert(conds, "(i.flags & 128) != 0") end
-- Free-text search across maker/model/lens/filename
if filters.search and filters.search ~= "" then
table.insert(conds, "(i.filename LIKE ? OR mk.name LIKE ? OR mo.name LIKE ? OR ln.name LIKE ?)")
local pat = "%" .. filters.search .. "%"
for _ = 1, 4 do table.insert(params, pat) end
end
-- Tag filter (correlated EXISTS; works for both data.tags and main.tags layouts)
if filters.tag and filters.tag ~= "" then
table.insert(conds, [[EXISTS (
SELECT 1 FROM tagged_images ti2, data.tags t2
WHERE ti2.imgid = i.id AND t2.id = ti2.tagid AND t2.name = ?)]])
table.insert(params, filters.tag)
end
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
return where, params
end
-- ── Public API ────────────────────────────────────────────────────────────────
function M.list_images(filters)
filters = filters or {}
local where, params = build_filter(filters)
local limit = math.max(1, math.min(tonumber(filters.limit) or 50, 500))
local offset = math.max(0, tonumber(filters.offset) or 0)
local sort_by = VALID_SORT[filters.sort_by] and ("i." .. filters.sort_by) or "i.datetime_taken"
local sort_dir = (filters.sort_dir == "asc") and "ASC" or "DESC"
-- Total count (same filter, no pagination)
local count_params = {}
for _, v in ipairs(params) do count_params[#count_params+1] = v end
local count_sql = string.format(
"SELECT COUNT(*) AS total FROM images i " ..
"JOIN film_rolls f ON f.id = i.film_id " ..
"LEFT JOIN makers mk ON mk.id = i.maker_id " ..
"LEFT JOIN models mo ON mo.id = i.model_id " ..
"LEFT JOIN lens ln ON ln.id = i.lens_id %s",
where)
local count_row = query_one(count_sql, count_params)
local total = count_row and count_row.total or 0
-- Data rows
local data_params = {}
for _, v in ipairs(params) do data_params[#data_params+1] = v end
data_params[#data_params+1] = limit
data_params[#data_params+1] = offset
local data_sql = IMAGE_SELECT ..
where ..
string.format(" ORDER BY %s %s LIMIT ? OFFSET ?", sort_by, sort_dir)
local images = {}
for _, row in ipairs(query(data_sql, data_params)) do
images[#images+1] = row_to_image(row)
end
return { total = total, limit = limit, offset = offset, items = images }
end
function M.get_image(id)
local row = query_one(IMAGE_SELECT .. " WHERE i.id = ?", { tonumber(id) })
if not row then return nil end
local img = row_to_image(row)
-- Tags
local tag_rows = query([[
SELECT t.name FROM tagged_images ti
JOIN data.tags t ON t.id = ti.tagid
WHERE ti.imgid = ? ORDER BY t.name
]], { img.id })
img.tags = {}
for _, tr in ipairs(tag_rows) do img.tags[#img.tags+1] = tr.name end
-- Color labels
local cl_rows = query("SELECT color FROM color_labels WHERE imgid=?", { img.id })
img.color_labels = {}
for _, cl in ipairs(cl_rows) do
if LABEL_NAMES[cl.color] then
img.color_labels[#img.color_labels+1] = LABEL_NAMES[cl.color]
end
end
-- Metadata (caption, description, creator, etc. via data.meta_data key map)
local ok_meta, meta_rows = pcall(query, [[
SELECT dm.tagname, m.value
FROM meta_data m JOIN data.meta_data dm ON dm.key = m.key
WHERE m.id = ?
]], { img.id })
img.metadata = {}
if ok_meta then
for _, mr in ipairs(meta_rows) do
local subkey = mr.tagname:match("%.([^%.]+)$") or mr.tagname
img.metadata[subkey] = mr.value
end
end
return img
end
-- ── Tags ──────────────────────────────────────────────────────────────────────
function M.list_tags(filters)
filters = filters or {}
local conds, params = {}, {}
if filters.search and filters.search ~= "" then
table.insert(conds, "t.name LIKE ?")
table.insert(params, "%" .. filters.search .. "%")
end
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
local limit = math.min(tonumber(filters.limit) or 200, 2000)
local offset = tonumber(filters.offset) or 0
params[#params+1] = limit
params[#params+1] = offset
local rows = query(string.format([[
SELECT t.id, t.name,
(SELECT COUNT(*) FROM tagged_images ti WHERE ti.tagid=t.id) AS image_count
FROM data.tags t %s ORDER BY t.name LIMIT ? OFFSET ?
]], where), params)
local tags = {}
for _, r in ipairs(rows) do
tags[#tags+1] = { id=r.id, name=r.name, image_count=r.image_count }
end
return tags
end
-- ── Film rolls ────────────────────────────────────────────────────────────────
function M.list_films(filters)
filters = filters or {}
local conds, params = {}, {}
if filters.search and filters.search ~= "" then
table.insert(conds, "f.folder LIKE ?")
table.insert(params, "%" .. filters.search .. "%")
end
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
local limit = math.min(tonumber(filters.limit) or 200, 2000)
local offset = tonumber(filters.offset) or 0
params[#params+1] = limit
params[#params+1] = offset
local rows = query(string.format([[
SELECT f.id, f.folder, f.access_timestamp,
(SELECT COUNT(*) FROM images i WHERE i.film_id=f.id) AS image_count
FROM film_rolls f %s ORDER BY f.folder LIMIT ? OFFSET ?
]], where), params)
local films = {}
for _, r in ipairs(rows) do
films[#films+1] = {
id = r.id,
folder = r.folder,
access_timestamp = r.access_timestamp,
image_count = r.image_count,
}
end
return films
end
-- ── Module history ────────────────────────────────────────────────────────────
-- The history table stores one or more entries per (imgid, operation).
-- Only the entry with the highest `num` is the active one.
-- `module` column = the params-struct version number for that row.
local modules_mod -- lazy-loaded to avoid circular requires
local function get_modules_mod()
if not modules_mod then modules_mod = require "modules" end
return modules_mod
end
local function history_row_to_module(row)
local mods = get_modules_mod()
local blob = row.op_params or ""
local ver = row.module or 0
local params = mods.decode(row.operation, ver, blob)
return {
operation = row.operation,
enabled = row.enabled ~= 0,
module_version = ver,
multi_name = row.multi_name or "",
multi_priority = row.multi_priority or 0,
history_num = row.num,
params = params, -- nil for unknown modules
params_b64 = params and nil or require("base64").encode(blob),
}
end
-- Return the active (highest-num) history entry for every operation on imgid.
function M.list_modules(imgid)
local rows = query([[
SELECT h.operation, h.module, h.enabled, h.op_params,
h.multi_name, h.multi_priority, h.num
FROM history h
INNER JOIN (
SELECT operation, MAX(num) AS max_num
FROM history WHERE imgid = ?
GROUP BY operation
) latest ON h.operation = latest.operation AND h.num = latest.max_num
WHERE h.imgid = ?
ORDER BY h.operation
]], { tonumber(imgid), tonumber(imgid) })
local result = {}
for _, row in ipairs(rows) do
result[#result+1] = history_row_to_module(row)
end
return result
end
-- Return the raw op_params binary blob for one operation (latest history entry).
function M.get_module_blob(imgid, operation)
local row = query_one([[
SELECT op_params FROM history
WHERE imgid = ? AND operation = ?
ORDER BY num DESC LIMIT 1
]], { tonumber(imgid), operation })
return row and row.op_params or nil
end
-- Return the single active history entry for one operation.
function M.get_module(imgid, operation)
local row = query_one([[
SELECT h.operation, h.module, h.enabled, h.op_params,
h.multi_name, h.multi_priority, h.num
FROM history h
WHERE h.imgid = ? AND h.operation = ?
ORDER BY h.num DESC LIMIT 1
]], { tonumber(imgid), operation })
if not row then return nil end
return history_row_to_module(row)
end
-- Update the active history entry for an operation.
-- `new_blob` (string|nil) – new binary op_params; nil to leave unchanged
-- `new_enabled` (bool|nil) – new enabled flag; nil to leave unchanged
-- Returns true on success, or false + error message.
function M.set_module_params(imgid, operation, new_enabled, new_blob)
-- Find the current latest entry and its num.
local row = query_one([[
SELECT num, module, op_params, enabled, blendop_params, blendop_version,
multi_priority, multi_name, multi_name_hand_edited
FROM history
WHERE imgid = ? AND operation = ?
ORDER BY num DESC LIMIT 1
]], { tonumber(imgid), operation })
if not row then
return false, "module '" .. operation .. "' not found in history for image " .. imgid
end
local d = get_db_rw()
-- Find the global max num for this image so we can insert after it.
local max_row = query_one("SELECT MAX(num) AS m FROM history WHERE imgid=?", {tonumber(imgid)})
local new_num = (max_row and max_row.m or 0) + 1
local enabled_val = (new_enabled == nil) and row.enabled or (new_enabled and 1 or 0)
local blob_val = new_blob or row.op_params
-- Insert a new history entry (preserves darktable's undo history).
local sql = [[
INSERT INTO history
(imgid, num, module, operation, op_params, enabled,
blendop_params, blendop_version, multi_priority, multi_name, multi_name_hand_edited)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]]
local stmt = d:prepare(sql)
if not stmt then
return false, "SQL prepare failed: " .. (d:errmsg() or "?")
end
stmt:bind_values(
tonumber(imgid), new_num, row.module, operation, blob_val, enabled_val,
row.blendop_params, row.blendop_version,
row.multi_priority, row.multi_name, row.multi_name_hand_edited
)
local rc = stmt:step()
stmt:finalize()
if rc ~= sqlite3.DONE then
return false, "INSERT failed (rc=" .. tostring(rc) .. "): " .. (d:errmsg() or "?")
end
return true
end
return M

View File

@@ -1,155 +0,0 @@
-- Minimal JSON encoder/decoder (pure Lua, no dependencies)
local M = {}
-- ── Encoder ──────────────────────────────────────────────────────────────────
-- Iterate byte-by-byte to avoid null-byte issues in Lua pattern ranges.
local function encode_str(s)
local buf = {'"'}
for i = 1, #s do
local b = s:byte(i)
if b == 34 then buf[#buf+1] = '\\"'
elseif b == 92 then buf[#buf+1] = '\\\\'
elseif b == 10 then buf[#buf+1] = '\\n'
elseif b == 13 then buf[#buf+1] = '\\r'
elseif b == 9 then buf[#buf+1] = '\\t'
elseif b < 32 or b == 127 then
buf[#buf+1] = string.format('\\u%04x', b)
else
buf[#buf+1] = s:sub(i, i)
end
end
buf[#buf+1] = '"'
return table.concat(buf)
end
local function enc(v, depth)
if depth > 64 then error("json.encode: table too deeply nested") end
local t = type(v)
if t == "nil" then return "null"
elseif t == "boolean" then return tostring(v)
elseif t == "number" then
if v ~= v or math.abs(v) == math.huge then return "null" end
return v == math.floor(v) and string.format("%d", v) or string.format("%.10g", v)
elseif t == "string" then
return encode_str(v)
elseif t == "table" then
-- Treat as array when it has a consecutive integer key sequence from 1
if #v > 0 then
local parts = {}
for i = 1, #v do parts[i] = enc(v[i], depth + 1) end
return "[" .. table.concat(parts, ",") .. "]"
end
local parts = {}
for k, x in pairs(v) do
if type(k) == "string" then
parts[#parts+1] = encode_str(k) .. ":" .. enc(x, depth + 1)
end
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
function M.encode(v) return enc(v, 0) end
-- ── Decoder ───────────────────────────────────────────────────────────────────
function M.decode(src)
local pos = 1
local function skip()
while pos <= #src and src:byte(pos) <= 32 do pos = pos + 1 end
end
local parse -- forward declaration
local function parse_string()
pos = pos + 1 -- consume opening "
local buf = {}
while pos <= #src do
local c = src:sub(pos, pos)
if c == '"' then pos = pos + 1; return table.concat(buf) end
if c == '\\' then
pos = pos + 1
c = src:sub(pos, pos)
if c == '"' then buf[#buf+1] = '"'
elseif c == '\\' then buf[#buf+1] = '\\'
elseif c == '/' then buf[#buf+1] = '/'
elseif c == 'n' then buf[#buf+1] = '\n'
elseif c == 'r' then buf[#buf+1] = '\r'
elseif c == 't' then buf[#buf+1] = '\t'
elseif c == 'b' then buf[#buf+1] = '\b'
elseif c == 'f' then buf[#buf+1] = '\f'
elseif c == 'u' then
local hex = tonumber(src:sub(pos+1, pos+4), 16) or 0
-- Only emit ASCII; multi-byte unicode emits a placeholder
buf[#buf+1] = hex < 128 and string.char(hex) or '\xEF\xBF\xBD'
pos = pos + 4
end
else
buf[#buf+1] = c
end
pos = pos + 1
end
error("json.decode: unterminated string near pos " .. pos)
end
local function parse_object()
pos = pos + 1 -- consume '{'
local obj = {}
skip()
if src:sub(pos, pos) == '}' then pos = pos + 1; return obj end
while true do
skip()
local key = parse_string()
skip()
if src:sub(pos, pos) ~= ':' then error("json.decode: expected ':' near pos " .. pos) end
pos = pos + 1
skip()
obj[key] = parse()
skip()
local ch = src:sub(pos, pos); pos = pos + 1
if ch == '}' then return obj end
if ch ~= ',' then error("json.decode: expected ',' or '}' near pos " .. pos) end
end
end
local function parse_array()
pos = pos + 1 -- consume '['
local arr = {}
skip()
if src:sub(pos, pos) == ']' then pos = pos + 1; return arr end
while true do
skip()
arr[#arr+1] = parse()
skip()
local ch = src:sub(pos, pos); pos = pos + 1
if ch == ']' then return arr end
if ch ~= ',' then error("json.decode: expected ',' or ']' near pos " .. pos) end
end
end
parse = function()
skip()
local c = src:sub(pos, pos)
if c == '"' then return parse_string()
elseif c == '{' then return parse_object()
elseif c == '[' then return parse_array()
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
else
-- Parentheses create the capture; without them find returns only start/end.
local _, e, n = src:find("^(-?%d+%.?%d*[eE]?[+-]?%d*)", pos)
if n then pos = e + 1; return tonumber(n) end
error("json.decode: unexpected token '" .. c .. "' at pos " .. pos)
end
end
local ok, result = pcall(parse)
if not ok then return nil, result end
return result
end
return M

View File

@@ -1,502 +0,0 @@
-- Module parameter decoder / encoder for the darktable REST API.
-- Runs in the LuaJIT server process. Uses FFI to unpack and repack the
-- binary op_params blobs stored in the library.db history table.
--
-- Each struct definition mirrors the darktable 4.6 iop C struct exactly so
-- that sizeof() matches the byte count in the database. The `module` column
-- in the history table carries the version number stored for that row.
--
-- Unknown modules (or unsupported versions) pass through as a base64 string.
local ffi = require "ffi"
local base64 = require "base64"
-- ── C struct definitions ───────────────────────────────────────────────────────
ffi.cdef[[
/* --- flip (ver=2, 4 bytes) --- */
typedef struct { int32_t orientation; } dt_flip_v2_t;
/* --- gamma / display encoding (ver=1, 8 bytes) --- */
typedef struct { float gamma; float linear; } dt_gamma_v1_t;
/* --- temperature / white balance (ver=3, 16 bytes) --- */
typedef struct { float red; float green; float blue; float various; } dt_temperature_v3_t;
/* --- exposure (ver=7, 28 bytes) --- */
typedef struct {
int32_t mode;
float black;
float exposure;
float deflicker_percentile;
float deflicker_target_level;
int32_t compensate_exposure_bias;
int32_t compensate_hilite_pres;
} dt_exposure_v7_t;
/* --- rawprepare (ver=1, 28 bytes) --- */
typedef struct {
int32_t left; int32_t top; int32_t right; int32_t bottom;
uint16_t raw_black_level_separate[4];
uint16_t raw_white_point;
uint16_t _pad1;
} dt_rawprepare_v1_t;
/* --- rawprepare (ver=2, 32 bytes) --- */
typedef struct {
int32_t left; int32_t top; int32_t right; int32_t bottom;
uint16_t raw_black_level_separate[4];
uint16_t raw_white_point;
uint16_t _pad1;
int32_t flat_field;
} dt_rawprepare_v2_t;
/* --- demosaic (ver=3, 20 bytes) --- */
typedef struct {
int32_t green_eq;
float median_thrs;
int32_t color_smoothing;
int32_t demosaicing_method;
int32_t lmmse_refine;
} dt_demosaic_v3_t;
/* --- highlights (ver=4, 48 bytes) --- */
typedef struct {
int32_t mode;
float blendL;
float blendC;
float strength;
float clip;
float noise_level;
int32_t iterations;
int32_t scales;
float candidating;
float combine;
int32_t recovery;
float solid_color;
} dt_highlights_v4_t;
/* --- channelmixerrgb (ver=3, 160 bytes) --- */
typedef struct {
float red[4];
float green[4];
float blue[4];
float saturation[4];
float lightness[4];
float grey[4];
int32_t normalize_R;
int32_t normalize_G;
int32_t normalize_B;
int32_t normalize_sat;
int32_t normalize_light;
int32_t normalize_grey;
int32_t illuminant;
int32_t illum_fluo;
int32_t illum_led;
int32_t adaptation;
float x;
float y;
float temperature;
float gamut;
int32_t clip;
int32_t version;
} dt_channelmixerrgb_v3_t;
/* --- filmicrgb (ver=6, 116 bytes) --- */
typedef struct {
float grey_point_source;
float black_point_source;
float white_point_source;
float reconstruct_threshold;
float reconstruct_feather;
float reconstruct_bloom_vs_details;
float reconstruct_grey_vs_color;
float reconstruct_structure_vs_texture;
float security_factor;
float grey_point_target;
float black_point_target;
float white_point_target;
float output_power;
float latitude;
float contrast;
float saturation;
float balance;
float noise_level;
int32_t preserve_color;
int32_t version;
int32_t auto_hardness;
int32_t custom_grey;
int32_t high_quality_reconstruction;
int32_t noise_distribution;
int32_t shadows;
int32_t highlights;
int32_t compensate_icc_black;
int32_t spline_version;
int32_t enable_highlight_reconstruction;
} dt_filmicrgb_v6_t;
]]
-- ── Internal helpers ──────────────────────────────────────────────────────────
local function bool(v) return v ~= 0 end
-- Cast a Lua binary string into a pointer to ctype.
local function cast_blob(ctype, blob)
local buf = ffi.new("uint8_t[?]", #blob)
ffi.copy(buf, blob, #blob)
return ffi.cast(ctype, buf), buf -- return buf too so GC doesn't collect it
end
-- Read a 4-float array field from a cdata array into a Lua table.
local function farr(p, n)
n = n or 4
local t = {}
for i = 0, n-1 do t[i+1] = p[i] end
return t
end
-- Write a Lua table back into a cdata 4-float array.
local function set_farr(p, t, n)
n = n or 4
for i = 0, n-1 do
if t[i+1] ~= nil then p[i] = t[i+1] end
end
end
-- Apply a field-level patch: if `src[k]` exists, set `dst.k = src[k]`.
local function patch_fields(dst, src, ...)
for _, k in ipairs({...}) do
if src[k] ~= nil then dst[k] = src[k] end
end
end
-- Build a fresh binary blob by copying the existing one and applying changes.
local function make_blob(ctype, size, existing, apply_fn)
local buf = ffi.new("uint8_t[?]", size)
if existing then ffi.copy(buf, existing, size) end
local p = ffi.cast(ctype, buf)
apply_fn(p)
return ffi.string(buf, size)
end
-- ── Per-module decode / encode ────────────────────────────────────────────────
local function decode_flip(blob, ver)
if ver ~= 2 or #blob ~= 4 then return nil end
local p, _ = cast_blob("dt_flip_v2_t *", blob)
return { orientation = tonumber(p.orientation) }
end
local function encode_flip(blob, changes)
return make_blob("dt_flip_v2_t *", 4, blob, function(p)
if changes.orientation ~= nil then p.orientation = changes.orientation end
end)
end
-- ---------------------------------------------------------------------------
local function decode_gamma(blob, ver)
if ver ~= 1 or #blob ~= 8 then return nil end
local p, _ = cast_blob("dt_gamma_v1_t *", blob)
return { gamma = tonumber(p.gamma), linear = tonumber(p.linear) }
end
local function encode_gamma(blob, changes)
return make_blob("dt_gamma_v1_t *", 8, blob, function(p)
patch_fields(p, changes, "gamma", "linear")
end)
end
-- ---------------------------------------------------------------------------
local function decode_temperature(blob, ver)
if ver ~= 3 or #blob ~= 16 then return nil end
local p, _ = cast_blob("dt_temperature_v3_t *", blob)
return {
red = tonumber(p.red),
green = tonumber(p.green),
blue = tonumber(p.blue),
various = tonumber(p.various),
}
end
local function encode_temperature(blob, changes)
return make_blob("dt_temperature_v3_t *", 16, blob, function(p)
patch_fields(p, changes, "red", "green", "blue", "various")
end)
end
-- ---------------------------------------------------------------------------
local function decode_exposure(blob, ver)
if ver ~= 7 or #blob ~= 28 then return nil end
local p, _ = cast_blob("dt_exposure_v7_t *", blob)
return {
mode = tonumber(p.mode),
black = tonumber(p.black),
exposure = tonumber(p.exposure),
deflicker_percentile = tonumber(p.deflicker_percentile),
deflicker_target_level = tonumber(p.deflicker_target_level),
compensate_exposure_bias = bool(p.compensate_exposure_bias),
compensate_hilite_pres = bool(p.compensate_hilite_pres),
}
end
local function encode_exposure(blob, changes)
return make_blob("dt_exposure_v7_t *", 28, blob, function(p)
patch_fields(p, changes, "mode", "black", "exposure",
"deflicker_percentile", "deflicker_target_level")
if changes.compensate_exposure_bias ~= nil then
p.compensate_exposure_bias = changes.compensate_exposure_bias and 1 or 0
end
if changes.compensate_hilite_pres ~= nil then
p.compensate_hilite_pres = changes.compensate_hilite_pres and 1 or 0
end
end)
end
-- ---------------------------------------------------------------------------
local function decode_rawprepare(blob, ver)
if ver == 1 and #blob == 28 then
local p, _ = cast_blob("dt_rawprepare_v1_t *", blob)
return {
left = tonumber(p.left), top = tonumber(p.top),
right = tonumber(p.right), bottom = tonumber(p.bottom),
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
raw_white_point = tonumber(p.raw_white_point),
}
elseif ver == 2 and #blob == 32 then
local p, _ = cast_blob("dt_rawprepare_v2_t *", blob)
return {
left = tonumber(p.left), top = tonumber(p.top),
right = tonumber(p.right), bottom = tonumber(p.bottom),
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
raw_white_point = tonumber(p.raw_white_point),
flat_field = tonumber(p.flat_field),
}
end
return nil
end
local function encode_rawprepare(blob, changes, ver)
if ver == 1 then
return make_blob("dt_rawprepare_v1_t *", 28, blob, function(p)
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point")
if changes.raw_black_level_separate then
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
end
end)
else
return make_blob("dt_rawprepare_v2_t *", 32, blob, function(p)
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point", "flat_field")
if changes.raw_black_level_separate then
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
end
end)
end
end
-- ---------------------------------------------------------------------------
local function decode_demosaic(blob, ver)
if ver ~= 3 or #blob ~= 20 then return nil end
local p, _ = cast_blob("dt_demosaic_v3_t *", blob)
return {
green_eq = tonumber(p.green_eq),
median_thrs = tonumber(p.median_thrs),
color_smoothing = tonumber(p.color_smoothing),
demosaicing_method = tonumber(p.demosaicing_method),
lmmse_refine = tonumber(p.lmmse_refine),
}
end
local function encode_demosaic(blob, changes)
return make_blob("dt_demosaic_v3_t *", 20, blob, function(p)
patch_fields(p, changes, "green_eq", "median_thrs", "color_smoothing",
"demosaicing_method", "lmmse_refine")
end)
end
-- ---------------------------------------------------------------------------
local function decode_highlights(blob, ver)
if ver ~= 4 or #blob ~= 48 then return nil end
local p, _ = cast_blob("dt_highlights_v4_t *", blob)
return {
mode = tonumber(p.mode),
blendL = tonumber(p.blendL),
blendC = tonumber(p.blendC),
strength = tonumber(p.strength),
clip = tonumber(p.clip),
noise_level = tonumber(p.noise_level),
iterations = tonumber(p.iterations),
scales = tonumber(p.scales),
candidating = tonumber(p.candidating),
combine = tonumber(p.combine),
recovery = tonumber(p.recovery),
solid_color = tonumber(p.solid_color),
}
end
local function encode_highlights(blob, changes)
return make_blob("dt_highlights_v4_t *", 48, blob, function(p)
patch_fields(p, changes, "mode", "blendL", "blendC", "strength", "clip",
"noise_level", "iterations", "scales", "candidating",
"combine", "recovery", "solid_color")
end)
end
-- ---------------------------------------------------------------------------
local function decode_channelmixerrgb(blob, ver)
if ver ~= 3 or #blob ~= 160 then return nil end
local p, _ = cast_blob("dt_channelmixerrgb_v3_t *", blob)
return {
red = farr(p.red),
green = farr(p.green),
blue = farr(p.blue),
saturation = farr(p.saturation),
lightness = farr(p.lightness),
grey = farr(p.grey),
normalize_R = bool(p.normalize_R),
normalize_G = bool(p.normalize_G),
normalize_B = bool(p.normalize_B),
normalize_sat = bool(p.normalize_sat),
normalize_light = bool(p.normalize_light),
normalize_grey = bool(p.normalize_grey),
illuminant = tonumber(p.illuminant),
illum_fluo = tonumber(p.illum_fluo),
illum_led = tonumber(p.illum_led),
adaptation = tonumber(p.adaptation),
x = tonumber(p.x),
y = tonumber(p.y),
temperature = tonumber(p.temperature),
gamut = tonumber(p.gamut),
clip = bool(p.clip),
version = tonumber(p.version),
}
end
local function encode_channelmixerrgb(blob, changes)
return make_blob("dt_channelmixerrgb_v3_t *", 160, blob, function(p)
for _, k in ipairs({"red","green","blue","saturation","lightness","grey"}) do
if changes[k] then set_farr(p[k], changes[k], 4) end
end
for _, k in ipairs({"normalize_R","normalize_G","normalize_B",
"normalize_sat","normalize_light","normalize_grey","clip"}) do
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
end
patch_fields(p, changes, "illuminant","illum_fluo","illum_led","adaptation",
"x","y","temperature","gamut","version")
end)
end
-- ---------------------------------------------------------------------------
local function decode_filmicrgb(blob, ver)
if ver ~= 6 or #blob ~= 116 then return nil end
local p, _ = cast_blob("dt_filmicrgb_v6_t *", blob)
return {
grey_point_source = tonumber(p.grey_point_source),
black_point_source = tonumber(p.black_point_source),
white_point_source = tonumber(p.white_point_source),
reconstruct_threshold = tonumber(p.reconstruct_threshold),
reconstruct_feather = tonumber(p.reconstruct_feather),
reconstruct_bloom_vs_details = tonumber(p.reconstruct_bloom_vs_details),
reconstruct_grey_vs_color = tonumber(p.reconstruct_grey_vs_color),
reconstruct_structure_vs_texture = tonumber(p.reconstruct_structure_vs_texture),
security_factor = tonumber(p.security_factor),
grey_point_target = tonumber(p.grey_point_target),
black_point_target = tonumber(p.black_point_target),
white_point_target = tonumber(p.white_point_target),
output_power = tonumber(p.output_power),
latitude = tonumber(p.latitude),
contrast = tonumber(p.contrast),
saturation = tonumber(p.saturation),
balance = tonumber(p.balance),
noise_level = tonumber(p.noise_level),
preserve_color = tonumber(p.preserve_color),
version = tonumber(p.version),
auto_hardness = bool(p.auto_hardness),
custom_grey = bool(p.custom_grey),
high_quality_reconstruction = tonumber(p.high_quality_reconstruction),
noise_distribution = tonumber(p.noise_distribution),
shadows = tonumber(p.shadows),
highlights = tonumber(p.highlights),
compensate_icc_black = bool(p.compensate_icc_black),
spline_version = tonumber(p.spline_version),
enable_highlight_reconstruction = bool(p.enable_highlight_reconstruction),
}
end
local function encode_filmicrgb(blob, changes)
return make_blob("dt_filmicrgb_v6_t *", 116, blob, function(p)
patch_fields(p, changes,
"grey_point_source","black_point_source","white_point_source",
"reconstruct_threshold","reconstruct_feather",
"reconstruct_bloom_vs_details","reconstruct_grey_vs_color",
"reconstruct_structure_vs_texture","security_factor",
"grey_point_target","black_point_target","white_point_target",
"output_power","latitude","contrast","saturation","balance","noise_level",
"preserve_color","version","high_quality_reconstruction",
"noise_distribution","shadows","highlights","spline_version")
for _, k in ipairs({"auto_hardness","custom_grey","compensate_icc_black",
"enable_highlight_reconstruction"}) do
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
end
end)
end
-- ── Dispatch tables ───────────────────────────────────────────────────────────
local DECODERS = {
flip = decode_flip,
gamma = decode_gamma,
temperature = decode_temperature,
exposure = decode_exposure,
rawprepare = decode_rawprepare,
demosaic = decode_demosaic,
highlights = decode_highlights,
channelmixerrgb = decode_channelmixerrgb,
filmicrgb = decode_filmicrgb,
}
local ENCODERS = {
flip = encode_flip,
gamma = encode_gamma,
temperature = encode_temperature,
exposure = encode_exposure,
rawprepare = function(b, c, v) return encode_rawprepare(b, c, v) end,
demosaic = encode_demosaic,
highlights = encode_highlights,
channelmixerrgb = encode_channelmixerrgb,
filmicrgb = encode_filmicrgb,
}
-- ── Public API ────────────────────────────────────────────────────────────────
local M = {}
-- Decode a binary op_params blob for `operation` (ver = history.module column).
-- Returns a Lua table of named fields, or nil if the operation/version is unknown.
function M.decode(operation, ver, blob)
local fn = DECODERS[operation]
if not fn then return nil end
local ok, result = pcall(fn, blob, ver)
if ok then return result end
return nil
end
-- Apply `changes` (a table of field overrides) to `blob` and return the
-- modified binary string. Returns nil if the operation is not known.
-- For unknown modules, the caller should pass params_b64 directly.
function M.encode(operation, ver, blob, changes)
local fn = ENCODERS[operation]
if not fn then return nil end
local ok, result = pcall(fn, blob, changes, ver)
if ok then return result end
return nil
end
return M

View File

@@ -1,131 +0,0 @@
-- Serve image previews from darktable's mipmap cache.
--
-- Darktable stores mipmaps as individual JPEG files at:
-- <cache_dir>/mipmaps-<sha1_of_library_realpath>.d/<mip_level>/<imgid>.jpg
--
-- Mip levels (approximate pixel sizes on the long edge):
-- 0 → ~67 px 1 → ~135 px 2 → ~270 px 3 → ~540 px
-- 4 → ~1080 px (good "medium") 5 → ~2160 px
--
-- If no cached mipmap is found we fall back to exiftool (embedded EXIF
-- thumbnail) and finally to serving the original file path as a redirect.
local ffi = require "ffi"
local config = require "config"
ffi.cdef[[
unsigned char *SHA1(const unsigned char *d, size_t n, unsigned char *md);
]]
local crypto = ffi.load("crypto")
local M = {}
-- ── Locate the mipmap cache directory (computed once) ─────────────────────────
local _mipmap_base -- e.g. "/home/user/.cache/darktable/mipmaps-abc123"
local function mipmap_base()
if _mipmap_base then return _mipmap_base end
-- Resolve the real path of library.db (darktable uses realpath before hashing)
local h = io.popen("realpath " .. config.library_db .. " 2>/dev/null")
local realpath = h:read("*l"); h:close()
realpath = realpath or config.library_db
-- SHA1 hex of the realpath (same algorithm as mipmap_cache.c)
local md = ffi.new("unsigned char[20]")
crypto.SHA1(realpath, #realpath, md)
local hex = {}
for i = 0, 19 do hex[i+1] = string.format("%02x", md[i]) end
local sha1hex = table.concat(hex)
_mipmap_base = config.cache_dir .. "/mipmaps-" .. sha1hex
return _mipmap_base
end
-- ── Size → mip level mapping ──────────────────────────────────────────────────
-- size param: "thumb" (~135 px), "small" (~270 px), "medium" (~540 px),
-- "large" (~1080 px), "xlarge" (~2160 px), or an integer level 0-5.
local SIZE_TO_MIP = {
thumb = 1,
small = 2,
medium = 3,
large = 4,
xlarge = 5,
}
local function size_to_mip(size)
if not size or size == "" then return 3 end -- default: "medium"
local n = tonumber(size)
if n then return math.max(0, math.min(5, math.floor(n))) end
return SIZE_TO_MIP[size:lower()] or 3
end
-- ── Try to read a file, return nil on failure ─────────────────────────────────
local function read_file(path)
local f = io.open(path, "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
return data
end
-- ── Public: get preview data for an image id ──────────────────────────────────
--
-- Returns: data (string), content_type (string)
-- or: nil, nil, redirect_url (caller should issue a 302)
-- or: nil, nil, nil (404)
function M.get(image_id, image_path, filename, size_param)
local mip = size_to_mip(size_param)
-- 1. Walk mip levels: first downward (smaller = smaller file, faster),
-- then upward (larger mip levels, let the browser scale down).
-- This ensures we serve something even when only higher-resolution
-- mipmaps have been cached (e.g. darktable only wrote levels 2-4).
local base = mipmap_base()
for level = mip, 0, -1 do
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
local data = read_file(path)
if data then return data, "image/jpeg" end
end
for level = mip + 1, 5 do
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
local data = read_file(path)
if data then return data, "image/jpeg" end
end
-- 2. Try exiftool to extract the embedded EXIF thumbnail from the source file.
if image_path and filename then
local src = image_path .. "/" .. filename
-- exiftool writes the thumbnail to stdout with -b
local cmd = string.format("exiftool -b -ThumbnailImage %q 2>/dev/null", src)
local fh = io.popen(cmd)
if fh then
local data = fh:read("*a"); fh:close()
if data and #data > 100 then
return data, "image/jpeg"
end
end
end
-- 3. For JPEG/PNG source files redirect to the original (client caches it).
if image_path and filename then
local src = image_path .. "/" .. filename
local ext = filename:match("%.([^%.]+)$") or ""
if ext:lower():match("^jpe?g$") or ext:lower() == "png" then
return nil, nil, "file://" .. src -- caller issues redirect or reads directly
end
end
return nil, nil, nil -- no preview available
end
-- ── Helper: read a local file and serve it inline ─────────────────────────────
-- Used by the API handler when get() returns a file:// redirect.
function M.read_local(path)
local stripped = path:match("^file://(.+)$") or path
return read_file(stripped)
end
return M

View File

@@ -1,68 +0,0 @@
-- Simple radix-style HTTP router.
-- Supports static segments and named captures (:name).
-- Usage:
-- router.get("/api/v1/images", handler)
-- router.get("/api/v1/images/:id", handler)
-- router.post("/oauth/token", handler)
--
-- local handler, params = router.match("GET", "/api/v1/images/42")
-- -- handler = <function>, params = { id = "42" }
local M = {}
local routes = {} -- { method, pattern_parts, handler }
-- Convert a route pattern like "/api/v1/images/:id/preview" into a segment list.
local function compile(pattern)
local parts = {}
-- Strip leading slash so the segment list aligns with the path splitter below.
for seg in (pattern:gsub("^/", "") .. "/"):gmatch("([^/]*)/") do
if seg:sub(1,1) == ":" then
parts[#parts+1] = { capture = seg:sub(2) }
else
parts[#parts+1] = { literal = seg }
end
end
return parts
end
local function register(method, pattern, handler)
routes[#routes+1] = { method = method:upper(), parts = compile(pattern), handler = handler }
end
function M.get(pattern, handler) register("GET", pattern, handler) end
function M.post(pattern, handler) register("POST", pattern, handler) end
function M.put(pattern, handler) register("PUT", pattern, handler) end
function M.patch(pattern, handler) register("PATCH", pattern, handler) end
function M.delete(pattern, handler) register("DELETE", pattern, handler) end
-- Match a request against registered routes.
-- Returns: handler, path_params or nil, nil
function M.match(method, path)
-- Split path into segments
local segs = {}
for seg in (path:gsub("^/","") .. "/"):gmatch("([^/]*)/") do
segs[#segs+1] = seg
end
method = method:upper()
for _, route in ipairs(routes) do
if route.method == method and #route.parts == #segs then
local params = {}
local matched = true
for i, part in ipairs(route.parts) do
if part.capture then
params[part.capture] = segs[i]
elseif part.literal ~= segs[i] then
matched = false
break
end
end
if matched then return route.handler, params end
end
end
return nil, nil
end
return M

View File

@@ -1,280 +0,0 @@
#!/usr/bin/env luajit
-- Darktable REST API — standalone LuaJIT HTTP server
-- Usage: luajit server.lua [port] [host]
--
-- Dependencies (install via luarocks):
-- luasocket — luarocks install luasocket
-- lsqlite3 — luarocks install lsqlite3
-- libcrypto — provided by the system OpenSSL package
--
-- Quick-start:
-- export DT_CLIENT_ID=myclient DT_CLIENT_SECRET=mysecret DT_JWT_SECRET=longrandomstring
-- luajit server.lua
--
-- Then get a token:
-- curl -s -X POST http://127.0.0.1:7645/oauth/token \
-- -d 'grant_type=client_credentials&client_id=myclient&client_secret=mysecret'
--
-- And use it:
-- curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:7645/api/v1/images
-- ── Bootstrap: make local modules findable ────────────────────────────────────
local script_dir = debug.getinfo(1, "S").source:match("^@(.+)/[^/]+$") or "."
package.path = script_dir .. "/?.lua;" .. package.path
-- ── Load modules ──────────────────────────────────────────────────────────────
local socket = require "socket"
local config = require "config"
local router = require "router"
local api = require "api"
-- ── Static file server (PWA) ──────────────────────────────────────────────────
local WEB_BUILD = script_dir .. "/web/build"
local MIME = {
html = "text/html; charset=utf-8",
js = "application/javascript; charset=utf-8",
mjs = "application/javascript; charset=utf-8",
css = "text/css; charset=utf-8",
json = "application/json",
webmanifest = "application/manifest+json",
png = "image/png",
jpg = "image/jpeg",
jpeg = "image/jpeg",
svg = "image/svg+xml",
ico = "image/x-icon",
woff = "font/woff",
woff2 = "font/woff2",
txt = "text/plain; charset=utf-8",
map = "application/json",
}
-- Read a file from the web build directory. Returns data, content-type or nil.
-- Prevents directory traversal by rejecting any path containing "..".
local function read_static(req_path)
if req_path:find("%.%.", 1, true) then return nil end
-- Strip leading slash; normalise double-slashes.
local rel = req_path:gsub("^/+", ""):gsub("//+", "/")
local full = WEB_BUILD .. "/" .. rel
local f = io.open(full, "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
local ext = rel:match("%.([^%.]+)$") or ""
local ct = MIME[ext:lower()] or "application/octet-stream"
return data, ct
end
-- Cache headers: hashed assets get long TTL; everything else no-store.
local function cache_header(path)
-- Vite fingerprints assets with a hash in the filename (e.g. index-Bx3Rk.js)
if path:match("/assets/") then
return "public, max-age=31536000, immutable"
end
return "no-store"
end
-- ── Register routes ───────────────────────────────────────────────────────────
router.post("/oauth/token", api.oauth_token)
router.get("/health", api.health)
router.get("/api/v1/images", api.list_images)
router.get("/api/v1/images/:id", api.get_image)
router.get("/api/v1/images/:id/preview", api.get_preview)
router.get("/api/v1/images/:id/full", api.get_full)
router.post("/api/v1/images/:id/refresh", api.refresh_cache)
router.post("/api/v1/images/:id/export", api.trigger_export)
router.get("/api/v1/images/:id/modules", api.list_modules)
router.get("/api/v1/images/:id/modules/:op", api.get_module)
router.patch("/api/v1/images/:id/modules/:op", api.patch_module)
router.get("/api/v1/tags", api.list_tags)
router.get("/api/v1/films", api.list_films)
-- ── HTTP helpers ──────────────────────────────────────────────────────────────
local STATUS_TEXT = {
[200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content",
[301] = "Moved Permanently",
[400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden",
[404] = "Not Found", [405] = "Method Not Allowed",
[500] = "Internal Server Error", [504] = "Gateway Timeout",
}
local function url_decode(s)
return s:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
end
-- Parse the raw HTTP request from a connected socket.
-- Returns a request table, or nil on parse/timeout error.
local function read_request(client)
client:settimeout(10)
local line, lerr = client:receive("*l")
if not line then return nil end
local method, raw_path = line:match("^(%S+)%s+(%S+)%s+HTTP/")
if not method then return nil end
-- Headers
local headers = {}
while true do
local hline, herr = client:receive("*l")
if not hline or hline == "" then break end
local name, value = hline:match("^([^:]+):%s*(.*)$")
if name then
-- normalise to lowercase, strip trailing whitespace
headers[name:lower():gsub("%s+$", "")] = value:gsub("%s+$", "")
end
end
-- Body
local body = ""
local clen = tonumber(headers["content-length"])
if clen and clen > 0 then
body = client:receive(math.min(clen, 4 * 1024 * 1024)) or "" -- 4 MiB cap
end
-- Split path from query string
local path, qs = raw_path:match("^([^?]*)%??(.*)")
-- Parse query parameters
local params = {}
for k, v in (qs or ""):gmatch("([^&=]+)=([^&]*)") do
params[url_decode(k)] = url_decode(v)
end
return {
method = method:upper(),
path = path,
raw_path = raw_path,
headers = headers,
body = body,
params = params,
path_params = {},
}
end
local CORS_HEADERS = {
["Access-Control-Allow-Origin"] = "*",
["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS",
["Access-Control-Allow-Headers"] = "Authorization, Content-Type",
["Access-Control-Max-Age"] = "86400",
}
-- Write an HTTP response to the socket.
local function write_response(client, status, body, content_type, extra_headers)
body = body or ""
content_type = content_type or "application/json; charset=utf-8"
local phrase = STATUS_TEXT[status] or "Unknown"
local lines = {
string.format("HTTP/1.1 %d %s\r\n", status, phrase),
"Server: darktable-api/1.0\r\n",
"Content-Type: " .. content_type .. "\r\n",
"Content-Length: " .. #body .. "\r\n",
"Connection: close\r\n",
}
for k, v in pairs(CORS_HEADERS) do
lines[#lines+1] = k .. ": " .. v .. "\r\n"
end
if extra_headers then
for k, v in pairs(extra_headers) do
lines[#lines+1] = k .. ": " .. v .. "\r\n"
end
end
lines[#lines+1] = "\r\n"
lines[#lines+1] = body
client:send(table.concat(lines))
end
local json = require "json"
local function handle_client(client)
local req = read_request(client)
if not req then
write_response(client, 400, '{"error":"bad_request"}')
return
end
-- CORS preflight
if req.method == "OPTIONS" then
write_response(client, 204, "")
return
end
-- API / OAuth routes take priority over static files.
local handler, path_params = router.match(req.method, req.path)
if handler then
req.path_params = path_params or {}
local status, body, ct, extra = handler(req)
write_response(client, status, body, ct, extra)
return
end
-- Static file serving for the PWA (GET/HEAD only).
if req.method == "GET" or req.method == "HEAD" then
local data, ct = read_static(req.path)
if not data then
-- SPA fallback: any unknown path gets index.html so client-side routing works.
data, ct = read_static("/index.html")
end
if data then
local extra = { ["Cache-Control"] = cache_header(req.path) }
write_response(client, 200, req.method == "HEAD" and "" or data, ct, extra)
return
end
end
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
end
-- ── Main server loop ──────────────────────────────────────────────────────────
local port = tonumber(arg and arg[1]) or config.port
local host = (arg and arg[2]) or config.host
local server, serr = socket.bind(host, port)
if not server then
io.stderr:write("darktable-api: cannot bind " .. host .. ":" .. port .. ": " .. tostring(serr) .. "\n")
os.exit(1)
end
server:settimeout(0) -- non-blocking accept
io.write(string.format(
"darktable-api: listening on http://%s:%d\n" ..
" library : %s\n" ..
" cache : %s\n" ..
" web app : http://%s:%d/\n",
host, port, config.library_db, config.cache_dir, host, port))
io.flush()
while true do
local client, cerr = server:accept()
if client then
local ok, result = pcall(handle_client, client)
if not ok then
pcall(write_response, client, 500,
json.encode({ error = "internal_server_error", detail = tostring(result) }))
io.stderr:write("darktable-api: error: " .. tostring(result) .. "\n")
end
pcall(function() client:close() end)
else
-- No connection yet — yield CPU briefly.
socket.select(nil, nil, 0.05)
end
end

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env bash
# start-with-api.sh — Launch the darktable REST API server and darktable together.
#
# What it does:
# 1. Optionally configures darktable's luarc to load the bridge plugin.
# 2. Starts the standalone LuaJIT REST API server in the background.
# 3. Starts darktable in the foreground.
# 4. Stops the API server when darktable exits.
#
# Usage:
# ./start-with-api.sh [darktable-options...]
#
# Environment variables (all optional):
# DT_CLIENT_ID OAuth client ID (default: darktable-client)
# DT_CLIENT_SECRET OAuth client secret (default: changeme)
# DT_JWT_SECRET JWT signing secret (default: change-this-secret-in-production)
# DT_API_HOST Server bind address (default: 127.0.0.1)
# DT_API_PORT Server port (default: 7645)
# DT_LIBRARY darktable library DB (default: ~/.config/darktable/library.db)
# DT_REFRESH_QUEUE IPC queue file (default: /tmp/dt-api-refresh.queue)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
: "${DT_CLIENT_ID:=darktable-client}"
: "${DT_CLIENT_SECRET:=changeme}"
: "${DT_JWT_SECRET:=change-this-secret-in-production}"
: "${DT_API_HOST:=127.0.0.1}"
: "${DT_API_PORT:=7645}"
: "${DT_REFRESH_QUEUE:=/tmp/dt-api-refresh.queue}"
export DT_CLIENT_ID DT_CLIENT_SECRET DT_JWT_SECRET
export DT_API_HOST DT_API_PORT DT_REFRESH_QUEUE
# ── 1. Ensure the bridge plugin is in darktable's luarc ───────────────────────
LUARC="$HOME/.config/darktable/luarc"
BRIDGE_REQUIRE='require "contrib/darktable-api/darktable-api-bridge"'
if ! grep -qF "$BRIDGE_REQUIRE" "$LUARC" 2>/dev/null; then
echo "[start-with-api] Adding bridge plugin to $LUARC"
echo "$BRIDGE_REQUIRE" >> "$LUARC"
fi
# ── 2. Install the bridge plugin into darktable's lua directory ───────────────
# darktable searches $HOME/.config/darktable/lua/ for scripts.
# We copy (not symlink) the bridge plugin file there so darktable can
# require "contrib/darktable-api/darktable-api-bridge" without any extra path config.
DT_LUA_DIR="$HOME/.config/darktable/lua"
BRIDGE_DEST="$DT_LUA_DIR/contrib/darktable-api"
BRIDGE_SRC="$SCRIPT_DIR/darktable-api-bridge.lua"
mkdir -p "$BRIDGE_DEST"
if ! cmp -s "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua" 2>/dev/null; then
cp "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua"
echo "[start-with-api] Installed bridge plugin → $BRIDGE_DEST/"
fi
# ── 3. Clean up any stale queue file ─────────────────────────────────────────
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
# ── 4. Start the LuaJIT REST API server ───────────────────────────────────────
API_LOG="${TMPDIR:-/tmp}/darktable-api.log"
echo "[start-with-api] Starting API server on http://${DT_API_HOST}:${DT_API_PORT}"
echo "[start-with-api] Log: $API_LOG"
luajit "$SCRIPT_DIR/server.lua" "$DT_API_PORT" "$DT_API_HOST" >"$API_LOG" 2>&1 &
API_PID=$!
# Give the server a moment to bind and confirm it's up.
sleep 0.5
if ! kill -0 "$API_PID" 2>/dev/null; then
echo "[start-with-api] ERROR: API server failed to start. Check $API_LOG" >&2
cat "$API_LOG" >&2
exit 1
fi
if ! curl -sf "http://${DT_API_HOST}:${DT_API_PORT}/health" >/dev/null 2>&1; then
echo "[start-with-api] WARNING: health check failed; server may still be starting"
fi
echo "[start-with-api] API server running (PID $API_PID)"
echo ""
echo " Get a token:"
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/oauth/token \\"
echo " -d 'grant_type=client_credentials&client_id=${DT_CLIENT_ID}&client_secret=${DT_CLIENT_SECRET}'"
echo ""
echo " Trigger a cache refresh (replace TOKEN and IMAGE_ID):"
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/api/v1/images/IMAGE_ID/refresh \\"
echo " -H 'Authorization: Bearer TOKEN'"
echo ""
# ── 5. Start darktable ────────────────────────────────────────────────────────
echo "[start-with-api] Starting darktable..."
darktable "$@" || true
# ── 6. Stop the API server on exit ───────────────────────────────────────────
echo ""
echo "[start-with-api] darktable exited; stopping API server (PID $API_PID)"
kill "$API_PID" 2>/dev/null || true
wait "$API_PID" 2>/dev/null || true
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
echo "[start-with-api] done"

View File

@@ -1,4 +0,0 @@
node_modules/
build/
dist/
.vite/

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d0d" />
<meta name="description" content="darktable REST API web interface" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="darktable" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>darktable Gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"name": "darktable-api-pwa",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11",
"vite-plugin-pwa": "^0.21.1"
}
}

View File

@@ -1,28 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Dark background -->
<rect width="512" height="512" rx="80" fill="#0d0d0d"/>
<!-- Aperture blades in orange accent -->
<!-- Center circle -->
<circle cx="256" cy="256" r="60" fill="none" stroke="#f4a22d" stroke-width="8"/>
<!-- Aperture blade 1 -->
<path d="M256 120 L296 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 2 -->
<path d="M370 181 L316 240 L296 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 3 -->
<path d="M392 296 L316 240 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 4 -->
<path d="M336 392 L256 316 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 5 -->
<path d="M176 392 L196 316 L256 316 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 6 -->
<path d="M120 296 L196 316 L176 240 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 7 -->
<path d="M120 181 L176 240 L196 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Aperture blade 8 -->
<path d="M216 120 L216 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
<!-- Outer ring -->
<circle cx="256" cy="256" r="180" fill="none" stroke="#f4a22d" stroke-width="12" opacity="0.6"/>
<!-- Inner highlight -->
<circle cx="256" cy="256" r="40" fill="#f4a22d" opacity="0.3"/>
<circle cx="256" cy="256" r="20" fill="#f4a22d"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,69 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
} from 'react';
import { ToastProvider } from './components/Toast.jsx';
import { getSettings } from './api.js';
import Settings from './screens/Settings.jsx';
import Gallery from './screens/Gallery.jsx';
// ── App Context ───────────────────────────────────────────────────────────────
const AppContext = createContext(null);
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppContext');
return ctx;
}
const initialState = {
screen: 'gallery', // 'settings' | 'gallery'
configured: false,
};
function reducer(state, action) {
switch (action.type) {
case 'INIT':
return { ...state, configured: action.configured, screen: action.configured ? 'gallery' : 'settings' };
case 'GO_SETTINGS':
return { ...state, screen: 'settings' };
case 'GO_GALLERY':
return { ...state, screen: 'gallery', configured: true };
default:
return state;
}
}
// ── App Root ──────────────────────────────────────────────────────────────────
export default function App() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const settings = getSettings();
dispatch({ type: 'INIT', configured: !!settings });
}, []);
const goSettings = useCallback(() => dispatch({ type: 'GO_SETTINGS' }), []);
const goGallery = useCallback(() => dispatch({ type: 'GO_GALLERY' }), []);
const contextValue = { goSettings, goGallery, configured: state.configured };
return (
<AppContext.Provider value={contextValue}>
<ToastProvider>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{state.screen === 'settings' ? (
<Settings />
) : (
<Gallery />
)}
</div>
</ToastProvider>
</AppContext.Provider>
);
}

View File

@@ -1,353 +0,0 @@
// Darktable REST API client
// Handles OAuth2 client_credentials, token caching, all endpoints.
const STORAGE_KEYS = {
SETTINGS: 'dt_settings',
TOKEN: 'dt_token',
};
// ── Settings ─────────────────────────────────────────────────────────────────
export function getSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function saveSettings(settings) {
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
}
export function clearSettings() {
localStorage.removeItem(STORAGE_KEYS.SETTINGS);
localStorage.removeItem(STORAGE_KEYS.TOKEN);
}
// ── Token management ──────────────────────────────────────────────────────────
function getCachedToken() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.TOKEN);
if (!raw) return null;
const token = JSON.parse(raw);
// Treat as expired 30 seconds early to avoid race conditions
if (Date.now() >= token.expiresAt - 30_000) return null;
return token.accessToken;
} catch {
return null;
}
}
function cacheToken(accessToken, expiresIn) {
const token = {
accessToken,
expiresAt: Date.now() + expiresIn * 1000,
};
localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
}
let tokenFetchPromise = null;
async function fetchToken(settings) {
const url = `${settings.baseUrl}/oauth/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: settings.clientId,
client_secret: settings.clientSecret,
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, `Token request failed: ${text || res.statusText}`);
}
const data = await res.json();
const expiresIn = data.expires_in ?? 3600;
cacheToken(data.access_token, expiresIn);
return data.access_token;
}
async function getToken() {
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
const cached = getCachedToken();
if (cached) return cached;
// Deduplicate concurrent token fetches
if (!tokenFetchPromise) {
tokenFetchPromise = fetchToken(settings).finally(() => {
tokenFetchPromise = null;
});
}
return tokenFetchPromise;
}
// ── Error class ───────────────────────────────────────────────────────────────
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
// ── Core fetch helper ─────────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
const token = await getToken();
const url = `${settings.baseUrl}${path}`;
const headers = {
Authorization: `Bearer ${token}`,
...options.headers,
};
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const text = await res.text().catch(() => '');
let message;
try {
const json = JSON.parse(text);
message = json.error || json.message || text;
} catch {
message = text || res.statusText;
}
throw new ApiError(res.status, message);
}
return res;
}
async function apiJson(path, options = {}) {
const res = await apiFetch(path, {
...options,
headers: { Accept: 'application/json', ...options.headers },
});
return res.json();
}
// ── Blob URL cache ────────────────────────────────────────────────────────────
// Keeps a reference-counted map of blob URLs so we don't recreate them for the
// same image+size combination within a session.
const blobCache = new Map(); // key → { url, refs }
export function releaseBlobUrl(key) {
const entry = blobCache.get(key);
if (!entry) return;
entry.refs -= 1;
if (entry.refs <= 0) {
URL.revokeObjectURL(entry.url);
blobCache.delete(key);
}
}
export async function getPreviewBlob(id, size = 'thumb') {
const key = `preview:${id}:${size}`;
const existing = blobCache.get(key);
if (existing) {
existing.refs += 1;
return { url: existing.url, key };
}
// Use raw fetch so we can inspect the status before assuming it's an image.
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured.');
const token = await getToken();
const res = await fetch(
`${settings.baseUrl}/api/v1/images/${id}/preview?size=${size}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
// 202 means the server has enqueued thumbnail generation; client should retry.
if (res.status === 202) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '4', 10);
return { generating: true, retryAfter };
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || res.statusText);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobCache.set(key, { url, refs: 1 });
return { url, key };
}
export async function getFullBlob(id) {
const key = `full:${id}`;
const existing = blobCache.get(key);
if (existing) {
existing.refs += 1;
return { url: existing.url, key };
}
const res = await apiFetch(`/api/v1/images/${id}/full`);
// May return 202 with Retry-After; caller handles polling
if (res.status === 202) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10);
return { pending: true, retryAfter };
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobCache.set(key, { url, refs: 1 });
return { url, key };
}
// ── Images ────────────────────────────────────────────────────────────────────
export async function listImages(params = {}) {
const qs = new URLSearchParams();
const allowed = [
'limit', 'offset', 'sort_by', 'sort_dir',
'rating_min', 'rating_max', 'film_id',
'from', 'to', 'search', 'tag',
];
for (const key of allowed) {
if (params[key] !== undefined && params[key] !== '' && params[key] !== null) {
qs.set(key, String(params[key]));
}
}
const path = `/api/v1/images${qs.toString() ? '?' + qs.toString() : ''}`;
return apiJson(path);
}
export async function getImage(id) {
return apiJson(`/api/v1/images/${id}`);
}
export async function refreshImage(id) {
return apiJson(`/api/v1/images/${id}/refresh`, { method: 'POST' });
}
export async function exportImage(id, { signal } = {}) {
// 1. Enqueue the export (POST returns 202 immediately).
await apiFetch(`/api/v1/images/${id}/export`, { method: 'POST', signal });
// 2. Poll GET /full until the JPEG is ready (up to 120 s).
const settings = getSettings();
const token = await getToken();
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
await new Promise(resolve => setTimeout(resolve, 2000));
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
const res = await fetch(`${settings.baseUrl}/api/v1/images/${id}/full`, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (res.ok) return res.blob();
if (res.status !== 202) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || res.statusText);
}
// 202 → still generating, loop again
}
throw new ApiError(504, 'Export timed out after 120 s');
}
// ── Modules ───────────────────────────────────────────────────────────────────
export async function listModules(imageId) {
return apiJson(`/api/v1/images/${imageId}/modules`);
}
export async function getModule(imageId, op) {
return apiJson(`/api/v1/images/${imageId}/modules/${op}`);
}
/**
* Patch a module and optionally request a preview render.
* Returns { preview: blobUrl } when previewSize is set and the server responds
* with image/jpeg, or { data: json } for plain JSON responses.
*
* The PATCH with preview can take up to 30 seconds.
*/
export async function patchModule(imageId, op, body, previewSize = null) {
const settings = getSettings();
if (!settings) throw new ApiError(0, 'Not configured.');
const token = await getToken();
let path = `/api/v1/images/${imageId}/modules/${op}`;
if (previewSize) path += `?preview=${previewSize}`;
const url = `${settings.baseUrl}${path}`;
const res = await fetch(url, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json, image/jpeg',
},
body: JSON.stringify(body),
// 35s timeout implemented via AbortController below
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || res.statusText);
}
const contentType = res.headers.get('Content-Type') ?? '';
if (contentType.includes('image/jpeg') || contentType.includes('image/')) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
return { previewUrl: url };
}
const data = await res.json();
return { data };
}
// ── Tags & Films ──────────────────────────────────────────────────────────────
export async function listTags() {
return apiJson('/api/v1/tags');
}
export async function listFilms() {
return apiJson('/api/v1/films');
}
// ── Health ────────────────────────────────────────────────────────────────────
export async function checkHealth(baseUrl) {
const url = `${baseUrl}/health`;
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new ApiError(res.status, `Health check failed: ${res.statusText}`);
return res.json();
}
/**
* Test the full connection: health check + token fetch.
* Returns { ok: true } or throws ApiError.
*/
export async function testConnection(settings) {
await checkHealth(settings.baseUrl);
await fetchToken(settings);
return { ok: true };
}

View File

@@ -1,235 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { listFilms, ApiError } from '../api.js';
const SORT_OPTIONS = [
{ value: 'datetime_taken:desc', label: 'Newest first' },
{ value: 'datetime_taken:asc', label: 'Oldest first' },
{ value: 'filename:asc', label: 'Filename A→Z' },
{ value: 'filename:desc', label: 'Filename Z→A' },
{ value: 'rating:desc', label: 'Top rated' },
];
const STAR_OPTIONS = [
{ value: '', label: 'Any rating' },
{ value: '1', label: '★ and up' },
{ value: '2', label: '★★ and up' },
{ value: '3', label: '★★★ and up' },
{ value: '4', label: '★★★★ and up' },
{ value: '5', label: '★★★★★ only' },
];
export default function FilterBar({ filters, onChange }) {
const [films, setFilms] = useState([]);
const [filmsError, setFilmsError] = useState(null);
const [expanded, setExpanded] = useState(false);
const searchRef = useRef(null);
const searchTimer = useRef(null);
useEffect(() => {
listFilms()
.then(data => setFilms(Array.isArray(data) ? data : (data.items ?? [])))
.catch(err => {
if (err instanceof ApiError && err.status === 0) return; // not configured yet
setFilmsError('Could not load film rolls');
});
}, []);
const handleSearchChange = useCallback((e) => {
const value = e.target.value;
// Debounce search by 400ms
clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
onChange({ ...filters, search: value, offset: 0 });
}, 400);
}, [filters, onChange]);
const handleSortChange = useCallback((e) => {
const [sort_by, sort_dir] = e.target.value.split(':');
onChange({ ...filters, sort_by, sort_dir, offset: 0 });
}, [filters, onChange]);
const handleRatingChange = useCallback((e) => {
onChange({ ...filters, rating_min: e.target.value, offset: 0 });
}, [filters, onChange]);
const handleFilmChange = useCallback((e) => {
onChange({ ...filters, film_id: e.target.value, offset: 0 });
}, [filters, onChange]);
const currentSort = `${filters.sort_by ?? 'datetime_taken'}:${filters.sort_dir ?? 'desc'}`;
const activeFilterCount = [
filters.search,
filters.rating_min,
filters.film_id,
].filter(Boolean).length;
return (
<div style={styles.root}>
{/* Search row */}
<div style={styles.searchRow}>
<div style={styles.searchWrap}>
<span style={styles.searchIcon}>⌕</span>
<input
ref={searchRef}
type="search"
className="form-input"
placeholder="Search images…"
defaultValue={filters.search ?? ''}
onChange={handleSearchChange}
style={{ paddingLeft: 36, height: 40 }}
aria-label="Search images"
/>
</div>
<button
className="btn btn-ghost"
style={{
height: 40,
padding: '0 12px',
fontSize: '0.8125rem',
gap: 6,
color: expanded || activeFilterCount > 0 ? 'var(--color-accent)' : undefined,
position: 'relative',
}}
onClick={() => setExpanded(x => !x)}
aria-expanded={expanded}
aria-label="Toggle filters"
>
<span>⚙</span>
<span>Filters</span>
{activeFilterCount > 0 && (
<span style={styles.badge}>{activeFilterCount}</span>
)}
</button>
</div>
{/* Expanded filters */}
{expanded && (
<div style={styles.filterRow}>
<div style={{ flex: 1, minWidth: 140 }}>
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
Sort by
</label>
<select
className="form-select"
style={{ height: 38, fontSize: '0.875rem' }}
value={currentSort}
onChange={handleSortChange}
aria-label="Sort order"
>
{SORT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
Min rating
</label>
<select
className="form-select"
style={{ height: 38, fontSize: '0.875rem' }}
value={filters.rating_min ?? ''}
onChange={handleRatingChange}
aria-label="Minimum star rating"
>
{STAR_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{films.length > 0 && (
<div style={{ flex: 1, minWidth: 150 }}>
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
Film roll
</label>
<select
className="form-select"
style={{ height: 38, fontSize: '0.875rem' }}
value={filters.film_id ?? ''}
onChange={handleFilmChange}
aria-label="Film roll"
>
<option value="">All rolls</option>
{films.map(f => (
<option key={f.id} value={f.id}>
{f.folder ?? f.id}
</option>
))}
</select>
</div>
)}
{activeFilterCount > 0 && (
<div style={{ alignSelf: 'flex-end', paddingBottom: 0 }}>
<button
className="btn btn-ghost"
style={{ height: 38, padding: '0 12px', fontSize: '0.8125rem', color: 'var(--color-danger)' }}
onClick={() => onChange({ ...filters, search: '', rating_min: '', film_id: '', offset: 0 })}
>
Clear
</button>
</div>
)}
</div>
)}
</div>
);
}
const styles = {
root: {
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 10,
position: 'sticky',
top: 'var(--header-height)',
zIndex: 'var(--z-filter)',
},
searchRow: {
display: 'flex',
gap: 8,
alignItems: 'center',
},
searchWrap: {
flex: 1,
position: 'relative',
},
searchIcon: {
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--color-text-muted)',
fontSize: '1.1rem',
pointerEvents: 'none',
lineHeight: 1,
},
filterRow: {
display: 'flex',
flexWrap: 'wrap',
gap: 10,
alignItems: 'flex-end',
paddingTop: 4,
},
badge: {
position: 'absolute',
top: 6,
right: 4,
minWidth: 16,
height: 16,
borderRadius: '9999px',
background: 'var(--color-accent)',
color: '#0d0d0d',
fontSize: '0.625rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 3px',
},
};

View File

@@ -1,246 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { getPreviewBlob, releaseBlobUrl } from '../api.js';
const COLOR_LABEL_COLORS = {
red: '#e05252',
yellow: '#f4c842',
green: '#52c06e',
blue: '#5299e0',
purple: '#a052c0',
};
function StarRating({ rating }) {
if (!rating || rating < 1) return null;
return (
<div style={{
color: 'var(--color-accent)',
fontSize: '0.6875rem',
lineHeight: 1,
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
}}>
{'★'.repeat(Math.min(5, rating))}
</div>
);
}
function ColorDots({ labels }) {
if (!labels || labels.length === 0) return null;
return (
<div style={{ display: 'flex', gap: 3 }}>
{labels.map(label => (
<span
key={label}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: COLOR_LABEL_COLORS[label] ?? '#aaa',
display: 'inline-block',
}}
title={label}
/>
))}
</div>
);
}
export default function ImageCard({ image, onClick }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [generating, setGenerating] = useState(false);
const blobKeyRef = useRef(null);
const isMounted = useRef(true);
const retriesRef = useRef(0);
const retryTimerRef = useRef(null);
useEffect(() => {
isMounted.current = true;
retriesRef.current = 0;
setLoading(true);
setError(false);
setGenerating(false);
function loadPreview() {
getPreviewBlob(image.id, 'thumb')
.then((result) => {
if (!isMounted.current) {
if (result.key) releaseBlobUrl(result.key);
return;
}
if (result.generating) {
setLoading(false);
setGenerating(true);
if (retriesRef.current < 15) {
retriesRef.current += 1;
retryTimerRef.current = setTimeout(loadPreview, (result.retryAfter ?? 4) * 1000);
} else {
setGenerating(false);
setError(true);
}
return;
}
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
blobKeyRef.current = result.key;
setBlobUrl(result.url);
setLoading(false);
setGenerating(false);
})
.catch(() => {
if (isMounted.current) {
setLoading(false);
setGenerating(false);
setError(true);
}
});
}
loadPreview();
return () => {
isMounted.current = false;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (blobKeyRef.current) {
releaseBlobUrl(blobKeyRef.current);
blobKeyRef.current = null;
}
};
}, [image.id]);
// Compute aspect ratio for the thumbnail placeholder
const aspect = image.width && image.height
? Math.min(1.5, Math.max(0.6, image.height / image.width))
: 1;
return (
<button
onClick={onClick}
style={{
display: 'block',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
cursor: 'pointer',
textAlign: 'left',
transition: 'transform var(--transition-fast), box-shadow var(--transition-fast)',
width: '100%',
padding: 0,
WebkitTapHighlightColor: 'transparent',
}}
aria-label={`Open ${image.filename}`}
type="button"
>
{/* Thumbnail container — padding-bottom trick for aspect ratio */}
<div style={{
position: 'relative',
width: '100%',
paddingBottom: `${aspect * 100}%`,
background: 'var(--color-surface-2)',
}}>
{(loading || generating) && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 4,
}}>
<span className="spinner" />
{generating && (
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', opacity: 0.7 }}>
Generating…
</span>
)}
</div>
)}
{error && !loading && !generating && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{ fontSize: '1.5rem', opacity: 0.4 }}>⊘</span>
</div>
)}
{blobUrl && !error && (
<img
src={blobUrl}
alt={image.filename}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
{/* Bottom overlay: stars + color dots */}
<div style={{
position: 'absolute',
bottom: 4,
left: 4,
right: 4,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 4,
pointerEvents: 'none',
}}>
<StarRating rating={image.rating} />
<ColorDots labels={image.color_labels} />
</div>
{/* RAW badge */}
{image.is_raw && (
<div style={{
position: 'absolute',
top: 4,
right: 4,
background: 'rgba(0,0,0,0.65)',
color: 'var(--color-accent)',
fontSize: '0.625rem',
fontWeight: 700,
padding: '1px 5px',
borderRadius: 3,
letterSpacing: '0.04em',
}}>
RAW
</div>
)}
</div>
{/* Caption */}
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{
fontSize: '0.75rem',
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}>
{image.filename}
</span>
{image.datetime_taken && (
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{formatDate(image.datetime_taken)}
</span>
)}
</div>
</button>
);
}
function formatDate(dt) {
if (!dt) return '';
try {
return new Date(dt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return dt;
}
}

View File

@@ -1,71 +0,0 @@
import React, { useEffect, useState } from 'react';
import { listModules, ApiError } from '../api.js';
import ModulePanel from './ModulePanel.jsx';
export default function ModuleList({ imageId, onPreviewUpdate }) {
const [modules, setModules] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!imageId) return;
setLoading(true);
setError(null);
setModules(null);
listModules(imageId)
.then(data => {
// API may return an array or { items: [] }
const list = Array.isArray(data) ? data : (data.items ?? data.modules ?? []);
setModules(list);
setLoading(false);
})
.catch(err => {
const msg = err instanceof ApiError ? err.message : String(err);
setError(msg);
setLoading(false);
});
}, [imageId]);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32 }}>
<span className="spinner spinner-lg" />
</div>
);
}
if (error) {
return (
<div className="error-banner" style={{ margin: '0 0 8px' }}>
<span>⚠</span>
<span>Modules: {error}</span>
</div>
);
}
if (!modules || modules.length === 0) {
return (
<div className="empty-state" style={{ padding: '24px' }}>
<span className="empty-state-icon">◻</span>
<p className="text-sm text-muted">No development modules found</p>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3 style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginBottom: 4 }}>
Development modules ({modules.length})
</h3>
{modules.map((mod, idx) => (
<ModulePanel
key={`${mod.operation}-${mod.history_num ?? idx}`}
imageId={imageId}
module={mod}
onPreviewUpdate={onPreviewUpdate}
/>
))}
</div>
);
}

View File

@@ -1,401 +0,0 @@
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { patchModule, ApiError } from '../api.js';
import { useToast } from './Toast.jsx';
// ── Known param schemas ───────────────────────────────────────────────────────
// Provides type and range hints for well-known module parameters.
// Fallback: treat everything as a generic number / string.
const MODULE_PARAMS = {
exposure: {
exposure: { type: 'float', min: -18, max: 18, step: 0.01, label: 'Exposure (EV)' },
black: { type: 'float', min: -1, max: 1, step: 0.001, label: 'Black point' },
mode: { type: 'int', label: 'Mode' },
},
temperature: {
red: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Red' },
green: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green' },
blue: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Blue' },
g2: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green2' },
},
filmicrgb: {
grey_point_source: { type: 'float', min: 0.1, max: 36, step: 0.01, label: 'Grey point src (%)' },
black_point_source: { type: 'float', min: -16, max: -0.1, step: 0.01, label: 'Black point src (EV)' },
white_point_source: { type: 'float', min: 0.1, max: 16, step: 0.01, label: 'White point src (EV)' },
contrast: { type: 'float', min: 0, max: 5, step: 0.01, label: 'Contrast' },
saturation: { type: 'float', min: 0, max: 4, step: 0.01, label: 'Saturation' },
balance: { type: 'float', min: -50, max: 50, step: 0.1, label: 'Shadows↔Highlights' },
output_power: { type: 'float', min: 0, max: 10, step: 0.01, label: 'Output power' },
latitude: { type: 'float', min: 0.01, max: 99, step: 0.1, label: 'Latitude (%)' },
noise_level: { type: 'float', min: 0, max: 0.5, step: 0.001, label: 'Noise level' },
},
highlights: {
mode: { type: 'int', label: 'Mode' },
strength: { type: 'float', min: 0, max: 1, step: 0.01, label: 'Strength' },
clip: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Clip' },
noise_level: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Noise level' },
iterations: { type: 'int', label: 'Iterations' },
},
channelmixerrgb: {
temperature: { type: 'float', min: 1667, max: 25000, step: 10, label: 'Temperature (K)' },
illuminant: { type: 'int', label: 'Illuminant' },
adaptation: { type: 'int', label: 'Adaptation' },
},
flip: {
orientation: { type: 'int', label: 'Orientation' },
},
gamma: {
gamma: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Gamma' },
linear: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Linear' },
},
rawprepare: {
left: { type: 'int', label: 'Left crop (px)' },
top: { type: 'int', label: 'Top crop (px)' },
right: { type: 'int', label: 'Right crop (px)' },
bottom: { type: 'int', label: 'Bottom crop (px)' },
raw_white_point: { type: 'int', label: 'Raw white point' },
},
demosaic: {
demosaicing_method: { type: 'int', label: 'Method' },
green_eq: { type: 'int', label: 'Green equalization' },
median_thrs: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Median threshold' },
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function inferType(key, value, opName) {
const schema = MODULE_PARAMS[opName]?.[key];
if (schema) return schema;
const numVal = Number(value);
if (typeof value === 'boolean') return { type: 'bool' };
if (!isNaN(numVal)) {
const isFloat = String(value).includes('.') || (Number.isFinite(numVal) && !Number.isInteger(numVal));
return isFloat
? { type: 'float', min: -100, max: 100, step: 0.01, label: key }
: { type: 'int', label: key };
}
return { type: 'string', label: key };
}
// ── ParamField ────────────────────────────────────────────────────────────────
function ParamField({ paramKey, value, schema, onChange }) {
const label = schema?.label ?? paramKey;
if (schema?.type === 'bool' || typeof value === 'boolean') {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, minHeight: 36 }}>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>{label}</span>
<label className="toggle">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(paramKey, e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
);
}
if (schema?.type === 'float' && schema.min !== undefined && schema.max !== undefined) {
const numVal = Number(value);
const displayVal = Number.isNaN(numVal) ? 0 : numVal;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>{label}</span>
<span style={{ fontSize: '0.8125rem', fontFamily: 'var(--font-mono)', color: 'var(--color-text)' }}>
{displayVal.toFixed(schema.step < 0.01 ? 4 : schema.step < 0.1 ? 3 : 2)}
</span>
</div>
<input
type="range"
className="param-slider"
min={schema.min}
max={schema.max}
step={schema.step ?? 0.01}
value={displayVal}
onChange={e => onChange(paramKey, parseFloat(e.target.value))}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.min}</span>
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.max}</span>
</div>
</div>
);
}
// int / string — text input
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 36 }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', flex: 1 }}>{label}</span>
<input
type="number"
className="form-input"
style={{ width: 90, height: 32, padding: '0 8px', fontSize: '0.8125rem' }}
value={value ?? ''}
step={schema?.type === 'float' ? (schema.step ?? 0.01) : 1}
onChange={e => {
const raw = e.target.value;
const num = Number(raw);
onChange(paramKey, Number.isNaN(num) ? raw : num);
}}
/>
</div>
);
}
// ── ModulePanel ───────────────────────────────────────────────────────────────
function paramsReducer(state, action) {
switch (action.type) {
case 'RESET':
return { ...action.params };
case 'SET':
return { ...state, [action.key]: action.value };
default:
return state;
}
}
export default function ModulePanel({ imageId, module: mod, onPreviewUpdate }) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(mod.enabled ?? true);
const [params, dispatchParams] = useReducer(paramsReducer, mod.params ?? {});
const [dirty, setDirty] = useState(false);
const [applying, setApplying] = useState(false);
const [renderState, setRenderState] = useState(null); // null | 'rendering' | 'done' | 'error'
const [previewUrl, setPreviewUrl] = useState(null);
const previewUrlRef = useRef(null);
// Sync when module prop changes (e.g., after gallery refresh)
useEffect(() => {
setEnabled(mod.enabled ?? true);
dispatchParams({ type: 'RESET', params: mod.params ?? {} });
setDirty(false);
}, [mod]);
// Cleanup preview blob URL on unmount
useEffect(() => {
return () => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
};
}, []);
function handleParamChange(key, value) {
dispatchParams({ type: 'SET', key, value });
setDirty(true);
}
function handleEnabledChange(newEnabled) {
setEnabled(newEnabled);
setDirty(true);
}
async function handleApply() {
setApplying(true);
setRenderState('rendering');
const body = {
enabled,
params,
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 35_000);
try {
const result = await patchModule(imageId, mod.operation, body, 'medium');
clearTimeout(timeoutId);
if (result.previewUrl) {
// Revoke previous preview blob
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = result.previewUrl;
setPreviewUrl(result.previewUrl);
onPreviewUpdate?.(result.previewUrl);
setRenderState('done');
toast(`${mod.operation}: applied`, 'success');
} else {
setRenderState('done');
toast(`${mod.operation}: applied (no preview)`, 'success');
}
setDirty(false);
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
setRenderState('error');
toast(`${mod.operation}: render timed out`, 'error');
} else {
const msg = err instanceof ApiError ? err.message : String(err);
setRenderState('error');
toast(`${mod.operation}: ${msg}`, 'error');
}
} finally {
setApplying(false);
}
}
const paramEntries = Object.entries(params);
return (
<div style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--color-surface-2)',
}}>
{/* Header row — always visible */}
<button
type="button"
onClick={() => setOpen(o => !o)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
minHeight: 44,
}}
aria-expanded={open}
>
{/* Enabled toggle — stop propagation so clicking it doesn't toggle open */}
<span
role="none"
onClick={e => {
e.stopPropagation();
handleEnabledChange(!enabled);
}}
style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}
title={enabled ? 'Disable module' : 'Enable module'}
>
<label className="toggle" style={{ pointerEvents: 'none' }}>
<input type="checkbox" readOnly checked={enabled} tabIndex={-1} />
<span className="toggle-track" />
</label>
</span>
<span style={{ flex: 1, fontSize: '0.9rem', fontWeight: 500, color: enabled ? 'var(--color-text)' : 'var(--color-text-muted)' }}>
{mod.multi_name ? `${mod.operation} · ${mod.multi_name}` : mod.operation}
</span>
{dirty && (
<span style={{
fontSize: '0.6875rem',
color: 'var(--color-accent)',
background: 'var(--color-accent-dim)',
padding: '1px 7px',
borderRadius: '9999px',
fontWeight: 600,
}}>
modified
</span>
)}
<span style={{
fontSize: '0.75rem',
color: 'var(--color-text-faint)',
transform: open ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform var(--transition-fast)',
display: 'inline-block',
}}>
▾
</span>
</button>
{/* Expanded body */}
{open && (
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ height: 1, background: 'var(--color-border)' }} />
{/* Module info row */}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
{mod.history_num !== undefined && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
history #{mod.history_num}
</span>
)}
{mod.module_version !== undefined && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
v{mod.module_version}
</span>
)}
</div>
{/* Param fields */}
{paramEntries.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{paramEntries.map(([key, value]) => {
const schema = inferType(key, value, mod.operation);
return (
<ParamField
key={key}
paramKey={key}
value={value}
schema={schema}
onChange={handleParamChange}
/>
);
})}
</div>
) : (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-faint)' }}>
No editable parameters
</p>
)}
{/* Apply button + render state */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%', height: 40, fontSize: '0.875rem' }}
onClick={handleApply}
disabled={applying || (!dirty && renderState !== null)}
>
{applying ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Rendering…
</>
) : 'Apply'}
</button>
{renderState === 'rendering' && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center' }}>
Waiting for preview render (up to 30 s)…
</p>
)}
{renderState === 'error' && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-danger)', textAlign: 'center' }}>
Render failed or timed out.
</p>
)}
</div>
{/* Inline preview after apply */}
{previewUrl && renderState === 'done' && (
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<img
src={previewUrl}
alt="Module preview"
style={{ width: '100%', height: 'auto', display: 'block' }}
/>
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-faint)', textAlign: 'center', padding: '4px 0' }}>
Preview after {mod.operation}
</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,133 +0,0 @@
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
// ── Context ───────────────────────────────────────────────────────────────────
const ToastContext = createContext(null);
let nextId = 0;
function reducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, action.toast];
case 'REMOVE':
return state.filter(t => t.id !== action.id);
default:
return state;
}
}
export function ToastProvider({ children }) {
const [toasts, dispatch] = useReducer(reducer, []);
const timers = useRef({});
const dismiss = useCallback((id) => {
clearTimeout(timers.current[id]);
delete timers.current[id];
dispatch({ type: 'REMOVE', id });
}, []);
const toast = useCallback((message, type = 'info', duration = 4000) => {
const id = ++nextId;
dispatch({ type: 'ADD', toast: { id, message, type } });
if (duration > 0) {
timers.current[id] = setTimeout(() => dismiss(id), duration);
}
return id;
}, [dismiss]);
return (
<ToastContext.Provider value={{ toast, dismiss }}>
{children}
<ToastContainer toasts={toasts} dismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
// ── Rendering ─────────────────────────────────────────────────────────────────
const ICONS = {
info: 'ℹ',
success: '✓',
warning: '⚠',
error: '✕',
};
function ToastContainer({ toasts, dismiss }) {
if (toasts.length === 0) return null;
return (
<div style={{
position: 'fixed',
bottom: 'max(80px, calc(env(safe-area-inset-bottom, 0px) + 80px))',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 'var(--z-toast)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'center',
width: 'min(calc(100vw - 32px), 400px)',
pointerEvents: 'none',
}}>
{toasts.map(t => (
<ToastItem key={t.id} toast={t} dismiss={dismiss} />
))}
</div>
);
}
function ToastItem({ toast, dismiss }) {
const colorMap = {
info: 'var(--color-text)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
error: 'var(--color-danger)',
};
return (
<div
role="alert"
aria-live="polite"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '12px 16px',
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
fontSize: '0.9rem',
pointerEvents: 'all',
animation: 'toastIn 200ms ease',
maxWidth: '100%',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={() => dismiss(toast.id)}
>
<span style={{ color: colorMap[toast.type] ?? colorMap.info, fontSize: '1rem', flexShrink: 0, lineHeight: 1.5 }}>
{ICONS[toast.type] ?? ICONS.info}
</span>
<span style={{ flex: 1, color: 'var(--color-text)', lineHeight: 1.5, wordBreak: 'break-word' }}>
{toast.message}
</span>
</div>
);
}
// Keyframe injected once
const style = document.createElement('style');
style.textContent = `
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`;
document.head.appendChild(style);

View File

@@ -1,456 +0,0 @@
/* ── CSS Custom Properties ───────────────────────────────────────────────── */
:root {
--color-bg: #0d0d0d;
--color-surface: #1a1a1a;
--color-surface-2: #242424;
--color-surface-3: #2e2e2e;
--color-border: #333333;
--color-accent: #f4a22d;
--color-accent-dim: rgba(244, 162, 45, 0.15);
--color-accent-hover: #f8b84a;
--color-text: #e0e0e0;
--color-text-muted: #888888;
--color-text-faint: #555555;
--color-danger: #e05252;
--color-success: #52c06e;
--color-warning: #f4a22d;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--tap-target: 44px;
--header-height: 56px;
--filter-height: 56px;
--transition-fast: 150ms ease;
--transition-med: 250ms ease;
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
--z-header: 100;
--z-filter: 90;
--z-backdrop: 200;
--z-sheet: 210;
--z-toast: 300;
}
/* ── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
height: 100%;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
}
#root {
height: 100%;
display: flex;
flex-direction: column;
}
img {
display: block;
max-width: 100%;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
a {
color: var(--color-accent);
text-decoration: none;
}
/* ── Typography ─────────────────────────────────────────────────────────── */
h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; }
h3 { font-size: 1rem; font-weight: 600; }
h4 { font-size: 0.875rem; font-weight: 600; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-muted { color: var(--color-text-muted); }
.text-accent { color: var(--color-accent); }
.text-mono { font-family: var(--font-mono); }
/* ── Utility classes ────────────────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.flex-1 { flex: 1; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
/* ── Shared component styles ─────────────────────────────────────────────── */
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: var(--tap-target);
padding: 0 18px;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
transition: background-color var(--transition-fast), opacity var(--transition-fast);
user-select: none;
white-space: nowrap;
cursor: pointer;
border: none;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--color-accent);
color: #0d0d0d;
font-weight: 700;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.btn-secondary {
background-color: var(--color-surface-3);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--color-surface-2);
}
.btn-ghost {
background-color: transparent;
color: var(--color-text-muted);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--color-surface-2);
color: var(--color-text);
}
.btn-danger {
background-color: var(--color-danger);
color: white;
}
.btn-icon {
padding: 0;
width: var(--tap-target);
height: var(--tap-target);
border-radius: var(--radius-md);
}
/* Form controls */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.form-input {
height: var(--tap-target);
padding: 0 12px;
background-color: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.9375rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
}
.form-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-dim);
}
.form-input::placeholder {
color: var(--color-text-faint);
}
.form-select {
height: var(--tap-target);
padding: 0 32px 0 12px;
background-color: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.9375rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
cursor: pointer;
width: 100%;
}
.form-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-dim);
}
/* Card */
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* Spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.spinner-lg {
width: 36px;
height: 36px;
border-width: 3px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Divider */
.divider {
height: 1px;
background-color: var(--color-border);
margin: 0;
}
/* Tag pill */
.tag-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
background-color: var(--color-surface-3);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
/* Star display */
.stars {
display: inline-flex;
gap: 2px;
color: var(--color-accent);
font-size: 0.875rem;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: var(--color-text-muted);
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
opacity: 0.4;
}
/* Error state */
.error-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background-color: rgba(224, 82, 82, 0.12);
border: 1px solid rgba(224, 82, 82, 0.3);
border-radius: var(--radius-md);
color: #f08080;
font-size: 0.875rem;
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background-color: var(--color-surface-3);
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
transition: background-color var(--transition-fast), border-color var(--transition-fast);
cursor: pointer;
}
.toggle input:checked + .toggle-track {
background-color: var(--color-accent);
border-color: var(--color-accent);
}
.toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background-color: white;
border-radius: 50%;
transition: transform var(--transition-fast);
}
.toggle input:checked + .toggle-track::after {
transform: translateX(20px);
}
/* Slider */
.param-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--color-surface-3);
outline: none;
cursor: pointer;
}
.param-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
border: 2px solid #0d0d0d;
box-shadow: 0 0 0 2px rgba(244, 162, 45, 0.3);
}
.param-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
border: 2px solid #0d0d0d;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border);
}
/* Safe area insets for notched phones */
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}

View File

@@ -1,11 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
const root = createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -1,368 +0,0 @@
import React, {
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from 'react';
import { listImages, ApiError } from '../api.js';
import { useApp } from '../App.jsx';
import { useToast } from '../components/Toast.jsx';
import FilterBar from '../components/FilterBar.jsx';
import ImageCard from '../components/ImageCard.jsx';
import ImageDetail from './ImageDetail.jsx';
const PAGE_SIZE = 50;
// ── State machine for the image list ─────────────────────────────────────────
const initialListState = {
items: [],
total: 0,
loading: false,
loadingMore: false,
error: null,
offset: 0,
hasMore: true,
};
function listReducer(state, action) {
switch (action.type) {
case 'LOAD_START':
return { ...state, loading: true, error: null };
case 'LOAD_MORE_START':
return { ...state, loadingMore: true, error: null };
case 'LOAD_SUCCESS':
return {
...state,
loading: false,
loadingMore: false,
items: action.replace ? action.items : [...state.items, ...action.items],
total: action.total,
offset: action.offset + action.items.length,
hasMore: action.offset + action.items.length < action.total,
error: null,
};
case 'LOAD_ERROR':
return { ...state, loading: false, loadingMore: false, error: action.error };
case 'RESET':
return { ...initialListState };
default:
return state;
}
}
const defaultFilters = {
sort_by: 'datetime_taken',
sort_dir: 'desc',
search: '',
rating_min: '',
film_id: '',
};
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
const PTR_THRESHOLD = 70; // px to trigger refresh
// ── Gallery ───────────────────────────────────────────────────────────────────
export default function Gallery() {
const { goSettings } = useApp();
const { toast } = useToast();
const [listState, dispatch] = useReducer(listReducer, initialListState);
const [filters, setFilters] = useState(defaultFilters);
const [selectedImage, setSelectedImage] = useState(null);
// Pull-to-refresh state
const [ptrY, setPtrY] = useState(0);
const [ptrActive, setPtrActive] = useState(false);
const touchStartY = useRef(null);
const scrollRef = useRef(null);
// Sentinel element for IntersectionObserver (infinite scroll)
const sentinelRef = useRef(null);
const observerRef = useRef(null);
// Current active fetch abort controller
const fetchControllerRef = useRef(null);
// ── Data loading ────────────────────────────────────────────────────────────
const loadPage = useCallback(async (currentOffset, replace) => {
// Cancel any previous fetch
if (fetchControllerRef.current) fetchControllerRef.current.abort();
const controller = new AbortController();
fetchControllerRef.current = controller;
dispatch({ type: replace ? 'LOAD_START' : 'LOAD_MORE_START' });
try {
const params = {
limit: PAGE_SIZE,
offset: currentOffset,
sort_by: filters.sort_by,
sort_dir: filters.sort_dir,
};
if (filters.search) params.search = filters.search;
if (filters.rating_min) params.rating_min = filters.rating_min;
if (filters.film_id) params.film_id = filters.film_id;
const data = await listImages(params);
if (controller.signal.aborted) return;
const items = Array.isArray(data) ? data : (data.items ?? []);
const total = data.total ?? items.length;
dispatch({
type: 'LOAD_SUCCESS',
items,
total,
offset: currentOffset,
replace,
});
} catch (err) {
if (controller.signal.aborted) return;
const msg = err instanceof ApiError ? err.message : String(err);
dispatch({ type: 'LOAD_ERROR', error: msg });
toast(msg, 'error');
}
}, [filters, toast]);
// Initial load and when filters change
useEffect(() => {
dispatch({ type: 'RESET' });
loadPage(0, true);
// Scroll to top
scrollRef.current?.scrollTo({ top: 0 });
}, [filters]); // eslint-disable-line react-hooks/exhaustive-deps
// Note: we intentionally omit loadPage from deps to avoid double-fires;
// filters change triggers reset + load, which is correct.
// ── IntersectionObserver for infinite scroll ────────────────────────────────
useEffect(() => {
observerRef.current?.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
!listState.loading &&
!listState.loadingMore &&
listState.hasMore
) {
loadPage(listState.offset, false);
}
},
{ rootMargin: '200px' }
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [listState.loading, listState.loadingMore, listState.hasMore, listState.offset, loadPage]);
// ── Pull-to-refresh touch handlers ─────────────────────────────────────────
function handleTouchStart(e) {
if (scrollRef.current?.scrollTop === 0) {
touchStartY.current = e.touches[0].clientY;
}
}
function handleTouchMove(e) {
if (touchStartY.current === null) return;
const dy = e.touches[0].clientY - touchStartY.current;
if (dy > 0) {
setPtrY(Math.min(dy * 0.5, PTR_THRESHOLD + 20));
}
}
function handleTouchEnd() {
if (ptrY >= PTR_THRESHOLD) {
setPtrActive(true);
dispatch({ type: 'RESET' });
loadPage(0, true).finally(() => {
setPtrActive(false);
setPtrY(0);
});
} else {
setPtrY(0);
}
touchStartY.current = null;
}
// ── Filter handler ──────────────────────────────────────────────────────────
const handleFiltersChange = useCallback((newFilters) => {
setFilters(newFilters);
}, []);
// ── Render ──────────────────────────────────────────────────────────────────
const showEmpty = !listState.loading && !listState.error && listState.items.length === 0;
const showError = !!listState.error && listState.items.length === 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Fixed header */}
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px',
height: 'var(--header-height)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
position: 'sticky',
top: 0,
zIndex: 'var(--z-header)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src="/icon.svg" alt="" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--color-accent)' }}>
darktable
</span>
{listState.total > 0 && (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
{listState.total.toLocaleString()} images
</span>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={goSettings}
aria-label="Settings"
title="Settings"
style={{ fontSize: '1.25rem' }}
>
⚙
</button>
</header>
{/* Filter bar */}
<FilterBar filters={filters} onChange={handleFiltersChange} />
{/* Scrollable gallery area */}
<div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain', position: 'relative' }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Pull-to-refresh indicator */}
{ptrY > 10 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: Math.min(ptrY, PTR_THRESHOLD + 20),
color: ptrY >= PTR_THRESHOLD ? 'var(--color-accent)' : 'var(--color-text-muted)',
fontSize: '0.875rem',
gap: 8,
transition: ptrActive ? 'height var(--transition-med)' : 'none',
}}>
{ptrActive
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Refreshing…</>
: (ptrY >= PTR_THRESHOLD ? '↑ Release to refresh' : '↓ Pull to refresh')
}
</div>
)}
{/* Initial loading */}
{listState.loading && listState.items.length === 0 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 64, gap: 12 }}>
<span className="spinner spinner-lg" />
</div>
)}
{/* Error (no items) */}
{showError && (
<div style={{ padding: '24px 16px' }}>
<div className="error-banner">
<span>⚠</span>
<div>
<p>{listState.error}</p>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 10, height: 36, fontSize: '0.875rem' }}
onClick={() => loadPage(0, true)}
>
Retry
</button>
</div>
</div>
</div>
)}
{/* Empty state */}
{showEmpty && (
<div className="empty-state">
<span className="empty-state-icon">◻</span>
<p>No images found</p>
<p className="text-sm text-muted">Try adjusting your filters</p>
</div>
)}
{/* Image grid */}
{listState.items.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 6,
padding: 6,
}}>
{listState.items.map(img => (
<ImageCard
key={img.id}
image={img}
onClick={() => setSelectedImage(img)}
/>
))}
</div>
)}
{/* Infinite scroll sentinel */}
<div
ref={sentinelRef}
style={{ height: 1 }}
aria-hidden="true"
/>
{/* Load-more spinner */}
{listState.loadingMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px 0' }}>
<span className="spinner" />
</div>
)}
{/* End-of-list indicator */}
{!listState.hasMore && listState.items.length > 0 && (
<p style={{
textAlign: 'center',
padding: '16px 0',
fontSize: '0.8125rem',
color: 'var(--color-text-faint)',
}}>
All {listState.total.toLocaleString()} images loaded
</p>
)}
</div>
{/* Image detail bottom sheet */}
{selectedImage && (
<ImageDetail
image={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}

View File

@@ -1,443 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { getPreviewBlob, releaseBlobUrl, refreshImage, exportImage, ApiError } from '../api.js';
import { useToast } from '../components/Toast.jsx';
import ModuleList from '../components/ModuleList.jsx';
const PREVIEW_SIZES = ['thumb', 'small', 'medium', 'large', 'xlarge'];
// ── EXIF metadata rows ────────────────────────────────────────────────────────
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div style={{ display: 'flex', gap: 8, padding: '5px 0' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120, flexShrink: 0 }}>
{label}
</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text)', wordBreak: 'break-word' }}>
{String(value)}
</span>
</div>
);
}
// ── Preview area ──────────────────────────────────────────────────────────────
function PreviewImage({ imageId, size, onModulePreview, modulePreviewUrl }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const blobKeyRef = useRef(null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
setLoading(true);
setError(false);
getPreviewBlob(imageId, size)
.then(({ url, key }) => {
if (!isMounted.current) { releaseBlobUrl(key); return; }
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
blobKeyRef.current = key;
setBlobUrl(url);
setLoading(false);
})
.catch(() => {
if (isMounted.current) { setLoading(false); setError(true); }
});
return () => {
isMounted.current = false;
if (blobKeyRef.current) { releaseBlobUrl(blobKeyRef.current); blobKeyRef.current = null; }
};
}, [imageId, size]);
const displayUrl = modulePreviewUrl ?? blobUrl;
return (
<div style={{
width: '100%',
minHeight: 200,
background: '#111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}>
{loading && !displayUrl && (
<span className="spinner spinner-lg" />
)}
{error && !displayUrl && (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<div style={{ fontSize: '2rem', marginBottom: 8 }}>⊘</div>
<p className="text-sm">Preview unavailable</p>
</div>
)}
{displayUrl && (
<img
src={displayUrl}
alt="Image preview"
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain', display: 'block' }}
/>
)}
{modulePreviewUrl && (
<div style={{
position: 'absolute',
top: 8, right: 8,
background: 'rgba(244, 162, 45, 0.9)',
color: '#000',
fontSize: '0.6875rem',
fontWeight: 700,
padding: '2px 8px',
borderRadius: '9999px',
}}>
MODULE PREVIEW
</div>
)}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ImageDetail({ image, onClose }) {
const { toast } = useToast();
const [previewSizeIdx, setPreviewSizeIdx] = useState(2); // 'medium'
const [tab, setTab] = useState('info'); // 'info' | 'modules'
const [actionBusy, setActionBusy] = useState(null); // 'refresh' | 'export' | null
const [modulePreviewUrl, setModulePreviewUrl] = useState(null);
const modulePreviewUrlRef = useRef(null);
// Animate in
const [visible, setVisible] = useState(false);
useEffect(() => {
const t = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(t);
}, []);
// Cleanup module preview blob
useEffect(() => {
return () => {
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
};
}, []);
function cyclePreviewSize() {
setPreviewSizeIdx(i => (i + 1) % PREVIEW_SIZES.length);
setModulePreviewUrl(null); // clear module preview when switching size
}
function handleModulePreview(url) {
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
modulePreviewUrlRef.current = url;
setModulePreviewUrl(url);
}
async function handleRefresh() {
setActionBusy('refresh');
try {
await refreshImage(image.id);
toast('Thumbnails regenerated', 'success');
} catch (err) {
toast(err instanceof ApiError ? err.message : String(err), 'error');
} finally {
setActionBusy(null);
}
}
const exportAbortRef = useRef(null);
async function handleExport() {
// Cancel any in-progress export if the button is clicked again.
exportAbortRef.current?.abort();
const controller = new AbortController();
exportAbortRef.current = controller;
setActionBusy('export');
try {
const blob = await exportImage(image.id, { signal: controller.signal });
const stem = image.filename?.replace(/\.[^.]+$/, '') ?? 'export';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${stem}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('Export downloaded', 'success');
} catch (e) {
if (e instanceof ApiError && e.message === 'Cancelled') return;
toast(e instanceof ApiError ? e.message : String(e), 'error');
} finally {
setActionBusy(null);
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onClose();
}
const currentSize = PREVIEW_SIZES[previewSizeIdx];
const meta = image.metadata ?? {};
return (
<>
{/* Backdrop */}
<div
onClick={handleBackdropClick}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
zIndex: 'var(--z-backdrop)',
transition: 'opacity var(--transition-slow)',
opacity: visible ? 1 : 0,
}}
aria-hidden="true"
/>
{/* Bottom sheet */}
<div
role="dialog"
aria-modal="true"
aria-label={image.filename}
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
zIndex: 'var(--z-sheet)',
background: 'var(--color-surface)',
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
boxShadow: 'var(--shadow-lg)',
maxHeight: '92dvh',
display: 'flex',
flexDirection: 'column',
transform: visible ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform var(--transition-slow)',
overflowY: 'hidden',
}}
>
{/* Drag handle */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
<div style={{
width: 40, height: 4,
borderRadius: 2,
background: 'var(--color-border)',
}} />
</div>
{/* Sheet header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 16px 12px',
borderBottom: '1px solid var(--color-border)',
gap: 12,
flexShrink: 0,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{
fontSize: '1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{image.filename}
</h2>
{image.path && (
<p style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: 2,
}}>
{image.path}
</p>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={onClose}
aria-label="Close"
style={{ flexShrink: 0 }}
>
✕
</button>
</div>
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain' }}>
{/* Preview image — tap to cycle sizes */}
<button
type="button"
onClick={cyclePreviewSize}
title={`Preview size: ${currentSize} (tap to cycle)`}
style={{
display: 'block',
width: '100%',
background: '#0a0a0a',
border: 'none',
cursor: 'pointer',
padding: 0,
position: 'relative',
}}
>
<PreviewImage
imageId={image.id}
size={currentSize}
modulePreviewUrl={modulePreviewUrl}
/>
<div style={{
position: 'absolute',
bottom: 8,
right: 8,
background: 'rgba(0,0,0,0.65)',
color: 'var(--color-text-muted)',
fontSize: '0.6875rem',
padding: '2px 8px',
borderRadius: '9999px',
pointerEvents: 'none',
}}>
{currentSize} · tap to cycle
</div>
</button>
{/* Action buttons */}
<div style={{ display: 'flex', gap: 10, padding: '12px 16px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
onClick={handleRefresh}
disabled={actionBusy !== null}
>
{actionBusy === 'refresh'
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Refreshing…</>
: '↺ Refresh'}
</button>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
onClick={handleExport}
disabled={actionBusy !== null}
>
{actionBusy === 'export'
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Exporting…</>
: '⬇ Export'}
</button>
</div>
{/* Tab switcher */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--color-border)',
padding: '0 16px',
gap: 4,
}}>
{['info', 'modules'].map(t => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
background: 'none',
border: 'none',
borderBottom: `2px solid ${tab === t ? 'var(--color-accent)' : 'transparent'}`,
padding: '10px 16px',
fontSize: '0.9rem',
fontWeight: tab === t ? 600 : 400,
color: tab === t ? 'var(--color-accent)' : 'var(--color-text-muted)',
cursor: 'pointer',
transition: 'color var(--transition-fast), border-color var(--transition-fast)',
minHeight: 44,
}}
>
{t === 'info' ? 'Info' : 'Modules'}
</button>
))}
</div>
{/* Tab content */}
<div style={{ padding: '16px' }}>
{tab === 'info' && (
<InfoTab image={image} meta={meta} />
)}
{tab === 'modules' && (
<ModuleList
imageId={image.id}
onPreviewUpdate={handleModulePreview}
/>
)}
</div>
{/* Safe area bottom padding */}
<div style={{ height: 'max(16px, env(safe-area-inset-bottom, 16px))' }} />
</div>
</div>
</>
);
}
// ── Info tab ──────────────────────────────────────────────────────────────────
function InfoTab({ image, meta }) {
const stars = image.rating > 0 ? '★'.repeat(image.rating) : '—';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Tags */}
{image.tags && image.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, paddingBottom: 12 }}>
{image.tags.map(tag => (
<span key={tag} className="tag-pill">{tag}</span>
))}
</div>
)}
<div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0 8px' }} />
<MetaRow label="Rating" value={stars} />
<MetaRow label="Date taken" value={image.datetime_taken} />
<MetaRow label="Dimensions" value={image.width && image.height ? `${image.width} × ${image.height}` : null} />
<MetaRow label="Camera" value={[image.maker, image.model].filter(Boolean).join(' ')} />
<MetaRow label="Type" value={image.is_raw ? 'RAW' : 'JPEG/other'} />
<MetaRow label="Film roll" value={image.film_name ?? image.film_id} />
{/* Color labels */}
{image.color_labels && image.color_labels.length > 0 && (
<div style={{ display: 'flex', gap: 8, padding: '5px 0', alignItems: 'center' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120 }}>
Color labels
</span>
<div style={{ display: 'flex', gap: 6 }}>
{image.color_labels.map(l => (
<span key={l} className="tag-pill" style={{ textTransform: 'capitalize' }}>{l}</span>
))}
</div>
</div>
)}
{/* Extra metadata fields from image.metadata object */}
{Object.keys(meta).length > 0 && (
<>
<div style={{ height: 1, background: 'var(--color-border)', margin: '8px 0 4px' }} />
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)', marginBottom: 4 }}>
Additional metadata
</p>
{Object.entries(meta).map(([key, value]) => (
<MetaRow key={key} label={key} value={value} />
))}
</>
)}
</div>
);
}

View File

@@ -1,267 +0,0 @@
import React, { useEffect, useReducer } from 'react';
import { useApp } from '../App.jsx';
import { getSettings, saveSettings, testConnection, ApiError } from '../api.js';
import { useToast } from '../components/Toast.jsx';
const initialForm = {
baseUrl: '',
clientId: '',
clientSecret: '',
};
function formReducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'LOAD':
return { ...action.data };
default:
return state;
}
}
const statusIdle = { kind: 'idle' };
const statusTesting = { kind: 'testing' };
const statusOk = { kind: 'ok' };
const statusErr = (msg) => ({ kind: 'error', msg });
export default function Settings() {
const { goGallery } = useApp();
const { toast } = useToast();
const [form, dispatchForm] = useReducer(formReducer, initialForm);
const [status, setStatus] = React.useState(statusIdle);
useEffect(() => {
const s = getSettings();
if (s) {
dispatchForm({
type: 'LOAD',
data: {
baseUrl: s.baseUrl ?? '',
clientId: s.clientId ?? '',
clientSecret: s.clientSecret ?? '',
},
});
}
}, []);
function setField(field) {
return (e) => dispatchForm({ type: 'SET_FIELD', field, value: e.target.value });
}
async function handleTest(e) {
e.preventDefault();
setStatus(statusTesting);
const settings = {
baseUrl: form.baseUrl.replace(/\/$/, ''),
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
};
try {
await testConnection(settings);
setStatus(statusOk);
toast('Connection successful', 'success');
} catch (err) {
const msg = err instanceof ApiError ? err.message : String(err);
setStatus(statusErr(msg));
}
}
async function handleSave(e) {
e.preventDefault();
setStatus(statusTesting);
const settings = {
baseUrl: form.baseUrl.replace(/\/$/, ''),
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
};
try {
await testConnection(settings);
saveSettings(settings);
setStatus(statusOk);
toast('Settings saved', 'success');
goGallery();
} catch (err) {
const msg = err instanceof ApiError ? err.message : String(err);
setStatus(statusErr(msg));
}
}
const isBusy = status.kind === 'testing';
const isValid = form.baseUrl.trim() && form.clientId.trim() && form.clientSecret.trim();
return (
<div style={styles.root}>
{/* Header */}
<header style={styles.header}>
<div style={styles.logoRow}>
<img src="/icon.svg" alt="" style={styles.logo} />
<div>
<h1 style={{ fontSize: '1.25rem' }}>darktable Gallery</h1>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
API connection settings
</p>
</div>
</div>
</header>
{/* Form */}
<main style={styles.main}>
<form onSubmit={handleSave} style={styles.form} noValidate>
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="form-group">
<label className="form-label" htmlFor="baseUrl">
API Base URL
</label>
<input
id="baseUrl"
className="form-input"
type="url"
placeholder="http://localhost:8080"
value={form.baseUrl}
onChange={setField('baseUrl')}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted">
Leave empty to use same-origin (when served by the Lua server)
</p>
</div>
<div className="form-group">
<label className="form-label" htmlFor="clientId">
Client ID
</label>
<input
id="clientId"
className="form-input"
type="text"
placeholder="my-client"
value={form.clientId}
onChange={setField('clientId')}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="clientSecret">
Client Secret
</label>
<input
id="clientSecret"
className="form-input"
type="password"
placeholder="••••••••"
value={form.clientSecret}
onChange={setField('clientSecret')}
/>
</div>
{status.kind === 'error' && (
<div className="error-banner">
<span>⚠</span>
<span>{status.msg}</span>
</div>
)}
{status.kind === 'ok' && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '12px 16px',
background: 'rgba(82, 192, 110, 0.1)',
border: '1px solid rgba(82, 192, 110, 0.3)',
borderRadius: 'var(--radius-md)',
color: 'var(--color-success)',
fontSize: '0.875rem',
}}>
<span>✓</span>
<span>Connection verified</span>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={handleTest}
disabled={isBusy || !isValid}
>
{isBusy ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Testing…
</>
) : 'Test Connection'}
</button>
<button
type="submit"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={isBusy || !isValid}
>
{isBusy ? (
<>
<span className="spinner" style={{ width: 16, height: 16 }} />
Saving…
</>
) : 'Save & Connect'}
</button>
</div>
<p className="text-xs text-muted" style={{ textAlign: 'center', lineHeight: 1.6 }}>
Credentials are stored only in your browser's localStorage and
are never sent anywhere except the configured API URL.
</p>
</form>
</main>
</div>
);
}
const styles = {
root: {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflowY: 'auto',
},
header: {
padding: '24px 20px 0',
},
logoRow: {
display: 'flex',
alignItems: 'center',
gap: 14,
marginBottom: 32,
},
logo: {
width: 48,
height: 48,
borderRadius: 12,
},
main: {
flex: 1,
padding: '0 20px 32px',
maxWidth: 480,
width: '100%',
margin: '0 auto',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: 20,
},
};

View File

@@ -1,81 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
root: '.',
build: {
outDir: 'build',
emptyOutDir: true,
},
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^.*\/api\/v1\/images\?/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-images-list',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 5,
},
networkTimeoutSeconds: 10,
},
},
{
urlPattern: /^.*\/api\/v1\/images\/\d+\/preview/,
handler: 'CacheFirst',
options: {
cacheName: 'image-previews',
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24,
},
},
},
{
urlPattern: /^.*\/api\/v1\/(tags|films)/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-metadata',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 10,
},
networkTimeoutSeconds: 10,
},
},
],
},
manifest: {
name: 'darktable Gallery',
short_name: 'darktable',
description: 'darktable REST API web interface',
theme_color: '#0d0d0d',
background_color: '#0d0d0d',
display: 'standalone',
orientation: 'any',
start_url: '/',
icons: [
{
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
devOptions: {
enabled: false,
},
}),
],
server: {
port: 5173,
},
});

View File

@@ -288,13 +288,7 @@ local function GetArgsFromPreference(prog_table, prefix) --for each arg in a pro
local temp = dt.preferences.read(mod, prefix..argument, arg_data.style)
if arg_data.style == 'bool' and temp then
prog_table.arg_string = prog_table.arg_string..arg_data.text..' '
elseif arg_data.style == 'integer' then
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'string' then
if dt.configuration.running_os == "windows" then
--escape % with %% as % means env var on windows and call will fail otherwise
temp = string.gsub(temp, "%%", "%%%%")
end
elseif arg_data.style == 'integer' or arg_data.style == 'string' then
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'float' then
temp = string.sub(tostring(temp),1,3)
@@ -351,7 +345,7 @@ local function UpdateENFargs(image_table, prefix) --updates the Enfuse arguments
end
ENF.arg_string = ENF.arg_string..' --depth='..GUI.Target.depth.value..' '
if GUI.Target.format.value == 'tif' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_tif.value..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..tostring(math.floor(GUI.Target.compression_level_jpg.value))..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_jpg.value..' '
end
if not GUI.Target.source_location.value then out_path = GUI.Target.output_directory.value end
out_name = smallest_name..'-'..largest_id
@@ -498,7 +492,6 @@ local function main(storage, image_table, extra_data)
remove_temp_files(images_to_remove)
dt.print_error(ENF.name..' failed')
dt.print(string.format(_("%s failed"), ENF.name))
job.valid = false
return
end
@@ -1172,4 +1165,4 @@ end
script_data.destroy = destroy
return script_data
return script_data

View File

@@ -328,7 +328,7 @@ local function export2collection(storage, image_table, extra_data)
-- create unique filename
new_name = image.path..PS..df.get_filename(temp_name)
new_name = df.create_unique_filename(new_name)
new_name = df.sanitize_filename(df.create_unique_filename(new_name))
-- move image to collection folder, check result, return if error
move_success = df.file_move(temp_name, new_name)

View File

@@ -211,6 +211,7 @@ local function year_month2months(year, month)
end
local function months2year_month(months)
dt.print_log("months is " .. months)
local year = math.floor(months / 12)
local month = months - (year * 12)
@@ -223,7 +224,7 @@ local function get_image_taken_time(image)
local exiv2 = df.check_if_bin_exists("exiv2")
if exiv2 then
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. df.sanitize_filename(image.path .. PS .. image.filename))
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. image.path .. PS .. image.filename)
if p then
for line in p:lines() do
if string.match(line, "Exif.Image.DateTime") then
@@ -262,7 +263,7 @@ end
local function _get_nix_image_file_creation_time(image)
local datetime = nil
local p = io.popen("ls -lL --time-style=full-iso " .. df.sanitize_filename(image.path .. PS .. image.filename))
local p = io.popen("ls -lL --time-style=full-iso " .. image.path .. PS .. image.filename)
if p then
for line in p:lines() do
if string.match(line, ds.sanitize_lua(image.filename)) then

View File

@@ -264,6 +264,10 @@ function PHOTILS.get_tags(image, with_export)
local out_arg = df.sanitize_filename(tmp_file)
local executable = photils_installed
if dt.configuration.running_os == "macos" then
executable = executable .. "/Contents/MacOS/photils-cli"
end
if with_export then
dt.print_log("use export to for prediction")
local export_file = df.create_tmp_file()

View File

@@ -185,6 +185,9 @@ local function do_rename(images)
end
end
local function reset_callback()
rename.widgets.pattern.text = ""
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- W I D G E T S
@@ -193,11 +196,7 @@ end
rename.widgets.pattern = dt.new_widget("entry"){
tooltip = ds.get_substitution_tooltip(),
placeholder = _("enter pattern") .. "$(FILE_FOLDER)/$(FILE_NAME)",
text = "",
reset_callback = function(self)
self.text = ""
dt.preferences.write(MODULE_NAME, "pattern", "string", self.text)
end
text = ""
}
local pattern_pref = dt.preferences.read(MODULE_NAME, "pattern", "string")

View File

@@ -20,19 +20,14 @@ Enable selection of non-existing images in the the currently worked on images, e
local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string"
local log = require "lib/dtutils.log"
-- module name
local MODULE <const> = "select_non_existing"
local MODULE = "select_non_existing"
du.check_min_api_version("9.1.0", MODULE)
-- figure out the path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
local DEFAULT_LOG_LEVEL <const> = log.info
log.log_level(DEFAULT_LOG_LEVEL)
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
local gettext = dt.gettext.gettext
@@ -44,61 +39,26 @@ local function stop_job(job)
job.valid = false
end
local function get_file_list(dir)
log.log_level(DEFAULT_LOG_LEVEL)
local file_list = {}
local cmd = nil
log.msg(log.debug, "called get_file_list with " .. dir)
if dt.configuration.running_os == "windows" then
cmd = "forfiles /P " .. ds.sanitize(dir) .. " /M * /C \"cmd /c echo @file\""
else
cmd = "cd " .. ds.sanitize(dir) ..";ls -1"
end
log.msg(log.info, "cmd is " .. cmd)
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:len() > 4 then
if not string.match(line, "xmp$") and not string.match(line, "XMP$") then
line = line:gsub("\"", "")
file_list[line] = true
end
end
end
p:close()
end
return file_list
end
local function select_nonexisting_images(event, images)
log.log_level(DEFAULT_LOG_LEVEL)
local selection = {}
local known_files = {}
local selection = {}
local job = dt.gui.create_job(_("select non existing images"), true, stop_job)
for key,image in ipairs(images) do
if(job.valid) then
if not known_files[image.path] then
known_files[image.path] = get_file_list(image.path)
end
if key % 10 == 0 then
job.percent = key/#images
end
local file_exists = known_files[image.path][image.filename]
log.msg(log.debug, image.path .. PS .. image.filename .." exists? => "..tostring(file_exists))
if (not file_exists) then
table.insert(selection, image)
end
else
break
end
end
stop_job(job)
return selection
local job = dt.gui.create_job(_("select non existing images"), true, stop_job)
for key,image in ipairs(images) do
if(job.valid) then
job.percent = (key - 1)/#images
local filepath = image.path..PS..image.filename
local file_exists = df.test_file(filepath, "e")
dt.print_log(filepath.." exists? => "..tostring(file_exists))
if (not file_exists) then
table.insert(selection, image)
end
else
break
end
end
stop_job(job)
return selection
end
local function destroy()

View File

@@ -1,199 +0,0 @@
--[[
selected_image_visible.lua - make the selected image visible in lighttable
Copyright (C) 2026 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 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/>.
]]
--[[
selected_image_visible - make the selected image visible in lighttable
selected_image_visible adds a bshortcut to make the currently
selected image or the first image of a selection visible in lighttable
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable the script in script_manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("9.6.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("selected image visible"), -- visible name of script
purpose = _("make the selected image visible in lighttable"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/selected_image_visible/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "selected_image_visible"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local selected_image_visible = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
selected_image_visible.log_level = DEFAULT_LOG_LEVEL
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = selected_image_visible
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function set_image_visible()
if dt.gui.current_view().name == "lighttable" and #dt.gui.action_images > 0 then
dt.gui.views.lighttable.set_image_visible(dt.gui.action_images[1])
end
end
local function set_button_sensitive()
if dt.gui.current_view().name == "lighttable"
and #dt.gui.selection() > 0
and not dt.gui.views.lighttable.is_image_visible(dt.gui.selection()[1]) then
dt.gui.libs.image.set_sensitive(MODULE, true)
else
dt.gui.libs.image.set_sensitive(MODULE, false)
end
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.image.register_action(
MODULE, _("selected image visible"),
function(event, images) set_image_visible() end,
_("make selected image visible in lighttable")
)
set_button_sensitive()
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
-- put things to destroy (events, storages, etc) here
dt.destroy_event(MODULE, "selection-changed")
dt.destroy_event(MODULE, "mouse-over-image-changed")
dt.gui.libs.image.destroy_action(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
if not dt.query_event(MODULE, "shortcut") then
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
set_image_visible()
end, _("make selected image visible in lighttable")
)
end
dt.register_event(MODULE, "selection-changed",
function(event)
set_button_sensitive()
end
)
dt.register_event(MODULE, "mouse-over-image-changed",
function(event, image)
set_button_sensitive()
end
)
return script_data

View File

@@ -1,313 +0,0 @@
--[[
toggle_group_view.lua - toggle only group visible in lighttable
Copyright (C) 2024 Bill Ferguson <wpferguson.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 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/>.
]]
--[[
toggle_group_view - toggle only group visible in lighttable
toggle_group_view provides a way to "separate" a group from
the lighttable display and limit the display to just the group.
Pressing the shortcut again returns to the full lighttable view.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* place the script in your lua scripts folder
* start the script with script manager
* assign a key sequence to the shortcut
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
local log = require "lib/dtutils.log"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "toggle_group_view"
local DEFAULT_LOG_LEVEL <const> = log.error
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
local function _(msgid)
return gettext(MODULE, msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "toggle_group_view", -- name of script
purpose = _("toggle only group visible in lighttable"), -- purpose of script
author = "Bill Ferguson <wpferguson.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/toggle_group_view/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local toggle_group_view = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local group_active = false
local filmroll = ""
local collection_rules = {}
local hover_active = true
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function is_group_member(image)
if image then
if #image:get_group_members() > 1 then
return true
else
return false
end
end
end
local function is_group_displayed()
local rules = dt.gui.libs.collect.filter()
if rules[1].item == "DT_COLLECTION_PROP_GROUP_ID" then
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = dt.collection[1].path
rule.item = "DT_COLLECTION_PROP_FILMROLL"
table.insert(collection_rules, rule)
group_active = true
end
end
local function set_button_sensitive(image)
local selected_images = dt.gui.selection()
local sensitive
if group_active then
dt.gui.libs.image.set_sensitive(MODULE, true)
return
end
if #selected_images > 0 then
if is_group_member(selected_images[1]) then
sensitive = true
end
else
if image then
if is_group_member(image) then
sensitive = true
else
sensitive = false
end
else
sensitive = false
end
end
dt.gui.libs.image.set_sensitive(MODULE, sensitive)
end
local function add_collection_rule(rule_string)
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = rule_string
rule.item = "DT_COLLECTION_PROP_FILENAME"
table.insert(collection_rules, rule)
end
local function show_group(image)
local collection_rule_string = ""
local first = true
collection_rules = {}
local group_id = image.group_leader
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = tostring(group_id.id)
rule.item = "DT_COLLECTION_PROP_GROUP_ID"
table.insert(collection_rules, rule)
-- dt.gui.libs.collect.filter returns the previous rule set when you install another
collection_rules = dt.gui.libs.collect.filter(collection_rules)
end
local function reset(image)
dt.gui.libs.collect.filter(collection_rules)
if image then
dt.gui.views.lighttable.set_image_visible(image)
else
log.msg(log.screen, _("no image supplied"))
end
set_button_sensitive(image)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N
-- - - - - - - - - - - - - - - - - - - - - - - -
-- check if we are starting with a group already displayed and
-- configure appropriately
if not dt.preferences.read("darktable", "plugins/lighttable/act_on", "bool") then
hover_active = false
end
is_group_displayed()
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.image.register_action(
MODULE, "toggle group view",
function(event, images)
if group_active then
local image
if dt.gui.action_images[1] then
image = dt.gui.action_images[1]
reset(dt.gui.action_images[1])
else
image = dt.collection[1]
reset(dt.collection[1])
end
group_active = false
set_button_sensitive(image)
else
show_group(dt.gui.action_images[1])
group_active = true
end
end,
_("toggle group view for selected image")
)
set_button_sensitive(nil)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
if hover_active then
dt.destroy_event(MODULE, "mouse-over-image-changed")
end
dt.destroy_event(MODULE, "selection_changed")
dt.gui.libs.image.destroy_action(MODULE, "toggle group view")
-- leave the shortcut so it doesn't need to be reassigned
-- the next time this module starts
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(
MODULE, "shortcut",
function(event, shortcut)
if #dt.gui.action_images > 0 then
if group_active then
reset(dt.gui.action_images[1])
group_active = false
else
show_group(dt.gui.action_images[1])
group_active = true
end
end
end,
_("toggle group view for selected image")
)
---[[
if hover_active then
dt.register_event(MODULE, "mouse-over-image-changed",
function(event, image)
set_button_sensitive(image)
end
)
end
--]]
dt.register_event(MODULE, "selection-changed",
function(event)
set_button_sensitive(dt.gui.action_images[1])
end
)
return script_data

View File

@@ -270,7 +270,7 @@ local function doTransfer(transferFunc)
local sourceBase = sourceTextBox.text
local destBase = destinationTextBox.text
local destFilms = {}
for film, i in pairs(films) do
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

View File

@@ -1,294 +0,0 @@
--[[Export module to create a web gallery from selected images
copyright (c) 2025 Tino Mettler
darktable 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.
darktable 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 software. If not, see <http://www.gnu.org/licenses/>.
]]
--[[
TODO:
- Lua: remove images dir if already existent
- Lua: translations
]]
local dt = require "darktable"
local df = require "lib/dtutils.file"
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
local temp = dt.preferences.read('web_gallery', 'title', 'string')
if temp == nil then temp = 'Darktable gallery' end
local function _(msgid)
return dt.gettext.gettext(msgid)
end
local title_widget = dt.new_widget("entry")
{
text = temp
}
local temp = dt.preferences.read('web_gallery', 'destination_dir', 'string')
if temp == nil then temp = '' end
local dest_dir_widget = dt.new_widget("file_chooser_button")
{
title = _("select output folder"),
tooltip = _("select output folder"),
value = temp,
is_directory = true,
changed_callback = function(this) dt.preferences.write('web_gallery', 'destination_dir', 'string', this.value) end
}
local gallery_widget = dt.new_widget("box")
{
orientation=vertical,
dt.new_widget("label"){label = _("gallery title")},
title_widget,
dt.new_widget("label"){label = _("destination directory")},
dest_dir_widget
}
local function get_file_name(file)
return file:match("[^" .. PS .. "]*.$")
end
function escape_js_string(str)
local replacements = {
['\\'] = '\\\\',
['"'] = '\\"',
["'"] = "\\'",
['\n'] = '\\n',
['\r'] = '\\r',
['\t'] = '\\t',
['\b'] = '\\b',
['\f'] = '\\f',
['\v'] = '\\v'
}
return (str:gsub('[\\\"\n\r\t\b\f\v\']', replacements))
end
local function export_thumbnail(image, filename)
exporter = dt.new_format("jpeg")
exporter.quality = 90
exporter.max_height = 512
exporter.max_width = 512
exporter:write_image(image, filename, true)
end
local function write_image(image, dest_dir, filename)
df.file_move(filename, dest_dir.. PS .. "images" .. PS .. get_file_name(filename))
export_thumbnail(image, dest_dir .. PS .. "thumbnails" .. PS .. "thumb_" .. get_file_name(filename))
end
function exiftool_get_image_dimensions(filename)
local handle = io.popen("exiftool " .. filename)
local result = handle:read("*a")
handle:close()
for line in result:gmatch("[^\r\n]+") do
local w = line:match("^Image Width%s*:%s*(%d+)")
if w then
width = tonumber(w)
end
local h = line:match("^Image Height%s*:%s*(%d+)")
if h then
height = tonumber(h)
end
end
if width and height then
return width, height
else
return nil, nil
end
end
local function stop_job(job)
job.valid = false
end
local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool)
local gallery_data = { name = escape_js_string(title) }
local images = {}
local index = 1
local job = dt.gui.create_job(_("exporting thumbnail images"), true, stop_job)
for i, image in pairs(images_ordered) do
local filename = df.sanitize_filename(images_table[image])
dt.print(_("export thumbnail image ") .. index .. "/" .. #images_ordered)
write_image(image, dest_dir, filename)
if exiftool then
width, height = exiftool_get_image_dimensions(df.sanitize_filename(dest_dir .. PS .. "images" .. PS .. get_file_name(filename)))
else
width = sizes[index].width
height = sizes[index].height
end
local entry = { filename = "images" .. PS .. get_file_name(filename),
width = width, height = height }
images[index] = entry
job.percent = index / #images_ordered
index = index + 1
end
stop_job(job)
gallery_data.images = images
return gallery_data
end
local function generate_javascript_gallery_object(gallery)
local js = 'const gallery_data = {\n'
js = js .. ' name: "' .. gallery.name .. '",\n'
js = js .. ' images: [\n'
for i, img in ipairs(gallery.images) do
js = js .. string.format(' { filename: "%s",\n height: %d,\n width: %d }', img.filename, img.height, img.width)
if i < #gallery.images then
js = js .. ',\n'
else
js = js .. '\n'
end
end
js = js .. ' ]\n};\n'
return(js)
end
local function write_javascript_file(gallery_table, dest_dir)
dt.print(_("write JavaScript file"))
javascript_object = generate_javascript_gallery_object(gallery_table)
local fileOut, errr = io.open(dest_dir .. PS .. "js" .. PS .. "images.js", 'w+')
if fileOut then
fileOut:write(javascript_object)
else
log.msg(log.error, errr)
end
fileOut:close()
end
local function copy_static_files(dest_dir)
gfsrc = dt.configuration.config_dir .. PS .. "lua" .. PS .. "data" .. PS .. "website_gallery"
local gfiles = {
"index.html",
"css" .. PS .. "gallery.css",
"css" .. PS .. "modal.css",
"css" .. PS .. "style.css",
"js" .. PS .. "gallery.js",
"js" .. PS .. "modal.js",
"js" .. PS .. "fullscreen.js"
}
dt.print(_("copy static gallery files"))
for _, file in ipairs(gfiles) do
df.file_copy(gfsrc .. PS .. file, dest_dir .. PS .. file)
end
end
local function build_gallery(storage, images_table, extra_data)
local dest_dir = dest_dir_widget.value
df.mkdir(df.sanitize_filename(dest_dir))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "images"))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "thumbnails"))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "css"))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "js"))
local images_ordered = extra_data["images"] -- process images in the correct order
local sizes = extra_data["sizes"]
local title = _("Darktable export")
if title_widget.text ~= "" then
title = title_widget.text
end
local exiftool = df.check_if_bin_exists("exiftool");
gallerydata = fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool)
write_javascript_file(gallerydata, dest_dir)
copy_static_files(dest_dir)
end
local script_data = {}
script_data.metadata = {
name = _("website gallery (new)"),
purpose = _("create a web gallery from exported images"),
author = "Tino Mettler <tino+darktable@tikei.de>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/TODO"
}
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
script_data.show = nil -- only required for libs since the destroy_method only hides them
local function destroy()
dt.preferences.write('web_gallery', 'title', 'string', title_widget.text)
dt.destroy_storage("module_webgallery")
end
script_data.destroy = destroy
local function show_status(storage, image, format, filename,
number, total, high_quality, extra_data)
dt.print(string.format(_("export image ").."%i/%i", number, total))
aspect = image.aspect_ratio
-- calculate the size of the exported image and store it in extra_data
-- to make it available in the finalize function
if image.final_height == 0 then
if aspect < 1 then
dimensions = { width = image.height, height = image.width }
else
dimensions = { width = image.width, height = image.height }
end
else
dimensions = { width = image.final_width, height = image.final_height }
end
if format.max_height > 0 and dimensions.height > format.max_height then
scale = format.max_height / dimensions.height
dimensions.height = math.floor(dimensions.height * scale + 0.5)
dimensions.width = math.floor(dimensions.width * scale + 0.5)
end
if format.max_width > 0 and dimensions.width > format.max_width then
scale = format.max_width / dimensions.width
dimensions.height = math.floor(dimensions.height * scale + 0.5)
dimensions.width = math.floor(dimensions.width * scale + 0.5)
end
extra_data["sizes"][number] = dimensions
end
local function initialize(storage, img_format, images, high_quality, extra_data)
dt.preferences.write('web_gallery', 'title', 'string', title_widget.text)
extra_data["images"] = images -- needed, to preserve images order
extra_data["sizes"] = {};
end
local supported_formats = { "jpg", "tif", "png", "webp" }
local formats_lut = {}
for key,format in pairs(supported_formats) do
formats_lut[format] = true
end
function check_supported(storage, format)
extension = format.extension
if formats_lut[extension] == true then
return true
else
return false
end
end
dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, check_supported, initialize, gallery_widget)
return script_data

View File

@@ -1,56 +0,0 @@
/*
copyright (c) 2025 Tino Mettler
darktable 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.
darktable 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 software. If not, see <http://www.gnu.org/licenses/>.
*/
body {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.heading h1 {
text-align: center;
display: grid;
}
.gallery {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
.gallery img {
cursor: pointer;
object-fit: contain;
}
.thumb {
object-fit: contain;
flex-shrink: 0;
min-width: 100%;
min-height: 100%;
max-width: 100%;
max-height: 100%;
}
.thumb-box {
display: flex;
align-items: center;
object-fit: contain;
justify-content: center;
}

View File

@@ -1,99 +0,0 @@
/*
copyright (c) 2025 Tino Mettler
darktable 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.
darktable 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 software. If not, see <http://www.gnu.org/licenses/>.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.viewer {
position: relative;
width: 100vw;
height: 100vh;
touch-action: none;
overflow: hidden;
display: none;
}
.navgrid {
display: grid;
}
.nav {
height: var(--navigation-bar-height);
justify-content: center;
align-items: center;
display: flex;
z-index: 1001;
}
.slider {
height: calc(100vh - var(--navigation-bar-height));
position: relative;
width: 100vw;
touch-action: none;
overflow: hidden;
}
.slide-container {
position: absolute;
top: 0;
left: 0;
width: 300%;
height: 100%;
display: flex;
transform: translateX(-33.333%);
transition: transform 0.3s ease-out;
will-change: transform;
}
.slide {
width: 33.333%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #000;
will-change: contents;
overflow: hidden;
position: relative;
touch-action: none;
object-fit: contain;
}
.slide img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
display: block;
transform-origin: 0 0;
cursor: zoom-in;
object-fit: contain;
}
.slide.zoomed img {
cursor: zoom-out;
max-width: none;
max-height: none;
}
.slide img.zooming {
transition: transform 0.2s ease-out;
}

View File

@@ -1,66 +0,0 @@
body {
font-family: system-ui, Arial, Helvetica, sans-serif;
background-color: rgb(238, 240, 242);
}
.heading h1 {
background-color: #cdd;
color: black;
}
.heading h1:after {
content: " ";
border-bottom: 1px solid #888888;
height: 1px;
}
.gallery {
gap: 3vw;
margin: 3vw auto;
padding: 1.5vw;
}
.gallery img {
background: none;
border: 1px outset #737780;
}
.gallery img:hover {
transform: scale(1.08);
}
.thumb-box {
background: none;
box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6);
}
.viewer {
--navigation-bar-height: 5vh;
}
.nav {
background-color: rgba(60,60,60, 0.4);
color: #ccc;
}
.navgrid {
grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em;
font-size: 3.5vh;
grid-column-gap: 1vw;
background-color: black;
}
.button {
cursor: pointer;
}
.button:hover {
cursor: pointer;
color: white;
text-shadow: 0px 0px 10px #ccc;
}
.counter {
font-size: 2.5vh;
}

View File

@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0">
<link rel="stylesheet" href="css/modal.css">
<link rel="stylesheet" href="css/gallery.css">
<link rel="stylesheet" href="css/style.css">
<title id="page-title">Image Gallery</title>
</head>
<body>
<div id="heading1" class="heading">
<h1 id="gallery-title">Image Gallery</h1>
</div>
<div id="gallery" class="gallery"></div>
<div class="viewer" id="viewer">
<div class="navgrid">
<div id="counter" class="nav"></div>
<div class="spacer"></div>
<div class="button nav nav-arrow" id="prevArrow">&#9664;</div>
<div class="button nav nav-arrow" id="nextArrow">&#9654;</div>
<div class="spacer"></div>
<div id="fullscreen" class="button nav">&#x26F6;</div>
<div id="close" class="nav button">&times;</div>
</div>
<div class="slider" id="slider1">
<div class="slide-container">
<div class="slide" id="prev"></div>
<div class="slide" id="current"></div>
<div class="slide" id="next"></div>
</div>
</div>
</div>
<script type='text/javascript' >
var currentIndex = 1;
</script>
<script src="js/images.js"></script>
<script src="js/fullscreen.js"></script>
<script src="js/modal.js"></script>
<script src="js/gallery.js"></script>
</body>
</html>

View File

@@ -1,40 +0,0 @@
var isFullscreen = false;
var toggleFullscreen = function (ele) {
return isFullscreen ? exitFullscreen(ele) : requestFullscreen(ele);
};
var requestFullscreen = function (ele) {
if(isFullscreen == true) return 0 ;
isFullscreen = true;
if (ele.requestFullscreen) {
ele.requestFullscreen();
} else if (ele.webkitRequestFullscreen) {
ele.webkitRequestFullscreen();
} else if (ele.mozRequestFullScreen) {
ele.mozRequestFullScreen();
} else if (ele.msRequestFullscreen) {
ele.msRequestFullscreen();
} else {
console.log('Fullscreen API is not supported.');
}
};
var exitFullscreen = function () {
if(isFullscreen == false) return 0;
isFullscreen = false;
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
} else {
console.log('Fullscreen API is not supported.');
}
};

View File

@@ -1,112 +0,0 @@
/*
copyright (c) 2025 Tino Mettler
darktable 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.
darktable 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 software. If not, see <http://www.gnu.org/licenses/>.
*/
var scrollPosX = 0;
var scrollPosY = 0;
document.addEventListener('DOMContentLoaded', function () {
var imageCount = 0;
const gallery = document.getElementById('gallery');
const viewer = document.getElementById('viewer');
function showModal(e) {
const thumbbox = e.target.parentElement;
const index = [...gallery.children].indexOf(thumbbox);
currentIndex = index;
scrollPosX = document.documentElement.scrollLeft;
scrollPosY = document.documentElement.scrollTop;
gallery.style.display = 'none';
document.getElementById('heading1').style.display = 'none';
viewer.style.display = 'grid';
loadSlides();
updateCounter(currentIndex);
updateNavigationState();
}
function closeModal() {
exitFullscreen(document.documentElement);
viewer.style.display = 'none';
document.getElementById('heading1').style.display = 'grid';
gallery.style.display = 'flex';
document.documentElement.scrollTo({
left: scrollPosX,
top: scrollPosY,
behavior: "instant",
});
};
function createThumbnailElement(imageObj) {
const frame = document.createElement('div');
frame.className = 'thumb-box';
const framesize = 18;
const width = parseInt(imageObj.width);
const height = parseInt(imageObj.height);
const sum = width + height;
const scalefactor = sum / (framesize * 2.0);
frame.style.width = (width / scalefactor) + 'vw';
frame.style.height = (height / scalefactor) + 'vw';
const img = document.createElement('img');
img.className = 'thumb';
img.src = imageObj.filename.replace(/images\/(.*)$/i, 'thumbnails/thumb_$1');
img.alt = imageObj.filename;
img.addEventListener('click', function (e) { e.stopPropagation(); showModal(e); });
frame.appendChild(img);
gallery.appendChild(frame);
}
const images = gallery_data.images;
const title = document.getElementById('gallery-title');
const pageTitle = document.getElementById('page-title');
if (gallery_data.name) {
title.textContent = gallery_data.name;
pageTitle.textContent = gallery_data.name;
}
document.getElementById('close').onclick = function (e) {
e.stopPropagation();
closeModal();
};
// Keyboard navigation using left/right arrow keys
document.onkeyup = function (e) {
e.stopPropagation();
switch(e.key) {
case "Escape":
closeModal();
break;
}
};
document.getElementById('fullscreen').onclick = function (e) {
e.stopPropagation();
toggleFullscreen(document.documentElement);
};
// Populate thumbnail gallery
images.forEach(function (imageObj) {
createThumbnailElement(imageObj);
});
});

View File

@@ -1,355 +0,0 @@
/*
copyright (c) 2025 Tino Mettler
darktable 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.
darktable 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 software. If not, see <http://www.gnu.org/licenses/>.
*/
const container = document.querySelector('.slide-container');
const slides = {
prev: document.getElementById('prev'),
current: document.getElementById('current'),
next: document.getElementById('next')
};
const prevArrow = document.getElementById('prevArrow');
const nextArrow = document.getElementById('nextArrow');
let startX = 0;
let startY = 0;
let isDragging = false; // For swipe navigation
let isZoomed = false;
let isPanning = false; // Panning in zoomed state
let hasPanned = false;
let translateX = 0;
let translateY = 0;
let currentScale = 1;
let img;
let baseWidth, baseHeight; // Image dimensions before zoom
let scaledWidth, scaledHeight;
const images = gallery_data.images;
function updateCounter(index) {
const counter = document.getElementById('counter');
counter.textContent = (index + 1) + ' / ' + images.length;
}
function updateBoundaries() {
containerRect = container.getBoundingClientRect();
// Calculate the actual scaled dimensions based on the base (pre-zoom) size
scaledWidth = baseWidth * currentScale;
scaledHeight = baseHeight * currentScale;
}
function limitPanning(proposedX, proposedY) {
const maxX = 0;
const minX = baseWidth - scaledWidth;
const maxY = 0;
const minY = baseHeight - scaledHeight;
let constrainedX = proposedX;
let constrainedY = proposedY;
// Apply constraints
if (scaledWidth > baseWidth) {
// Image wider than original - constrain panning
constrainedX = Math.max(minX, Math.min(maxX, proposedX));
} else {
// Image narrower than original - center it
constrainedX = (baseWidth - scaledWidth) / 2;
}
if (scaledHeight > baseHeight) {
// Image taller than original - constrain panning
constrainedY = Math.max(minY, Math.min(maxY, proposedY));
} else {
// Image shorter than original - center it
constrainedY = (baseHeight - scaledHeight) / 2;
}
return {
x: constrainedX,
y: constrainedY
};
}
function handleZoom(e) {
// If we were actually panning, don't zoom
if (hasPanned) {
isPanning = false;
hasPanned = false;
return;
}
// If we're zoomed and haven't panned, zoom out
if (isZoomed) {
img.classList.add('zooming');
translateX = 0;
translateY = 0;
currentScale = 1;
img.style.transform = 'none';
container.classList.remove('zoomed');
isZoomed = false;
updateNavigationState();
setTimeout(() => {
img.classList.remove('zooming');
}, 200);
return;
}
// Zoom in at clicked/tapped point
if (!isZoomed) {
const rect = img.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Store the base (pre-zoom) dimensions and offset
baseWidth = rect.width;
baseHeight = rect.height;
// Calculate the scale for 1:1 pixel zoom
currentScale = img.naturalWidth / rect.width;
// Calculate the translation needed to keep clicked point under cursor
// After scaling, the point at (x, y) will be at (x * currentScale, y * currentScale)
// We want it to remain at (e.clientX - rect.left, e.clientY - rect.top)
translateX = e.clientX - rect.left - (x * currentScale);
translateY = e.clientY - rect.top - (y * currentScale);
updateBoundaries();
const limited = limitPanning(translateX, translateY);
translateX = limited.x;
translateY = limited.y;
img.classList.add('zooming');
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`;
container.classList.add('zoomed');
setTimeout(() => {
img.classList.remove('zooming');
}, 200);
}
isZoomed = true;
updateNavigationState();
}
function createImageElement(imageData) {
if (!imageData) return null;
const img = new Image();
img.src = imageData.filename;
img.width = imageData.width;
img.height = imageData.height;
img.addEventListener('dragstart', (e) => e.preventDefault());
return img;
}
function loadSlides() {
slides.prev.innerHTML = '';
slides.current.innerHTML = '';
slides.next.innerHTML = '';
if (currentIndex > 0) {
const prevImg = createImageElement(images[currentIndex - 1]);
if (prevImg) slides.prev.appendChild(prevImg);
}
const currentImg = createImageElement(images[currentIndex]);
if (currentImg) {
img = currentImg;
slides.current.appendChild(currentImg);
}
if (currentIndex < images.length - 1) {
const nextImg = createImageElement(images[currentIndex + 1]);
if (nextImg) slides.next.appendChild(nextImg);
}
updateCounter(currentIndex)
updateNavigationState();
}
function updateNavigationState() {
prevArrow.style.visibility = (currentIndex === 0 || isZoomed) ? 'hidden' : 'visible';
nextArrow.style.visibility = (currentIndex === images.length - 1 || isZoomed) ? 'hidden' : 'visible';
}
async function showPreviousImage() {
if (currentIndex > 0 && !isZoomed) {
currentIndex--;
container.style.transition = 'transform 0.3s ease-out';
container.style.transform = 'translateX(0%)';
await waitForTransition();
container.style.transition = 'none';
container.style.transform = 'translateX(-33.333%)';
loadSlides();
}
}
async function showNextImage() {
if (currentIndex < images.length - 1 && !isZoomed) {
currentIndex++;
container.style.transition = 'transform 0.3s ease-out';
container.style.transform = 'translateX(-66.666%)';
await waitForTransition();
container.style.transition = 'none';
container.style.transform = 'translateX(-33.333%)';
loadSlides();
}
}
function handleTouchStart(e) {
if (isZoomed) return;
startX = e.touches[0].clientX;
isDragging = true;
container.style.transition = 'none';
}
function handleTouchMove(e) {
if (!isDragging || isZoomed) return;
const currentX = e.touches[0].clientX;
const diff = currentX - startX;
const baseOffset = -33.333;
const percentMoved = (diff / window.innerWidth) * 33.333;
container.style.transform = `translateX(${baseOffset + percentMoved}%)`;
}
async function handleTouchEnd(e) {
if (!isDragging)
if(isZoomed) {
handleZoom(e);
return;
}
isDragging = false;
const endX = e.changedTouches[0].clientX;
const diff = endX - startX;
const threshold = window.innerWidth * 0.2;
container.style.transition = 'transform 0.3s ease-out';
if (diff > threshold && currentIndex > 0) {
await showPreviousImage();
} else if (diff < -threshold && currentIndex < images.length - 1) {
await showNextImage();
} else {
container.style.transform = 'translateX(-33.333%)';
}
}
function handleKeyDown(e) {
if (isZoomed) return;
if (e.code === 'Space' || e.code === 'ArrowRight') {
e.preventDefault();
showNextImage();
} else if (e.code === 'Backspace' || e.code === 'ArrowLeft') {
e.preventDefault();
showPreviousImage();
}
}
function waitForTransition() {
return new Promise(resolve => {
container.addEventListener('transitionend', resolve, { once: true });
});
}
// Initialize
loadSlides();
// Navigation event listeners
container.addEventListener('touchstart', handleTouchStart);
container.addEventListener('touchmove', handleTouchMove);
container.addEventListener('touchend', handleTouchEnd);
container.addEventListener('click', handleZoom);
document.addEventListener('keydown', handleKeyDown);
prevArrow.addEventListener('click', showPreviousImage);
nextArrow.addEventListener('click', showNextImage);
// Mouse panning event listeners
container.addEventListener('mousedown', function(e) {
if (isZoomed && e.target.tagName === 'IMG') {
isPanning = true;
hasPanned = false;
updateBoundaries();
startX = e.clientX - translateX;
startY = e.clientY - translateY;
e.preventDefault();
container.style.cursor = 'grabbing';
}
});
window.addEventListener('mousemove', function(e) {
if (isPanning && isZoomed) {
const proposedX = e.clientX - startX;
const proposedY = e.clientY - startY;
const limited = limitPanning(proposedX, proposedY);
translateX = limited.x;
translateY = limited.y;
const img = slides.current.querySelector('img');
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`;
hasPanned = true;
}
});
window.addEventListener('mouseup', function() {
if (isPanning) {
isPanning = false;
container.style.cursor = isZoomed ? 'zoom-out' : 'zoom-in';
}
});
// Touch panning event listeners
container.addEventListener('touchstart', function(e) {
if (isZoomed) {
isPanning = true;
hasPanned = false;
updateBoundaries();
const touch = e.touches[0];
startX = touch.clientX - translateX;
startY = touch.clientY - translateY;
e.preventDefault();
}
});
container.addEventListener('touchmove', function(e) {
if (isPanning && isZoomed) {
const touch = e.touches[0];
const proposedX = touch.clientX - startX;
const proposedY = touch.clientY - startY;
const limited = limitPanning(proposedX, proposedY);
translateX = limited.x;
translateY = limited.y;
const img = slides.current.querySelector('img');
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`;
hasPanned = true;
e.preventDefault();
}
});
container.addEventListener('touchend', function(e) {
if (isPanning) {
isPanning = false;
}
});

View File

@@ -51,10 +51,6 @@ midi:C0=iop/channelmixerrgb;focus
midi:C#0=iop/colorequal;focus
midi:D0=iop/colorequal/page;previous
midi:D#0=iop/colorequal/page;next
USAGE:
To add a new module, simply add an entry to MODULE_PROFILES below.
Get action paths from darktable's shortcut interface.
]]
local dt = require "darktable"
@@ -63,283 +59,150 @@ local du = require "lib/dtutils"
du.check_min_api_version("9.2.0", "x-touch")
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
local function _(msgid)
return gettext(msgid)
end
-- Constants
local NUM_KNOBS = 8
local NAN = 0/0
local darkroom_view = dt.gui.views.darkroom
-- return data structure for script_manager
-- ============================================================================
-- USER CONFIGURATION
-- ============================================================================
local script_data = {}
-- Module Profiles: Define behavior when each module has focus
-- Structure: [module_name] = { knob1_action, knob2_action, ... }
local MODULE_PROFILES = {
-- Color Zones: Control individual color channels
colorzones = {
{"iop/colorzones/graph", "red"},
{"iop/colorzones/graph", "orange"},
{"iop/colorzones/graph", "yellow"},
{"iop/colorzones/graph", "green"},
{"iop/colorzones/graph", "aqua"},
{"iop/colorzones/graph", "blue"},
{"iop/colorzones/graph", "purple"},
{"iop/colorzones/graph", "magenta"}
},
-- Color Equalizer: Control color channels
colorequal = {
{"iop/colorequal/graph", "red"},
{"iop/colorequal/graph", "orange"},
{"iop/colorequal/graph", "yellow"},
{"iop/colorequal/graph", "green"},
{"iop/colorequal/graph", "cyan"},
{"iop/colorequal/graph", "blue"},
{"iop/colorequal/graph", "lavender"},
{"iop/colorequal/graph", "magenta"}
},
-- Sigmoid: Control primary color adjustments
sigmoid = {
{"iop/sigmoid/primaries/red attenuation"},
{"iop/sigmoid/primaries/red rotation"},
{"iop/sigmoid/primaries/green attenuation"},
{"iop/sigmoid/primaries/green rotation"},
{"iop/sigmoid/primaries/blue attenuation"},
{"iop/sigmoid/primaries/blue rotation"},
{"iop/sigmoid/primaries/recover purity"}
},
-- Color Calibration (Primaries)
primaries = {
{"iop/primaries/red hue"},
{"iop/primaries/red purity"},
{"iop/primaries/green hue"},
{"iop/primaries/green purity"},
{"iop/primaries/blue hue"},
{"iop/primaries/blue purity"},
{"iop/primaries/tint hue"},
{"iop/primaries/tint purity"}
},
-- AgX
agx = {
{"iop/agx/white relative exposure"},
{"iop/agx/black relative exposure"},
{"iop/agx/curve/pivot relative exposure"},
{"iop/agx/curve/pivot target output"},
{"iop/agx/curve/contrast"},
{"iop/agx/curve/shoulder power"},
{"iop/agx/curve/toe power"},
{"iop/agx/curve/curve y gamma"}
},
-- Tone Equalizer: Control EV bands
-- For 8 knobs, using offsets -8 to get bands
toneequal = {
{"iop/toneequal/simple/-8 EV"},
{"iop/toneequal/simple/-7 EV"},
{"iop/toneequal/simple/-6 EV"},
{"iop/toneequal/simple/-5 EV"},
{"iop/toneequal/simple/-4 EV"},
{"iop/toneequal/simple/-3 EV"},
{"iop/toneequal/simple/-2 EV"},
{"iop/toneequal/simple/-1 EV"}
},
-- Color Balance RGB: Selected controls
colorbalancergb = {
nil, -- Knob 1: unused --> default
{"iop/colorbalancergb/global vibrance"},
{"iop/colorbalancergb/contrast"},
{"iop/colorbalancergb/global chroma"},
{"iop/colorbalancergb/global saturation"},
{"iop/colorbalancergb/global brilliance"},
nil, -- Knob 7: --> default
nil -- Knob 8: --> default
},
-- Channel Mixer RGB (Color Calibration): Tab control and sliders
channelmixerrgb = {
nil, -- Knob 1-4: --> default
nil,
nil,
nil,
{"iop/channelmixerrgb/page", "CAT", "tab"}, -- Knob 5: Tab selector
{"iop/focus/sliders", "1st"}, -- Knob 6: First slider
{"iop/focus/sliders", "2nd"}, -- Knob 7: Second slider
{"iop/focus/sliders", "3rd"} -- Knob 8: Third slider
},
script_data.metadata = {
name = _("x-touch"),
purpose = _("example of how to control an x-touch midi device"),
author = "Diederik ter Rahe",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/x-touch"
}
local DEFAULT_MAPPINGS = {
{"iop/exposure/exposure"},
{"iop/filmicrgb/white relative exposure"},
{"iop/filmicrgb/black relative exposure"},
{"iop/filmicrgb/contrast"},
{"iop/crop/left"},
{"iop/crop/right"},
{"iop/crop/top"},
{"iop/crop/bottom"}
}
-- Mask properties: Always checked first, regardless of module focus
local MASK_PROPERTIES = {
{"lib/masks/properties/opacity"},
{"lib/masks/properties/size"},
{"lib/masks/properties/feather"},
{"lib/masks/properties/hardness"},
{"lib/masks/properties/rotation"},
{"lib/masks/properties/curvature"},
{"lib/masks/properties/compression"}
}
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
-- Priority order for checking module focus (most commonly used first)
local MODULE_CHECK_ORDER = {
"toneequal",
"colorequal",
"colorbalancergb",
"agx",
"sigmoid",
"primaries",
"channelmixerrgb",
"colorzones"
}
-- set up 8 mimic sliders with the same function
for k = 1,8 do
dt.gui.mimic("slider", "knob ".. k,
function(action, element, effect, size)
-- take the number from the mimic name
local k = tonumber(action:sub(-1))
-- ============================================================================
-- CORE LOGIC - No need to edit below unless changing architecture
-- ============================================================================
-- only operate in darkroom; return NAN otherwise
if dt.gui.current_view() ~= dt.gui.views.darkroom then
return 0/0
end
-- Helper functions
local function is_in_darkroom()
return dt.gui.current_view() == darkroom_view
end
local maskval = 0/0
if k < 8 then
-- first try if the mask slider at that position is active
local s = { "opacity",
"size",
"feather",
"hardness",
"rotation",
"curvature",
"compression" }
maskval = dt.gui.action("lib/masks/properties/" .. s[k],
element, effect, size)
end
-- if a value different from NAN is returned, the slider was active
if maskval == maskval then
return maskval
local function is_valid(value)
return value == value -- NaN check
end
-- try if colorzones module is focused; if so select element of graph
elseif dt.gui.action("iop/colorzones", "focus") ~= 0 then
which = "iop/colorzones/graph"
local e = { "red",
"orange",
"yellow",
"green",
"aqua",
"blue",
"purple",
"magenta" }
element = e[k]
-- try if colorequalizer module is focused; if so select element of graph
elseif dt.gui.action("iop/colorequal", "focus") ~= 0 then
local e = { "red",
"orange",
"yellow",
"green",
"cyan",
"blue",
"lavender",
"magenta" }
which = "iop/colorequal/graph"
element = e[k]
-- if the sigmoid rgb primaries is focused,
-- check sliders
elseif dt.gui.action("iop/sigmoid", "focus") ~= 0 and k <8 then
local e = { "red attenuation", "red rotation", "green attenuation", "green rotation", "blue attenuation", "blue rotation", "recover purity" }
which = "iop/sigmoid/primaries/"..e[k]
local function translate_tab_effect(effect)
if effect == "up" then
return "next"
elseif effect == "down" then
return "previous"
else
return "activate"
end
end
-- Execute an action configuration
local function execute_action(action_config, element, effect, size)
if not action_config then
return nil
end
local path = action_config[1]
local elem = action_config[2] or element
local action_type = action_config[3]
-- Handle special action types
if action_type == "tab" then
effect = translate_tab_effect(effect)
end
return dt.gui.action(path, elem, effect, size)
end
-- Try mask properties first
local function try_mask_properties(knob_idx, element, effect, size)
if knob_idx >= NUM_KNOBS then
return NAN
end
local mask_action = MASK_PROPERTIES[knob_idx]
if not mask_action then
return NAN
end
local value = execute_action(mask_action, element, effect, size)
return value
end
-- Main knob handler
local function handle_knob_action(knob_idx, element, effect, size)
-- Early exit for non-darkroom view
if not is_in_darkroom() then
return NAN
end
-- Try mask properties first (no focus check needed)
local mask_value = try_mask_properties(knob_idx, element, effect, size)
if is_valid(mask_value) then
return mask_value
end
-- Check focused modules in priority order
for _, module_name in ipairs(MODULE_CHECK_ORDER) do
local focus_status = dt.gui.action("iop/" .. module_name, "focus")
if focus_status ~= 0 then
-- Module has focus, get its profile
local profile = MODULE_PROFILES[module_name]
-- if the rgb primaries is focused,
-- check sliders
elseif dt.gui.action("iop/primaries", "focus") ~= 0 and k >=1 then
local e = { "red hue", "red purity", "green hue", "green purity", "blue hue", "blue purity", "tint hue", "tint purity" }
which = "iop/primaries/" ..e[k]
if profile then
local action_config = profile[knob_idx]
-- If profile has explicit mapping for this knob, use it
if action_config then
local result = execute_action(action_config, element, effect, size)
if result then
return result
-- if the tone equalizer is focused,
-- select one of the sliders in the "simple" tab
elseif dt.gui.action("iop/toneequal", "focus") ~= 0 then
which ="iop/toneequal/simple/"..(k-9).." EV"
-- if color calibration is focused,
-- the last 4 knobs are sent there
elseif dt.gui.action("iop/channelmixerrgb", "focus") ~= 0
and k >= 5 then
-- knob 5 selects the active tab; pressing it resets to CAT
if k == 5 then
which = "iop/channelmixerrgb/page"
element = "CAT"
-- since the tab action is not a slider,
-- the effects need to be translated
if effect == "up" then effect = "next"
elseif effect == "down" then effect = "previous"
else effect = "activate"
end
else
-- Profile exists but this knob is nil -> use default
-- This avoids checking other modules when we know the focused one
local default_action = DEFAULT_MAPPINGS[knob_idx]
return execute_action(default_action, element, effect, size) or NAN
-- knobs 6, 7 and 8 are for the three color sliders on each tab
which = "iop/focus/sliders"
local e = { "1st",
"2nd",
"3rd" }
element = e[k - 5]
end
-- the 4th knob is contrast;
-- either colorbalance if it is focused, or filmic
elseif dt.gui.action("iop/colorbalancergb", "focus") ~= 0
and k == 4 then
which = "iop/colorbalancergb/contrast"
-- in all other cases use a default selection
else
local s = { "iop/exposure/exposure",
"iop/filmicrgb/white relative exposure",
"iop/filmicrgb/black relative exposure",
"iop/filmicrgb/contrast",
"iop/crop/left",
"iop/crop/right",
"iop/crop/top",
"iop/crop/bottom" }
which = s[k]
end
-- If no profile exists for this module, continue checking others
end
end
-- No module has focus or no profile matched, fall back to default
local default_action = DEFAULT_MAPPINGS[knob_idx]
return execute_action(default_action, element, effect, size) or NAN
-- now pass the element/effect/size to the selected slider
return dt.gui.action(which, element, effect, size)
end)
end
-- Setup mimic sliders
local function setup_knobs()
for k = 1, NUM_KNOBS do
dt.gui.mimic("slider", "knob " .. k,
function(action, element, effect, size)
local knob_idx = tonumber(action:sub(-1))
return handle_knob_action(knob_idx, element, effect, size)
end)
end
local function destroy()
-- nothing to destroy
end
-- Script metadata
local script_data = {
metadata = {
name = _("x-touch"),
purpose = _("data-driven x-touch midi controller configuration"),
author = "Martin Straeten",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/x-touch"
},
destroy = function() end,
destroy_method = nil,
restart = nil,
show = nil
}
-- Initialize
setup_knobs()
script_data.destroy = destroy
return script_data

View File

@@ -773,29 +773,33 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
local labels = get_colorlabels(image)
local emon_long, emon_short
local eyear, emon, eday, ehour, emin, esec, emsec
local datetime_taken = ""
local use_millisecs = false
if dt.preferences.read("darktable", "lighttable/ui/milliseconds", "bool") and is_api_9_1 then
use_millisecs = true
end
if image.exif_datetime_taken and image.exif_datetime_taken ~= "" then
emon_long = os.date("%B", exiftime2systime(image.exif_datetime_taken))
emon_short = os.date("%b", exiftime2systime(image.exif_datetime_taken))
if use_millisecs then
eyear, emon, eday, ehour, emin, esec, emsec =
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)%.(%d+)$")
else
emsec = "0"
eyear, emon, eday, ehour, emin, esec =
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
end
datetime_taken = image.exif_datetime_taken
else
log.msg(log.warn, "no capture date and time found in EXIF data")
emon_long, emon_short, eyear, emon, eday, ehour, emin, esec, emsec = "", "", "", "", "", "", "", "", ""
if use_millisecs then
datetime_taken = "0000:00:00 00:00:00.0"
else
datetime_taken = "0000:00:00 00:00:00"
end
end
local eyear, emon, eday, ehour, emin, esec, emsec
if use_millisecs then
eyear, emon, eday, ehour, emin, esec, emsec =
string.match(datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)%.(%d+)$")
else
emsec = "0"
eyear, emon, eday, ehour, emin, esec =
string.match(datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
end
local version_multi = #image:get_group_members() > 1 and image.duplicate_index or ""
local replacements = {dtutils_string.get_basename(image.film.path),-- ROLL.NAME
@@ -834,10 +838,10 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
string.format("%02d", datetime.sec), -- SECOND
0, -- MSEC
eyear, -- EXIF.YEAR
(eyear ~= nil) and string.sub(eyear, 3) or "", -- EXIF.YEAR.SHORT
string.sub(eyear, 3), -- EXIF.YEAR.SHORT
emon, -- EXIF.MONTH
emon_long, -- EXIF.MONTH.LONG
emon_short, -- EXIF.MONTH.SHORT
os.date("%B", exiftime2systime(datetime_taken)), -- EXIF.MONTH.LONG
os.date("%b", exiftime2systime(datetime_taken)), -- EXIF.MONTH.SHORT
eday, -- EXIF.DAY
ehour, -- EXIF.HOUR
"", -- EXIF.HOUR.AMPM
@@ -890,7 +894,7 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
home_folder, -- FOLDER.HOME
desktop_folder, -- FOLDER.DESKTOP
"", -- OPENCL.ACTIVATED - wont be implemented
(user_name ~= nil) and user_name or "",-- USERNAME
user_name, -- USERNAME
"", -- NL - wont be implemented
"" -- JOBCODE - wont be implemented
}

View File

@@ -91,10 +91,8 @@ function dtutils_system.windows_command(command)
file:write("chcp 65001>nul\n") -- change the encoding of the terminal to handle non-english characters in path
file:write("\n")
file:write(command .. "\n")
file:write("set COMMAND_RC=%ERRORLEVEL%")
file:write("\n")
file:write("chcp %cp%>nul\n")
file:write("exit /b %COMMAND_RC%") -- otherwise, when the real command fails, script will still return "good" error code of chcp
file:close()
result = dt.control.execute(fname)

View File

@@ -1,283 +0,0 @@
--[[
auto_straighten.lua - straighten an image in darkroom using image metadata
Copyright (C) 2025 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 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/>.
]]
--[[
auto_straighten - straighten an image in darkroom using image metadata
auto_straighten reads the roll and pitch angle imformation captured when
an image is captured. A correction is computed and applied to correct the
roll to the nearest vertical or horizontal axis using the rotate and
perspective module.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
exiftool - https://exiftool.org
USAGE
* add it sowewhere in your scripts directory
* ensure exiftool is installed and accessible
* enable it using script manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
LIMITATIONS
* Roll and pitch angles are taken when an image is captured. If you shoot
in burst mode, the roll and pitch angles of the first image are used in
the rest of the burst (at least for Canon).
* If you are running multiple scripts that trigger on an image being opened
in darkroom then the auto crop may not work. There is a shortcut included
that can have a key sequence assigned so that the auto crop will be reapplied.
* Currently oply roll angle is corrected
LIEENSE
GPL V2
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local da = require "lib/dtutils.action"
local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "auto_straighten"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("auto_straighten"), -- visible name of script
purpose = _("straighten an image in darkroom using image metadata"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/auto_straighten/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local auto_straighten = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
auto_straighten.pixelpipe_busy = false
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "crop_area", "enum",
_("automatic_cropping"),
_("what automatic cropping to perform"),
_("original format"),
_("off"), _("largest area"), _("original format"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = auto_straighten
local as = auto_straighten
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function determine_correction(roll_angle)
--[[
Canon camera roll angle is as foillows
* landscape orientation camera upright = 0
* camera rolled to the left (shutter button up) portrait = -90
* camera inverted landscape = +/- 180
* camera rolled to the right (shutter button down) portrait = 90
rotate and perspective corrections
* rotate the image to the right is a negative correction
* rotate the image left is a positive correction
]]
if roll_angle == 0 or -- landscape
roll_angle == -90 or -- roll left portrait
math.abs(roll_angle) == 180 or -- inverted landscape
roll_angle == 90 then -- roll right portrait
dt.print_log("on axis, no correction required")
return 0
elseif roll_angle > 0 and roll_angle <= 45 then
return (roll_angle * -1.0)
elseif roll_angle > 45 and roll_angle < 90 then
return (90 - roll_angle)
elseif roll_angle > 90 and roll_angle <= 135 then
return (roll_angle - 90) * -1.0
elseif roll_angle > 135 and roll_angle < 180 then
return (180 - roll_angle)
elseif roll_angle > -180 and roll_angle <= -135 then
return (-180 - roll_angle)
elseif roll_angle > -135 and roll_angle <= -90 then
return (math.abs(roll_angle) - 90)
elseif roll_angle > -90 and roll_angle <= -45 then
return (-90 - roll_angle)
else
return roll_angle * -1.0
end
end
local function toggle_auto_crop()
local crop = dt.preferences.read(MODULE, "crop_area", "enum")
dt.gui.action("iop/ashift/automatic cropping", "selection", "item:off", 1.000)
dt.control.sleep(100)
dt.gui.action("iop/ashift/automatic cropping", "selection", "item:" .. crop, 1.000)
end
local function straighten_image(image)
local roll = nil
local pitch = nil
-- Roll Angle 0 is landscape, -90 is rolled to the left in portrait, +- 180 is landscape inverted, 90 is portrait rolled to the right
local crop = dt.preferences.read(MODULE, "crop_area", "enum")
local pipe = io.popen(as.exiftool .. " -RollAngle -PitchAngle " .. image.path .. PS .. image.filename)
if pipe then
for line in pipe:lines() do
local pr, val = string.match(line, "(.).+: (.+)")
if pr == "R" then
dt.print_log("roll angle is " .. val)
log.msg(log.debug, "roll angle is " .. val)
roll = determine_correction(tonumber(val))
dt.print_log("corrected roll angle is " .. roll)
log.msg(log.debug, "corrected roll angle is " .. roll)
elseif pr == "P" then
pitch = tonumber(val)
end
end
pipe:close()
end
if roll and dt.gui.action("iop/ashift", "enable", "", "") == 0.0 then
dt.print_log("straightening image")
dt.gui.action("iop/ashift/rotation", "value", "set", roll)
-- dt.gui.action("iop/ashift/automatic cropping", "selection", "item:" .. crop, 1.000)
-- da.wait_until_pixelpipe_complete()
toggle_auto_crop()
end
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
as.exiftool = df.check_if_bin_exists("exiftool")
if not as.exiftool then
log.msg(log.error, "exiftool not found, exiting...")
log.msg(log.screen, _("exiftool not found, exiting..."))
return
end
-- da.register_events(MODULE, straighten_image)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "darkroom-image-loaded")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
---[[
dt.register_event(MODULE, "darkroom-image-loaded",
function(event, clean, image)
if clean then
straighten_image(image)
end
end
)
--]]
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
toggle_auto_crop()
end, "toggle auto crop off and on"
)
return script_data

View File

@@ -1,459 +0,0 @@
--[[
extract_burst_roll_images.lua - extract burst images from a burst roll file
Copyright (C) 2026 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 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/>.
]]
--[[
extract_burst_roll_images - extract burst images from a burst roll file
Canon cameras are capable of shooting in burst mode. When the shutter
button is half pressed the camera starts "recording". When the shutter is
fully pressed the pervious 1/2 second of imformation is recorded as individual
images stored in one file as well as images captured at 30 frames/sec until
the buffer fills or the shutter button is released. So a Canon R7 burst file
could contain ~90 images.
Extracting the images from this file required use of Canon proprietary software
until recently when dnglab got the capability to extract the embedded images as
DNG raws.
This script can be set to run on import, scanning for burst roll containers,
extracting the embedded images and grouping them with the burst roll container.
The script can alsu be utilized via a short cut and applied to selected images.
When set to on import the script scans each image after import using exiv2 or exiftool
to check the image EXIF information to see if the file is a busrt roll container.
Once the import images are scanned, the detected burst roll containers are processed,
the embedded images extracted as DNSs, and grouped with the container image.
In shortcut mode the user selects the burst roll containers manually and uses the shortcut
to extract the embedded images as DNGs
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
- exiv2 - https://exiv2.org
- exiftool - https://exiftool.org
- dnglab - https://github.com/dnglab/dnglab
USAGE
- add the script to your lua scripts
- enable in script manager
- set the preferences in perferences->Lua options
LIMITATIONS
extract_burst_roll_images makes heavy use of external programs to identify burst roll
containters and to extract the images. Windows users may want to use it in shortcut mode
to lessen the number of windows popping up.
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string"
local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("9.6.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("extract burst roll images"), -- visible name of script
purpose = _("extract burst images from a burst roll file"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/extract_burst_roll_images/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "extract_burst_roll_images"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
local EXIFTOOL <const> = df.check_if_bin_exists("exiftool")
local EXIV2 <const> = df.check_if_bin_exists("exiv2")
local DNGLAB <const> = df.check_if_bin_exists("dnglab")
local DNGLAB_ARGS <const> = " convert --image-index all --embed-raw false "
local EXIV2_ARGS <const> = " -K Exif.Canon.RawBurstModeRoll -pt "
local EXIFTOOL_ARGS <const> = " -T -RawBurstImageCount "
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local extract_burst_roll_images = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
extract_burst_roll_images.preferred_tag_reader = nil
extract_burst_roll_images.on_import = true
extract_burst_roll_images.imported_images = {}
extract_burst_roll_images.log_level = DEFAULT_LOG_LEVEL
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- exiv2 or exiftool
dt.preferences.register(MODULE, -- script: This is a string used to avoid name collision in preferences (i.e namespace). Set it to something unique, usually the name of the script handling the preference.
"tag_reader", -- name
"enum", -- type
_("preferred exif tag reader"), -- label
_("use exiftool or exiv2 to read exif tags"), -- tooltip
"exiftool", -- default
"exiftool", "exiv2") -- values
-- run on import
dt.preferences.register(MODULE, -- script: This is a string used to avoid name collision in preferences (i.e namespace). Set it to something unique, usually the name of the script handling the preference.
"on_import", -- name
"bool", -- type
_("extract burst roll images on import"), -- label
_("extract burst roll images on import"), -- tooltip
true) -- default
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = extract_burst_roll_images
local ebri = extract_burst_roll_images
local sleep = dt.control.sleep
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(ebri.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(ebri.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
local function get_last_value(valuestr)
local old_log_level = set_log_level()
local parts = du.split(valuestr, " +")
if parts then
log.msg(log.debug, "last value of parts is " .. tostring(parts[#parts]))
return parts[#parts]
else
log.msg(log.debug, "last part not detected, returning nil")
return nil
end
restore_log_level(old_log_level)
end
local function is_burst_roll_image(image)
local old_log_level = set_log_level()
local result = false
local cmd = EXIFTOOL .. EXIFTOOL_ARGS
if ebri.preferred_tag_reader == "exiv2" then
cmd = EXIV2 .. EXIV2_ARGS
end
local p = io.popen(cmd .. ds.sanitize(image.path) .. PS .. ds.sanitize(image.filename))
if p then
for line in p:lines() do
if ebri.preferred_tag_reader == "exiv2" then
line = get_last_value(line)
end
line = tonumber(line)
if type(line) == "number" then
if tonumber(line) > 0 then
log.msg(log.info, image.filename .. " is a burst roll container image")
result = true
end
end
end
p:close()
end
restore_log_level(old_log_level)
return result
end
local function group_extracted_dngs(image)
local old_log_level = set_log_level()
local count = 0
local cmd = "ls -1 " .. ds.sanitize(image.path) .. PS .. ds.sanitize(ds.get_basename(image.filename)) .. "*.dng"
if dt.configuration.running_os == "windows" then
cmd = "forfiles /P " .. ds.sanitize(image.path) .. " /M " .. ds.sanitize(ds.get_basename(image.filename)) .. "*.dng" .. " /C \"cmd /c echo @path\\@fname\""
end
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:length() > 5 then -- skip blank lines created by forfiles
local dng_image = dt.database.import(line)
dng_image:group_with(image)
count = count + 1
end
end
p:close()
log.msg(log.screen, string.format(_("extracted %d dng files from %s"), count, image.filename))
end
restore_log_level(old_log_level)
end
local function extract_burst(image)
local old_log_level = set_log_level()
local cmd = DNGLAB .. DNGLAB_ARGS .. ds.sanitize(image.path) .. PS .. ds.sanitize(image.filename) .. " " .. ds.sanitize(image.path)
local result = dtsys.external_command(cmd)
if result == 0 then
group_extracted_dngs(image)
end
restore_log_level(old_log_level)
end
local function process_image(image)
local old_log_level = set_log_level()
if is_burst_roll_image(image) then
extract_burst(image)
end
restore_log_level(old_log_level)
end
local function stop_job(job)
job.valid = false
end
local function process_images(images)
local old_log_level = set_log_level()
local job = dt.gui.create_job(_("scanning for burst roll images"), true, stop_job)
for i, image in ipairs(images) do
if ebri.job.valid then
extract_burst(image)
ebri.job.percent = i / #images
sleep(10)
end
end
stop_job(job)
restore_log_level(old_log_level)
end
local function process_import(images)
local old_log_level = set_log_level()
local burst_roll_images = {}
local job = dt.gui.create_job(_("scanning for burst roll images"), true, stop_job)
for i, image in ipairs(images) do
if job.valid then
if is_burst_roll_image(image) then
table.insert(burst_roll_images, image)
end
if i % 10 == 0 then
job.percent = i / #images
end
sleep(10)
end
end
stop_job(job)
if #burst_roll_images > 0 then
job = dt.gui.create_job(_("extracting burst roll images"), true, stop_job)
for j, image in ipairs(burst_roll_images) do
if job.valid then
extract_burst(image)
job.percent = j / #burst_roll_images
sleep(10)
end
end
stop_job(job)
end
restore_log_level(old_log_level)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
if not DNGLAB then
log.msg(log.error, MODULE .. _("dnglab executable not found"))
log.msg(log.screen, script_data.metadata.name .. _(": dnglab executable not found, exiting..."))
return
end
if not EXIFTOOL and not EXIV2 then
log.msg(log.error, MODULE .. _("no exif tag reader available"))
log.msg(log.screen, script_data.metadata.name .. _(" no exif tag reader available, exiting..."))
return
end
ebri.preferred_tag_reader = pref_read("tag_reader", "enum")
if not string.match(EXIFTOOL, ebri.preferred_tag_reader) and not string.match(EXIV2, ebri.preferred_tag_reader) then
log.msg(log.error, MODULE .. " selected tag " .. ebri.preferred_tag_reader .." not available")
log.msg(log.screen, script_data.metadata.name .. string.format(_(" selected tag reader %s not available"), ebri.preferred_tag_reader))
return
end
ebri.on_import = dt.preferences.read(MODULE, "on_import", "bool")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "post-import-film")
dt.destroy_event(MODULE, "post-import-image")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "post-import-film",
function(event, film)
process_import(ebri.imported_images)
ebri.imported_images = {}
end
)
dt.register_event(MODULE, "post-import-image",
function(event, image)
if ebri.on_import then
table.insert(ebri.imported_images, image)
end
end
)
if not dt.query_event(MODULE, "shortcut") then
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
if #dt.gui.action_images > 1 then
process_images(dt.gui.action_images)
else
process_image(dt.gui.action_images[1])
end
end, "extract roll burst images"
)
end
return script_data

View File

@@ -1,694 +0,0 @@
--[[
group_persistence.lua - preserve groups between darktable instances
Copyright (C) 2024-2026 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 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/>.
]]
--[[
group_persistence - preserve groups between darktable instances
group_persistence uses "functional" tagging to maintain image groups
across database instances, allowing image grouping to be restored when
the database is rebuilt from the XMP sidecar files.
Once the script is started it "listens" for changes in image grouping and
applies or removes the necessary tags automatically.
A shortcut can be assigned to read a collection and apply the necessary
grouping tags so that existing collections can be maintained.
If the database is lost and needs to be rebuilt then start the script before
importing any images. As images are imported the tags will be read and the
grouping applied in the database.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* Enable the script using script manager
* Optionally, create a keystroke combination for the shortcut
LIMITATIONS
In normal use you wont notice that the script is running.
However if you do something like import 4000+ images, then
run the autogrouper script to create groups whenever there is
a 5 second difference between 2 images, your system will be busy
for awhile. If for some reason you need to do something like this
turn off group_persistence, run the autogrouper, turn on group_persistence
and use the shortcut to read all the grouping information and apply
the necessary tags. It will still be an impact, but less than trying
to do both at once. Learned from experience :-)
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "group_persistence"
local DEFAULT_LOG_LEVEL <const> = log.error
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("9.4.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "group_persistence", -- name of script
purpose = _("preserve groups between darktable instances"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local group_persistence = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = group_persistence
local gp = group_persistence
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- table of imported images that need grouping rebuilt
gp.imported_images = {}
-- uuid to group leader mapping
gp.uuids = {}
-- group leader to uuid mapping
gp.leaders = {}
-- uuid to group tag mapping
gp.group_tags = {}
-- uuid to group leader tag mapping
gp.leader_tags = {}
-- when the groups are rebuilt after import they
-- cause the group change events to fire but the
-- events are queued until after rebuilding completes
-- so we need to build a table of images to ignore when
-- the queued events catch up. As the event fires for an
-- image, the image is removed.
gp.rebuilt_images = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- utilities for building/debugging
local function dump_image_tags(image)
log.msg(log.debug, "")
for _, tag in ipairs(image:get_tags()) do
log.msg(log.debug, "tag for image " .. image.id .. " is " .. tag.name)
end
log.msg(log.debug, "group_leader is " .. image.group_leader.id .. " and group has " .. #image:get_group_members() .. " members")
log.msg(log.debug, "")
end
local function dump_group_tags(image)
local leader = image.group_leader
for _, tag in ipairs(image:get_tags()) do
if string.match(tag.name, "darktable|group") then
log.msg(log.debug, "group leader image " .. image.id .. " has tag " .. tag.name)
end
end
for _, img in ipairs(image:get_group_members()) do
for _, tag in ipairs(img:get_tags()) do
if string.match(tag.name, "darktable|group") then
log.msg(log.debug, "group member image " .. img.id .. " has tag " .. tag.name)
end
end
end
end
-- rebuild grouping on import
local function save_group_leader(image, uuid)
log.log_level(DEFAULT_LOG_LEVEL)
log.msg(log.debug, "input image is " .. image.id .. " and uuid is " .. uuid)
gp.uuids[image] = uuid
gp.leaders[uuid] = image
log.msg(log.debug, "after saving group leader uuid is " .. gp.leaders[uuid].id .. " and image is " .. gp.uuids[image])
end
local function get_group_and_uuid(tags)
log.log_level(DEFAULT_LOG_LEVEL)
local group_type = nil
local uuid = nil
for _, tag in ipairs(tags) do
if string.match(tag.name, "darktable|group") then
log.msg(log.debug, "found tag darktable|group")
local parts = du.split(tag.name, "|")
group_type = parts[2]
uuid = parts[3]
log.msg(log.info, "found type " .. group_type .. " with uuid " .. uuid)
end
end
return group_type, uuid
end
local function get_uuid_from_tag(tag)
local parts = du.split(tag.name, "|")
return parts[#parts]
end
local function find_group_leader(uuid)
local group_leader = nil
local tag_name = "darktable|group_leader|" .. uuid
local found_tag = dt.tags.find(tag_name)
if found_tag then
group_leader = dt.tags.get_tagged_images(found_tag)[1]
end
return group_leader
end
local function rebuild_groups()
log.log_level(DEFAULT_LOG_LEVEL)
for _, image in ipairs(gp.imported_images) do
log.msg(log.info, "processing image " .. image.id)
local tags = image:get_tags()
local group_type, uuid = get_group_and_uuid(tags)
if group_type =="group_leader" then
table.insert(gp.rebuilt_images, image)
log.msg(log.debug, "took group_leader branch")
save_group_leader(image, uuid)
elseif group_type == "group" then
table.insert(gp.rebuilt_images, image)
log.msg(log.debug, "took group branch")
local leader = gp.uuids[uuid]
if leader then
image:group_with(leader)
else
local group_leader = find_group_leader(uuid)
if group_leader then
save_group_leader(group_leader, uuid)
image:group_with(group_leader)
end
end
end
end
gp.imported_images = {}
end
-- rebuild grouping on command (shortcut)
local function is_group_leader(image)
result = false
if image.group_leader == image and #image:get_group_members() > 1 then
result = true
end
return result
end
local function has_group_tag(image)
local result = false
for _, tag in ipairs(image:get_tags()) do
if string.match(tag.name, "darktable|group|") then
result = tag
end
end
return result
end
local function has_group_leader_tag(image)
local result = false
for _, tag in ipairs(image:get_tags()) do
if string.match(tag.name, "darktable|group_leader") then
result = tag
end
end
return result
end
-- handle grouping changes in the UI
local function group_exists(group_leader)
local exists = false
local tags = group_leader:get_tags()
for _, tag in ipairs(tags) do
if string.match(tag.name, "group_leader") then
exists = true
break
end
end
return exists
end
local function add_group_leader(image, group)
-- tag image as group leader
-- create the uuid and leader/group tags if necessary
log.log_level(DEFAULT_LOG_LEVEL)
local uuid = du.gen_uuid()
if uuid then
log.msg(log.debug, "in add_group_leader and uuid is " .. uuid)
else
log.msg(log.debug, "uuid not created")
end
local tag = dt.tags.create("darktable|group_leader|" .. uuid)
local group_tag = dt.tags.create("darktable|group|" .. uuid)
log.msg(log.info, "created group leader tag " .. tag.name)
log.msg(log.info, "created group tag " .. group_tag.name)
gp.group_tags[uuid] = group_tag
gp.leader_tags[uuid] = tag
gp.uuids[uuid] = image
gp.leaders[image] = uuid
image:attach_tag(tag)
log.msg(log.info, "attached tag " .. tag.name .. " to image " .. image.id)
end
local function add_to_group(image, group_leader)
-- add an image to a group
-- create the group if necessary
log.log_level(DEFAULT_LOG_LEVEL)
if image == group_leader then
add_group_leader(image, group_leader)
else
if not group_exists(group_leader) then
add_group_leader(group_leader, group_leader)
end
local group_tag = nil
log.msg(log.debug, "image is " .. image.id .. " and group_leader is " .. image.group_leader.id)
local group, uuid = get_group_and_uuid(image.group_leader:get_tags())
group_tag = gp.group_tags[uuid]
if not group_tag then
log.msg(log.info, "creating group tag darktable|group|" .. uuid)
group_tag = dt.tags.create("darktable|group|" .. uuid)
gp.group_tags[uuid] = group_tag
end
image:attach_tag(group_tag)
log.msg(log.info, "group tag attached to image " .. image.id)
end
end
local function build_group(leader)
if not has_group_leader_tag(leader) then
add_group_leader(leader)
end
for _, image in ipairs(leader:get_group_members()) do
if not has_group_tag(image) then
add_to_group(image, leader)
end
end
end
local function process(image_set)
local images
if image_set == "collection" then
images = dt.collection
else
images = dt.gui.selection()
if #images == 0 then
images = dt.gui.action_images
end
end
for _, image in ipairs(images) do
if is_group_leader(image) then
build_group(image)
end
end
end
local function check_and_remove_empty_group_data(group_tag)
if not group_tag then
return -- handle duplicate getting deleted
end
local group_leader_tag = nil
group_leader_tag = dt.tags.find(string.gsub(group_tag.name, "|group|", "|group_leader|"))
local group_leader_count = 0
local group_count = #dt.tags.get_tagged_images(group_tag)
if group_leader_tag then
group_leader_count = #dt.tags.get_tagged_images(group_leader_tag)
end
if group_leader_count + group_count < 2 then -- there's either 1 or 0, so no group
local uuid = get_uuid_from_tag(group_tag)
if gp.group_tags[uuid] then
gp.group_tags[uuid] = nil
end
if gp.uuids[image] then
gp.uuids[image] = nil
end
if gp.leaders[uuid] then
gp.leaders[uuid] = nil
end
group_tag:delete()
if group_leader_tag then
group_leader_tag:delete()
end
end
end
local function remove_group_leader(image, new_group_leader)
-- remove image group leader tag
-- if it's the last image, then remove the leader/group tags
log.log_level(DEFAULT_LOG_LEVEL)
dump_image_tags(image)
local group, uuid = get_group_and_uuid(image:get_tags())
local leader_tag = dt.tags.find("darktable|group_leader|" .. uuid)
local group_tag = dt.tags.find("darktable|group|" .. uuid)
image:detach_tag(leader_tag)
gp.leaders[image] = nil
log.msg(log.info, "removed image " .. image.id .. " as group leader for uuid " .. uuid)
new_group_leader:detach_tag(group_tag)
new_group_leader:attach_tag(leader_tag)
log.msg(log.info, "added image " .. new_group_leader.id .. " as group leader for uuid " .. uuid)
gp.leaders[new_group_leader] = uuid
gp.uuids[uuid] = new_group_leader
check_and_remove_empty_group_data(group_tag)
end
local function remove_from_group(image, group_leader)
-- remove an image from a group
-- if it's the last image then remove the tags
-- image.id > group_leder.id - simple remove
-- image.id < group_leader.id - deleting old group_leader (image) with new group_leader (group_leader)
log.log_level(DEFAULT_LOG_LEVEL)
if image.id < group_leader.id then
remove_group_leader(image, group_leader)
else
local group_tag = has_group_tag(image)
if group_tag then
image:detach_tag(group_tag)
log.msg(log.info, "image " .. image.filename .. " with id " .. image.id .. " removed from group " .. group_tag.name)
end
check_and_remove_empty_group_data(group_tag)
end
end
local function change_group_leader(image)
-- remove the group leader tag from the old leader
-- replace the old group leader tag with a group tag
-- add the group leader tag to the new image
log.log_level(DEFAULT_LOG_LEVEL)
dump_image_tags(image)
local group, uuid = get_group_and_uuid(image:get_tags())
local leader_tag = dt.tags.find("darktable|group_leader|" .. uuid)
local group_tag = dt.tags.find("darktable|group|" .. uuid)
old_leader = dt.tags.get_tagged_images(leader_tag)[1]
old_leader:detach_tag(leader_tag)
log.msg(log.info, "remove leader tag from image " .. old_leader.id)
old_leader:attach_tag(group_tag)
log.msg(log.info, "attached group tag to image " .. old_leader.id)
image:detach_tag(group_tag)
log.msg(log.info, "removed group tag from new leader image " .. image.id)
image:attach_tag(leader_tag)
log.msg(log.info, "attached leader tag to new leader image " .. image.id)
gp.leaders[uuid] = image
gp.uuids[image] = uuid
end
-- event handling
local function add_group_event()
dt.register_event(MODULE, "image-group-information-changed",
function(event, reason, image, other_image)
log.msg(log.debug, "")
log.msg(log.debug, "caught event with reason " .. reason .. " and image " .. image.id .. " and other image " .. other_image.id)
log.msg(log.debug, "")
for pos, rimage in ipairs(gp.rebuilt_images) do
if rimage == image then
table.remove(gp.rebuilt_images, pos)
return
end
end
if reason == "add" then
log.msg(log.debug, "in add, image is " .. image.id .. " and group is " .. other_image.id)
if image == other_image then
add_group_leader(image, other_image)
else
add_to_group(image, other_image)
end
elseif reason == "remove" then
log.msg(log.debug, "in remove with image " .. image.id .. " and group_id " .. other_image.id)
remove_from_group(image, other_image)
elseif reason == "remove-leader" then
remove_group_leader(image, other_image)
log.msg(log.debug, "in remove-leader with image " .. image.id .. " and new group id " .. other_image.id)
else
if image ~= other_image then
log.msg(log.debug, "in change-leader with image " .. image.id .. " and other_id " .. other_image.id)
change_group_leader(image)
log.msg(log.debug, "in change-leader with image " .. image.id .. " and other_id " .. other_image.id)
else
log.msg(log.debug, "in change-leader with image " .. image.id .. " and other_id " .. other_image.id)
log.msg(log.debug, "calling add_group_leader")
add_group_leader(image, other_image)
end
end
end
)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.util.message(MODULE, "autogrouper", "running")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "post-import-image")
dt.destroy_event(MODULE, "post-import-film")
dt.destroy_event(MODULE, "image-group-information-changed")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "post-import-image",
function(event, image)
table.insert(gp.imported_images, image)
end
)
dt.register_event(MODULE, "post-import-film",
function(event, filmroll)
rebuild_groups()
end
)
add_group_event()
dt.register_event(MODULE, "shortcut",
function(event, string)
dump_image_tags(dt.gui.action_images[1])
end, "dump image tags"
)
dt.register_event(MODULE, "shortcut",
function(event, string)
dump_group_tags(dt.gui.action_images[1])
end, "dump group_tags"
)
dt.register_event(MODULE, "shortcut",
function(event, string)
process("collection")
end, "group collection"
)
return script_data

View File

@@ -1,343 +0,0 @@
--[[
recent_bookmarks.lua - add recently edited and exported images to system recent bookmarks
Copyright (C) 2026 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 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/>.
]]
--[[
recent_bookmarks - add recently edited and exported images to system recently used files
recent_bookmarks adds images edited in darktable and the directory of images exported from
darktable to the reccently-used.xbel file so that they show up in the recently used system
shortcuts.
Currently recent_bookmarks only works on Linux
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
file - linux system file utility to get mime-type
USAGE
eanble from script manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("recent_bookmarks"), -- visible name of script
purpose = _("add recently edited and exported images to system recent bookmarks"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "recent_bookmarks"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- format strings for writing the bookmarks
local BOOKMARK_FORMAT <const> = " <bookmark href=\"file://%s\" added=\"%s\" modified=\"%s\" visited=\"%s\">\n"
local MIME_FORMAT <const> = " <mime:mime-type type=\"%s\"/>\n"
local EXPORT_FORMAT <const> = " <bookmark:application name=\"xdg-open\" exec=\"&apos;xdg-open %%u&apos;\" modified=\"%s\" count=\"1\"/>\n"
local EDIT_FORMAT <const> = " <bookmark:application name=\"darktable\" exec=\"&apos;darktable %%u&apos;\" modified=\"%s\" count=\"1\"/>\n"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local recent_bookmarks = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
recent_bookmarks.log_level = DEFAULT_LOG_LEVEL
recent_bookmarks.bookmark_file = nil
recent_bookmarks.current_view = nil
recent_bookmarks.in_darkroom = false
recent_bookmarks.current_image = nil
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = recent_bookmarks
local rb = recent_bookmarks
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
--------------------
-- program functions
--------------------
local function zulu_timestamp()
return os.date("!%Y-%m-%dT%H:%M:%SZ")
end
local function get_mimetype(filepath)
local mimetype = "application/octet-stream"
local p = io.popen("file -L --mime-type " .. df.sanitize_filename(filepath))
if p then
for line in p:lines() do
local result = string.match(line, ".-: (.+)$")
if result then
mimetype = result
end
end
end
return mimetype
end
local function find_bookmark_file()
local file = nil
local home = os.getenv("HOME")
if home then
if df.test_file(home .. PS ..".local/share/recently-used.xbel", "e") then
file = home .. PS ..".local/share/recently-used.xbel"
end
end
return file
end
local function write_bookmark_file(bookmark, mimetype, application)
local file = io.open(rb.bookmark_file, "r+")
if file then
local pos = file:seek("end", -7)
if pos > 0 then
file:write(bookmark)
file:write(" <info>\n <metadata owner=\"http://freedesktop.org\">\n")
file:write(mimetype)
file:write(" <bookmark:applications>\n")
file:write(application)
file:write(" </bookmark:applications>\n </metadata>\n </info>\n </bookmark>\n</xbel>")
file:close()
else
log.msg(log.screen, _("failed to open bookmark file"))
end
else
log.msg(log.screen, _("failed to open bookmark file"))
end
end
local function create_bookmark_string(filename, zulu)
return string.format(BOOKMARK_FORMAT, filename, zulu, zulu, zulu)
end
local function create_mimetype_string(mimetype)
return string.format(MIME_FORMAT, mimetype)
end
local function process_bookmark(image, filename)
local zulu = zulu_timestamp()
local filepath = filename and filename or (image.path .. PS .. image.filename)
local format = filename and EXPORT_FORMAT or EDIT_FORMAT
local mimetype = get_mimetype(filepath)
local bookmark_string = create_bookmark_string(filepath, zulu)
local mime_string = create_mimetype_string(mimetype)
local app_string = string.format(format, zulu)
write_bookmark_file(bookmark_string, mime_string, app_string)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
if dt.configuration.running_os ~= "linux" then
log.msg(log.screen, script_data.metadata.name .. _(" only runs on Linux"))
return
end
rb.bookmark_file = find_bookmark_file()
if not rb.bookmark_file then
log.msg(log.screen, _("recent bookmarks file not found"))
return
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "intermediate-export-image")
dt.destroy_event(MODULE, "darkroom-image-loaded")
dt.destroy_event(MODULE, "darkroom-image-history-changed")
dt.destroy_event(MODULE, "view-changed")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "intermediate-export-image",
function(event, image, filename, format, storage)
process_bookmark(image, filename)
end
)
dt.register_event(MODULE, "darkroom-image-loaded",
function(event, clean, image)
if clean then
rb.in_darkroom = true
rb.current_view = "darkroom"
rb.current_image = image
end
end
)
dt.register_event(MODULE, "darkroom-image-history-changed",
function(event, image)
if image == rb.current_image then
process_bookmark(image)
end
end
)
dt.register_event(MODULE, "view-changed",
function(event, old_view, new_view)
rb.current_view = new_view.id
if rb.current_view == "darkroom" then
rb.in_darkroom = true
else
if rb.in_darkroom then
rb.in_darkroom = false
end
end
end
)
return script_data

View File

@@ -1,224 +0,0 @@
--[[
scheduler.lua - provide scheduler services for multitasking
Copyright (C) 2024 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 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/>.
]]
--[[
scheduler - provide scheduler services for multitasking
scheduler implements a sinple first in first out (FIFO) scheduler
necessary for lua script multitasking.
Scripts voluntarily yield to the scheduler and they are placed at
the end of the run queue. The first item in the run queue is told
to resume.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
start script from script_manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "scheduler"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
local BROADCAST_MSG <const> = "broadcast"
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("9.4.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "scheduler", -- name of script
purpose = _("provide scheduler services for multitasking"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/scheduler/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local scheduler = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
scheduler.run_queue = {}
scheduler.message_queue = {}
scheduler.log_level = log.debug
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "target_time_slice_seconds", "float", "scheduler preferred time slice", "target time slice before interrupt", 1.0, 0.1, 1.0, 0.1)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local sch = scheduler
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function remove_from_run_queue()
log.log_level(sch.log_level)
if #sch.run_queue > 0 then
local module = sch.run_queue[1]
local message = sch.message_queue[1]
table.remove(sch.run_queue, 1)
table.remove(sch.message_queue, 1)
log.msg(log.info, "run queue length is " .. #sch.run_queue)
log.msg(log.debug, "sending " .. message .. " to " .. module)
dt.util.message(MODULE, module, message)
else
log.msg(log.warn, "run queue empty, nothing to resume")
end
end
local function add_to_run_queue(module, message)
log.log_level(sch.log_level)
table.insert(sch.run_queue, module)
table.insert(sch.message_queue, message)
log.msg(log.info, "run queue length is " .. #sch.run_queue)
dt.control.sleep(500) -- allow time for waiting scripts to start
remove_from_run_queue()
end
local function send_status(sender)
dt.util.message(MODULE, sender, "running, queue length is " .. #sch.run_queue)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.util.message(MODULE, BROADCAST_MSG, "starting")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
-- put things to destroy (events, storages, etc) here
dt.util.message(MODULE, BROADCAST_MSG, "stopping")
dt.destroy_event(MODULE, "inter-script-communication")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "inter-script-communication",
function(event, sender,receiver, message)
log.log_level(sch.log_level)
if receiver == "scheduler" then
log.msg(log.debug, "got message from " .. sender .. " to " .. receiver .. " with message " .. message)
if string.match(message, "^yield") then
log.msg(log.debug, "got message " .. message .. " from " .. sender)
add_to_run_queue(sender, string.gsub(message, "yield", "resume"))
elseif message == "status" then
send_status(sender)
elseif messages == "done" then
-- do nothing
else
log.msg(log.warn, "unrecognized message " .. message)
end
end
end
)
return script_data

View File

@@ -1,266 +0,0 @@
--[[
select_duplicates.lua - select duplicate images from a collection
Copyright (C) 2026 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 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/>.
]]
--[[
select_duplicates - select duplicate images from a collection
select_duplicates adds a button to the select module to select
duplicate images from a collection. If the selection2collection
script is also active an option is available to have the selected
duplicates appear as a temporary collection.
A shortcut is available to select the duplicates from the collection
but it is somewhat slower because it doesn't have access to the current
image cache and therfore has to open each image separately.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable in script manager
* Press the button
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("select duplicates"), -- visible name of script
purpose = _("select duplicate images from a collection"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_duplicates/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "select_duplicates"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local select_duplicates = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
select_duplicates.log_level = DEFAULT_LOG_LEVEL
select_duplicates.create_collection = false
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "create_collection", "bool",
script_data.metadata.name .. _(" create collection from selection"),
_("create collection of selected duplicates"), false)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = select_duplicates
local sd = select_duplicates
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
local function select_duplicate_files(event, images)
local duplicates = {}
local sorted_files = {}
sd.create_collection = pref_read("create_collection", "bool")
for _, img in ipairs(images) do
local rawname = img.path .. PS .. img.filename
if not sorted_files[rawname] then
sorted_files[rawname] = {}
end
table.insert(sorted_files[rawname], img)
end
for k,v in pairs(sorted_files) do
if #v > 1 then
local old_image = nil
for _,i in ipairs(v) do
if not old_image then
old_image = i
else
if i.duplicate_index > old_image.duplicate_index then
table.insert(duplicates, i)
else
table.insert(duplicates, old_image)
old_image = i
end
end
end
end
end
if sd.create_collection then
dt.util.message(MODULE, "selection2collection", "create")
end
return duplicates
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.select.register_selection(MODULE, _("select duplicates"), select_duplicate_files,
_("select duplicated images"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.gui.libs.select.destroy_selection(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
if not dt.query_event(MODULE, "shortcut") then -- skip if shortcut already registered
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
local duplicates = select_duplicate_files(event, dt.collection)
if duplicates then
dt.gui.selection(duplicates)
end
end, _("select duplicates")
)
end
return script_data

View File

@@ -1,180 +0,0 @@
--[[
select_raw_non_raw.lua - select raw or non raw files from a collection
Copyright (C) 2025 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 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/>.
]]
--[[
select_raw_non_raw - select raw or non raw files from a collection
select_raw_non_raw adds two selection buttons, one to select raw
images and one to select non raw images.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable the script with script_manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "select_raw_non_raw"
local DEFAULT_LOG_LEVEL <const> = log.warn
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = "select_raw_non_raw", -- name of script
purpose = _("select raw or non raw files from collection"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_raw_non_raw" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local select_group_followers = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = select_group_followers
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function select_raw_files(event, images)
local raws = {}
for _, img in ipairs(images) do
if img.is_raw then
table.insert(raws, img)
end
end
return raws
end
local function select_non_raw_files(event, images)
local non_raws = {}
for _, img in ipairs(images) do
if not img.is_raw then
table.insert(non_raws, img)
end
end
return non_raws
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.select.register_selection(MODULE .. "_raws", _("select raw"), select_raw_files,
_("select raw files"))
dt.gui.libs.select.register_selection(MODULE .. "_non_raws", _("select non raw"), select_non_raw_files,
_("select non raw files"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
-- put things to destroy (events, storages, etc) here
dt.gui.libs.select.destroy_selection(MODULE .. "_raws")
dt.gui.libs.select.destroy_selection(MODULE .. "_non_raws")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
return script_data

View File

@@ -1,168 +0,0 @@
--[[
select_unaltered.lua - select images that have not been altered in any way
Copyright (C) 2026 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 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/>.
]]
--[[
select_unaltered - select images that have not been altered in any way
select_unaltered selects images from the current collection that have not
been altered in any way. In other words just imported into darktable, not
loaded into darkroom nor having any style applied.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable with script_manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("select_unaltered"), -- visible name of script
purpose = _("select images that have not been altered in any way"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_unaltered/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "select_unaltered"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local select_unaltered = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
select_unaltered.log_level = DEFAULT_LOG_LEVEL
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = select_unaltered
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function select_unaltered_files(event, images)
local unaltered = {}
for _, img in ipairs(images) do
if not img.is_altered then
table.insert(unaltered, img)
end
end
return unaltered
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.select.register_selection(MODULE, _("select unaltered"), select_unaltered_files,
_("select unaltered files"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.gui.libs.select.destroy_selection(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
return script_data

View File

@@ -1,354 +0,0 @@
--[[
selection2collection.lua - create a temporary collection from a selection
Copyright (C) 2026 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 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/>.
]]
--[[
selection2collection - create a temporary collection from a selection
selection2collection creates a temporary collection, using functional
tagging, from a selection. The collection lasts until darktable exits
and then the collections are removed, but not the images.
To create the temporary collection, a function tag (darktable|functional|s2c)
tag with a date and a time is applied to the selected images. The collection
module filters on the tag to display the connection. More than one temporary
collection can be created and collections can be changed by selecting the
appropriate tag.
The tags are autmatically destroyed when darktable exits, thus removing the
collections. There is a preference in the Lua options to keep the collections
across darktable runs.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* start from script_manager
* make a selection
* click the create temporary collection button the the actions on selected images
module
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("9.6.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("selection2collection"), -- visible name of script
purpose = _("create a temporary collection from a selection"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection2collection/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "selection2collection"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- script specific
local TAG_HEADER <const> = "darktable|functional|s2c|"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local selection2collection = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
selection2collection.log_level = DEFAULT_LOG_LEVEL
selection2collection.keep_collections = false
selection2collection.collection_rules = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "keep_collections", "bool",
script_data.metadata.name .. _(" keep collections created from selection"),
_("keep collections created from selections"), false)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = selection2collection
local s2c = selection2collection
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
-------------------
-- program functions
-------------------
local function clear_collections()
s2c.keep_collections = pref_read("keep_collections", "bool")
if not s2c.keep_collections then
for _, tag in ipairs(dt.tags) do
if string.match(tag.name, TAG_HEADER) then
tag:delete()
end
end
end
end
local function create_collection_tag()
local t = os.date("*t")
local ymd = string.format("%4d:%02d:%02d", t.year, t.month, t.day)
local hms = string.format("%02d:%02d:%02d", t.hour, t.min, t.sec)
local tag_string = TAG_HEADER .. ymd .. "|" .. hms
dt.print_log("tag_string is " .. tag_string)
local tag = dt.tags.create(tag_string)
return tag
end
local function display_collection(tag_name)
local collection_rules = dt.gui.libs.collect.filter()
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = tostring(tag_name)
rule.item = "DT_COLLECTION_PROP_TAG"
table.insert(collection_rules, rule)
-- dt.gui.libs.collect.filter returns the previous rule set when you install another
s2c.collection_rules = dt.gui.libs.collect.filter(collection_rules)
end
local function create_collection()
local images = dt.gui.selection()
if #images > 1 then
local tag = create_collection_tag()
if tag then
for _, image in ipairs(images) do
image:attach_tag(tag)
end
end
display_collection(tag.name)
else
log.msg(log.screen, _("not enough images to create a collection"))
end
end
local function communications_handler(sender, receiver, message)
if receiver == MODULE then
if message == "create" then
create_collection()
end
end
end
local function set_button_sensitive(images)
if #images > 1 then
dt.gui.libs.image.set_sensitive(MODULE, true)
else
dt.gui.libs.image.set_sensitive(MODULE, false)
end
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
s2c.keep_collections = pref_read("keep_collections", "bool")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.image.register_action(
MODULE, _("create temporary collection"),
function(event, images)
create_collection()
end,
_("convert selection to collection")
)
set_button_sensitive(dt.gui.selection())
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "inter-script-communication")
dt.destroy_event(MODULE, "exit")
dt.destroy_event(MODULE, "selection-changed")
dt.gui.libs.image.destroy_action(MODULE, "create_collection")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "inter-script-communication",
function(event, sender, receiver, message)
communications_handler(sender, receiver, message)
end
)
if not dt.query_event(MODULE, "shortcut") then -- skip if shortcut already registered
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
create_collection()
end, _("convert selection to collection")
)
end
dt.register_event(MODULE, "exit",
function(event)
if not s2c.keep_collections then
dt.gui.libs.collect.filter(s2c.collection_rules)
clear_collections()
end
end
)
dt.register_event(MODULE, "selection-changed",
function(event)
set_button_sensitive(dt.gui.selection())
end
)
return script_data

View File

@@ -1,337 +0,0 @@
--[[
use_paired_jpg_as_mipmap.lua - Use the JPG from the RAW JPG pair as the full size mipmap
Copyright (C) 2026 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 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/>.
]]
--[[
use_paired_jpg_as_mipmap - Use the JPG from the RAW JPG pair as the full size mipmap
use_paired_jpg_as_mipmap looks for RAW+JPEG image pairs as images are imported. After
import the JPEG image is copied to the mipmap cache as the full resolution mipmap. Requests
for smaller mipmap sizes can be satisfied by down sampling the full resolution mipmap. User's can
decide whether or not to keep the paired JPEG files. The default is to keep them. If the
user doesn't want them, they are deleted after being copied to the mipmap cache.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
- Add the script to the lua-scripts
- Enable the script
- Turn off the keep_jpgs preference if the JPEGS are not wanted
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
]]
local dt = require "darktable"
local du = require "lib/dtutils"
local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A P I C H E C K
-- - - - - - - - - - - - - - - - - - - - - - - -
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- I 1 8 N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
end
-- - - - - - - - - - - - - - - - - - - - - - - - - -
-- S C R I P T M A N A G E R I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - - - -
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
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = {
name = _("use paired jpg as mipmap"), -- visible name of script
purpose = _("Use the JPG from the RAW JPG pair as the full size mipmap"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/use_paired_jpg_as_mipmap/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "use_paired_jpg_as_mipmap"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
-- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
-- command separator
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
-- max mipmap size - 5.4.x = 8, after is 10
local MAX_MIPMAP_SIZE <const> = (dt.configuration.version > "5.4.1") and "10" or "8"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local use_paired_jpg_as_mipmap = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
use_paired_jpg_as_mipmap.log_level = DEFAULT_LOG_LEVEL
use_paired_jpg_as_mipmap.imported_images = {}
use_paired_jpg_as_mipmap.image_count = 0
use_paired_jpg_as_mipmap.mipmap_dir = nil
use_paired_jpg_as_mipmap.keep_jpgs = true
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "keep_jpgs", "bool", script_data.metadata.name .. " - " .. _("keep JPEG files"),
_("don't delete JPEGs after copying them to mipmap folder"), true)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = use_paired_jpg_as_mipmap
local pmj = use_paired_jpg_as_mipmap
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, MODULE .. ": name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, MODULE .. ": read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, MODULE .. ": writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
local function refresh_collection()
local rules = dt.gui.libs.collect.filter()
dt.gui.libs.collect.filter(rules)
end
local function get_mipmap_dir()
local old_log_level = set_log_level(pmj.log_level)
local mipmap_dir = nil
local cachedir = dt.configuration.cache_dir
local cmd = "cd " .. cachedir .. "; ls -d mip*"
if dt.configuration.running_os == "windows" then
cmd = "forfiles /P \"" .. cachedir .. "\" /M mip* /C \"cmd /c echo @file\""
end
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:len() > 4 then
mipmap_dir = cachedir .. PS .. line
log.msg(log.info, MODULE .. ": mipmap_dir is " .. mipmap_dir)
end
end
p:close()
end
restore_log_level(old_log_level)
return mipmap_dir
end
local function stop_job(job)
job.valid = false
end
local function process_images(images, count)
local old_log_level = set_log_level(pmj.log_level)
-- if dt.collection is equal to count there are no
-- RAW+JPEG pairs
if count == #dt.collection then
log.msg(log.screen, _("No RAW+JPEG pairs to process"))
return
end
local job = dt.gui.create_job(_("Generating cache from jpgs"), true, stop_job)
local processed_count = 0
local mipmap_dir = get_mipmap_dir() .. PS .. MAX_MIPMAP_SIZE .. PS
local rc = df.mkdir(mipmap_dir)
log.msg(log.debug, MODULE .. ": rc from mkdir is " .. rc)
for k,v in pairs(images) do
if job.valid then
if v["raw"] and v["jpg"] then
local raw_image_id = v["raw"].id
local fname = v["jpg"].path .. PS .. v["jpg"].filename
df.file_copy(fname, mipmap_dir .. raw_image_id .. ".jpg")
if not pmj.keep_jpgs then
log.msg(log.debug, MODULE .. ": removing jpg")
v["jpg"]:delete()
local success, msg = os.remove(v["jpg"].path .. PS .. v["jpg"].filename)
if not success then
log.msg(log.warn, MODULE .. ": unable to remove jpg - reason: " .. msg)
end
success, msg = os.remove(v["jpg"].path .. PS .. v["jpg"].filename .. ".xmp")
if not success then
log.msg(log.warn, MODULE .. ": unable to remove jpg xmp - reason: " .. msg)
end
end
-- v["raw"]:generate_cache(true, 6, 6)
processed_count = processed_count + 1
end
job.percent = processed_count / count
end
dt.control.sleep(5)
end
stop_job(job)
if not pmj.keep_jpgs then
refresh_collection()
end
dt.util.message(MODULE, "broadcast", "finished")
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
use_paired_jpg_as_mipmap.keep_jpgs = pref_read("keep_jpgs", "bool")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
local function destroy()
dt.destroy_event(MODULE, "post-import-film")
dt.destroy_event(MODULE, "post-import-image")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "post-import-film",
function(event, film)
process_images(pmj.imported_images, pmj.image_count)
pmj.imported_images = {}
pmj.image_count = 0
end
)
dt.register_event(MODULE, "post-import-image",
function(event, image)
local basename = df.get_basename(image.filename)
local extension = string.lower(df.get_filetype(image.filename))
if not string.match(extension, "jpg") then
extension = "raw"
end
if not pmj.imported_images[basename] then
pmj.imported_images[basename] = {}
end
pmj.imported_images[basename][extension] = image
if not string.match(extension, "jpg") then
pmj.image_count = pmj.image_count + 1
end
end
)
return script_data

View File

@@ -67,11 +67,13 @@ end
local info = debug.getinfo(1, "S")
local scripts_path = info.source:sub(2)
dt.print_log("scripts path is " .. scripts_path)
-- assume user based
local system_based = false
if string.match(scripts_path, dt.configuration.data_dir) then
log.msg(log.info, "script_manager using system based scripts")
dt.print_log("script_manager using system based scripts")
system_based = true
end
@@ -98,7 +100,7 @@ local MIN_BUTTONS_PER_PAGE <const> = 5
local MAX_BUTTONS_PER_PAGE <const> = 20
local DEFAULT_BUTTONS_PER_PAGE <const> = 10
local DEFAULT_LOG_LEVEL <const> = log.info
local DEFAULT_LOG_LEVEL <const> = log.warn
local USER_LUA_DIR <const> = dt.configuration.config_dir .. PS .. "lua"
local SYSTEM_LUA_DIR <const> = dt.configuration.data_dir .. PS .. "lua-scripts"
@@ -127,15 +129,6 @@ end
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "disable_scripts", "bool",
_("disable Lua scripts"),
_("do not run the Lua scripts"),
false)
if dt.preferences.read(MODULE, "disable_scripts", "bool") then
return
end
dt.preferences.register(MODULE, "check_update", "bool",
_("check for updated scripts on start up"),
_("automatically update scripts to correct version"),
@@ -757,12 +750,7 @@ local function scan_scripts(script_dir)
local output = io.popen(find_cmd)
for line in output:lines() do
log.msg(log.debug, "line is " .. line)
local script_dir = SYSTEM_LUA_DIR
if string.match(line, ds.sanitize_lua(USER_LUA_DIR)) then
script_dir = USER_LUA_DIR
end
-- add a string.match to figure out which lua dir to use
local l = string.gsub(line, ds.sanitize_lua(script_dir) .. PS, "") -- strip the lua dir off
local l = string.gsub(line, ds.sanitize_lua(LUA_DIR) .. PS, "") -- strip the lua dir off
local script_file = l:sub(1,-5) -- strip off .lua\n
if not string.match(script_file, "script_manager") then -- let's not include ourself
if not string.match(script_file, "plugins") then -- skip plugins
@@ -822,9 +810,7 @@ local function update_script_update_choices()
for i, repo in ipairs(sm.installed_repositories) do
table.insert(installs, repo.name)
if not string.match(pref_string, ds.sanitize_lua(repo.name)) then
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
end
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
end
update_combobox_choices(sm.widgets.update_script_choices, installs, 1)
@@ -894,17 +880,8 @@ local function install_scripts()
local url = sm.widgets.script_url.text
local folder = sm.widgets.new_folder.text
if not df.test_file(USER_LUA_DIR, "d") then
df.mkdir(USER_LUA_DIR)
if not df.test_file(USER_LUA_DIR, "d") then
log.msg(log.error, "unable to create user lua directory")
log.msg(log.screen, _("unable to create user lua directory, installing additional scripts failed"))
return
end
end
if string.match(du.join(sm.folders, " "), ds.sanitize_lua(folder)) then
log.msg(log.screen, string.format(_("folder %s is already in use. Please specify a different folder name."), folder))
log.msg(log.screen, _(string.format("folder %s is already in use. Please specify a different folder name.", folder)))
log.msg(log.error, "folder " .. folder .. " already exists, returning...")
restore_log_level(old_log_level)
return
@@ -920,7 +897,7 @@ local function install_scripts()
return
end
local git_command = "cd " .. USER_LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
local git_command = "cd " .. LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
log.msg(log.debug, "update git command is " .. git_command)
if dt.configuration.running_os == "windows" then
@@ -932,12 +909,12 @@ local function install_scripts()
log.msg(log.info, "result from import is " .. result)
if result == 0 then
local count = scan_scripts(USER_LUA_DIR)
local count = scan_scripts(LUA_DIR .. PS .. folder)
if count > 0 then
update_combobox_choices(sm.widgets.folder_selector, sm.folders, sm.widgets.folder_selector.selected)
dt.print(string.format(_("scripts successfully installed into folder %s"), folder))
table.insert(sm.installed_repositories, {name = folder, directory = USER_LUA_DIR .. PS .. folder})
dt.print(_(string.format("scripts successfully installed into folder %s"), folder))
table.insert(sm.installed_repositories, {name = folder, directory = LUA_DIR .. PS .. folder})
update_script_update_choices()
for i = 1, #sm.widgets.folder_selector do
@@ -1043,7 +1020,6 @@ local function populate_buttons(folder, first, last)
button_num = button_num + 1
end
log.msg(log.debug, "button num is " .. button_num .. " and num_buttons is " .. sm.page_status.num_buttons)
if button_num <= sm.page_status.num_buttons then
for i = button_num, sm.page_status.num_buttons do
clear_button(i)
@@ -1376,7 +1352,7 @@ if (check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string)
end
scan_scripts(LUA_DIR)
log.msg(log.info, "finished processing scripts")
log.msg(log.debug, "finished processing scripts")
if system_based then
-- exec user luarc file if it exists
@@ -1636,7 +1612,7 @@ script_manager_running_script = nil
-- D A R K T A B L E I N T E G R A T I O N
-- - - - - - - - - - - - - - - - - - - - - - - -
if (dt.gui.current_view().id == "lighttable") and darktable_gui_safe then
if dt.gui.current_view().id == "lighttable" then
install_module()
else
if not sm.event_registered then