web: name a roll's folders before reading their frames

This commit is contained in:
2026-09-28 20:06:10 +07:00
parent d0fddba1e8
commit 52732a17ee
5 changed files with 300 additions and 101 deletions
+38 -4
View File
@@ -154,7 +154,12 @@ await context.addInitScript(
names: [JPG],
// One frame at the top, one a folder down, one two folders down: the walk has
// to reach the bottom of the roll, not just its first level.
subs: { 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } } },
// One folder down, one two deep, and one holding no frame the catalogue can
// read: the walk names it long before there is a thumbnail for it.
subs: {
2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } },
Empty: { files: ['notes.txt'], dirs: {} },
},
folder: 'CheckRoll',
}
);
@@ -225,6 +230,8 @@ step(
['lib-node-CheckRoll', '3'],
['lib-node-CheckRoll/2026', '2'],
['lib-node-CheckRoll/2026/04', '1'],
// Named by the walk, counted by nothing: a row before a frame.
['lib-node-CheckRoll/Empty', '0'],
]),
JSON.stringify(nodes)
);
@@ -245,11 +252,11 @@ step(
`${foldedThumbs} tiles, aria-expanded=${shut}, stage ${foldedMeta.split('\n')[0]}`
);
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, {
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, {
timeout: 10_000,
polling: 50,
});
step('the same click draws it open again', true, '3 rows');
step('the same click draws it open again', true, '4 rows');
// Show subfolders or not: the strip follows the open folder alone, or the whole
// branch under it. The switch is the reader's and the tree does not change with
@@ -260,13 +267,40 @@ const shallow = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length
const shallowStage = await page.locator('[data-key="lib-stage-meta"]').innerText();
step(
'the switch leaves the open folder its own frames',
shallow === 3 && /P1010256\.JPG/.test(shallowStage),
shallow === 4 && /P1010256\.JPG/.test(shallowStage),
`${shallow} rows, stage ${shallowStage.split('\n')[0]}`
);
await page.click('[data-key="lib-subs"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 10_000, polling: 50 });
step('and puts the branch back', true, '3 tiles from the roll below');
// A folder the walk named and the catalogue found nothing in: the row is there,
// the strip says so rather than drawing nothing at all.
await page.click('[data-key="lib-node-CheckRoll/Empty"]');
await page.waitForFunction(() => document.querySelector('.adm-filmstrip .adm-row-empty') !== null, null, {
timeout: 10_000,
polling: 50,
});
const emptyThumbs = await page.$$eval(thumbs, (els) => els.length);
const emptyHint = await page.$eval('.adm-filmstrip .adm-row-empty', (e) => e.textContent);
step(
'a folder the walk named but holds no frame draws an empty strip',
emptyThumbs === 0 && /chưa có ảnh|nothing in this folder/i.test(emptyHint ?? ''),
`${emptyThumbs} tiles, "${emptyHint}"`
);
// Back to the roll: the first click folds it on the way, so it takes two.
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, {
timeout: 10_000,
polling: 50,
});
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, {
timeout: 10_000,
polling: 50,
});
step('and the roll comes back with its frames', true, '3 tiles');
// Opening the subfolder redraws the strip and the frame above it.
await page.click('[data-key="lib-node-CheckRoll/2026"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 });
+68 -34
View File
@@ -1,8 +1,9 @@
// The walk that reads a picked folder (src/engine/rollWalk.ts) is the part of a
// scan that can run for minutes, and its order is what the reader sees: parents
// before children, shallower layers before deeper ones, and the folder they just
// clicked in front of everything. None of that can be watched in a browser, so
// it is walked here over a stand-in tree.
// scan that can run for minutes, and its order is what the reader sees: the
// picked folder with its own frames, then every folder one layer down with
// theirs, then the layer below that — and the folder they just clicked in front
// of everything. None of that can be watched in a browser, so it is walked here
// over a stand-in tree.
//
// node scripts/roll-walk-check.mjs
import assert from 'node:assert/strict';
@@ -15,9 +16,10 @@ const js = ts.transpileModule(src, {
}).outputText;
const { DIRS_PER_PASS, jumpQueue, walkPass } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
// A directory handle is only ever read for `values()`, so a stand-in tree needs
// nothing else: `{ name: { … } }` for folders and `name: null` for a file.
// `@eaDir` and `.thumbnails` are the folders a real roll drags along.
// A directory handle is only ever read for `values()` and `getDirectoryHandle()`,
// so a stand-in tree needs nothing else: `{ name: { … } }` for folders and
// `name: null` for a file. `@eaDir` and `.thumbnails` are the folders a real roll
// drags along.
const dir = (name, children) => ({
kind: 'directory',
name,
@@ -26,31 +28,48 @@ const dir = (name, children) => ({
yield kids === null ? { kind: 'file', name: child } : dir(child, kids);
}
},
getDirectoryHandle: async (part) => {
if (!(part in children) || children[part] === null) throw new DOMException('not a folder', 'NotFoundError');
return dir(part, children[part]);
},
});
const keep = (name) => /\.(jpe?g|rw2)$/i.test(name);
// A walk is its root, its queue, and the folders it has read.
const at = (root, pending) => ({ root, pending, walked: new Set() });
const roll = dir('CheckRoll', {
'.thumbnails': { hidden: null },
'@eaDir': { junk: null },
'P1.JPG': null,
'Empty': { 'notes.txt': null },
'2026': { 'P2.JPG': null, '04': { 'P3.RW2': null, 'notes.txt': null } },
'2025': { 'P4.JPG': null },
});
// Walks to the end, one click per pass, which is what the browser does between
// passes: whatever the reader clicked last is what `jump` names.
// passes: whatever the reader clicked last is what `jump` names. `seen` is the
// folders each pass named, which is what the column draws.
const walk = async (jumps = []) => {
const pending = [{ dir: roll, rel: '' }];
const tree = at(roll, [{ dir: roll, rel: '' }]);
const out = [];
let pass = 0;
let clicked = 0;
let passes = 0;
let walked = false;
while (!walked) walked = await walkPass(pending, out, keep, () => jumps[pass++] ?? null);
return { out, passes: pass };
let seen = null;
while (!walked) {
const names = [];
walked = await walkPass(tree, out, names, keep, () => jumps[clicked++] ?? null);
passes++;
seen ??= names;
}
return { out, names: seen, passes };
};
// Nothing clicked: the picked folder's own frames, then a layer down, then the
// deepest — a folder is never walked before the folder that holds it.
const { out, passes } = await walk();
// deepest — a folder is never read before the folder that holds it. Every folder
// is named in the pass that finds it, which is how a row exists before its own
// frames do.
const { out, names, passes } = await walk();
const rels = out.map((f) => f.rel);
assert.deepEqual(rels, ['P1.JPG', '2025/P4.JPG', '2026/P2.JPG', '2026/04/P3.RW2'], 'layer by layer from the parent down');
assert.ok(
@@ -58,11 +77,17 @@ assert.ok(
'no deeper frame is read before a shallower one'
);
assert.ok(!rels.some((rel) => /\.thumbnails|@eaDir|notes\.txt/.test(rel)), 'hidden and unsupported names are skipped');
assert.equal(passes, 4, 'one folder a pass — the thread goes back to the page between them');
assert.deepEqual(
names,
['2025/', '2026/', 'Empty/', '2026/04/'],
'the first pass names every folder it walked into, frames or no frames'
);
assert.ok(names.includes('Empty/'), 'a folder holding no frame at all is still a row');
assert.equal(passes, 1, 'a roll this size is one pass, and the names are in it');
// A click on one of the two folders a layer down while its layer is still to be
// walked puts that folder first: the frames the reader is looking at are read
// before the branch beside them.
// A click on a folder one layer down, while that layer is still to be read, puts
// it first: the frames the reader is looking at are read before the branch beside
// them.
const clicked = await walk([null, '2026/']);
assert.deepEqual(
clicked.out.map((f) => f.rel),
@@ -70,30 +95,39 @@ assert.deepEqual(
'the clicked folder jumps the queue'
);
// And the same rule without a browser around it: a pass reads the queue in its
// own order unless the click says otherwise, and a name the queue does not hold
// changes nothing.
// A folder the walk has not reached yet — a row on screen from the parent pass —
// is opened from the picked folder and read next: `getDirectoryHandle` walks the
// path, and a path that will not open is left where it was.
const deep = at(roll, [{ dir: dir('Other', { 'X.JPG': null }), rel: 'other/' }]);
const deepOut = [];
await walkPass(deep, deepOut, [], keep, () => '2026/04/');
assert.deepEqual(
deepOut.map((f) => f.rel),
['2026/04/P3.RW2', 'other/X.JPG'],
'a folder the queue never held is opened and read first'
);
const missing = at(roll, [{ dir: roll, rel: '' }]);
await jumpQueue(missing, '2026/nope/');
assert.equal(missing.pending.length, 1, 'a path that does not open leaves the queue alone');
// And the rule without a browser around it: a pass reads the queue in its own
// order unless the click says otherwise.
const wide = dir('Wide', { a: { 'A.JPG': null }, b: { 'B.JPG': null } });
const before = [];
await walkPass([{ dir: wide, rel: '' }], before, keep, () => null);
await walkPass(at(wide, [{ dir: wide, rel: '' }]), before, [], keep, () => null);
assert.deepEqual(before.map((f) => f.rel), ['a/A.JPG', 'b/B.JPG'], 'untouched, the folders are read in the order they were found');
const after = [];
await walkPass([{ dir: wide, rel: '' }], after, keep, () => 'b/');
assert.deepEqual(
after.map((f) => f.rel),
['b/B.JPG', 'a/A.JPG'],
'the folder the reader clicked is read first'
);
assert.equal(jumpQueue([{ rel: 'a/' }], 'nowhere/'), undefined, 'a folder already walked, or never seen, is left alone');
await walkPass(at(wide, [{ dir: wide, rel: '' }]), after, [], keep, () => 'b/');
assert.deepEqual(after.map((f) => f.rel), ['b/B.JPG', 'a/A.JPG'], 'the folder the reader clicked is read first');
// A pass is bounded: a library wider than the budget is read over several passes,
// and the rest waits in the queue rather than in a loop that never yields.
const children = {};
for (let i = 0; i < DIRS_PER_PASS + 5; i++) children[`d${String(i).padStart(3, '0')}`] = { 'x.jpg': null };
const many = [{ dir: dir('Many', children), rel: '' }];
const many = at(dir('Many', children), [{ dir: dir('Many', children), rel: '' }]);
const found = [];
assert.equal(await walkPass(many, found, keep, () => null), false, 'the queue is not empty after one pass');
assert.equal(found.length, DIRS_PER_PASS - 1, `one pass walked the picked folder and ${DIRS_PER_PASS - 1} below it`);
assert.equal(many.length, 6, 'the rest is left in the queue, for the next pass');
assert.equal(await walkPass(many, found, [], keep, () => null), false, 'the queue is not empty after one pass');
assert.equal(found.length, DIRS_PER_PASS - 1, `one pass read the picked folder and ${DIRS_PER_PASS - 1} below it`);
assert.equal(many.pending.length, 6, 'the rest is left in the queue, for the next pass');
console.log(`ok roll walk — ${rels.length} frames layer by layer, a click jumps the queue, ${DIRS_PER_PASS} folders a pass`);
console.log(`ok roll walk — ${rels.length} frames layer by layer, names a pass ahead, click jumps the queue, ${DIRS_PER_PASS} folders a pass`);