// 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 catalogue's own header, read out of the file and not out of this browser's // memory of it. A folder the reader is pointing at may be a copy carried over on // a stick, written by another machine, at another time: it is its own catalogue // that says which backup it is, and its own count and date that the screen has // to show before anything is written to it or read from it. async function head(root: FileSystemDirectoryHandle): Promise { try { const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text(); const cat = JSON.parse(text) as { at?: number; photos?: unknown[] }; return { id: 'meta', at: cat.at ?? 0, photos: (cat.photos ?? []).length, wrote: 0 }; } catch { return null; } } // 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. It is the // only way a folder is ever chosen, and the only thing that hands a remembered // one its permission back: the browser takes that away when the tab closes, and // a permission asked for without a picker behind it is one it may refuse. 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()); // And the row goes back to saying what this folder holds rather than what // the last one did: a count and a date from the folder next door is a count // and a date about a backup the reader is not looking at. const meta = await head(handle); await ask(WHERE, 'readwrite', (s) => (meta ? s.put(meta) : s.delete('meta'))); } 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 }; }