Files
RecipesCam/docker/frontend/src/engine/libraryBackup.ts
T
3dtours 39f80088c7 web: a folder is chosen by picking one — the backup row names it and opens the picker, and restore always asks which backup
RESTORE was dead on a machine that had never written a backup: the button was
`disabled` while no folder was remembered, and nothing in the handler could ever
pick one, so the reader who had carried a copy over on a stick had no way to
point at it. The button now stands, and the folder it restores from is whatever
the reader picks in the dialog that opens — which is the whole of the choice
there is, because a backup is a folder of rows and tiles. The folder on the other
disk, the one made before the edit: the folder is the version.

BACK UP asked for a folder only the first time one was ever chosen. After that
it wrote into it in silence, and after a restart — when the browser takes the
permission back, since a permission outlives the tab only while the tab does —
it wrote nothing and said so, which is a backup that quietly stops happening.
It now picks whenever it cannot write, and the picker is the only thing that can
hand a remembered folder its permission back; a permission asked for with no
picker behind it is one the browser may refuse. The run that follows a scan is
untouched: it is guarded by the permission it would be asking for.

And the folder is a control, not a caption. The line that names it — with the
frames in it and when they were written — is what tells one backup from another,
and pressing it is how a folder is picked, a name given, a permission handed
back. It keeps the hint's own look through five lines of CSS, so the row still
reads as a caption and not as a third button.

What the line says is now read out of the folder rather than out of this
browser's memory of it. `pickBackupFolder` reads the catalogue file inside the
folder just picked and takes its count and its date as the row's own; a folder
with no catalogue of its own has none, and the row says so instead of showing
the numbers of the folder next door. That is also what the confirm names before
a restore — the frames and the time in the folder just picked, which is the
thing about to be restored. `restoreNow` already read that file; this reads its
header first.

Checked on the running bundle with the picker stubbed, since the dialog is one a
probe cannot press: with nothing remembered, RESTORE now opens the picker where
it did nothing at all, the row names the folder the picker returned, and a
folder without a catalogue of its own is refused with a line saying so rather
than restored as an empty catalogue. BACK UP opens it too, from the same fresh
state. The line computes to a button with no border, no background, the page's
own font and the hint's own 12px dim grey — a caption that happens to be
pressable. The wall, the strip, the grid, the deep link and the phone's recipes
all pass their checks.
2026-10-01 17:12:29 +07:00

344 lines
14 KiB
TypeScript

// 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 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<Meta | null> {
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<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());
// 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<Meta | undefined>(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<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 };
}