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