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; }