web: a roll is read where it was left, and only for the frames that moved

A reader with a large roll ran into three things at once, and they were one
thing: a reading is dropped the moment the tab goes, and the second one over
the same folder took as long as the first.

The catalogue was never emptied — `scanFolder` has no delete anywhere in it —
but it read every frame again. The test that was meant to skip a frame that has
not moved compared the frame's *shutter* time with the file's write time
(`seen.taken === file.lastModified`), two numbers that are equal only by
accident: a still's EXIF date is when the picture was taken, not when the file
was written. So a rescan of any indexed roll went back to the disk for every
file, decoded every frame and wrote it back — which is what reads as "it threw
the index away and started over", and it cost the same minutes the first read
did. A frame that cannot say when it was taken was worse off: it falls back to
the file's own time, so it matched, was skipped forever, and never picked up an
edit.

A row now carries `mtime`, the write time the browser reports for the file, and
a frame is skipped on the same size and the same write time — which is what the
comment over that line always claimed. A row filed before the field existed has
no `mtime` and is read one last time. On the 36-frame roll the bench serves (24
JPEG 8.2MB + 12 RAW 22.6MB, two levels deep):

  first reading        5209ms
  the same roll again  2887ms   24/36 frames read again
  first reading        5320ms
  the same roll again   603ms    0/36 frames read again

And the screen starts that reading itself. Opening LIBRARY on a roll whose
reading ended when the app did now walks it again on the way in — and again
when the tab is raised — so the frames it never got to are read with no one
asking, and frames that landed in the folder since are picked up by the same
walk. The scan belongs to the tab and the walk skips what the catalogue already
holds, so a frame that has not moved is a name, a size and a time and nothing
else; the run that does it says nothing in the toolbar, the ring on the row and
the progress line are the report.

The folder menu's commands lead with a mark of their own — fold ▴, rename ✎,
scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view
switch already do: a column of marks reads at a glance where a block of
uppercase does not.

Verified:
  library-check.mjs — 43 steps, all passed, six of them new: the folder menu's
    three marks, the row menu's four, the add-only menu's one, the refused
    folder's lone reconnect carrying its ⚿, a reading cut short that goes on by
    itself (three rows back, and the bytes read are the two frames the
    catalogue had lost, not the one it still held), and the folder read again
    from its own menu. The stand-in folder now carries `__fake` on both handle
    kinds — a folder handle that does not is one the screen cannot ask about
    after a reload, which is a folder it offers to reconnect — and the frames
    it hands out report one write time instead of `Date.now()` per call, which
    is what a real handle does and what a frame is skipped on.
  scan-nav-check.mjs — all passed, the row still counting the reading as it
    comes rather than the catalogue standing still. roll-walk-check.mjs — all
    passed. frontend tsc --noEmit clean.

ponytail: nothing watches the folder, so a roll that changes under a screen
left open is picked up on the next visit or the next raise, not on the change —
a FileSystemObserver when the browsers ship one. A frame is skipped on size and
time alone, so an edit that keeps both is invisible until that frame is read
again; the row's own rescan is the way to ask for exactly that.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 06:33:41 +07:00
parent 36cd711302
commit fbe9a1bb5b
4 changed files with 170 additions and 8 deletions
+115 -4
View File
@@ -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');