diff --git a/docker/frontend/scripts/backup-check.mjs b/docker/frontend/scripts/backup-check.mjs new file mode 100644 index 0000000..1cca53d --- /dev/null +++ b/docker/frontend/scripts/backup-check.mjs @@ -0,0 +1,326 @@ +// The catalogue's own copy, end to end: a roll of two frames is read, the +// catalogue is written into a folder the visitor picked, the browser's storage is +// taken away from under it, and the catalogue is read back out — with the frames +// never read a second time. +// +// The folder of frames is a stand-in served off a local server, because the check +// cannot hand a real directory to a page. The backup folder is not: the picker +// answers with a directory out of the origin's own file system (OPFS), which is a +// real `FileSystemDirectoryHandle` with a real `createWritable`, so what is +// written is written by the code under test through the API it will use on a +// visitor's disk — and what the check then reads back is the bytes on it. +// +// What the run claims, in order: +// * the catalogue file holds the rows and neither of the two things a file +// cannot hold — a handle and a tile; +// * the tiles are written beside it, one per frame, under `thumbs/`; +// * a second run writes no tile at all; +// * a catalogue with nothing in it gets every row back, tiles included, and the +// frames themselves are never read from the disk again — not by the restore, +// which takes the tiles off the backup folder, and not by the reading that +// follows it, which matches each frame on its size and its write time. +// +// npm run build && node scripts/backup-check.mjs +// PLAYWRIGHT_CORE= node scripts/backup-check.mjs +// +// Playwright is not a dependency of this package, so a run without it says SKIP +// and exits 0. APP_PORT says where the preview goes, CHROME says which browser. +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..'); +const APP_PORT = Number(process.env.APP_PORT ?? 4198); +const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4321); +// Two frames of one folder, both read off the sample roll. +const FILES = ['P1010256.JPG', 'DSCF1701.JPG']; +const FOLDER = 'TestRoll'; +const BACKUP = 'BackupRoll'; + +let failures = 0; +function step(name, ok, detail = '') { + if (!ok) failures++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + return ok; +} + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); + process.exit(0); +} + +// --- the frames, off a local server (the page fetches them by CORS) ---------- +const sampleServer = createServer(async (req, res) => { + const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); + if (!FILES.includes(name)) { + res.writeHead(404).end(); + return; + } + const bytes = await readFile(path.join(SAMPLES, name)); + res.writeHead(200, { + 'Content-Type': 'image/jpeg', + 'Content-Length': bytes.length, + 'Access-Control-Allow-Origin': '*', + }); + res.end(bytes); +}); +await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); + +// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which +// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails. +const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', +}); +const base = `http://127.0.0.1:${APP_PORT}`; +let up = false; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + app.kill('SIGTERM'); + sampleServer.close(); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); + process.exit(1); +} + +const browser = await playwright.chromium.launch({ + // Playwright's own Chromium when it has one; CHROME points at any other. + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], +}); +const context = await browser.newContext(); +// The words this screen says are read below, so the run is pinned to one of the +// two dictionaries rather than to whatever the browser's own language is. +await context.addInitScript(() => localStorage.setItem('rc.lang', 'en')); +// The picker, twice over: the roll of frames is a stand-in — a page cannot be +// handed a real directory by a test — and the backup folder is the origin's own +// file system, which is a real one. Which of the two a call is for is the `id` +// the code passes, the same one it passes to a visitor's browser. +await context.addInitScript( + ({ origin, files, folder, backup }) => { + const bytes = new Map(); + const load = async (name) => { + if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); + return bytes.get(name); + }; + // What a frame costs is its bytes, and the bytes of a frame are read through + // `arrayBuffer` — on the file, or on a slice of it, which is how a scan reads + // the head of a JPEG. Only the stand-in's own files are counted: the tiles + // that come back out of the backup folder are read the same way, and they are + // named after the frames. + window.__frameReads = 0; + const off = new WeakMap(); + const part = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { + const cut = part.apply(this, args); + off.set(cut, off.get(this) ?? this); + return cut; + }; + const pull = Blob.prototype.arrayBuffer; + Blob.prototype.arrayBuffer = function () { + if ((off.get(this) ?? this).__fake) window.__frameReads++; + return pull.apply(this); + }; + const fileHandle = (name) => { + const handle = { kind: 'file', name }; + Object.defineProperty(handle, 'getFile', { + value: async () => { + const file = new File([await load(name)], name, { + type: 'image/jpeg', + // One write time per frame, and the same one on every read: a fresh + // `Date.now()` would make every frame look touched and hide what is + // being checked — the reading after the restore has to match. + lastModified: Date.UTC(2016, 1, 15, 9, 30), + }); + Object.defineProperty(file, '__fake', { value: true }); + return file; + }, + }); + return handle; + }; + const dirHandle = (label, node) => { + const handle = { kind: 'directory', name: label }; + Object.defineProperties(handle, { + values: { value: () => [...(node.files ?? []).map(fileHandle)][Symbol.iterator]() }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + getDirectoryHandle: { value: async (name) => { + throw new Error(`no folder named ${name}`); + } }, + getFileHandle: { + value: async (name) => { + if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`); + return fileHandle(name); + }, + }, + }); + return handle; + }; + const root = (label) => dirHandle(label, { files }); + window.showDirectoryPicker = async (opts) => { + if (opts?.id !== 'recipescam-backup') return root(folder); + const opfs = await navigator.storage.getDirectory(); + // A folder of the origin's own, emptied at the start of the run and named + // as the picker would name it. + await opfs.removeEntry(backup, { recursive: true }).catch(() => {}); + const dir = await opfs.getDirectoryHandle(backup, { create: true }); + // OPFS handles are always writable and say so only through the API the + // picker's handles carry: the two methods are stood in where the real + // handle leaves them out, so the code under test takes the same road it + // would with a folder off a disk. + for (const mode of ['queryPermission', 'requestPermission']) { + if (typeof dir[mode] !== 'function') Object.defineProperty(dir, mode, { value: async () => 'granted' }); + } + return dir; + }; + }, + { origin: `http://127.0.0.1:${SAMPLE_PORT}`, files: FILES, folder: FOLDER, backup: BACKUP } +); + +const page = await context.newPage(); +// The restore asks before it replaces anything, and a dismissed dialog is a +// cancel — the run says yes the way a reader would. +page.on('dialog', (dialog) => void dialog.accept()); +const note = () => page.$eval('.adm-note', (el) => el.textContent ?? '').catch(() => ''); +const waitNote = (re) => + page.waitForFunction( + (src) => new RegExp(src).test(document.querySelector('.adm-note')?.textContent ?? ''), + re.source, + { timeout: 120_000 } + ); +const count = () => page.$eval('[data-key="lib-count"]', (el) => el.textContent ?? ''); +// The bytes of a frame actually read off the disk, counted inside the page: the +// whole claim of the catalogued size and write time is this number staying put. +const framesRead = () => page.evaluate(() => window.__frameReads ?? 0); +// The catalogue the page itself keeps, read straight out of the browser: what the +// restore puts back is a row in there, not a picture on the screen. +const rows = () => + page.evaluate( + () => + new Promise((resolve) => { + const open = indexedDB.open('recipescam-library'); + open.onsuccess = () => { + const req = open.result.transaction('photos', 'readonly').objectStore('photos').getAll(); + req.onsuccess = () => + resolve( + req.result.map((r) => ({ + id: r.id, + tile: r.thumb ? r.thumb.size : 0, + handle: !!r.handle, + star: r.star ?? null, + })) + ); + req.onerror = () => resolve([]); + }; + open.onerror = () => resolve([]); + }) + ); +// Everything the backup folder holds, walked the way a person looking at it in a +// file manager would. +const backupTree = () => + page.evaluate(async (name) => { + const opfs = await navigator.storage.getDirectory(); + const root = await opfs.getDirectoryHandle(name); + const out = []; + const walk = async (dir, at) => { + for await (const [child, handle] of dir.entries()) { + const rel = at ? `${at}/${child}` : child; + if (handle.kind === 'directory') await walk(handle, rel); + else out.push({ rel, size: (await handle.getFile()).size }); + } + }; + await walk(root, ''); + return out; + }, BACKUP); + +let code = 0; +try { + // --- the roll is read ------------------------------------------------------ + await page.goto(`${base}/library`); + await page.click('[data-key="lib-add"]'); + await waitNote(/Scanned TestRoll/); + const read = await rows(); + step('the roll was read and both frames are in the catalogue', read.length === 2, `${read.length} rows`); + step('every frame has its tile', read.every((r) => r.tile > 0)); + step('the count on the bar says two', /2/.test(await count()), await count()); + const firstRead = await framesRead(); + step('reading a roll of two frames costs two frames', firstRead === 2, `${firstRead} reads`); + + // --- the catalogue is written --------------------------------------------- + await page.click('[data-key="lib-backup"]'); + await waitNote(/Backed up 2 frames, 2 tiles written/); + const tree = await backupTree(); + const cat = await page.evaluate(async (name) => { + const opfs = await navigator.storage.getDirectory(); + const dir = await opfs.getDirectoryHandle(name); + return JSON.parse(await (await (await dir.getFileHandle('recipescam-catalogue.json')).getFile()).text()); + }, BACKUP); + step('the catalogue file holds both rows', cat.photos.length === 2, `${cat.photos.length}`); + step( + 'a row carries no handle and no tile', + cat.photos.every((r) => !('handle' in r) && !('thumb' in r)), + Object.keys(cat.photos[0]).join(',') + ); + step( + 'a row carries the size and the write time of its frame', + cat.photos.every((r) => r.size > 0 && r.mtime === Date.UTC(2016, 1, 15, 9, 30)) + ); + const tiles = tree.filter((f) => f.rel.startsWith('thumbs/')); + step( + 'the tiles are beside it, one file per frame', + tiles.length === 2 && tiles.every((f) => f.size > 0) && tiles.every((f) => /^thumbs\/TestRoll\/[^/]+$/.test(f.rel)), + tiles.map((f) => f.rel).join(' ') + ); + + // --- a second run writes no tile ------------------------------------------ + await page.click('[data-key="lib-backup"]'); + await waitNote(/Backed up 2 frames, 0 tiles written/); + + // --- the browser's storage is taken away ---------------------------------- + await page.goto('about:blank'); + await page.goto(`${base}/`); + const gone = await page.evaluate( + () => + new Promise((resolve) => { + const req = indexedDB.deleteDatabase('recipescam-library'); + req.onsuccess = () => resolve(true); + req.onerror = req.onblocked = () => resolve(false); + }) + ); + step('the catalogue is gone from the browser', gone === true); + await page.goto(`${base}/library`); + step('the library comes up empty', /^0\b/.test((await count()).trim()), await count()); + step('and has read nothing', (await framesRead()) === 0); + + // --- and it is put back ---------------------------------------------------- + await page.click('[data-key="lib-restore"]'); + await waitNote(/Restored 2 frames/); + const back = await rows(); + step('both rows are back', back.length === 2, `${back.length} rows`); + step('each row came back with its tile', back.every((r) => r.tile > 0)); + step('no row came back with a handle — a handle is not a file', back.every((r) => !r.handle)); + step('the restore read no frame from the disk', (await framesRead()) === 0, `${await framesRead()} reads`); + step('the count on the bar says two again', /2/.test(await count()), await count()); + + // --- the folder is picked again, and no frame is read for it --------------- + await page.click('[data-key="lib-add"]'); + await waitNote(/Scanned TestRoll/); + const joined = await rows(); + step('the reading gave every row its handle back', joined.length === 2 && joined.every((r) => r.handle)); + step('and read no frame to do it', (await framesRead()) === 0, `${await framesRead()} reads`); +} catch (err) { + step(`the run finished`, false, String(err).split('\n')[0]); + code = 1; +} + +console.log(failures ? `FAIL ${failures} step(s)` : 'ok the catalogue survives its browser'); +await browser.close(); +app.kill('SIGTERM'); +sampleServer.close(); +process.exit(failures || code ? 1 : 0); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index d376114..7f5ac80 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -27,6 +27,13 @@ import { type LibraryFolder, type LibraryPhoto, } from './engine/library'; +import { + backupNow, + backupStatus, + pickBackupFolder, + restoreNow, + type BackupStatus, +} from './engine/libraryBackup'; // The catalogue screen, laid out like the admin's picture manager: the tree of // folders and subfolders down the left, the frame that is up in the middle, and @@ -167,12 +174,108 @@ export function Library() { // the folders and cannot tell whether the remembered subfolder still exists. const [loaded, setLoaded] = useState(false); + // The folder the catalogue is copied into, and whether the browser is still + // letting this page write to it — which it stops doing when the tab closes, so + // the row below says so rather than letting a write fail quietly. The ref is + // the same value for the scan that ends behind this screen's back: it reads it + // without being subscribed to it. + const [back, setBack] = useState(null); + const backRef = useRef(null); + const [backBusy, setBackBusy] = useState(false); + const reload = useCallback(async () => { const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]); setPhotos(rows); setEdited(edits); }, []); + // --- the catalogue's own copy --------------------------------------------- + + const show = useCallback(async (status: BackupStatus) => { + backRef.current = status; + setBack(status); + }, []); + + useEffect(() => { + let alive = true; + void backupStatus().then((status) => { + if (alive) void show(status); + }); + return () => { + alive = false; + }; + }, [show]); + + // Write the catalogue into the folder. With none picked yet this is also the + // way to pick one: one button, because a backup folder is not something to + // choose and then not use. `quiet` is the run that follows a scan — it says + // nothing, since a note about a backup would sit over the note about the scan. + const backup = useCallback( + async (quiet = false) => { + if (backBusy) return; + if (!backRef.current?.folder) { + try { + await pickBackupFolder(); + } catch (err) { + // Closing the picker is a "not now", not a failure worth a line of text. + if (err instanceof DOMException && err.name === 'AbortError') return; + setNote(t('lib.failed')); + return; + } + } + setBackBusy(true); + if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 })); + try { + const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total }))); + await show(await backupStatus()); + if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written })); + } catch { + if (!quiet) setNote(t('lib.backupFailed')); + } finally { + setBackBusy(false); + } + }, + [backBusy, show, t] + ); + + // The other direction, and the one that replaces rows: the frames come back + // without a file handle — a handle is not a thing that can be written down — + // and picking the folder again afterwards is what hands every one of them back, + // without a frame being read. + const restore = useCallback(async () => { + const status = backRef.current; + if (!status?.folder) { + setNote(t('lib.restoreNone')); + return; + } + if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder }))) return; + if (backBusy) return; + setBackBusy(true); + setNote(t('lib.restoreRun', { done: 0, total: status.photos })); + try { + const result = await restoreNow((p) => setNote(t('lib.restoreRun', { done: p.done, total: p.total }))); + await reload(); + setFolders(await listFolders()); + setNote(t('lib.restoreDone', { n: result.photos })); + } catch { + setNote(t('lib.restoreFailed')); + } finally { + setBackBusy(false); + } + }, [backBusy, reload, t]); + + // A reading that is over is the one moment the rows are finished, so it is the + // moment the catalogue is copied out: a timer would fire in the middle of a + // roll or not at all. Held in a ref because the watch below subscribes once and + // this changes with every render — and nothing is written without a folder + // picked and the browser still allowing the write, which are the two states the + // row on screen is already saying. + const afterScan = useRef(() => {}); + afterScan.current = () => { + const status = backRef.current; + if (status?.folder && status.granted) void backup(true); + }; + // A scan runs for as long as the roll takes, and this screen watches it rather // than running it: the studio may be up instead, and the scan carries on. The // names a pass has walked into are drawn as they arrive, a pass ahead of the @@ -200,6 +303,7 @@ export function Library() { if (was && !folder) { was = null; void reload(); + afterScan.current(); return; } was = folder; @@ -667,6 +771,39 @@ export function Library() { {t('lib.stop')} ) : null} + {/* The catalogue's own copy: one button that picks the folder the + first time and writes into it every time after, and one that + reads it back. Both are furniture next to the reading itself, + so they sit behind the hint rather than in front of it. */} + + + + {back?.folder + ? back.granted + ? t('lib.backupAt', { + folder: back.folder, + n: back.photos, + time: back.at ? new Date(back.at).toLocaleString() : '—', + }) + : t('lib.backupBlocked', { folder: back.folder }) + : t('lib.backupNone')} + {t('lib.hint')} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 46c94a5..c15ace3 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -572,7 +572,17 @@ export async function scanFolder( // exactly as the catalogue already holds it. This is what makes a second scan // of the same folder cheap, and what finishes a roll whose first scan was cut // short — the frames it got through are skipped, the rest are read. - if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) return; + if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) { + // A row restored from a backup has no handle: a handle is not a thing that + // can be written to a file, so the row comes back with everything but the + // one thing that opens the frame, and the walk is where it gets it back. + // The row is put again — the bytes of the frame are still never read. + if (!seen.handle) { + batch.push({ ...seen, handle }); + if (batch.length >= BATCH) await flush(); + } + return; + } // A RAW is read whole because that is the only way LibRaw can seek to the // preview the camera wrote inside it; a JPEG is handed to the decoder as it // is, and only its header is read for the date. Both tiles come off what the @@ -966,3 +976,83 @@ export async function listEditedIds(): Promise> { return new Set(); } } + +export async function listEdits(): Promise { + try { + return await ask(EDITS, 'readonly', (s) => s.getAll()); + } catch { + return []; + } +} + +// --- restore --------------------------------------------------------------- + +// The catalogue as it travels: the rows without the two things that cannot be +// written to a file — a handle, which only the visitor's picker can hand out, and +// the tile itself, which is a blob and would be a second copy of the backup +// folder. Everything else is here, `mtime` and `size` and `star` included, which +// is what lets the walk that follows match every frame and read none of them. +export type CatalogueRow = Omit; + +// The rows a set of ids stands for, gaps left as gaps — one transaction for the +// lot, where `getPhoto` would be one per frame. +async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(PHOTOS, 'readonly'); + const store = tx.objectStore(PHOTOS); + const out: (LibraryPhoto | undefined)[] = []; + ids.forEach((id, i) => { + const req = store.get(id); + req.onsuccess = () => { + out[i] = req.result as LibraryPhoto | undefined; + }; + }); + tx.oncomplete = () => resolve(out); + tx.onerror = () => reject(tx.error); + }); +} + +// Put a catalogue back. A frame the catalogue already holds and can open — one +// with a handle — is left exactly as it is: the backup's copy of it would come +// back without one, which is a working catalogue traded for a worse one. Every +// other row goes in as it came out. `thumb` fetches the tile that was backed up +// beside the row, and a tile that will not read leaves the row without one: the +// grid paints a blank cell until the next scan reads the frame. +export async function restoreCatalogue( + photos: CatalogueRow[], + edits: LibraryEdit[], + thumb?: (id: string) => Promise +): Promise { + const db = await openDb(); + const size = 200; + let put = 0; + for (let at = 0; at < photos.length; at += size) { + const slice = photos.slice(at, at + size); + const had = await rowsAt(slice.map((r) => r.id)); + const want = slice.filter((_, i) => !had[i]?.handle); + if (!want.length) continue; + const rows = await Promise.all( + want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto) + ); + await new Promise((resolve, reject) => { + const tx = db.transaction(PHOTOS, 'readwrite'); + const store = tx.objectStore(PHOTOS); + for (const row of rows) store.put(row); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + put += rows.length; + } + for (let at = 0; at < edits.length; at += size) { + const slice = edits.slice(at, at + size); + await new Promise((resolve, reject) => { + const tx = db.transaction(EDITS, 'readwrite'); + const store = tx.objectStore(EDITS); + for (const row of slice) store.put(row); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + return put; +} diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts new file mode 100644 index 0000000..2fe1a03 --- /dev/null +++ b/docker/frontend/src/engine/libraryBackup.ts @@ -0,0 +1,318 @@ +// The catalogue's own copy, kept in a folder on the visitor's own disk. The +// catalogue lives in the browser's storage, which is the one place a cleared +// profile, a new machine or a reinstall takes away — and the frames themselves +// are not in it, so what is lost is the reading: every tile, every star, every +// recipe. This writes that reading out to a folder the visitor picks, and reads +// it back into a catalogue that has nothing. +// +// The folder holds one file of rows — thumbnail, star, recipe, the frame's size +// and write time, and no handle, which is not a thing that can be written down — +// and the tiles beside it, one file per frame under `thumbs/`, so the +// folder mirrors the library exactly and a tile can be looked at, copied or +// rsynced by hand. A frame that is being restored is matched to a frame on the +// disk by that size and write time, so putting a catalogue back costs the tiles +// and not one frame read. +// +// ponytail: no incremental tree walk, no deletes. A tile is written when the +// frame is new or its tile changed, and one left behind by a frame that was +// dropped from the library stays there — it is a few dozen kilobytes in a folder +// the visitor owns. Add a sweep when a library that is edited down to a fraction +// of its size makes the leftovers worth the walk. +import type { LibraryEdit, LibraryFolder } from './library'; +import { + ensurePermission, + listEdits, + listFolders, + listPhotos, + renameFolder, + restoreCatalogue, + type CatalogueRow, +} from './library'; + +const DB_NAME = 'recipescam-backup'; +// Two stores of its own, not the library's: the library opens without a version +// on purpose (see `openDb` there), and a version it has to reach for is one a +// second tab can block. This one is nothing but a handle and a list, so it can +// have a version and be upgraded. +const WHERE = 'where'; +const WRITTEN = 'written'; +const DB_VERSION = 1; + +const CATALOGUE = 'recipescam-catalogue.json'; +const THUMBS = 'thumbs'; +// The catalogue is written whole however little changed: it is one file, and a +// row that was left behind in it is a frame that will not come back. +const VERSION = 1; + +interface Meta { + id: 'meta'; + at: number; + photos: number; + wrote: number; +} + +export interface BackupStatus { + // The folder's own name, or null when the visitor has not chosen one. + folder: string | null; + // Whether the browser has let the page write to it. It comes back `prompt` + // after a restart — a permission outlives the tab only while the tab does, and + // Chromium will not hand it back without a click — which is why the screen + // says so rather than failing silently. + granted: boolean; + at: number; + photos: number; + wrote: number; +} + +export interface BackupProgress { + done: number; + total: number; +} + +let dbPromise: Promise | null = null; + +function openDb(): Promise { + dbPromise ??= new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(WHERE)) db.createObjectStore(WHERE, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(WRITTEN)) db.createObjectStore(WRITTEN, { keyPath: 'id' }); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return dbPromise; +} + +function ask(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const req = run(db.transaction(store, mode).objectStore(store)); + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => reject(req.error); + }) + ); +} + +async function keep(store: string, rows: object[]): Promise { + if (!rows.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const s = tx.objectStore(store); + for (const row of rows) s.put(row); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function folderHandle(): Promise { + try { + const row = await ask<{ id: string; handle: FileSystemDirectoryHandle } | undefined>(WHERE, 'readonly', (s) => + s.get('folder') + ); + return row?.handle ?? null; + } catch { + return null; + } +} + +// The permission is asked for silently and never granted from here: a prompt +// without a click behind it is a prompt the browser may refuse outright, and the +// buttons on the screen are where the click is. +async function granted(handle: FileSystemHandle, write: boolean): Promise { + const askable = handle as FileSystemHandle & { + queryPermission?: (o: { mode: string }) => Promise; + }; + try { + return (await askable.queryPermission?.({ mode: write ? 'readwrite' : 'read' })) === 'granted'; + } catch { + return false; + } +} + +export async function backupStatus(): Promise { + const handle = await folderHandle(); + const meta = await ask(WHERE, 'readonly', (s) => s.get('meta')).catch(() => undefined); + return { + folder: handle?.name ?? null, + granted: handle ? await granted(handle, true) : false, + at: meta?.at ?? 0, + photos: meta?.photos ?? 0, + wrote: meta?.wrote ?? 0, + }; +} + +// The picker's own dialog, the same way a folder of frames is picked — a second +// `id` so the browser remembers this folder apart from the library's. +export async function pickBackupFolder(): Promise { + if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker'); + const handle = await ( + window as unknown as { + showDirectoryPicker: (o?: unknown) => Promise; + } + ).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-backup' }); + const before = await folderHandle(); + await keep(WHERE, [{ id: 'folder', handle }]); + // A folder that is not the one the last run wrote into starts empty, and the + // list of tiles already written is about that folder — kept, it would say every + // tile is there and the new folder would come out with none of them. + if (before?.name !== handle.name) await ask(WRITTEN, 'readwrite', (s) => s.clear()); + return handle.name; +} + +// A handle comes back from IndexedDB without its permission, so every write goes +// through this: the visitor's click is the one thing that can hand it back. +async function writable(): Promise { + const handle = await folderHandle(); + if (!handle) throw new Error('no-backup-folder'); + if (!(await ensurePermission(handle, true))) throw new Error('no-backup-permission'); + return handle; +} + +// The directories are asked for by the path in the frame id, and the id of every +// frame in a folder starts with that folder's: a cache of what has been opened +// turns a folder of 220 000 frames into a walk of the folders it is made of. +const opened = new WeakMap>(); + +async function at(root: FileSystemDirectoryHandle, rel: string, create = false): Promise { + let cache = opened.get(root); + if (!cache) opened.set(root, (cache = new Map())); + let node = root; + let path = ''; + for (const part of rel.split('/')) { + if (!part) continue; + path = path ? `${path}/${part}` : part; + const had = cache.get(path); + if (had) { + node = had; + continue; + } + node = await node.getDirectoryHandle(part, { create }); + cache.set(path, node); + } + return node; +} + +async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise { + const cut = rel.lastIndexOf('/'); + const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true); + const file = await dir.getFileHandle(cut < 0 ? rel : rel.slice(cut + 1), { create: true }); + // One write at a time and closed at once: a writable that is left open is a + // file that is not on the disk yet, and a backup that is interrupted is one the + // next run has to write again. + const writer = await file.createWritable(); + await writer.write(blob); + await writer.close(); +} + +// What the last run left in the folder: the tile's own size, per frame. A tile +// that has not changed is not written again, which is the whole of what makes a +// second run cheap. +async function already(): Promise> { + const rows = await ask<{ id: string; size: number }[]>(WRITTEN, 'readonly', (s) => s.getAll()).catch(() => []); + return new Map(rows.map((r) => [r.id, r.size])); +} + +export interface BackupResult extends BackupStatus { + // The tiles this run actually wrote, and the frames it found. + written: number; +} + +export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise { + const root = await writable(); + const [photos, edits, folders] = await Promise.all([listPhotos(), listEdits(), listFolders()]); + const had = await already(); + const at0 = Date.now(); + const cat = { + version: VERSION, + at: at0, + folders: folders.map(({ name, label }) => ({ name, label })), + // The two things a file cannot hold: the handle, and the tile, which is + // written beside this as a file of its own. + photos: photos.map(({ handle, thumb, ...row }) => row) as CatalogueRow[], + edits, + }; + await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' })); + + let written = 0; + let done = 0; + const rows: { id: string; size: number }[] = []; + for (const photo of photos) { + // Progress counts frames and not tiles: a folder of frames read without one + // is still a reading, and a bar that does not move is a bar that reads as a + // screen that has stopped. + if (++done % 64 === 0) onProgress?.({ done, total: photos.length }); + if (!photo.thumb) continue; + if (had.get(photo.id) === photo.thumb.size) continue; + const parts = photo.id.split('/'); + parts.pop(); + const dir = await at(root, [THUMBS, ...parts].join('/'), true); + const file = await dir.getFileHandle(photo.name, { create: true }); + const writer = await file.createWritable(); + await writer.write(photo.thumb); + await writer.close(); + rows.push({ id: photo.id, size: photo.thumb.size }); + written++; + // The list is kept in step with the writes: a run that is interrupted + // continues from the frame it stopped at instead of writing the folder + // again. + if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length)); + } + await keep(WRITTEN, rows); + onProgress?.({ done: photos.length, total: photos.length }); + const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written }; + await keep(WHERE, [meta]); + return { folder: root.name, granted: true, at: at0, photos: photos.length, wrote: written, written }; +} + +export interface RestoreResult { + photos: number; + at: number; +} + +// Put a catalogue back. Nothing is deleted and nothing that is already there is +// spared: the backup is the reading, and a frame it knows about is a frame the +// catalogue should know about too. Folders are not in it — a folder is a handle, +// and a handle only comes from the visitor's own picker — so the frames come back +// without one and the folders are picked again afterwards, which is also what +// gives every frame its handle back without reading a byte of it. +export async function restoreNow(onProgress?: (p: BackupProgress) => void): Promise { + const root = await folderHandle(); + if (!root) throw new Error('no-backup-folder'); + if (!(await ensurePermission(root, false))) throw new Error('no-backup-permission'); + const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text(); + const cat = JSON.parse(text) as { + at?: number; + folders?: { name: string; label?: string }[]; + photos: CatalogueRow[]; + edits: LibraryEdit[]; + }; + // The folders this catalogue still has keep the names the backup remembers: + // a rename is the reader's own, and it is not in the frame's file anywhere. + const here = new Map((await listFolders()).map((f) => [f.name, f])); + for (const f of cat.folders ?? []) { + const folder = here.get(f.name); + if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label); + } + let done = 0; + const photos = await restoreCatalogue(cat.photos ?? [], cat.edits ?? [], async (id) => { + if (++done % 64 === 0) onProgress?.({ done, total: (cat.photos ?? []).length }); + try { + const parts = id.split('/'); + const name = parts.pop() as string; + const dir = await at(root, [THUMBS, ...parts].join('/')); + const file = await dir.getFileHandle(name); + return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' }); + } catch { + // A tile that is not there — or that has been taken away by hand — leaves + // the row without one, and the next scan of the folder reads the frame and + // paints it. Losing the picture is not worth losing the reading. + return null; + } + }); + onProgress?.({ done: photos, total: photos }); + return { photos, at: cat.at ?? 0 }; +} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 4669d23..9b58223 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -279,6 +279,21 @@ export const en: Dict = { 'lib.failed': 'That folder could not be read.', 'lib.unsupported': 'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.', + // The catalogue's own copy: the browser's storage is the one place a cleared + // profile takes the reading away from, and these two are what puts it back. + 'lib.backup': 'BACK UP', + 'lib.restore': 'RESTORE', + 'lib.backupNone': 'No backup folder yet — this picks one and writes the catalogue into it.', + 'lib.backupAt': 'Backup: {folder} · {n} frames · {time}', + 'lib.backupBlocked': 'The browser has not let this page write to {folder}. Press BACK UP and allow it.', + 'lib.backupRun': 'Backing up {done}/{total}…', + 'lib.backupDone': 'Backed up {n} frames, {w} tiles written.', + 'lib.backupFailed': 'The backup folder could not be written to.', + 'lib.restoreConfirm': 'Restore the catalogue from {folder}? Frames the library already holds are left as they are.', + 'lib.restoreRun': 'Restoring {done}/{total}…', + 'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.', + 'lib.restoreNone': 'Pick a backup folder first — BACK UP does that and writes the catalogue in one go.', + 'lib.restoreFailed': 'The backup folder could not be read.', 'nav.admin': 'Admin', 'nav.photos': 'My photos', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 145cc9b..fba8144 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -293,6 +293,21 @@ export const vi = { 'lib.failed': 'Không đọc được thư mục này.', 'lib.unsupported': 'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.', + // Bản sao của danh mục: bộ nhớ trình duyệt là chỗ duy nhất mà một profile bị + // xoá lấy mất phần đã đọc, và hai nút này là cách lấy lại. + 'lib.backup': 'SAO LƯU', + 'lib.restore': 'PHỤC HỒI', + 'lib.backupNone': 'Chưa có thư mục sao lưu — bấm để chọn một thư mục và ghi danh mục vào đó.', + 'lib.backupAt': 'Sao lưu: {folder} · {n} ảnh · {time}', + 'lib.backupBlocked': 'Trình duyệt chưa cho trang ghi vào {folder}. Bấm SAO LƯU và cho phép.', + 'lib.backupRun': 'Đang sao lưu {done}/{total}…', + 'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.', + 'lib.backupFailed': 'Không ghi được vào thư mục sao lưu.', + 'lib.restoreConfirm': 'Phục hồi danh mục từ {folder}? Những ảnh thư viện đã có sẽ được giữ nguyên.', + 'lib.restoreRun': 'Đang phục hồi {done}/{total}…', + 'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.', + 'lib.restoreNone': 'Chọn thư mục sao lưu trước — nút SAO LƯU làm việc đó và ghi danh mục luôn.', + 'lib.restoreFailed': 'Không đọc được thư mục sao lưu.', 'adm.title': 'Quản trị dải phim', 'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',