diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index cacfbd3..0cec1b9 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -36,6 +36,14 @@ function step(name, ok, detail = '') { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); return ok; } +// The mark each command of an open context menu leads with. Every command carries +// one of its own: a column of marks reads at a glance where a block of uppercase +// does not, and a mark the platform's fonts have no glyph for is a box. +const menuMarks = () => + page.$$eval('[data-key="lib-menu"] .lib-menu-item', (els) => + els.map((e) => (e.querySelector('.lib-menu-icon')?.textContent ?? '').trim()) + ); +const marked = (marks) => marks.length > 0 && marks.every((m) => [...m].length === 1) && new Set(marks).size === marks.length; const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { @@ -94,23 +102,56 @@ const context = await browser.newContext(); // the way back out of IndexedDB. await context.addInitScript( ({ origin, names, subs, folder }) => { + // A folder the browser will not let the page read is the one state a run of + // its own has to ask for: the check sets the key, reloads, and the handles + // come back refused. + window.__deny = localStorage.getItem('check.deny') === '1'; const bytes = new Map(); const load = async (name) => { if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); return bytes.get(name); }; + // What a scan costs is the bytes it reads, so they are counted: a part cut + // off a file remembers the file it came off, and every `arrayBuffer` call on + // the whole or the part lands on that file's name. A frame the catalogue + // already holds is left on its size and its write time without a byte of it + // being read, which is the claim the run below makes. + const reads = (window.__reads = {}); + const off = new WeakMap(); + const part = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { + const cut = part.apply(this, args); + off.set(cut, off.get(this) ?? this.name ?? ''); + return cut; + }; + const pull = Blob.prototype.arrayBuffer; + Blob.prototype.arrayBuffer = function () { + const name = off.get(this) ?? this.name ?? ''; + if (name) reads[name] = (reads[name] ?? 0) + 1; + return pull.apply(this); + }; const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { - value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }), + value: async () => + new File([await load(name)], name, { + type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', + // A real handle reports one write time for one file, and the same one + // on every read; a fresh `Date.now()` per call would make every frame + // look touched and hide what is being checked. + lastModified: Date.UTC(2026, 0, 1), + }), }); return handle; }; // The picked folder holds `names` at its top level and the subfolders in // `subs`, each `{ files, dirs }` — a roll really is a folder of dated // directories inside dated directories, which is what the tree column walks. + // `__fake` on both kinds is what the revival below goes by: a handle that + // comes back out of IndexedDB is a plain object with no methods on it, and a + // folder that is not revived is a folder the screen has no permission for. const dirHandle = (label, node) => { - const handle = { kind: 'directory', name: label }; + const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => @@ -119,8 +160,8 @@ await context.addInitScript( ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), ][Symbol.iterator](), }, - queryPermission: { value: async () => 'granted' }, - requestPermission: { value: async () => 'granted' }, + queryPermission: { value: async () => (window.__deny ? 'denied' : 'granted') }, + requestPermission: { value: async () => (window.__deny ? 'denied' : 'granted') }, }); return handle; }; @@ -333,6 +374,15 @@ await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu)); +// Every command leads with a mark of its own, the way the tool rail and the view +// switch do: a column of marks reads at a glance where a block of uppercase does +// not, and a mark that is not in the font the platform ships is a box. +const marks = await menuMarks(); +step( + 'every command in the folder menu leads with its own mark', + marks.length === menu.length && marked(marks), + JSON.stringify(marks) +); await page.keyboard.press('Escape'); step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null); @@ -372,6 +422,8 @@ await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank)); +const blankMarks = await menuMarks(); +step('and that one command carries its mark too', blankMarks.length === 1 && marked(blankMarks), JSON.stringify(blankMarks)); await page.keyboard.press('Escape'); step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); @@ -398,6 +450,8 @@ step( rowMenu.join(',') === 'lib-menu-collapse,lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(rowMenu), ); +const rowMarks = await menuMarks(); +step('and its four commands each lead with a mark of their own', rowMarks.length === 4 && marked(rowMarks), JSON.stringify(rowMarks)); await page.click('[data-key="lib-menu-collapse"]'); await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { timeout: 10_000, @@ -567,6 +621,63 @@ step( `held at version ${held}, ${seenAgain} tiles on the other tab` ); +// A reading that was cut short — the app closed, the tab was gone — is finished by +// the next visit with no one asking for it: the walk starts on the way in, the +// frame the catalogue still holds is left on its size and its write time, and the +// two it never got to are read. Two rows are taken out to stand for a scan that +// stopped before it reached them. +await page.evaluate(async (keep) => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const rows = await new Promise((resolve) => { + const req = db.transaction('photos').objectStore('photos').getAll(); + req.onsuccess = () => resolve(req.result); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('photos', 'readwrite'); + for (const row of rows) if (!row.name.endsWith(keep)) tx.objectStore('photos').delete(row.id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +}, RAW); +await page.reload(); +await page + .waitForFunction((n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n, 3, { timeout: 60_000, polling: 50 }) + .catch(() => {}); +const restored = await page.$$eval(thumbs, (els) => els.length).catch(() => 0); +const read = await page.evaluate(() => window.__reads ?? {}); +step( + 'an interrupted reading goes on by itself, over the frames it still holds', + restored === 3 && !read[RAW] && read[JPG] > 0, + `${restored} tiles, ${JSON.stringify(read)}` +); + +// The last of the six marks is the one only a folder whose permission has lapsed +// ever shows — and the one command that can do anything about it. The folder comes +// back refused, offers its way back, and takes it. +await page.evaluate(() => localStorage.setItem('check.deny', '1')); +await page.reload(); +await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); +await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-reconnect-CheckRoll"]', { timeout: 10_000 }); +const refused = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); +const refusedMarks = await menuMarks(); +step( + 'a folder the browser refuses offers the one command that answers it, marked', + refused.join(',') === 'lib-menu-collapse,lib-reconnect-CheckRoll' && marked(refusedMarks) && refusedMarks[1] === '⚿', + `${JSON.stringify(refused)} ${JSON.stringify(refusedMarks)}` +); +await page.evaluate(() => localStorage.removeItem('check.deny')); +await page.evaluate(() => (window.__deny = false)); +await page.click('[data-key="lib-reconnect-CheckRoll"]'); +await page + .waitForFunction(() => document.querySelectorAll('[data-key^="lib-thumb-"]').length === 3, null, { timeout: 60_000, polling: 50 }) + .catch(() => {}); +step('and taking it reads the folder again', (await page.$$eval(thumbs, (els) => els.length)) === 3); + await browser.close(); sampleServer.close(); app.kill('SIGTERM'); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 1cee070..e8094c2 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -221,11 +221,11 @@ export function Library() { }, [menu]); const scan = useCallback( - async (folder: LibraryFolder) => { + async (folder: LibraryFolder, quiet = false) => { setNote(null); try { const result = await startScan(folder); - setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); + if (!quiet) setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); setDirs(result.dirs); await reload(); } catch { @@ -235,6 +235,30 @@ export function Library() { [reload, t] ); + // The folder this screen is on is read back to the disk on the way in, and again + // every time the tab is raised. A frame that has not moved costs a name, a size + // and a write time and nothing else, so the walk is cheap — and cheap for the + // two things the reader keeps running into: a roll whose first reading ended + // when the app did (nothing of it was lost, and nothing finished it either), and + // frames that landed in the folder since. No note: the ring on the row and the + // progress line are the report. + const rootKey = node ? node.split('/')[0] : null; + useEffect(() => { + const root = folders.find((f) => f.name === rootKey); + if (!loaded || !root || !ready.has(root.name)) return; + const catchUp = () => { + if (!scanSession()) void scan(root, true); + }; + catchUp(); + const raised = () => { + if (document.visibilityState === 'visible') catchUp(); + }; + document.addEventListener('visibilitychange', raised); + return () => document.removeEventListener('visibilitychange', raised); + // `folders` and `ready` are read here, not watched: the two only ever gain a + // name or a permission, and neither is a reason to read the disk again. + }, [loaded, rootKey, scan]); + const add = useCallback(async () => { setNote(null); try { @@ -778,6 +802,7 @@ export function Library() { setFolded(new Set(nested)); }} > + {t('lib.collapseAll')} ) : null} @@ -793,6 +818,7 @@ export function Library() { void rename(menu.folder!); }} > + {t('lib.rename')} @@ -830,6 +858,7 @@ export function Library() { void reconnect(menu.folder!); }} > + {t('lib.reconnect')} ) @@ -844,6 +873,7 @@ export function Library() { void add(); }} > + {t('lib.add')} )} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index ba70dd3..3e44c6f 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -58,6 +58,12 @@ export interface LibraryPhoto { // 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; addedAt: number; } @@ -304,7 +310,10 @@ export interface ScanProgress { } // 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. +// 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 @@ -372,7 +381,11 @@ export async function scanFolder( const file = await handle.getFile(); const id = photoId(folder.name, rel); const seen = known.get(id); - if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) return; + // 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) 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 @@ -392,6 +405,7 @@ export async function scanFolder( thumb, taken, size: file.size, + mtime: file.lastModified, addedAt: seen?.addedAt ?? Date.now(), }); progress.added++; diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 627fba8..6179fe4 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1109,7 +1109,14 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } letter-spacing: 0.04em; text-align: left; cursor: pointer; + /* Each command leads with a mark of its own — rename, scan, forget, fold — the + way the tool rail and the view switch do, so a menu read at a glance is a + column of marks rather than a block of uppercase. */ + display: flex; + align-items: center; + gap: 8px; } +.lib-menu-icon { flex: 0 0 auto; width: 12px; text-align: center; opacity: 0.85; } .lib-menu-item:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); } .lib-menu-item:disabled { opacity: 0.45; cursor: not-allowed; }