library: the reading is copied into a folder of the reader's own, so a cleared profile gets it back without a frame being read twice

This commit is contained in:
2026-09-30 16:41:11 +07:00
parent 325d5c0470
commit 3e3045d912
6 changed files with 902 additions and 1 deletions
+326
View File
@@ -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/<frame id>`;
// * 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=<path to playwright-core/index.mjs> 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=<path to its index.mjs>');
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);
+137
View File
@@ -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<BackupStatus | null>(null);
const backRef = useRef<BackupStatus | null>(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')}
</button>
) : 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. */}
<button
type="button"
className="btn"
data-key="lib-backup"
disabled={backBusy}
onClick={() => void backup()}
>
{t('lib.backup')}
</button>
<button
type="button"
className="btn"
data-key="lib-restore"
disabled={backBusy || !back?.folder}
onClick={() => void restore()}
>
{t('lib.restore')}
</button>
<span className="hint" data-key="lib-backup-state">
{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')}
</span>
<span className="hint lib-bar-hint" title={t('lib.hint')}>
{t('lib.hint')}
</span>
+91 -1
View File
@@ -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<Set<string>> {
return new Set();
}
}
export async function listEdits(): Promise<LibraryEdit[]> {
try {
return await ask<LibraryEdit[]>(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<LibraryPhoto, 'handle' | 'thumb'>;
// 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<Blob | null>
): Promise<number> {
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<void>((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<void>((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;
}
+318
View File
@@ -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/<frame id>`, 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<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
dbPromise ??= new Promise<IDBDatabase>((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<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((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<void> {
if (!rows.length) return;
const db = await openDb();
await new Promise<void>((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<FileSystemDirectoryHandle | null> {
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<boolean> {
const askable = handle as FileSystemHandle & {
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
};
try {
return (await askable.queryPermission?.({ mode: write ? 'readwrite' : 'read' })) === 'granted';
} catch {
return false;
}
}
export async function backupStatus(): Promise<BackupStatus> {
const handle = await folderHandle();
const meta = await ask<Meta | undefined>(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<string> {
if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker');
const handle = await (
window as unknown as {
showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle>;
}
).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<FileSystemDirectoryHandle> {
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<FileSystemDirectoryHandle, Map<string, FileSystemDirectoryHandle>>();
async function at(root: FileSystemDirectoryHandle, rel: string, create = false): Promise<FileSystemDirectoryHandle> {
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<void> {
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<Map<string, number>> {
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<BackupResult> {
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<RestoreResult> {
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<string, LibraryFolder>((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 };
}
+15
View File
@@ -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',
+15
View File
@@ -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ủ.',