137d54d37d
LIBRARY read the whole catalogue back on every batch the scan wrote: `reload()` is a `getAll` of the `photos` store, every row and every thumbnail in it, and on a catalogue of 150,000 frames one read was measured at 2036ms, twenty of them at 315s with the worst at 58s, the heap behind them going from 44MB to 89MB — beside a LibRaw open of a quarter of a gigabyte and four lanes of RAW bytes. On a folder of RAW that is the crash, and on any long roll it is the strip taking the roll's own time to move. The read-back now runs on a clock: at most once every five seconds of a scan, and once more when the reading ends, which is when the last of it is due. A batch lands every second or so, and reading it back costs every frame in the catalogue — so a scan that read it back each time spent the roll doing nothing else. The strip still fills while the scan runs; it fills in steps. The walk's own position went to the origin private file system. It is one path per frame the walk has found and not read — about twenty-eight characters each, measured over a folder of three thousand, which is the 85,626-character write — and local storage on this origin takes 5,000,000 characters and then throws, measured the same way. That is a hundred and seventy-five thousand frames: past it the first write of a long roll fails, the position is nowhere, and the next visit walks the whole roll from the top. That is the reading that goes back to the start, and OPFS takes the same text as a file. It goes down at most once every two seconds, for the same reason the catalogue is read back on a clock: the string is a few megabytes of paths. The folder is walked again for being raised no more than once every thirty seconds. A hundred thousand frames is a hundred thousand stats on the disk, and this screen is raised every time the reader comes back from the studio. And the column no longer says there is no folder before it has looked. The note waited on `folders` alone, which is empty for as long as the screen is coming up: on the catalogue of 150,000 it said so for the whole of the load and showed the folder after. It now waits on `loaded`, and says nothing until there is something to say. Checked on the running bundle: a 3000-frame scan reads the catalogue back 13 times where the code read it back once per batch, writes the position 21 times, and leaves the peak heap at 116MB with no long tasks. A reading stopped at 483 of 4000 leaves `walk/ROLL.json` at 101,107 characters naming 3,550 unread frames, with no local storage key beside it, and the visit after a reload carries on at 585 rather than at the top. LIBRARY on 150,000 frames shows no "No folder yet" at any point and settles on "150000 photos". The wall, the strip, the grid, the deep link and the phone's recipes all pass their checks.
1098 lines
47 KiB
TypeScript
1098 lines
47 KiB
TypeScript
// The local catalogue: the folders a visitor has handed over, a thumbnail of
|
||
// every frame found in them, and the recipe each frame was last graded with.
|
||
// Nothing here leaves the machine — no upload, no server, no account.
|
||
//
|
||
// The three stores are what the folder is made of: `folders` keeps the directory
|
||
// handle so the catalogue survives a reload without a second trip through the
|
||
// picker, `photos` keeps a file handle (the RAW itself is read only when a frame
|
||
// is opened) plus the small JPEG the grid paints, and `edits` keeps the recipe a
|
||
// frame was left at, keyed by the same id the grid uses.
|
||
//
|
||
// ponytail: Dexie would wrap this in three lines, but IndexedDB stores a
|
||
// FileSystemHandle by itself and the whole surface is seven calls — a dependency
|
||
// does not pay for itself here. Revisit if the catalogue grows queries (tags,
|
||
// smart collections) that hand-rolled cursors would make ugly.
|
||
import type { Recipe } from '../../shared/types';
|
||
import { readCapturedAt } from './imageOps';
|
||
import { isRawName, rawThumbnail } from './rawDevelop';
|
||
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||
|
||
const DB_NAME = 'recipescam-library';
|
||
const FOLDERS = 'folders';
|
||
const PHOTOS = 'photos';
|
||
const EDITS = 'edits';
|
||
|
||
// The grid cell is ~220px wide and a retina display doubles it: 512 on the long
|
||
// edge is the largest a tile ever shows, and it costs ~30KB per frame.
|
||
const THUMB_MAX = 512;
|
||
const THUMB_QUALITY = 0.75;
|
||
// Frames go down in batches, so a 3000-file folder is 60 transactions rather
|
||
// than 3000 of them.
|
||
const BATCH = 50;
|
||
// Frames read at once. Reading a frame is nearly all waiting — the bytes come off
|
||
// the disk, the decode runs on a thread of its own — and a wait that is not spent
|
||
// on the next frame is time the roll does not get back. Past a few, the disk and
|
||
// the decoder are the limit and the page has less room to breathe.
|
||
const LANES = 4;
|
||
// How often the walk's position is written down, in the same seconds as the
|
||
// catalogue is read back on: the position is the whole of what the walk has found
|
||
// and not read, and writing it is a string of a few megabytes on a long roll.
|
||
const WALK_MS = 2000;
|
||
|
||
// One LibRaw open at a time, whoever asks for it. A RAW is opened inside a worker
|
||
// the library builds with a quarter of a gigabyte of linear memory of its own
|
||
// (libraw-wasm instantiates emscripten's shared memory at 256MB), and the open
|
||
// copies the frame into it whole: the four lanes are what a folder of RAW would
|
||
// otherwise read at once, and measured on a roll of 48 22.6MB frames that is a
|
||
// 1335MB page against a 565MB one before the roll is picked — past what a page is
|
||
// given, and the page goes with it, which is the crash this answers. One at a time
|
||
// the same roll peaks at 1180MB: the frames around the one being opened still have
|
||
// their bytes read, their decodes run and their tiles encoded side by side, because
|
||
// only the open queues. A folder of JPEGs never touches this.
|
||
let rawTurn: Promise<unknown> = Promise.resolve();
|
||
function oneRawAtATime<T>(fn: () => Promise<T>): Promise<T> {
|
||
const turn = rawTurn.then(fn, fn);
|
||
rawTurn = turn.catch(() => undefined);
|
||
return turn;
|
||
}
|
||
|
||
export interface LibraryFolder {
|
||
// The directory's own name: the store's key, and the prefix of every frame id
|
||
// found in it. A rename never touches this.
|
||
name: string;
|
||
// What the tree paints instead, when the visitor has renamed the folder.
|
||
label?: string;
|
||
handle: FileSystemDirectoryHandle;
|
||
}
|
||
|
||
export interface LibraryPhoto {
|
||
// `${folder}/${dir}/${name}` — one frame appears once in the catalogue, under
|
||
// the folder it was found in.
|
||
id: string;
|
||
folder: string;
|
||
// The subfolder it sits in, relative to the picked folder — '' at the root.
|
||
// This is what the tree column reads.
|
||
dir: string;
|
||
name: string;
|
||
handle: FileSystemFileHandle;
|
||
thumb: Blob | null;
|
||
// When the shutter fired, per EXIF, else the file's own lastModified.
|
||
taken: number;
|
||
size: number;
|
||
// When the file itself was last written, as the browser reports it. This — with
|
||
// the size — is what says a frame has not moved since the last scan and does not
|
||
// have to be read again; `taken` is the shutter's own time and says nothing
|
||
// about the file. A frame stored before this field existed has none, and is
|
||
// read one last time.
|
||
mtime?: number;
|
||
// The reader's own score for the frame, 0 or absent when they have not given
|
||
// it one. It belongs to the catalogue and not to the disk — nothing about the
|
||
// file changes — so a rescan carries it over, the way it carries `addedAt`.
|
||
star?: number;
|
||
addedAt: number;
|
||
}
|
||
|
||
// A folder a scan has walked into, whether or not a frame has been read out of
|
||
// it yet: the column draws a roll from these before its frames arrive. They are
|
||
// the scan's own report and live no longer than it does — a reload draws the
|
||
// folders that hold frames, and the next scan names the rest again.
|
||
export interface LibraryDir {
|
||
// `${folder}/${rel}` — the tree key, the same way a frame id is spelled.
|
||
id: string;
|
||
folder: string;
|
||
// The path under the picked folder, no leading or trailing slash.
|
||
rel: string;
|
||
}
|
||
|
||
export interface LibraryEdit {
|
||
photoId: string;
|
||
recipe: Recipe;
|
||
updatedAt: number;
|
||
}
|
||
|
||
export const SUPPORTED = /\.(jpe?g|png|webp|heic|heif|tiff?|avif)$/i;
|
||
|
||
export function isSupportedPhoto(name: string): boolean {
|
||
return isRawName(name) || SUPPORTED.test(name);
|
||
}
|
||
|
||
export function photoId(folder: string, name: string): string {
|
||
return `${folder}/${name}`;
|
||
}
|
||
|
||
// The picker is Chromium-only (Chrome, Edge, Opera, Brave): Firefox and Safari
|
||
// have no way to hand a page a folder that outlives the visit, and the caller
|
||
// says so instead of showing a button that cannot work.
|
||
export function canBrowseFolders(): boolean {
|
||
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
||
}
|
||
|
||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||
|
||
function openDb(): Promise<IDBDatabase> {
|
||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||
// No version on purpose. A version the browser has to reach for is a version
|
||
// another tab can block: a tab still running the previous build holds the
|
||
// catalogue open, the upgrade waits on it, and this one never gets an answer
|
||
// — the screen comes up empty on a catalogue that is all there. Opening
|
||
// without one takes the catalogue as it is and creates it when it is missing.
|
||
const req = indexedDB.open(DB_NAME);
|
||
req.onupgradeneeded = () => {
|
||
const db = req.result;
|
||
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||
if (!db.objectStoreNames.contains(PHOTOS)) {
|
||
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
|
||
photos.createIndex('folder', 'folder');
|
||
photos.createIndex('taken', 'taken');
|
||
}
|
||
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
||
};
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
return dbPromise;
|
||
}
|
||
|
||
// One request per call: the transaction is the store's own, which is all a
|
||
// keyed get/put/delete and a getAll ever need. A cursor-based scan (the folder
|
||
// walk) goes through `walk` below instead, because it holds one transaction
|
||
// across many requests.
|
||
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);
|
||
})
|
||
);
|
||
}
|
||
|
||
// --- folders ---------------------------------------------------------------
|
||
|
||
export async function listFolders(): Promise<LibraryFolder[]> {
|
||
try {
|
||
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
|
||
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// The picker's own dialog; `id` makes the browser reopen at the folder this page
|
||
// was last pointed at, which is what makes a second visit one click.
|
||
export async function pickFolder(): Promise<LibraryFolder | null> {
|
||
if (!canBrowseFolders()) throw new Error('no-folder-picker');
|
||
const handle = await (
|
||
window as unknown as { showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle> }
|
||
).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-library' });
|
||
const folder: LibraryFolder = { name: handle.name, handle };
|
||
await ask(FOLDERS, 'readwrite', (s) => s.put(folder));
|
||
return folder;
|
||
}
|
||
|
||
// A rename is a label over the folder, not a new folder: everything downstream —
|
||
// the frame ids, the thumbnail store, the recipe each frame was left at — hangs
|
||
// off `name`, which stays the directory's own.
|
||
export async function renameFolder(folder: LibraryFolder, label: string): Promise<void> {
|
||
await ask(FOLDERS, 'readwrite', (s) => s.put({ ...folder, label }));
|
||
}
|
||
|
||
export async function removeFolder(name: string): Promise<void> {
|
||
const ids = (await listPhotos(name)).map((p) => p.id);
|
||
const db = await openDb();
|
||
await new Promise<void>((resolve, reject) => {
|
||
const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite');
|
||
tx.objectStore(FOLDERS).delete(name);
|
||
const photos = tx.objectStore(PHOTOS);
|
||
for (const id of ids) {
|
||
photos.delete(id);
|
||
tx.objectStore(EDITS).delete(id);
|
||
}
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
// The reading's position goes with the folder: a folder that is gone is not
|
||
// one to carry on reading, and a folder picked again starts at the top.
|
||
await clearWalk(name);
|
||
}
|
||
|
||
// A handle kept in IndexedDB comes back without its permission: the browser
|
||
// dropped it when the tab closed, and only the visitor can hand it back. Asking
|
||
// costs one quiet prompt, which is why this runs at start-up rather than the
|
||
// moment a frame is clicked. `requestPermission` needs the user's gesture in
|
||
// some builds, so a refusal is not fatal — the folder is listed as needing a
|
||
// click.
|
||
export async function ensurePermission(handle: FileSystemHandle, write = false): Promise<boolean> {
|
||
// `FileSystemPermissionMode` is not in the DOM lib this project compiles
|
||
// against, and the two values are the whole type.
|
||
type PermissionMode = 'read' | 'readwrite';
|
||
const mode: PermissionMode = write ? 'readwrite' : 'read';
|
||
const askable = handle as FileSystemHandle & {
|
||
queryPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
||
requestPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
||
};
|
||
try {
|
||
if (!askable.queryPermission) return false;
|
||
if ((await askable.queryPermission({ mode })) === 'granted') return true;
|
||
return (await askable.requestPermission?.({ mode })) === 'granted';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// --- thumbnails ------------------------------------------------------------
|
||
|
||
// EXIF is one JPEG segment, and a segment is 64KB at most — but only a JPEG keeps
|
||
// it in the first one, so the slice that is handed to the parser is a few hundred
|
||
// kilobytes rather than the whole frame. Reading 8MB through a JS array to find a
|
||
// date in the first kilobyte of it is most of what a scan of JPEGs costs.
|
||
export const HEAD_BYTES = 256 * 1024;
|
||
// The probe is decoded at this width, purely to learn which edge of the frame is
|
||
// the long one; the decoder scales the real one off that. A JPEG does not need
|
||
// it: its own header says how big the frame is, in the first few hundred bytes.
|
||
const PROBE_PX = 8;
|
||
|
||
// A JPEG's frame header — the SOF segment — carries the frame's size, and it sits
|
||
// well inside the bytes already read off the disk for the date. Reading it costs a
|
||
// walk over a few hundred bytes where the probe costs a second decode of every
|
||
// pixel of the file.
|
||
function jpegSize(bytes: Uint8Array): { width: number; height: number } | null {
|
||
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
||
for (let i = 2; i + 8 < bytes.length; ) {
|
||
if (bytes[i] !== 0xff) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const marker = bytes[i + 1];
|
||
// SOF0–SOF15 minus the ones that are not frames: DHT, JPG, DAC.
|
||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||
return { height: (bytes[i + 5] << 8) | bytes[i + 6], width: (bytes[i + 7] << 8) | bytes[i + 8] };
|
||
}
|
||
const length = (bytes[i + 2] << 8) | bytes[i + 3];
|
||
if (length < 2) return null;
|
||
i += 2 + length;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// What a frame whose header this code cannot read is measured with: one decode
|
||
// eight pixels wide. Only the aspect comes out of it — the two numbers that say
|
||
// which edge is the long one — and the frame is decoded once more for real.
|
||
async function sizeOf(blob: Blob): Promise<{ width: number; height: number } | null> {
|
||
try {
|
||
const probe = await createImageBitmap(blob, { resizeWidth: PROBE_PX });
|
||
const size = { width: probe.width, height: probe.height };
|
||
probe.close();
|
||
return size;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// The tile, read at the size the tile is painted at. Asking the decoder for 512
|
||
// on the long edge scales on the way out of the decoder — a quarter of the pixels
|
||
// of a 24MP frame ever exist — where decoding the frame whole and drawing it
|
||
// small pays for every one of them. The probe says which edge that is, and the
|
||
// canvas is only here to re-encode to JPEG.
|
||
async function tile(blob: Blob, size?: { width: number; height: number } | null): Promise<Blob | null> {
|
||
try {
|
||
const known = size ?? (await sizeOf(blob));
|
||
if (!known) return null;
|
||
const wide = known.width >= known.height;
|
||
const bitmap = await createImageBitmap(blob, wide ? { resizeWidth: THUMB_MAX } : { resizeHeight: THUMB_MAX });
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = bitmap.width;
|
||
canvas.height = bitmap.height;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return null;
|
||
ctx.drawImage(bitmap, 0, 0);
|
||
return await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
|
||
} finally {
|
||
bitmap.close();
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// --- scanning --------------------------------------------------------------
|
||
|
||
export interface ScanProgress {
|
||
folder: string;
|
||
total: number;
|
||
done: number;
|
||
added: number;
|
||
// How many frames have reached the catalogue. `done` counts the frames the
|
||
// reading has got to, which is nearly all of them long before they are stored,
|
||
// so a screen that read the catalogue back on `done` would read it again every
|
||
// frame — and reading it back means every thumbnail in it again. This moves a
|
||
// batch at a time, which is exactly when there is something new to read.
|
||
written: number;
|
||
// How many frames the scan has read out of each row of the tree, keyed the way
|
||
// the tree spells a row (`folder` for the picked folder, `folder/sub/dir` for
|
||
// the rest) and counted the way the rows count: a frame sits on its own row and
|
||
// on every row above it. A fresh object every frame, so a screen drawing the
|
||
// column sees a change; the frames themselves only reach the catalogue in one
|
||
// batch at the end, which would leave every row reading zero until then.
|
||
counts: Record<string, number>;
|
||
// Every folder the walk has been into so far, a fresh list each time: the
|
||
// column draws them while the frames under them are still being read. Not
|
||
// filed away — a folder name is worth nothing once the scan it came from ends.
|
||
dirs: LibraryDir[];
|
||
}
|
||
|
||
// Walk the folder, keep what is new or changed, and leave the rest alone: a
|
||
// second scan of a 2000-file folder only reads the frames that moved. A scan
|
||
// that was cut off — the tab closed, the reader stopped it — is finished by the
|
||
// next one: the frames it got through are skipped on their size and their time,
|
||
// so asking again on the way in is the whole of resuming.
|
||
//
|
||
// The walk itself lives in `rollWalk`: layer by layer from the picked folder
|
||
// down, one bounded pass at a time, with the folder the reader just clicked
|
||
// pulled to the front of the queue. `jump` is that click.
|
||
//
|
||
// ponytail: the lanes run on the main thread. The reading itself is done
|
||
// elsewhere (disk, the decoder's own threads), so what is left here is small —
|
||
// but a 5000-frame folder still makes the page lumpy. Move the walk into a
|
||
// worker (LibRaw is happy in one) if a catalogue that large is ever real.
|
||
|
||
// Where the reading had got to. A reload in the middle of a few thousand frames
|
||
// takes the page with it, and what it would take from the reading is the
|
||
// position: the folders the walk had been through, the ones still in its queue,
|
||
// and the frames it had found and not read. None of that is in the catalogue —
|
||
// the catalogue only holds the frames that were read — so the reading would
|
||
// start again at the top of the roll and walk every folder a second time.
|
||
//
|
||
// The position is held for the whole origin, which is the point: the tab that
|
||
// reloads carries on from the frame it stopped at, and so does the next context
|
||
// to open the app — the installed one beside the browser, where session storage,
|
||
// being the tab's own, handed it nothing and the walk started at the top of the
|
||
// roll a second time, every folder listed again and every frame it already held
|
||
// read again only to be skipped. The file goes when the reading finishes.
|
||
//
|
||
// It is a file in the origin private file system and not a key in local storage,
|
||
// which is where it was. The position is one path per frame the walk has found
|
||
// and not read — about twenty-eight characters each, measured over a folder of
|
||
// three thousand — and local storage on this origin takes 5,000,000 characters
|
||
// and then throws, also measured. That is a hundred and seventy-five thousand
|
||
// frames: past it the first write of a long roll fails, the position is nowhere,
|
||
// and the next visit walks the whole roll from the top — the reader's own
|
||
// complaint, and one a roll of RAW reaches in a morning. OPFS takes the same text
|
||
// as a file, has room for a catalogue's worth of them, and is the origin's the
|
||
// same way. An unwritable position is still a reading that starts at the top:
|
||
// private mode lands there, as it always did.
|
||
const WALK_DIR = 'walk';
|
||
const WALK_FILE = (folder: string) => `${encodeURIComponent(folder)}.json`;
|
||
|
||
async function walkFile(folder: string, create: boolean): Promise<FileSystemFileHandle | null> {
|
||
const root = await navigator.storage?.getDirectory?.();
|
||
if (!root) return null;
|
||
const dir = await root.getDirectoryHandle(WALK_DIR, { create });
|
||
return dir.getFileHandle(WALK_FILE(folder), { create });
|
||
}
|
||
|
||
interface WalkSaved {
|
||
// The folders the walk is through, and the ones it has not read yet, in the
|
||
// order it was going to read them.
|
||
walked: string[];
|
||
pending: string[];
|
||
// The frames the walk has found and the reading has not got to. The front of
|
||
// this is the frame a reloaded reading picks up at.
|
||
frames: string[];
|
||
// How many of the front of `frames` were already counted as read by the reading
|
||
// that wrote this down: the rows are in hand and not in the catalogue yet, so
|
||
// the next reading reads them again — and counts none of them twice.
|
||
restored: number;
|
||
progress: ScanProgress;
|
||
}
|
||
|
||
async function loadWalk(folder: string): Promise<WalkSaved | null> {
|
||
// One reading at a time: a peer that has announced this folder is walking it
|
||
// right now, and what it has written down is a step of a walk still in
|
||
// progress. Taken up here, two readings would write the same roll down at
|
||
// once, each undoing the other's queue. In the tab's own storage this could not
|
||
// come up; now that the position is the origin's, it can.
|
||
if (peer?.folder === folder) return null;
|
||
try {
|
||
const fh = await walkFile(folder, false);
|
||
if (!fh) return null;
|
||
const saved = JSON.parse(await (await fh.getFile()).text()) as WalkSaved;
|
||
return Array.isArray(saved?.walked) &&
|
||
Array.isArray(saved?.pending) &&
|
||
Array.isArray(saved?.frames) &&
|
||
Number.isInteger(saved?.restored) &&
|
||
saved.progress
|
||
? saved
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// A position that cannot be written down is a reading that starts from the top,
|
||
// which is what this did before — private mode lands here, as it always did.
|
||
async function saveWalk(
|
||
folder: string,
|
||
walked: string[],
|
||
pending: string[],
|
||
frames: string[],
|
||
restored: number,
|
||
progress: ScanProgress
|
||
): Promise<void> {
|
||
try {
|
||
const fh = await walkFile(folder, true);
|
||
if (!fh) return;
|
||
const w = await fh.createWritable();
|
||
await w.write(JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved));
|
||
await w.close();
|
||
} catch {
|
||
// Nothing to write down: the reading starts from the top, as it did before.
|
||
}
|
||
}
|
||
|
||
async function clearWalk(folder: string): Promise<void> {
|
||
try {
|
||
const root = await navigator.storage?.getDirectory?.();
|
||
const dir = await root?.getDirectoryHandle(WALK_DIR, { create: false });
|
||
await dir?.removeEntry(WALK_FILE(folder));
|
||
} catch {
|
||
// Absent is the desired state.
|
||
}
|
||
}
|
||
|
||
// The frames a written-down position names, asked of the root again: a frame is
|
||
// spelled by its path, and a handle is not a thing that can be written down. The
|
||
// folder part is asked once and kept, because a reading that stopped in a folder
|
||
// of a few thousand frames has nearly all of them in the same folder.
|
||
async function framesAt(root: FileSystemDirectoryHandle, rels: string[]): Promise<WalkFile[]> {
|
||
const dirs = new Map<string, FileSystemDirectoryHandle>();
|
||
const out: WalkFile[] = [];
|
||
for (const rel of rels) {
|
||
const cut = rel.lastIndexOf('/');
|
||
const at = cut < 0 ? '' : rel.slice(0, cut);
|
||
let dir = dirs.get(at);
|
||
if (!dir) {
|
||
const opened = await openAt(root, at);
|
||
// A folder that will not open takes its frames with it; they are read
|
||
// again when the walk reaches it, if it opens at all.
|
||
if (!opened) continue;
|
||
dirs.set(at, opened);
|
||
dir = opened;
|
||
}
|
||
try {
|
||
out.push({ handle: await dir.getFileHandle(rel.slice(cut + 1)), rel });
|
||
} catch {
|
||
// A frame that is gone is not a frame to read.
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export async function scanFolder(
|
||
folder: LibraryFolder,
|
||
onProgress?: (p: ScanProgress) => void,
|
||
shouldStop?: () => boolean,
|
||
jump?: () => string | null
|
||
): Promise<ScanProgress> {
|
||
const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p]));
|
||
const saved = await loadWalk(folder.name);
|
||
const tree: Walk = { root: folder.handle, pending: [], walked: new Set(saved?.walked ?? []) };
|
||
// A queue comes back as paths, so the folders are asked for again; a reading
|
||
// with nothing written down starts at the picked folder.
|
||
if (saved) {
|
||
for (const rel of saved.pending) {
|
||
const dir = await openAt(folder.handle, rel);
|
||
if (dir) tree.pending.push({ dir, rel });
|
||
}
|
||
} else {
|
||
tree.pending.push({ dir: folder.handle, rel: '' });
|
||
}
|
||
// The frames this reading has found and not read: the walk fills it, the lanes
|
||
// empty it, and a frame taken off the front is one a reload will not read
|
||
// again. Also where a reading that was cut off picks up.
|
||
const entries: WalkFile[] = saved ? await framesAt(folder.handle, saved.frames) : [];
|
||
// What the passes found: the folders this scan walked into, and the names they
|
||
// put in the column. `names` is cleared a pass at a time; `found` keeps the
|
||
// whole scan so a folder that has gone from the disk goes from the column too.
|
||
const names: string[] = [];
|
||
const progress: ScanProgress = saved?.progress ?? {
|
||
folder: folder.name,
|
||
total: 0,
|
||
done: 0,
|
||
added: 0,
|
||
written: 0,
|
||
dirs: [],
|
||
counts: {},
|
||
};
|
||
progress.folder = folder.name;
|
||
// A position written down by an older visit has no count to carry on from.
|
||
progress.written ??= 0;
|
||
const found = new Set(progress.dirs.map((d) => d.id));
|
||
const counts: Record<string, number> = progress.counts;
|
||
// Count the frame the moment the scan gets to it, before it knows whether the
|
||
// frame is new: the rows say how far the reading has come, not what it kept.
|
||
const count = (rel: string) => {
|
||
const cut = rel.lastIndexOf('/');
|
||
counts[folder.name] = (counts[folder.name] ?? 0) + 1;
|
||
let path = '';
|
||
for (const part of (cut < 0 ? '' : rel.slice(0, cut)).split('/')) {
|
||
if (!part) continue;
|
||
path = path ? `${path}/${part}` : part;
|
||
const key = `${folder.name}/${path}`;
|
||
counts[key] = (counts[key] ?? 0) + 1;
|
||
}
|
||
progress.counts = { ...counts };
|
||
};
|
||
// The folders this scan has walked into, kept for the whole of it: the column
|
||
// is redrawn from this list, so it only ever grows. A reading that was cut off
|
||
// hands the list on with the rest of its position.
|
||
const dirs: LibraryDir[] = progress.dirs;
|
||
let batch: LibraryPhoto[] = [];
|
||
// The frames in hand: the ones a lane has been handed, and the ones whose row is
|
||
// in `batch` and not yet in the catalogue. They are on neither side of the line a
|
||
// position is written on — the catalogue does not hold them and the walk will not
|
||
// find them again — so a reload has to read them again, and they are also the
|
||
// frames the written-down count has already counted.
|
||
let inHand: string[] = [];
|
||
const pathOf = (row: LibraryPhoto) => (row.dir ? `${row.dir}/${row.name}` : row.name);
|
||
// The position goes down on a clock, not on every batch. The whole of it is
|
||
// written each time — a copy of every path the walk has found and not read —
|
||
// and on a roll of a hundred thousand that is a few megabytes turned into a
|
||
// string per batch, which is the page held still for the whole roll. A position
|
||
// a step behind is a frame or two read again on the next visit, and a frame read
|
||
// again is skipped on its size and its time.
|
||
let wroteAt = 0;
|
||
const write = async () => {
|
||
const at = Date.now();
|
||
if (at - wroteAt < WALK_MS) return;
|
||
wroteAt = at;
|
||
const held = [...inHand, ...batch.map(pathOf)];
|
||
await saveWalk(
|
||
folder.name,
|
||
[...tree.walked],
|
||
tree.pending.map((p) => p.rel),
|
||
[...held, ...entries.map((e) => e.rel)],
|
||
held.length,
|
||
progress
|
||
);
|
||
};
|
||
// One transaction per batch, a put per row: a store with `keyPath: 'id'` takes
|
||
// a record, not an array of them. The position is written with it — a batch is
|
||
// the beat the reading and the stored position are kept in step at, so at most
|
||
// one batch is read again after a reload, and a frame that is read again is
|
||
// skipped on its size and its time.
|
||
const flush = async () => {
|
||
if (!batch.length) return;
|
||
const rows = batch;
|
||
batch = [];
|
||
const db = await openDb();
|
||
await new Promise<void>((resolve, reject) => {
|
||
const tx = db.transaction(PHOTOS, 'readwrite');
|
||
const photos = tx.objectStore(PHOTOS);
|
||
for (const row of rows) photos.put(row);
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
progress.written += rows.length;
|
||
await write();
|
||
};
|
||
// One frame, end to end: its bytes, its tile, its shutter time, and the row the
|
||
// catalogue keeps. A frame that has not moved is dropped on its size and its
|
||
// time before a byte of it is read, which is what makes a second scan of the
|
||
// same folder cheap; a frame that will not read at all is skipped by the lane
|
||
// that ran it — one bad file in a folder is not a failed folder.
|
||
const readOne = async (handle: FileSystemFileHandle, rel: string): Promise<void> => {
|
||
const file = await handle.getFile();
|
||
const id = photoId(folder.name, rel);
|
||
const seen = known.get(id);
|
||
// Unchanged means the same size and the same write time: the frame is left
|
||
// 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) {
|
||
// 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
|
||
// camera wrote — a RAW's preview is a copy, where a develop is every pixel.
|
||
const raw = isRawName(file.name);
|
||
const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer()));
|
||
const preview = raw ? await rawThumbnail(bytes) : null;
|
||
// The tile is the preview the camera wrote inside a RAW, or the frame itself.
|
||
// Both carry their own size in their first few hundred bytes — the same bytes
|
||
// the date comes out of — so the decoder is handed the size instead of being
|
||
// asked with a second decode of the whole frame to learn which edge is long.
|
||
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
|
||
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
|
||
const thumb = src ? await tile(src, size) : null;
|
||
const taken = (await readCapturedAt(bytes)) ?? file.lastModified;
|
||
const cut = rel.lastIndexOf('/');
|
||
batch.push({
|
||
id,
|
||
folder: folder.name,
|
||
dir: cut < 0 ? '' : rel.slice(0, cut),
|
||
name: handle.name,
|
||
handle,
|
||
thumb,
|
||
taken,
|
||
size: file.size,
|
||
mtime: file.lastModified,
|
||
// The reader's own score is not the file's: a frame that is read again
|
||
// keeps the stars it was given.
|
||
star: seen?.star,
|
||
addedAt: seen?.addedAt ?? Date.now(),
|
||
});
|
||
progress.added++;
|
||
inHand = inHand.filter((r) => r !== rel);
|
||
if (batch.length >= BATCH) await flush();
|
||
};
|
||
|
||
// The frames in hand, read a few at a time: reading a frame is almost all
|
||
// waiting — the bytes come off the disk, the decode runs on a thread of its own
|
||
// — and the wait of one frame is spent on the next one instead of sitting
|
||
// still. Lanes are why the same roll lands in a fraction of the time; past a
|
||
// few the disk and the decoder are the limit. A frame leaves the queue the
|
||
// moment its lane is handed it, so the position on disk trails the reading by
|
||
// no more than one batch.
|
||
const drain = async () => {
|
||
const lanes = new Set<Promise<void>>();
|
||
while (entries.length) {
|
||
if (shouldStop?.()) {
|
||
stop = true;
|
||
break;
|
||
}
|
||
const { handle, rel } = entries.shift()!;
|
||
inHand.push(rel);
|
||
// A frame a reload handed back that was counted already is read without
|
||
// being counted twice; the ones behind it are frames that reading found and
|
||
// never reached, and they are counted here as they are reached.
|
||
if (restored) restored--;
|
||
else {
|
||
progress.done++;
|
||
count(rel);
|
||
}
|
||
// A RAW takes the one open there is before its lane does anything, so a
|
||
// folder of them is read a frame at a time whatever the lanes say.
|
||
const read = isRawName(handle.name) ? oneRawAtATime(() => readOne(handle, rel)) : readOne(handle, rel);
|
||
const lane = read
|
||
.catch(() => undefined)
|
||
.finally(() => lanes.delete(lane));
|
||
lanes.add(lane);
|
||
if (lanes.size >= LANES) await Promise.race(lanes);
|
||
onProgress?.(progress);
|
||
}
|
||
await Promise.all(lanes);
|
||
};
|
||
|
||
// How many of the frames in hand were counted by the reading that handed them
|
||
// back: the rows it had read and not yet stored. The rest of the queue is frames
|
||
// it found and never got to, and this reading counts those as it reaches them —
|
||
// the rows and the counter both say how far the reading has come, and a reload
|
||
// that stopped at a frame must not count it twice.
|
||
let restored = Math.min(saved?.restored ?? 0, entries.length);
|
||
|
||
let stop = false;
|
||
let walked = false;
|
||
// One bounded pass at a time, and the pass yields between frames, so a folder
|
||
// clicked while the scan runs is read on the next jump rather than after the
|
||
// whole tree. Nothing clicked: the picked folder and its frames, then each
|
||
// layer below with theirs, deepest last — the order `pending` is filled in.
|
||
do {
|
||
names.length = 0;
|
||
// The queue a reading that was cut off left behind goes first, and the walk
|
||
// is asked for nothing until it is through: the frames already found are
|
||
// what that reading was in the middle of.
|
||
if (!entries.length) {
|
||
walked = await walkPass(tree, entries, names, isSupportedPhoto, jump ?? (() => null));
|
||
// The names come in before the frames they hold: the column grows one pass
|
||
// ahead of the strip, which is the whole point of reading layer by layer.
|
||
for (const rel of names) {
|
||
const id = `${folder.name}/${rel.slice(0, -1)}`;
|
||
if (found.has(id)) continue;
|
||
found.add(id);
|
||
dirs.push({ id, folder: folder.name, rel: rel.slice(0, -1) });
|
||
}
|
||
// The column is handed the names the moment the pass is through, before a
|
||
// single frame under them has been read. A copy, so the redraw has
|
||
// something new to look at rather than the list growing under it, and a
|
||
// reading that is cut off hands the same list on with its position.
|
||
progress.dirs = dirs.slice();
|
||
// Every frame found is a frame the reading has or will reach, so what is
|
||
// still in the queue is what is left of the total.
|
||
progress.total = progress.done + entries.length;
|
||
// The position before a frame of this pass is looked at: a reload here
|
||
// reads the pass's frames again, and a frame already in the catalogue is
|
||
// skipped anyway — where the other order would leave the last pass's
|
||
// frames out of the catalogue until the next visit.
|
||
await write();
|
||
onProgress?.(progress);
|
||
}
|
||
await drain();
|
||
} while (!walked && !stop);
|
||
await flush();
|
||
// A reading that came to its end has no position worth keeping: the next one
|
||
// walks the roll from the top and skips what has not moved.
|
||
if (!stop) await clearWalk(folder.name);
|
||
return progress;
|
||
}
|
||
|
||
// --- the scan in flight ----------------------------------------------------
|
||
|
||
// A scan belongs to the tab, not to the screen it was started from. A roll takes
|
||
// minutes, and the reader who started it has every reason to go to the studio and
|
||
// work on a frame while it runs — so the screen that started it may well be gone
|
||
// before the scan is. What that screen would have held lives here instead: one
|
||
// scan at a time, watched by whichever screen is up.
|
||
export interface ScanSession {
|
||
// The folder being read, by name.
|
||
folder: string;
|
||
progress: ScanProgress;
|
||
// What the reader has asked for since: stop, or read this folder next (the path
|
||
// as `rollWalk` spells it — `''` for the picked folder itself).
|
||
stop: boolean;
|
||
jump: string | null;
|
||
}
|
||
|
||
let live: ScanSession | null = null;
|
||
const watchers = new Set<() => void>();
|
||
|
||
const tell = () => {
|
||
for (const fn of watchers) fn();
|
||
};
|
||
|
||
// --- the other windows on this origin --------------------------------------
|
||
|
||
// An installed app and a browser tab share one origin, one catalogue and one
|
||
// roll: without a word between them the second window opens, finds nothing in
|
||
// flight here, and reads the same frames again — two readers of one folder, two
|
||
// RAW decoders at 256MB apiece, for a progress the first window already has.
|
||
// So the window holding the reading says so, on every frame and on a heartbeat,
|
||
// and the windows that do not hold it take the reading as their own: they draw
|
||
// the same progress, offer the same stop, and above all start nothing.
|
||
const CHANNEL = 'recipescam-library';
|
||
const SELF = Math.random().toString(36).slice(2);
|
||
// A window that has heard nothing for this long has a peer that was closed, or
|
||
// one that is wedged — either way the reading is nobody's again, and this window
|
||
// may pick it up as it always could.
|
||
const PEER_MS = 6000;
|
||
const HEART_MS = 2000;
|
||
// One round trip, for the window that has just opened: whether another window
|
||
// holds the reading is what decides whether this one starts its own.
|
||
const ASK_MS = 250;
|
||
|
||
interface Peer {
|
||
folder: string;
|
||
progress: ScanProgress;
|
||
at: number;
|
||
}
|
||
|
||
let peer: Peer | null = null;
|
||
let peerTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let heart: ReturnType<typeof setInterval> | null = null;
|
||
let askers: ((busy: boolean) => void)[] = [];
|
||
// A browser without the channel (an old one, a worker) simply has no peers, and
|
||
// everything below is a no-op there.
|
||
const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(CHANNEL) : null;
|
||
|
||
const say = (m: Record<string, unknown>): void => {
|
||
channel?.postMessage({ ...m, from: SELF });
|
||
};
|
||
|
||
const dropPeer = (): void => {
|
||
if (peerTimer) {
|
||
clearTimeout(peerTimer);
|
||
peerTimer = null;
|
||
}
|
||
if (!peer) return;
|
||
peer = null;
|
||
tell();
|
||
};
|
||
|
||
const holdPeer = (folder: string, progress: ScanProgress): void => {
|
||
peer = { folder, progress, at: Date.now() };
|
||
for (const fn of askers) fn(true);
|
||
askers = [];
|
||
if (peerTimer) clearTimeout(peerTimer);
|
||
peerTimer = setTimeout(dropPeer, PEER_MS);
|
||
tell();
|
||
};
|
||
|
||
// A peer holds the reading the way this window holds one, as far as a screen is
|
||
// concerned: the same shape, read the same way. Whether the reading is stopped
|
||
// or jumped from here is answered by relaying it, which is why the window
|
||
// watching can offer the buttons the window reading has.
|
||
const peerSession = (): ScanSession | null =>
|
||
peer ? { folder: peer.folder, progress: peer.progress, stop: false, jump: null } : null;
|
||
|
||
const announce = (): void => {
|
||
if (live) say({ k: 'scan', folder: live.folder, progress: live.progress });
|
||
};
|
||
|
||
if (channel) {
|
||
channel.onmessage = (e: MessageEvent) => {
|
||
const m = e.data as { k?: string; from?: string; folder?: string; progress?: ScanProgress; rel?: string | null } | null;
|
||
if (!m || m.from === SELF) return;
|
||
if (m.k === 'scan' && m.folder && m.progress) holdPeer(m.folder, m.progress);
|
||
else if (m.k === 'end') {
|
||
if (peer?.folder === m.folder) dropPeer();
|
||
} else if (m.k === 'who') announce(); // a window just opened and asks who reads
|
||
else if (m.k === 'stop' && live && live.folder === m.folder) {
|
||
live.stop = true;
|
||
tell();
|
||
} else if (m.k === 'jump' && live && live.folder === m.folder) live.jump = m.rel ?? null;
|
||
};
|
||
}
|
||
|
||
// Whether another window is reading right now, asked rather than assumed: the
|
||
// window that opens beside a running scan knows nothing until the reading
|
||
// answers, and reading the disk twice over is exactly what the answer prevents.
|
||
export function scanBusy(): Promise<boolean> {
|
||
if (live) return Promise.resolve(true);
|
||
if (!channel) return Promise.resolve(false);
|
||
const answered = new Promise<boolean>((resolve) => {
|
||
const settle = (busy: boolean): void => {
|
||
askers = askers.filter((fn) => fn !== settle);
|
||
resolve(busy);
|
||
};
|
||
askers.push(settle);
|
||
// Nobody answered: no reading is running, and waiting longer only delays
|
||
// this window's own.
|
||
setTimeout(() => settle(false), ASK_MS);
|
||
});
|
||
say({ k: 'who' });
|
||
return answered;
|
||
}
|
||
|
||
// The scan in flight, or null. The same object for the whole of the scan, with
|
||
// `progress` replaced on every pass — watchers are told, so they re-read it.
|
||
// A reading another window holds answers the same way: the screens no longer
|
||
// know or care which window the reading is in.
|
||
export function scanSession(): ScanSession | null {
|
||
return live ?? peerSession();
|
||
}
|
||
|
||
// Watch, and get the unsubscribe back: the shape an effect already has.
|
||
export function watchScan(fn: () => void): () => void {
|
||
watchers.add(fn);
|
||
return () => {
|
||
watchers.delete(fn);
|
||
};
|
||
}
|
||
|
||
export function stopScan(): void {
|
||
// A reading another window holds is stopped by telling it so: the button this
|
||
// window drew is the same button, on the window that can act on it.
|
||
if (!live) {
|
||
if (peer) say({ k: 'stop', folder: peer.folder });
|
||
return;
|
||
}
|
||
live.stop = true;
|
||
tell();
|
||
}
|
||
|
||
export function jumpScan(rel: string | null): void {
|
||
if (live) {
|
||
live.jump = rel;
|
||
return;
|
||
}
|
||
if (peer) say({ k: 'jump', folder: peer.folder, rel });
|
||
}
|
||
|
||
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
|
||
// this thread, so a second scan would only slow the first one down. The promise
|
||
// settles when the scan does — the caller that started it may be long gone.
|
||
export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
|
||
if (live) return Promise.reject(new Error('a scan is already running'));
|
||
const session: ScanSession = {
|
||
folder: folder.name,
|
||
progress: { folder: folder.name, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {} },
|
||
stop: false,
|
||
jump: null,
|
||
};
|
||
live = session;
|
||
tell();
|
||
announce();
|
||
// Between two frames there is nothing to announce, and a window that hears
|
||
// silence long enough would take the reading for abandoned and start its own.
|
||
if (heart) clearInterval(heart);
|
||
heart = setInterval(announce, HEART_MS);
|
||
return scanFolder(
|
||
folder,
|
||
(p) => {
|
||
session.progress = p;
|
||
tell();
|
||
announce();
|
||
},
|
||
() => session.stop,
|
||
() => session.jump
|
||
).finally(() => {
|
||
if (heart) {
|
||
clearInterval(heart);
|
||
heart = null;
|
||
}
|
||
live = null;
|
||
say({ k: 'end', folder: session.folder });
|
||
tell();
|
||
});
|
||
}
|
||
|
||
// --- reading ---------------------------------------------------------------
|
||
|
||
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
||
try {
|
||
const rows = await ask<LibraryPhoto[]>(PHOTOS, 'readonly', (s) =>
|
||
folder ? s.index('folder').getAll(folder) : s.getAll()
|
||
);
|
||
// Newest shutter first — a folder of stills reads in the order it was shot.
|
||
return rows.sort((a, b) => b.taken - a.taken);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
|
||
try {
|
||
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Score a frame, or take the score back with a 0. Nothing on the disk changes —
|
||
// the stars are the catalogue's own — so this is a read and a put, and a frame
|
||
// that is read again on the next scan carries its score over.
|
||
export async function setStar(id: string, star: number): Promise<void> {
|
||
const photo = await getPhoto(id);
|
||
if (!photo) return;
|
||
try {
|
||
await ask(PHOTOS, 'readwrite', (s) => s.put({ ...photo, star }));
|
||
} catch {
|
||
// Private mode: the score lives no longer than the visit.
|
||
}
|
||
}
|
||
|
||
// The frame itself, at last: nothing is read from the disk until this runs, so
|
||
// the catalogue costs thumbnails and no more.
|
||
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
||
return photo.handle.getFile();
|
||
}
|
||
|
||
// --- edits -----------------------------------------------------------------
|
||
|
||
export async function saveEdit(id: string, recipe: Recipe): Promise<void> {
|
||
try {
|
||
const edit: LibraryEdit = { photoId: id, recipe, updatedAt: Date.now() };
|
||
await ask(EDITS, 'readwrite', (s) => s.put(edit));
|
||
} catch {
|
||
// Private mode: the frame is still editable, it just forgets the recipe.
|
||
}
|
||
}
|
||
|
||
export async function loadEdit(id: string): Promise<LibraryEdit | null> {
|
||
try {
|
||
return (await ask<LibraryEdit | undefined>(EDITS, 'readonly', (s) => s.get(id))) ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function listEditedIds(): Promise<Set<string>> {
|
||
try {
|
||
const rows = await ask<LibraryEdit[]>(EDITS, 'readonly', (s) => s.getAll());
|
||
return new Set(rows.map((e) => e.photoId));
|
||
} catch {
|
||
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;
|
||
}
|