From dadf0909dd235a7764a59a63d2b51861fa81e385 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 4 Aug 2026 16:54:07 +0100 Subject: add photo import: upload -> photoprism, fix desktop file dialog - POST /api/import with native multipart parser (no new deps); stages files then delegates indexing+thumbnailing to PhotoPrism via `photoprism cp` - pathways reflects new photos immediately via refresh() - upload UI shown only in empty-library state - nixos: PHOTOPRISM_READONLY=false; pathways service gets STAGING_ROOT and photoprism env; PATH includes /run/wrappers/bin so sudo is found - fix: stage.setPointerCapture was swallowing the import label's click on desktop, preventing the native file dialog from opening --- .gitignore | 1 + public/app.css | 117 ++++++++++++++++++++++++++++++++--- public/app.js | 163 +++++++++++++++++++++++++++++++++++++------------ public/index.html | 22 ++++--- server.mjs | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 424 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 6659201..5e3e4a4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .DS_Store *.log .env* +staging/ diff --git a/public/app.css b/public/app.css index 8a1b201..24bb927 100644 --- a/public/app.css +++ b/public/app.css @@ -25,6 +25,10 @@ html, body { user-select: none; } +#stage.blocked { + pointer-events: none; +} + #world { position: absolute; left: 0; @@ -128,22 +132,35 @@ html, body { } } -#splash { +#import-state { position: fixed; inset: 0; + z-index: 10; display: grid; place-content: center; justify-items: center; gap: 14px; text-align: center; - background: var(--bg); - z-index: 10; - transition: opacity .4s, visibility .4s; + padding: 24px; + pointer-events: auto; +} +#import-state[hidden] { display: none; } + +#file { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + border: 0; + opacity: 0; } -#splash.hide { opacity: 0; visibility: hidden; pointer-events: none; } -#splash h1 { margin: 0; font-size: 44px; font-weight: 300; letter-spacing: .18em; } -#splash h1 .dot { color: var(--ring); } -#splash p { max-width: 34ch; color: var(--muted); margin: 0; font-size: 15px; line-height: 1.6; } +#import-state h1 { margin: 0; font-size: 44px; font-weight: 300; letter-spacing: .18em; } +#import-state h1 .dot { color: var(--ring); } +#import-state p { max-width: 34ch; color: var(--muted); margin: 0; font-size: 15px; line-height: 1.6; } +/* app.css?v=38 */ #start { margin-top: 10px; padding: 10px 34px; @@ -158,7 +175,91 @@ html, body { } #start:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } +#import { + margin-top: 14px; + position: relative; + z-index: 11; + display: inline-flex; + align-items: center; + gap: 9px; + padding: 10px 22px; + font-size: 14px; + letter-spacing: .06em; + border: 1px solid rgba(255, 255, 255, .3); + border-radius: 999px; + background: transparent; + color: var(--ink); + cursor: pointer; + opacity: 0; + transform: translateY(6px); + transition: opacity .4s, transform .4s, border-color .15s, background .15s; +} +#import:not([hidden]) { opacity: 1; transform: translateY(0); } +#import svg { + width: 17px; + height: 17px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} +#import:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } +#import.busy { + border-color: var(--ring); + color: var(--ring); + pointer-events: none; + animation: pulse 1.1s ease-in-out infinite; +} +#import[hidden] { display: none; } + +@keyframes pulse { + 0%, 100% { opacity: .55; } + 50% { opacity: 1; } +} + +#drop { + position: fixed; + inset: 0; + z-index: 9; + display: grid; + place-content: center; + background: rgba(14, 15, 19, .82); + border: 2px dashed rgba(125, 211, 252, .55); + color: var(--ink); + font-size: 16px; + letter-spacing: .1em; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity .15s, visibility .15s; +} +#drop.show { opacity: 1; visibility: visible; pointer-events: auto; } + +#toast { + position: fixed; + right: 18px; + bottom: 72px; + z-index: 8; + max-width: 46ch; + padding: 9px 15px; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 8px; + background: rgba(14, 15, 19, .94); + color: var(--ink); + font-size: 13px; + line-height: 1.5; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(4px); + transition: opacity .2s, visibility .2s, transform .2s; +} +#toast.show { opacity: 1; visibility: visible; pointer-events: auto; } +#toast.err { border-color: rgba(248, 113, 113, .6); color: #fca5a5; } + @media (prefers-reduced-motion: reduce) { #world { transition: none; } .tile.lock { animation: none; } + #upload.busy { animation: none; } } diff --git a/public/app.js b/public/app.js index 11f19b9..dfd6e6b 100644 --- a/public/app.js +++ b/public/app.js @@ -3,8 +3,15 @@ const $ = (s) => document.querySelector(s) const stage = $("#stage") const world = $("#world") -const splash = $("#splash") -const startBtn = $("#start") +const importState = $("#import-state") +const importEl = $("#import") +const fileInput = $("#file") +const dropOverlay = $("#drop") +const toastEl = $("#toast") + +addEventListener("error", (e) => { + toast(`error: ${e.message || "script failed"}`, true) +}) const GAP = 10 const FIT_TOLERANCE = 350 @@ -515,8 +522,9 @@ let pinchDist = 0 stage.addEventListener("pointerdown", (e) => { if (e.target.closest(".nav-zone")) return + if (e.target.closest("#import-state")) return pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) - stage.setPointerCapture(e.pointerId) + try { stage.setPointerCapture(e.pointerId) } catch { /* ignore */ } hideGhost() world.style.transition = "none" if (pointers.size === 2) { @@ -638,48 +646,123 @@ addEventListener("keydown", (e) => { addEventListener("resize", center) -const MIN_READ = 1500 -const splashShownAt = Date.now() let started = false -let splashTimer = 0 -function start() { - if (started) return - started = true - clearTimeout(splashTimer) - splash.classList.add("hide") - ;(async () => { - try { - const r = await fetch("/api/random") - const data = await r.json() - const photo = data.photo - photoInfo.set(photo.id, photo) - const a = aspect(photo) - const wf = a >= 1 ? a : 1 - const hf = a >= 1 ? 1 : 1 / a - const uW = (window.innerWidth * 0.92) / wf - const uH = (window.innerHeight * 0.92) / hf - U = Math.max(100, Math.min(640, Math.round(Math.min(uW, uH)))) - const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) } - addTile(photo, rect) - setCurrent(photo.id, rect) - } catch { /* ignore */ } - })() +function showImportState() { + importState.hidden = false + stage.classList.add("blocked") +} + +function hideImportState() { + importState.hidden = true + stage.classList.remove("blocked") } -function noteActivity() { +async function beginWalk() { if (started) return - const left = MIN_READ - (Date.now() - splashShownAt) - if (left <= 0) start() - else { - clearTimeout(splashTimer) - splashTimer = setTimeout(start, left) + started = true + try { + const r = await fetch("/api/random") + const data = await r.json() + if (!data.photo) { + showImportState() + return + } + hideImportState() + const photo = data.photo + photoInfo.set(photo.id, photo) + const a = aspect(photo) + const wf = a >= 1 ? a : 1 + const hf = a >= 1 ? 1 : 1 / a + const uW = (window.innerWidth * 0.92) / wf + const uH = (window.innerHeight * 0.92) / hf + U = Math.max(100, Math.min(640, Math.round(Math.min(uW, uH)))) + const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) } + addTile(photo, rect) + setCurrent(photo.id, rect) + } catch { /* ignore */ } +} + +;(async () => { + try { + const r = await fetch("/api/paths") + const data = await r.json() + if (data.total) await beginWalk() + else showImportState() + } catch { + showImportState() } +})() + +let toastTimer = 0 + +function toast(msg, isError = false) { + toastEl.textContent = msg + toastEl.classList.toggle("err", isError) + toastEl.classList.add("show") + clearTimeout(toastTimer) + toastTimer = setTimeout(() => toastEl.classList.remove("show"), isError ? 7000 : 3500) +} + +function plural(n) { + return n === 1 ? "photo" : "photos" +} + +async function importFiles(list) { + const files = Array.from(list || []).filter((f) => f.size > 0) + if (!files.length) return + const form = new FormData() + for (const f of files) form.append("photos", f, f.name) + importEl.classList.add("busy") + toast(`importing ${files.length} ${plural(files.length)}...`) + try { + const r = await fetch("/api/import", { method: "POST", body: form }) + const data = await r.json().catch(() => ({})) + if (!r.ok || !data.ok) throw new Error(data.error || `import failed (${r.status})`) + exitsCache.clear() + exitPending.clear() + hideImportState() + await beginWalk() + toast(`imported ${data.imported} ${plural(data.imported)} - keep walking to find them`) + } catch (err) { + toast(err.message || "import failed", true) + } finally { + importEl.classList.remove("busy") + fileInput.value = "" + } +} + +function hasFiles(e) { + return Array.from(e.dataTransfer?.types || []).includes("Files") } -startBtn.addEventListener("click", start) -addEventListener("pointermove", noteActivity) -addEventListener("pointerdown", noteActivity) -addEventListener("keydown", noteActivity) -addEventListener("wheel", noteActivity) -addEventListener("touchstart", noteActivity) + +fileInput.addEventListener("change", () => importFiles(fileInput.files)) + +let dragDepth = 0 + +addEventListener("dragenter", (e) => { + if (!hasFiles(e)) return + e.preventDefault() + dragDepth++ + dropOverlay.classList.add("show") +}) + +addEventListener("dragover", (e) => { + if (!hasFiles(e)) return + e.preventDefault() + e.dataTransfer.dropEffect = "copy" +}) + +addEventListener("dragleave", () => { + dragDepth = Math.max(0, dragDepth - 1) + if (dragDepth === 0) dropOverlay.classList.remove("show") +}) + +addEventListener("drop", (e) => { + if (!e.dataTransfer) return + e.preventDefault() + dragDepth = 0 + dropOverlay.classList.remove("show") + importFiles(e.dataTransfer.files) +}) diff --git a/public/index.html b/public/index.html index 549ca5d..0f44c6b 100644 --- a/public/index.html +++ b/public/index.html @@ -5,17 +5,25 @@ pathways - +
+
-
-

pathways.

-

four paths, one per direction. walk them.

- -
- + +
drop photos to import
+
+ diff --git a/server.mjs b/server.mjs index 5ae51a0..e783acc 100644 --- a/server.mjs +++ b/server.mjs @@ -1,9 +1,14 @@ import http from "node:http" -import { readFile } from "node:fs/promises" +import { readFile, mkdir, writeFile, rm } from "node:fs/promises" import { createReadStream, existsSync } from "node:fs" import { extname, join, normalize } from "node:path" +import { execFile } from "node:child_process" +import { randomUUID } from "node:crypto" +import { promisify } from "node:util" import mysql from "mysql2/promise" +const execFileAsync = promisify(execFile) + const DB = { host: process.env.DB_HOST || "127.0.0.1", port: Number(process.env.DB_PORT || 3306), @@ -17,6 +22,16 @@ const PORT = Number(process.env.PORT || 3100) const PUBLIC = join(process.cwd(), "public") const MIN_PATH = 3 +const STAGING_ROOT = process.env.STAGING_ROOT || join(process.cwd(), "staging") +const PHOTOPRISM_BIN = process.env.PHOTOPRISM_BIN || "photoprism" +const PHOTOPRISM_USER = process.env.PHOTOPRISM_USER || "photoprism" +const PHOTOPRISM_HOME = process.env.PHOTOPRISM_HOME || "/srv/photoprism" +const ORIGINALS_PATH = process.env.PHOTOPRISM_ORIGINALS_PATH || "/srv/photoprism/originals" +const STORAGE_PATH = process.env.PHOTOPRISM_STORAGE_PATH || "/srv/photoprism/storage" +const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 200 * 1024 * 1024) +const MAX_UPLOAD_FILES = Number(process.env.MAX_UPLOAD_FILES || 20) +const IMPORT_TIMEOUT_MS = Number(process.env.IMPORT_TIMEOUT_MS || 10 * 60 * 1000) + const pool = mysql.createPool(DB) const THUMB_SIZE = "1280x1024_fit" @@ -152,7 +167,7 @@ async function load() { exits.set(pid, list) } - return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets, photoOrder, byId, exits } + return { builtAt: new Date().toISOString(), total: photoOrder.length, photos: Object.fromEntries(byId), facets, photoOrder, byId, exits } } async function refresh() { @@ -181,6 +196,163 @@ function json(res, code, data) { res.end(JSON.stringify(data)) } +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +const SIGNATURES = [ + { ext: "jpg", accepts: /\.jpe?g$/i, test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, + { ext: "png", accepts: /\.png$/i, test: (b) => b.length > 8 && b.subarray(0, 8).equals(PNG_MAGIC) }, + { + ext: "webp", + accepts: /\.webp$/i, + test: (b) => b.length > 12 && b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP", + }, +] + +function sniff(buf) { + return SIGNATURES.find((s) => s.test(buf)) || null +} + +function readBody(req, limit) { + return new Promise((resolve, reject) => { + const chunks = [] + let size = 0 + req.on("data", (c) => { + size += c.length + if (size > limit) { + const err = new Error("payload too large") + err.code = "TOO_LARGE" + reject(err) + req.destroy() + return + } + chunks.push(c) + }) + req.on("end", () => resolve(Buffer.concat(chunks))) + req.on("error", reject) + }) +} + +function parseMultipart(buf, boundary) { + const parts = [] + const delim = Buffer.from(`--${boundary}`) + const sep = Buffer.from("\r\n\r\n") + let pos = buf.indexOf(delim) + if (pos < 0) return parts + pos += delim.length + while (pos < buf.length) { + if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break + if (buf[pos] === 0x0d && buf[pos + 1] === 0x0a) pos += 2 + const headEnd = buf.indexOf(sep, pos) + if (headEnd < 0) break + const headers = buf.subarray(pos, headEnd).toString("utf8") + const bodyStart = headEnd + sep.length + const next = buf.indexOf(delim, bodyStart) + if (next < 0) break + let bodyEnd = next + if (buf[bodyEnd - 2] === 0x0d && buf[bodyEnd - 1] === 0x0a) bodyEnd -= 2 + parts.push({ headers, body: buf.subarray(bodyStart, bodyEnd) }) + pos = next + delim.length + } + return parts +} + +function fileNameOf(headers) { + const m = /filename\*?=(?:UTF-8''([^\r\n;]+)|"([^"]*)"|([^\r\n;]+))/i.exec(headers) + if (!m) return null + const raw = m[1] ? decodeURIComponent(m[1]) : (m[2] ?? m[3] ?? "") + return raw.trim() +} + +function safeName(raw, sig, taken) { + const base = (raw.split(/[\\/]/).pop() || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120) + let name = base || "upload" + if (!sig.accepts.test(name)) name += `.${sig.ext}` + let out = name + let n = 1 + while (taken.has(out)) { + const dot = name.lastIndexOf(".") + out = `${name.slice(0, dot)}-${n}${name.slice(dot)}` + n++ + } + taken.add(out) + return out +} + +function photoprismCopy(dir) { + const env = { + PHOTOPRISM_DATABASE_DRIVER: "mysql", + PHOTOPRISM_DATABASE_SERVER: `${DB.host}:${DB.port}`, + PHOTOPRISM_DATABASE_NAME: DB.database, + PHOTOPRISM_DATABASE_USER: DB.user, + PHOTOPRISM_DATABASE_PASSWORD: DB.password, + PHOTOPRISM_ORIGINALS_PATH: ORIGINALS_PATH, + PHOTOPRISM_STORAGE_PATH: STORAGE_PATH, + PHOTOPRISM_READONLY: "false", + PHOTOPRISM_DISABLE_TENSORFLOW: "true", + PHOTOPRISM_DISABLE_CLASSIFICATION: "true", + PHOTOPRISM_DISABLE_FACES: "true", + PHOTOPRISM_LOG_LEVEL: "info", + } + const assignments = Object.entries(env).map(([k, v]) => `${k}=${v}`) + const args = ["-n", "-u", PHOTOPRISM_USER, "env", ...assignments, PHOTOPRISM_BIN, "cp", dir] + return execFileAsync("sudo", args, { cwd: PHOTOPRISM_HOME, timeout: IMPORT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }) +} + +async function handleImport(req, res) { + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }) + + const ctype = req.headers["content-type"] || "" + const bm = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(ctype) + if (!/^multipart\/form-data/i.test(ctype) || !bm) { + return json(res, 400, { error: "expected multipart/form-data" }) + } + + let body + try { + body = await readBody(req, MAX_UPLOAD_BYTES) + } catch (err) { + if (err.code === "TOO_LARGE") return json(res, 413, { error: `upload exceeds ${MAX_UPLOAD_BYTES} bytes` }) + return json(res, 400, { error: "could not read upload" }) + } + + const taken = new Set() + const files = [] + for (const part of parseMultipart(body, (bm[1] || bm[2]).trim())) { + const raw = fileNameOf(part.headers) + if (!raw || part.body.length === 0) continue + const sig = sniff(part.body) + if (!sig) return json(res, 415, { error: `unsupported file type: ${raw}` }) + if (files.length >= MAX_UPLOAD_FILES) return json(res, 413, { error: `too many files (max ${MAX_UPLOAD_FILES})` }) + files.push({ name: safeName(raw, sig, taken), data: part.body }) + } + if (files.length === 0) return json(res, 400, { error: "no image files in request" }) + + const batch = randomUUID() + const dir = join(STAGING_ROOT, batch) + try { + await mkdir(dir, { recursive: true }) + for (const f of files) await writeFile(join(dir, f.name), f.data) + } catch (err) { + console.error("pathways: staging failed:", err.message) + return json(res, 500, { error: "could not stage upload" }) + } + + try { + const { stdout, stderr } = await photoprismCopy(dir) + console.log(`pathways: imported ${files.length} file(s) via batch ${batch}`) + if (stderr) console.error(`pathways: photoprism cp stderr: ${stderr.slice(-2000)}`) + else if (stdout) console.log(`pathways: photoprism cp: ${stdout.slice(-2000)}`) + } catch (err) { + const detail = (err.stderr || err.stdout || err.message || "").slice(-2000) + console.error(`pathways: import failed (batch ${batch} kept at ${dir}):`, detail) + return json(res, 500, { error: "photoprism import failed", batch, detail }) + } + + await refresh() + await rm(dir, { recursive: true, force: true }).catch(() => {}) + return json(res, 200, { ok: true, imported: files.length, batch }) +} + const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`) const path = url.pathname @@ -212,6 +384,7 @@ const server = http.createServer(async (req, res) => { photos, }) } + if (path === "/api/import") return await handleImport(req, res) if (path === "/api/health") return json(res, 200, { ok: true }) if (path === "/" || path === "") { @@ -230,6 +403,7 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, "127.0.0.1", () => { console.log(`pathways listening on 127.0.0.1:${PORT}`) + mkdir(STAGING_ROOT, { recursive: true }).catch((err) => console.error("pathways: staging dir:", err.message)) refresh() }) setInterval(refresh, 30 * 60 * 1000).unref() -- cgit v1.3.1