// 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