Compare commits
112 Commits
17422_bund
...
lua-api
| Author | SHA1 | Date | |
|---|---|---|---|
| dca1d1c25b | |||
| 0831ee943c | |||
| bd40b4f850 | |||
|
|
5ebadd83ea | ||
|
|
01c3b7d2f6 | ||
|
|
bfee6b12dc | ||
|
|
503a59d590 | ||
|
|
78176e32e8 | ||
|
|
4d8d076d8e | ||
|
|
72f31d6208 | ||
|
|
784a6ea85d | ||
|
|
bbfd9d12e3 | ||
|
|
a55de740e5 | ||
|
|
dc31b221ef | ||
|
|
34b88490c2 | ||
|
|
d9b77bb3a2 | ||
|
|
cc198d6ca1 | ||
|
|
84582cbf29 | ||
|
|
9e7a18e290 | ||
|
|
8ff81fcfa4 | ||
|
|
b649e7a840 | ||
|
|
efa06ee34c | ||
|
|
77959422e0 | ||
|
|
0bff346b05 | ||
|
|
d05c494147 | ||
|
|
9cbbae9621 | ||
|
|
fb2aade198 | ||
|
|
7ffd8bdc27 | ||
|
|
b689dbc7ee | ||
|
|
027135a257 | ||
|
|
643057deaf | ||
|
|
a00f71f533 | ||
|
|
36d6db24e7 | ||
|
|
f62a5a817f | ||
|
|
44b40b305b | ||
|
|
97dade06cf | ||
|
|
30167631e7 | ||
|
|
8235de1f0a | ||
|
|
d67ada8300 | ||
|
|
c6788e4370 | ||
|
|
d238fcee61 | ||
|
|
28d71c989f | ||
|
|
74f020c809 | ||
|
|
80769a753a | ||
|
|
427359769e | ||
|
|
5e5b073cec | ||
|
|
c771c5fc1b | ||
|
|
7cdf1d4c7f | ||
|
|
b6ea6211db | ||
|
|
4d3924939b | ||
|
|
80ff1ef0aa | ||
|
|
af362315e8 | ||
|
|
28f3e9f6a2 | ||
|
|
4f05672809 | ||
|
|
8d8f295c77 | ||
|
|
a81ab437cf | ||
|
|
a94a0c2c90 | ||
|
|
0eda1b6ac5 | ||
|
|
58bc3a69df | ||
|
|
35398c55e5 | ||
|
|
1762b372b1 | ||
|
|
1907f9a1f5 | ||
|
|
b1dd3eca5a | ||
|
|
732d2788ca | ||
|
|
04908e3f0b | ||
|
|
3ba3facad2 | ||
|
|
cb209d1d56 | ||
|
|
36533b1004 | ||
|
|
cbbf0f413f | ||
|
|
181aea5bc2 | ||
|
|
94c6dff245 | ||
|
|
79ac6fc31e | ||
|
|
7a61d77c27 | ||
|
|
2635e261a0 | ||
|
|
d197851478 | ||
|
|
6f519b3ce8 | ||
|
|
03eb79e83a | ||
|
|
fb03b49a93 | ||
|
|
c0865c37bb | ||
|
|
6bbb619513 | ||
|
|
182a2c96c3 | ||
|
|
06522802d8 | ||
|
|
ad263fed11 | ||
|
|
ced60016fb | ||
|
|
1d6d0695fe | ||
|
|
fbe504b47a | ||
|
|
7da38b3665 | ||
|
|
08fe1ba410 | ||
|
|
088c79e140 | ||
|
|
f06eebe316 | ||
|
|
d6a1cec9fa | ||
|
|
e194ad2bc7 | ||
|
|
22e3c59d69 | ||
|
|
eb743e4b3b | ||
|
|
7a8f3afc40 | ||
|
|
2b3910c698 | ||
|
|
c584ee05c9 | ||
|
|
bb3a74feac | ||
|
|
90d2764a5a | ||
|
|
b0373f7166 | ||
|
|
c6002e7eb7 | ||
|
|
a47de82afd | ||
|
|
7f00cb8d6c | ||
|
|
dfeff73fcd | ||
|
|
7f4bae4234 | ||
|
|
071a3527c1 | ||
|
|
22c9a7cd6e | ||
|
|
12ccefcc3b | ||
|
|
fa6014fefa | ||
|
|
3c131c41d1 | ||
|
|
ffa125b134 | ||
|
|
056f616283 |
@@ -79,6 +79,7 @@ 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
|
||||
|
||||
|
||||
486
contrib/darktable-api/api.lua
Normal file
486
contrib/darktable-api/api.lua
Normal file
@@ -0,0 +1,486 @@
|
||||
-- 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
|
||||
96
contrib/darktable-api/auth.lua
Normal file
96
contrib/darktable-api/auth.lua
Normal file
@@ -0,0 +1,96 @@
|
||||
-- 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
|
||||
52
contrib/darktable-api/base64.lua
Normal file
52
contrib/darktable-api/base64.lua
Normal file
@@ -0,0 +1,52 @@
|
||||
-- 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
|
||||
33
contrib/darktable-api/bruno/Health Check.bru
Normal file
33
contrib/darktable-api/bruno/Health Check.bru
Normal file
@@ -0,0 +1,33 @@
|
||||
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>}`.
|
||||
}
|
||||
33
contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru
Normal file
33
contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru
Normal file
@@ -0,0 +1,33 @@
|
||||
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.
|
||||
}
|
||||
64
contrib/darktable-api/bruno/auth/Get Token.bru
Normal file
64
contrib/darktable-api/bruno/auth/Get Token.bru
Normal file
@@ -0,0 +1,64 @@
|
||||
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.
|
||||
}
|
||||
6
contrib/darktable-api/bruno/bruno.json
Normal file
6
contrib/darktable-api/bruno/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Darktable API",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
12
contrib/darktable-api/bruno/environments/local.bru
Normal file
12
contrib/darktable-api/bruno/environments/local.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
vars {
|
||||
baseUrl: http://127.0.0.1:7645
|
||||
clientId: darktable-client
|
||||
clientSecret: changeme
|
||||
accessToken:
|
||||
imageId: 1
|
||||
}
|
||||
|
||||
vars:secret [
|
||||
clientSecret,
|
||||
accessToken
|
||||
]
|
||||
12
contrib/darktable-api/bruno/environments/remote.bru
Normal file
12
contrib/darktable-api/bruno/environments/remote.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
vars {
|
||||
baseUrl: http://192.168.1.134:7645
|
||||
clientId: darktable-client
|
||||
clientSecret: changeme
|
||||
accessToken:
|
||||
imageId: 1
|
||||
}
|
||||
|
||||
vars:secret [
|
||||
clientSecret,
|
||||
accessToken
|
||||
]
|
||||
54
contrib/darktable-api/bruno/films/List Films.bru
Normal file
54
contrib/darktable-api/bruno/films/List Films.bru
Normal file
@@ -0,0 +1,54 @@
|
||||
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).
|
||||
}
|
||||
52
contrib/darktable-api/bruno/images/Export Image.bru
Normal file
52
contrib/darktable-api/bruno/images/Export Image.bru
Normal file
@@ -0,0 +1,52 @@
|
||||
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`
|
||||
}
|
||||
57
contrib/darktable-api/bruno/images/Get Full Image.bru
Normal file
57
contrib/darktable-api/bruno/images/Get Full Image.bru
Normal file
@@ -0,0 +1,57 @@
|
||||
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`
|
||||
}
|
||||
31
contrib/darktable-api/bruno/images/Get Image (Not Found).bru
Normal file
31
contrib/darktable-api/bruno/images/Get Image (Not Found).bru
Normal file
@@ -0,0 +1,31 @@
|
||||
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.
|
||||
}
|
||||
72
contrib/darktable-api/bruno/images/Get Image.bru
Normal file
72
contrib/darktable-api/bruno/images/Get Image.bru
Normal file
@@ -0,0 +1,72 @@
|
||||
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).
|
||||
}
|
||||
67
contrib/darktable-api/bruno/images/Get Preview.bru
Normal file
67
contrib/darktable-api/bruno/images/Get Preview.bru
Normal file
@@ -0,0 +1,67 @@
|
||||
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.
|
||||
}
|
||||
27
contrib/darktable-api/bruno/images/List Images (No Auth).bru
Normal file
27
contrib/darktable-api/bruno/images/List Images (No Auth).bru
Normal file
@@ -0,0 +1,27 @@
|
||||
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.
|
||||
}
|
||||
87
contrib/darktable-api/bruno/images/List Images.bru
Normal file
87
contrib/darktable-api/bruno/images/List Images.bru
Normal file
@@ -0,0 +1,87 @@
|
||||
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 |
|
||||
}
|
||||
49
contrib/darktable-api/bruno/images/Refresh Cache.bru
Normal file
49
contrib/darktable-api/bruno/images/Refresh Cache.bru
Normal file
@@ -0,0 +1,49 @@
|
||||
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`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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.
|
||||
}
|
||||
55
contrib/darktable-api/bruno/modules/Get Module.bru
Normal file
55
contrib/darktable-api/bruno/modules/Get Module.bru
Normal file
@@ -0,0 +1,55 @@
|
||||
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.
|
||||
}
|
||||
79
contrib/darktable-api/bruno/modules/List Modules.bru
Normal file
79
contrib/darktable-api/bruno/modules/List Modules.bru
Normal file
@@ -0,0 +1,79 @@
|
||||
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`
|
||||
}
|
||||
89
contrib/darktable-api/bruno/modules/Patch Module.bru
Normal file
89
contrib/darktable-api/bruno/modules/Patch Module.bru
Normal file
@@ -0,0 +1,89 @@
|
||||
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 |
|
||||
}
|
||||
54
contrib/darktable-api/bruno/tags/List Tags.bru
Normal file
54
contrib/darktable-api/bruno/tags/List Tags.bru
Normal file
@@ -0,0 +1,54 @@
|
||||
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).
|
||||
}
|
||||
38
contrib/darktable-api/config.lua
Normal file
38
contrib/darktable-api/config.lua
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
302
contrib/darktable-api/darktable-api-bridge.lua
Normal file
302
contrib/darktable-api/darktable-api-bridge.lua
Normal file
@@ -0,0 +1,302 @@
|
||||
--[[
|
||||
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
|
||||
525
contrib/darktable-api/db.lua
Normal file
525
contrib/darktable-api/db.lua
Normal file
@@ -0,0 +1,525 @@
|
||||
-- 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
|
||||
155
contrib/darktable-api/json.lua
Normal file
155
contrib/darktable-api/json.lua
Normal file
@@ -0,0 +1,155 @@
|
||||
-- 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
|
||||
502
contrib/darktable-api/modules.lua
Normal file
502
contrib/darktable-api/modules.lua
Normal file
@@ -0,0 +1,502 @@
|
||||
-- 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
|
||||
131
contrib/darktable-api/preview.lua
Normal file
131
contrib/darktable-api/preview.lua
Normal file
@@ -0,0 +1,131 @@
|
||||
-- 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
|
||||
68
contrib/darktable-api/router.lua
Normal file
68
contrib/darktable-api/router.lua
Normal file
@@ -0,0 +1,68 @@
|
||||
-- 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
|
||||
280
contrib/darktable-api/server.lua
Normal file
280
contrib/darktable-api/server.lua
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/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
|
||||
109
contrib/darktable-api/start-with-api.sh
Executable file
109
contrib/darktable-api/start-with-api.sh
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/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"
|
||||
4
contrib/darktable-api/web/.gitignore
vendored
Normal file
4
contrib/darktable-api/web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
.vite/
|
||||
19
contrib/darktable-api/web/index.html
Normal file
19
contrib/darktable-api/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!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>
|
||||
6058
contrib/darktable-api/web/package-lock.json
generated
Normal file
6058
contrib/darktable-api/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
contrib/darktable-api/web/package.json
Normal file
20
contrib/darktable-api/web/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
28
contrib/darktable-api/web/public/icon.svg
Normal file
28
contrib/darktable-api/web/public/icon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
69
contrib/darktable-api/web/src/App.jsx
Normal file
69
contrib/darktable-api/web/src/App.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
353
contrib/darktable-api/web/src/api.js
Normal file
353
contrib/darktable-api/web/src/api.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// 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 };
|
||||
}
|
||||
235
contrib/darktable-api/web/src/components/FilterBar.jsx
Normal file
235
contrib/darktable-api/web/src/components/FilterBar.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
246
contrib/darktable-api/web/src/components/ImageCard.jsx
Normal file
246
contrib/darktable-api/web/src/components/ImageCard.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
71
contrib/darktable-api/web/src/components/ModuleList.jsx
Normal file
71
contrib/darktable-api/web/src/components/ModuleList.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
401
contrib/darktable-api/web/src/components/ModulePanel.jsx
Normal file
401
contrib/darktable-api/web/src/components/ModulePanel.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
133
contrib/darktable-api/web/src/components/Toast.jsx
Normal file
133
contrib/darktable-api/web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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);
|
||||
456
contrib/darktable-api/web/src/index.css
Normal file
456
contrib/darktable-api/web/src/index.css
Normal file
@@ -0,0 +1,456 @@
|
||||
/* ── 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);
|
||||
}
|
||||
11
contrib/darktable-api/web/src/main.jsx
Normal file
11
contrib/darktable-api/web/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
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>
|
||||
);
|
||||
368
contrib/darktable-api/web/src/screens/Gallery.jsx
Normal file
368
contrib/darktable-api/web/src/screens/Gallery.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
443
contrib/darktable-api/web/src/screens/ImageDetail.jsx
Normal file
443
contrib/darktable-api/web/src/screens/ImageDetail.jsx
Normal file
@@ -0,0 +1,443 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
267
contrib/darktable-api/web/src/screens/Settings.jsx
Normal file
267
contrib/darktable-api/web/src/screens/Settings.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
81
contrib/darktable-api/web/vite.config.js
Normal file
81
contrib/darktable-api/web/vite.config.js
Normal file
@@ -0,0 +1,81 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -288,7 +288,13 @@ 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' or arg_data.style == 'string' then
|
||||
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
|
||||
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)
|
||||
@@ -345,7 +351,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='..GUI.Target.compression_level_jpg.value..' '
|
||||
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..tostring(math.floor(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
|
||||
@@ -492,6 +498,7 @@ 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
|
||||
|
||||
@@ -1165,4 +1172,4 @@ end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
return script_data
|
||||
|
||||
@@ -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.sanitize_filename(df.create_unique_filename(new_name))
|
||||
new_name = 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)
|
||||
|
||||
@@ -211,7 +211,6 @@ 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)
|
||||
|
||||
@@ -224,7 +223,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 " .. image.path .. PS .. image.filename)
|
||||
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. df.sanitize_filename(image.path .. PS .. image.filename))
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
if string.match(line, "Exif.Image.DateTime") then
|
||||
@@ -263,7 +262,7 @@ end
|
||||
|
||||
local function _get_nix_image_file_creation_time(image)
|
||||
local datetime = nil
|
||||
local p = io.popen("ls -lL --time-style=full-iso " .. image.path .. PS .. image.filename)
|
||||
local p = io.popen("ls -lL --time-style=full-iso " .. df.sanitize_filename(image.path .. PS .. image.filename))
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
if string.match(line, ds.sanitize_lua(image.filename)) then
|
||||
|
||||
@@ -264,10 +264,6 @@ 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()
|
||||
|
||||
@@ -185,9 +185,6 @@ local function do_rename(images)
|
||||
end
|
||||
end
|
||||
|
||||
local function reset_callback()
|
||||
rename.widgets.pattern.text = ""
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- W I D G E T S
|
||||
@@ -196,7 +193,11 @@ end
|
||||
rename.widgets.pattern = dt.new_widget("entry"){
|
||||
tooltip = ds.get_substitution_tooltip(),
|
||||
placeholder = _("enter pattern") .. "$(FILE_FOLDER)/$(FILE_NAME)",
|
||||
text = ""
|
||||
text = "",
|
||||
reset_callback = function(self)
|
||||
self.text = ""
|
||||
dt.preferences.write(MODULE_NAME, "pattern", "string", self.text)
|
||||
end
|
||||
}
|
||||
|
||||
local pattern_pref = dt.preferences.read(MODULE_NAME, "pattern", "string")
|
||||
|
||||
@@ -20,14 +20,19 @@ 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 = "select_non_existing"
|
||||
local MODULE <const> = "select_non_existing"
|
||||
|
||||
du.check_min_api_version("9.1.0", MODULE)
|
||||
|
||||
-- figure out the path separator
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.info
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
@@ -39,26 +44,61 @@ local function stop_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function select_nonexisting_images(event, images)
|
||||
local selection = {}
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
stop_job(job)
|
||||
|
||||
return selection
|
||||
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 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
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
|
||||
199
contrib/selected_image_visible.lua
Normal file
199
contrib/selected_image_visible.lua
Normal file
@@ -0,0 +1,199 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
313
contrib/toggle_group_view.lua
Normal file
313
contrib/toggle_group_view.lua
Normal file
@@ -0,0 +1,313 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
@@ -270,7 +270,7 @@ local function doTransfer(transferFunc)
|
||||
local sourceBase = sourceTextBox.text
|
||||
local destBase = destinationTextBox.text
|
||||
local destFilms = {}
|
||||
for film, _ in pairs(films) do
|
||||
for film, i 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
|
||||
|
||||
294
contrib/website_gallery_export.lua
Normal file
294
contrib/website_gallery_export.lua
Normal file
@@ -0,0 +1,294 @@
|
||||
--[[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
|
||||
56
data/website_gallery/css/gallery.css
Normal file
56
data/website_gallery/css/gallery.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
99
data/website_gallery/css/modal.css
Normal file
99
data/website_gallery/css/modal.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
|
||||
66
data/website_gallery/css/style.css
Normal file
66
data/website_gallery/css/style.css
Normal file
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
46
data/website_gallery/index.html
Normal file
46
data/website_gallery/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!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">◀</div>
|
||||
<div class="button nav nav-arrow" id="nextArrow">▶</div>
|
||||
<div class="spacer"></div>
|
||||
<div id="fullscreen" class="button nav">⛶</div>
|
||||
<div id="close" class="nav button">×</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>
|
||||
40
data/website_gallery/js/fullscreen.js
Normal file
40
data/website_gallery/js/fullscreen.js
Normal file
@@ -0,0 +1,40 @@
|
||||
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.');
|
||||
}
|
||||
};
|
||||
|
||||
112
data/website_gallery/js/gallery.js
Normal file
112
data/website_gallery/js/gallery.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
355
data/website_gallery/js/modal.js
Normal file
355
data/website_gallery/js/modal.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -51,6 +51,10 @@ 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"
|
||||
@@ -59,150 +63,283 @@ 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
|
||||
|
||||
-- return data structure for script_manager
|
||||
-- Constants
|
||||
local NUM_KNOBS = 8
|
||||
local NAN = 0/0
|
||||
local darkroom_view = dt.gui.views.darkroom
|
||||
|
||||
local script_data = {}
|
||||
-- ============================================================================
|
||||
-- USER CONFIGURATION
|
||||
-- ============================================================================
|
||||
|
||||
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"
|
||||
-- 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.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
|
||||
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"}
|
||||
}
|
||||
|
||||
-- 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))
|
||||
-- Priority order for checking module focus (most commonly used first)
|
||||
local MODULE_CHECK_ORDER = {
|
||||
"toneequal",
|
||||
"colorequal",
|
||||
"colorbalancergb",
|
||||
"agx",
|
||||
"sigmoid",
|
||||
"primaries",
|
||||
"channelmixerrgb",
|
||||
"colorzones"
|
||||
}
|
||||
|
||||
-- only operate in darkroom; return NAN otherwise
|
||||
if dt.gui.current_view() ~= dt.gui.views.darkroom then
|
||||
return 0/0
|
||||
end
|
||||
-- ============================================================================
|
||||
-- CORE LOGIC - No need to edit below unless changing architecture
|
||||
-- ============================================================================
|
||||
|
||||
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
|
||||
-- Helper functions
|
||||
local function is_in_darkroom()
|
||||
return dt.gui.current_view() == darkroom_view
|
||||
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 is_valid(value)
|
||||
return value == value -- NaN check
|
||||
end
|
||||
|
||||
-- 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]
|
||||
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 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"
|
||||
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
|
||||
end
|
||||
else
|
||||
-- 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]
|
||||
-- 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
|
||||
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
|
||||
|
||||
-- now pass the element/effect/size to the selected slider
|
||||
return dt.gui.action(which, element, effect, size)
|
||||
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
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
-- nothing to destroy
|
||||
-- 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
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
-- 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()
|
||||
|
||||
return script_data
|
||||
|
||||
@@ -773,33 +773,29 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
|
||||
|
||||
local labels = get_colorlabels(image)
|
||||
|
||||
local datetime_taken = ""
|
||||
local emon_long, emon_short
|
||||
local eyear, emon, eday, ehour, emin, esec, emsec
|
||||
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
|
||||
datetime_taken = image.exif_datetime_taken
|
||||
else
|
||||
emon_long = os.date("%B", exiftime2systime(image.exif_datetime_taken))
|
||||
emon_short = os.date("%b", exiftime2systime(image.exif_datetime_taken))
|
||||
if use_millisecs then
|
||||
datetime_taken = "0000:00:00 00:00:00.0"
|
||||
eyear, emon, eday, ehour, emin, esec, emsec =
|
||||
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)%.(%d+)$")
|
||||
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+)$")
|
||||
emsec = "0"
|
||||
eyear, emon, eday, ehour, emin, esec =
|
||||
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
|
||||
end
|
||||
else
|
||||
emsec = "0"
|
||||
eyear, emon, eday, ehour, emin, esec =
|
||||
string.match(datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
|
||||
log.msg(log.warn, "no capture date and time found in EXIF data")
|
||||
emon_long, emon_short, eyear, emon, eday, ehour, emin, esec, emsec = "", "", "", "", "", "", "", "", ""
|
||||
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
|
||||
@@ -838,10 +834,10 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
|
||||
string.format("%02d", datetime.sec), -- SECOND
|
||||
0, -- MSEC
|
||||
eyear, -- EXIF.YEAR
|
||||
string.sub(eyear, 3), -- EXIF.YEAR.SHORT
|
||||
(eyear ~= nil) and string.sub(eyear, 3) or "", -- EXIF.YEAR.SHORT
|
||||
emon, -- EXIF.MONTH
|
||||
os.date("%B", exiftime2systime(datetime_taken)), -- EXIF.MONTH.LONG
|
||||
os.date("%b", exiftime2systime(datetime_taken)), -- EXIF.MONTH.SHORT
|
||||
emon_long, -- EXIF.MONTH.LONG
|
||||
emon_short, -- EXIF.MONTH.SHORT
|
||||
eday, -- EXIF.DAY
|
||||
ehour, -- EXIF.HOUR
|
||||
"", -- EXIF.HOUR.AMPM
|
||||
@@ -894,7 +890,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, -- USERNAME
|
||||
(user_name ~= nil) and user_name or "",-- USERNAME
|
||||
"", -- NL - wont be implemented
|
||||
"" -- JOBCODE - wont be implemented
|
||||
}
|
||||
|
||||
@@ -91,8 +91,10 @@ 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)
|
||||
|
||||
283
official/auto_straighten.lua
Normal file
283
official/auto_straighten.lua
Normal file
@@ -0,0 +1,283 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
459
official/extract_burst_roll_images.lua
Normal file
459
official/extract_burst_roll_images.lua
Normal file
@@ -0,0 +1,459 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
694
official/group_persistence.lua
Normal file
694
official/group_persistence.lua
Normal file
@@ -0,0 +1,694 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
343
official/recent_bookmarks.lua
Normal file
343
official/recent_bookmarks.lua
Normal file
@@ -0,0 +1,343 @@
|
||||
--[[
|
||||
|
||||
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=\"'xdg-open %%u'\" modified=\"%s\" count=\"1\"/>\n"
|
||||
local EDIT_FORMAT <const> = " <bookmark:application name=\"darktable\" exec=\"'darktable %%u'\" 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
|
||||
224
official/scheduler.lua
Normal file
224
official/scheduler.lua
Normal file
@@ -0,0 +1,224 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
266
official/select_duplicates.lua
Normal file
266
official/select_duplicates.lua
Normal file
@@ -0,0 +1,266 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
180
official/select_raw_non_raw.lua
Normal file
180
official/select_raw_non_raw.lua
Normal file
@@ -0,0 +1,180 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
168
official/select_unaltered.lua
Normal file
168
official/select_unaltered.lua
Normal file
@@ -0,0 +1,168 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
354
official/selection2collection.lua
Normal file
354
official/selection2collection.lua
Normal file
@@ -0,0 +1,354 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
337
official/use_paired_jpg_as_mipmap.lua
Normal file
337
official/use_paired_jpg_as_mipmap.lua
Normal file
@@ -0,0 +1,337 @@
|
||||
--[[
|
||||
|
||||
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
|
||||
@@ -54,7 +54,7 @@ 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"
|
||||
local dtdebug = require "darktable.debug"
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
@@ -62,6 +62,19 @@ local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- figure out where we are running from
|
||||
|
||||
local info = debug.getinfo(1, "S")
|
||||
local scripts_path = info.source:sub(2)
|
||||
|
||||
-- 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")
|
||||
system_based = true
|
||||
end
|
||||
|
||||
-- api check
|
||||
|
||||
-- du.check_min_api_version("9.3.0", "script_manager")
|
||||
@@ -71,7 +84,7 @@ end
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
-- script_manager required API version
|
||||
local SM_API_VER_REQD <const> = "9.3.0"
|
||||
local SM_API_VER_REQD <const> = "9.7.0"
|
||||
|
||||
-- path separator
|
||||
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
@@ -85,21 +98,44 @@ 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.warn
|
||||
local DEFAULT_LOG_LEVEL <const> = log.info
|
||||
|
||||
local USER_LUA_DIR <const> = dt.configuration.config_dir .. PS .. "lua"
|
||||
local SYSTEM_LUA_DIR <const> = dt.configuration.data_dir .. PS .. "lua-scripts"
|
||||
|
||||
local LUA_DIR <const> = dt.configuration.config_dir .. PS .. "lua"
|
||||
local LUA_SCRIPT_REPO <const> = "https://github.com/darktable-org/lua-scripts.git"
|
||||
|
||||
local LUA_API_VER <const> = "API-" .. dt.configuration.api_version_string
|
||||
|
||||
-- local POWER_ICON = dt.configuration.config_dir .. "/lua/data/data/icons/power.png"
|
||||
local POWER_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/path20.png"
|
||||
local BLANK_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/blank20.png"
|
||||
local USER_POWER_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/path20.png"
|
||||
local USER_BLANK_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/blank20.png"
|
||||
|
||||
local SYSTEM_POWER_ICON <const> = dt.configuration.data_dir .. "/lua-scripts/data/icons/path20.png"
|
||||
local SYSTEM_BLANK_ICON <const> = dt.configuration.data_dir .. "/lua-scripts/data/icons/blank20.png"
|
||||
|
||||
local LUA_DIR = USER_LUA_DIR
|
||||
local POWER_ICON = USER_POWER_ICON
|
||||
local BLANK_ICON = USER_BLANK_ICON
|
||||
|
||||
if system_based then
|
||||
LUA_DIR = SYSTEM_LUA_DIR
|
||||
POWER_ICON = SYSTEM_POWER_ICON
|
||||
BLANK_ICON = SYSTEM_BLANK_ICON
|
||||
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"),
|
||||
@@ -721,7 +757,12 @@ 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 l = string.gsub(line, ds.sanitize_lua(LUA_DIR) .. PS, "") -- strip the lua dir off
|
||||
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 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
|
||||
@@ -781,7 +822,9 @@ local function update_script_update_choices()
|
||||
|
||||
for i, repo in ipairs(sm.installed_repositories) do
|
||||
table.insert(installs, repo.name)
|
||||
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
|
||||
if not string.match(pref_string, ds.sanitize_lua(repo.name)) then
|
||||
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
|
||||
end
|
||||
end
|
||||
|
||||
update_combobox_choices(sm.widgets.update_script_choices, installs, 1)
|
||||
@@ -851,8 +894,17 @@ 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
|
||||
@@ -868,7 +920,7 @@ local function install_scripts()
|
||||
return
|
||||
end
|
||||
|
||||
local git_command = "cd " .. LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
|
||||
local git_command = "cd " .. USER_LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
|
||||
log.msg(log.debug, "update git command is " .. git_command)
|
||||
|
||||
if dt.configuration.running_os == "windows" then
|
||||
@@ -880,12 +932,12 @@ local function install_scripts()
|
||||
log.msg(log.info, "result from import is " .. result)
|
||||
|
||||
if result == 0 then
|
||||
local count = scan_scripts(LUA_DIR .. PS .. folder)
|
||||
local count = scan_scripts(USER_LUA_DIR)
|
||||
|
||||
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 = LUA_DIR .. PS .. folder})
|
||||
dt.print(string.format(_("scripts successfully installed into folder %s"), folder))
|
||||
table.insert(sm.installed_repositories, {name = folder, directory = USER_LUA_DIR .. PS .. folder})
|
||||
update_script_update_choices()
|
||||
|
||||
for i = 1, #sm.widgets.folder_selector do
|
||||
@@ -991,6 +1043,7 @@ 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)
|
||||
@@ -1243,7 +1296,7 @@ end
|
||||
|
||||
script_manager_running_script = "script_manager"
|
||||
|
||||
if check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string then
|
||||
if (check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string) and not system_based then
|
||||
local repo_data = get_repo_status(LUA_DIR)
|
||||
local current_branch = get_current_repo_branch(LUA_DIR)
|
||||
local clean = is_repo_clean(repo_data)
|
||||
@@ -1323,7 +1376,26 @@ if check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string th
|
||||
end
|
||||
|
||||
scan_scripts(LUA_DIR)
|
||||
log.msg(log.debug, "finished processing scripts")
|
||||
log.msg(log.info, "finished processing scripts")
|
||||
|
||||
if system_based then
|
||||
-- exec user luarc file if it exists
|
||||
local luarc = loadfile(USER_LUA_DIR .. "rc")
|
||||
if luarc then
|
||||
luarc()
|
||||
end
|
||||
|
||||
if df.test_file(USER_LUA_DIR, "d") then
|
||||
LUA_DIR = USER_LUA_DIR
|
||||
scan_scripts(LUA_DIR)
|
||||
LUA_DIR = SYSTEM_LUA_DIR
|
||||
end
|
||||
|
||||
dt.configuration["lua_dir"] = LUA_DIR
|
||||
|
||||
dt.print_log("lua dir is " .. dt.configuration.lua_dir)
|
||||
-- scan_script(USER_LUA_DIR)
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
@@ -1564,7 +1636,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" then
|
||||
if (dt.gui.current_view().id == "lighttable") and darktable_gui_safe then
|
||||
install_module()
|
||||
else
|
||||
if not sm.event_registered then
|
||||
|
||||
Reference in New Issue
Block a user