Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dca1d1c25b | |||
| 0831ee943c | |||
| bd40b4f850 |
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user