From 52732a17ee2b6c3205ccdef7b23df9468ed80598 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 20:06:10 +0700 Subject: [PATCH] web: name a roll's folders before reading their frames --- docker/frontend/scripts/library-check.mjs | 42 +++++++- docker/frontend/scripts/roll-walk-check.mjs | 102 ++++++++++++------- docker/frontend/src/Library.tsx | 59 ++++++++--- docker/frontend/src/engine/library.ts | 103 ++++++++++++++++---- docker/frontend/src/engine/rollWalk.ts | 95 ++++++++++++------ 5 files changed, 300 insertions(+), 101 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index fde4d9a..17e46ab 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -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 }); diff --git a/docker/frontend/scripts/roll-walk-check.mjs b/docker/frontend/scripts/roll-walk-check.mjs index 0f52979..deea1c9 100644 --- a/docker/frontend/scripts/roll-walk-check.mjs +++ b/docker/frontend/scripts/roll-walk-check.mjs @@ -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`); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index bff09f1..b80f3c0 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -6,12 +6,14 @@ import { getPhoto, listEditedIds, listFolders, + listDirs, listPhotos, pickFolder, readPhotoFile, removeFolder, renameFolder, scanFolder, + type LibraryDir, type LibraryFolder, type LibraryPhoto, type ScanProgress, @@ -67,6 +69,9 @@ export function Library() { const [folders, setFolders] = useState([]); const [ready, setReady] = useState>(new Set()); const [photos, setPhotos] = useState([]); + // The folders a scan has walked into. A roll's own shape is on screen from the + // first pass, before the frames under its folders have been read. + const [dirs, setDirs] = useState([]); const [edited, setEdited] = useState>(new Set()); // Which tree node is open, which frame is up, and which of the two middle // views (the frame, or every thumbnail of the open node) is on. The open node @@ -106,9 +111,10 @@ export function Library() { const [loaded, setLoaded] = useState(false); const reload = useCallback(async () => { - const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]); + const [rows, edits, dirs] = await Promise.all([listPhotos(), listEditedIds(), listDirs()]); setPhotos(rows); setEdited(edits); + setDirs(dirs); }, []); // The catalogue outlives the tab; the permission to read those folders does @@ -174,7 +180,23 @@ export function Library() { setBusy(true); setNote(null); try { - const result = await scanFolder(folder, setProgress, () => stopRef.current, () => jumpRef.current); + // The column grows while the roll is read: the folders the walk has named + // are on screen a pass before the frames under them. A refresh reads the + // whole catalogue, so it keeps to one every few hundred milliseconds + // rather than one per frame. + let last = 0; + const result = await scanFolder( + folder, + (p) => { + setProgress(p); + if (p.done === 0 || Date.now() - last > 700) { + last = Date.now(); + void reload(); + } + }, + () => stopRef.current, + () => jumpRef.current + ); setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); await reload(); } catch { @@ -265,30 +287,37 @@ export function Library() { const relOf = (key: string): string | null => !rootName ? null : key === rootName ? '' : key.startsWith(`${rootName}/`) ? `${key.slice(rootName.length + 1)}/` : null; - // The tree: one row per picked folder, then one per subfolder a frame sits in, - // indented by its depth, each carrying how many frames it holds at or under it. + // The tree: one row per picked folder, then one per subfolder, indented by its + // depth — the folders the walk has named, whether or not a frame has been read + // out of them, each carrying how many frames sit at or under it. const nodes = useMemo(() => { const hits = new Map(); - const dirs = new Map>(); - for (const photo of photos) { - hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1); - if (!photo.dir) continue; - const set = dirs.get(photo.folder) ?? new Set(); - dirs.set(photo.folder, set); + const foldersUnder = new Map>(); + // One path per folder between the picked one and `rel`, so a row is drawn for + // every folder on the way to a frame as well as for the frame's own. + const add = (folder: string, rel: string, counted: boolean) => { + const set = foldersUnder.get(folder) ?? new Set(); + foldersUnder.set(folder, set); let path = ''; - for (const part of photo.dir.split('/')) { + for (const part of rel.split('/')) { path = path ? `${path}/${part}` : part; set.add(path); - hits.set(`${photo.folder}/${path}`, (hits.get(`${photo.folder}/${path}`) ?? 0) + 1); + if (counted) hits.set(`${folder}/${path}`, (hits.get(`${folder}/${path}`) ?? 0) + 1); } + }; + for (const photo of photos) { + hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1); + if (photo.dir) add(photo.folder, photo.dir, true); } + // A folder the walk found is a row before it holds a frame: the names arrive + // a pass ahead of the thumbnails. + for (const dir of dirs) add(dir.folder, dir.rel, false); const out: Node[] = []; for (const folder of folders) { out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: hits.get(folder.name) ?? 0 }); // Path order puts a folder before its own children, which is the order the // indented rows have to come in. - const own = [...(dirs.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b)); - for (const dir of own) { + for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) { const parts = dir.split('/'); out.push({ key: `${folder.name}/${dir}`, @@ -299,7 +328,7 @@ export function Library() { } } return out; - }, [folders, photos]); + }, [dirs, folders, photos]); // A row with anything under it is the one that folds: its key is the prefix of // its children's, in the path order the rows come in. diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 1fffbf3..4ee795a 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -15,12 +15,15 @@ import type { Recipe } from '../../shared/types'; import { readCapturedAt } from './imageOps'; import { isRawName, rawThumbnail } from './rawDevelop'; -import { walkPass, type WalkFile } from './rollWalk'; +import { walkPass, type Walk, type WalkFile } from './rollWalk'; const DB_NAME = 'recipescam-library'; const FOLDERS = 'folders'; const PHOTOS = 'photos'; const EDITS = 'edits'; +// The folders a scan has walked into. They are names before they are frames: the +// column draws the shape of a roll while the frames are still being read. +const DIRS = 'dirs'; // 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. @@ -56,6 +59,16 @@ export interface LibraryPhoto { addedAt: number; } +// A folder the walk has found, whether or not a frame has been read out of it +// yet: the column draws a roll from these before its frames arrive. +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; @@ -83,14 +96,19 @@ let dbPromise: Promise | null = null; function openDb(): Promise { dbPromise ??= new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 1); + const req = indexedDB.open(DB_NAME, 2); req.onupgradeneeded = () => { const db = req.result; - db.createObjectStore(FOLDERS, { keyPath: 'name' }); - const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' }); - photos.createIndex('folder', 'folder'); - photos.createIndex('taken', 'taken'); - db.createObjectStore(EDITS, { keyPath: 'photoId' }); + 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' }); + if (!db.objectStoreNames.contains(DIRS)) { + db.createObjectStore(DIRS, { keyPath: 'id' }).createIndex('folder', 'folder'); + } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -145,15 +163,18 @@ export async function renameFolder(folder: LibraryFolder, label: string): Promis export async function removeFolder(name: string): Promise { const ids = (await listPhotos(name)).map((p) => p.id); + const dirs = await listDirs(name); const db = await openDb(); await new Promise((resolve, reject) => { - const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite'); + const tx = db.transaction([FOLDERS, PHOTOS, EDITS, DIRS], 'readwrite'); tx.objectStore(FOLDERS).delete(name); const photos = tx.objectStore(PHOTOS); for (const id of ids) { photos.delete(id); tx.objectStore(EDITS).delete(id); } + const store = tx.objectStore(DIRS); + for (const dir of dirs) store.delete(dir.id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); @@ -254,21 +275,31 @@ export async function scanFolder( jump?: () => string | null ): Promise { const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p])); - const pending = [{ dir: folder.handle, rel: '' }]; + const tree: Walk = { root: folder.handle, pending: [{ dir: folder.handle, rel: '' }], walked: new Set() }; const entries: WalkFile[] = []; + // 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 found = new Set(); const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0 }; let batch: LibraryPhoto[] = []; + let dirs: LibraryDir[] = []; // One transaction per batch, a put per row: a store with `keyPath: 'id'` takes // a record, not an array of them. const flush = async () => { - if (!batch.length) return; + if (!batch.length && !dirs.length) return; const rows = batch; + const folders = dirs; batch = []; + dirs = []; const db = await openDb(); await new Promise((resolve, reject) => { - const tx = db.transaction(PHOTOS, 'readwrite'); - const store = tx.objectStore(PHOTOS); - for (const row of rows) store.put(row); + const tx = db.transaction([PHOTOS, DIRS], 'readwrite'); + const photos = tx.objectStore(PHOTOS); + for (const row of rows) photos.put(row); + const store = tx.objectStore(DIRS); + for (const dir of folders) store.put(dir); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); @@ -276,12 +307,22 @@ export async function scanFolder( 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 walked on the next jump rather than after the - // whole tree. Nothing clicked: parents first, then each layer below, deepest - // last — the order `pending` is filled in. + // 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 { const from = entries.length; - walked = await walkPass(pending, entries, isSupportedPhoto, jump ?? (() => null)); + names.length = 0; + 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) }); + } + if (dirs.length >= BATCH) await flush(); progress.total += entries.length - from; for (const { handle, rel } of entries.slice(from)) { if (shouldStop?.()) { @@ -321,9 +362,27 @@ export async function scanFolder( } } while (!walked && !stop); await flush(); + // A stopped scan has not seen the whole roll, so only a walk that reached the + // end may say which folders are gone. + if (walked) await pruneDirs(folder.name, found); return progress; } +// The folders that are no longer under the picked one: a rescan of a roll that +// has been tidied on the disk takes the tidied rows out of the column with it. +async function pruneDirs(folder: string, keep: Set): Promise { + const gone = (await listDirs(folder)).filter((d) => !keep.has(d.id)); + if (!gone.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(DIRS, 'readwrite'); + const store = tx.objectStore(DIRS); + for (const dir of gone) store.delete(dir.id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + // --- reading --------------------------------------------------------------- export async function listPhotos(folder?: string): Promise { @@ -338,6 +397,16 @@ export async function listPhotos(folder?: string): Promise { } } +// The folders a scan has walked into, whether or not a frame has been read out +// of them yet — the column draws these as soon as the parent pass named them. +export async function listDirs(folder?: string): Promise { + try { + return await ask(DIRS, 'readonly', (s) => (folder ? s.index('folder').getAll(folder) : s.getAll())); + } catch { + return []; + } +} + export async function getPhoto(id: string): Promise { try { return (await ask(PHOTOS, 'readonly', (s) => s.get(id))) ?? null; diff --git a/docker/frontend/src/engine/rollWalk.ts b/docker/frontend/src/engine/rollWalk.ts index bb2d76c..c23b4e2 100644 --- a/docker/frontend/src/engine/rollWalk.ts +++ b/docker/frontend/src/engine/rollWalk.ts @@ -1,14 +1,17 @@ // Walking a picked folder is the one part of the catalogue that can run for // minutes: a roll is dated folders inside dated folders, and a library can hold -// thousands. The walk goes from the parent down, one layer at a time, so the -// frames nearest the top are indexed first — and a folder the reader clicks is -// put in front of the queue, so the branch in front of them is read before the -// layer it sits in would have come round. A pass covers `DIRS_PER_PASS` -// directories and then hands the thread back, which is what keeps the page -// answering while a scan runs. +// thousands. The walk goes from the parent down, one layer at a time — the picked +// folder and its own frames, then every folder one level down with theirs, then +// the level below that — so the names in the column and the frames near the top +// are on screen within the first pass. A folder the reader clicks is opened +// straight away and put in front of the queue, so the branch in front of them is +// read before the layer it sits in would have come round. A pass covers +// `DIRS_PER_PASS` directories and then hands the thread back, which is what keeps +// the page answering while a scan runs. // -// Pure on purpose: it only reads `values()` off a directory handle, so a check -// can walk a stand-in tree without a browser (scripts/roll-walk-check.mjs). +// Pure on purpose: it only reads `values()` and `getDirectoryHandle()` off a +// directory handle, so a check can walk a stand-in tree without a browser +// (scripts/roll-walk-check.mjs). export interface WalkFile { handle: FileSystemFileHandle; @@ -20,49 +23,79 @@ interface Pending { rel: string; } -// Directories one pass walks before it yields: small enough that a click is +// What the walk is: the folder everything is measured from, the directories still +// to read, and the ones already read. `rel` is how a folder is spelled here — '' +// for the picked one, `2026/04/` under it, always with the trailing slash. +export interface Walk { + root: FileSystemDirectoryHandle; + pending: Pending[]; + walked: Set; +} + +// Directories one pass reads before it yields: small enough that a click is // answered within a second, large enough that a folder of a few hundred is one // pass rather than a hundred. export const DIRS_PER_PASS = 200; -// The folder the reader clicked goes first. `rel` is how the queue spells a -// folder — '' for the picked one, `2026/04/` under it — and a folder already -// walked is simply not in the queue, which is the no-op case. -// -// ponytail: only a folder the queue already holds can jump, which is every -// folder the tree can draw, because a row exists per indexed frame. A tree that -// listed folders before their first frame would need `getDirectoryHandle` to -// descend to the clicked path here. -export function jumpQueue(pending: Pending[], rel: string | null): void { - if (rel === null) return; - const at = pending.findIndex((p) => p.rel === rel); - if (at > 0) pending.unshift(pending.splice(at, 1)[0]); +// The folder the reader clicked goes first. When it is not in the queue the walk +// has not reached it yet — its row is on screen because the parent pass named it +// — so it is opened from the picked folder and read next. A folder already read, +// or one that will not open, changes nothing. +export async function jumpQueue(walk: Walk, rel: string | null): Promise { + if (!rel || walk.walked.has(rel)) return; + const at = walk.pending.findIndex((p) => p.rel === rel); + if (at >= 0) { + if (at > 0) walk.pending.unshift(walk.pending.splice(at, 1)[0]); + return; + } + let dir = walk.root; + for (const part of rel.split('/').filter(Boolean)) { + try { + dir = await dir.getDirectoryHandle(part); + } catch { + return; + } + } + // ponytail: the reader's folder jumps the whole queue, ancestors included — + // the folders between it and the picked one are read when their layer comes. + walk.pending.unshift({ dir, rel }); } // One pass over the queue. The queue is the walk's own state: what is behind has -// been walked, what is ahead is in the order it was found — parents before -// children, shallower layers before deeper ones. Returns true when nothing is -// left to walk. +// been read, what is ahead is in the order it was found — parents before +// children, shallower layers before deeper ones. `seen` collects the folders this +// pass found, which is what the column draws before they hold a frame. Returns +// true when nothing is left to walk. export async function walkPass( - pending: Pending[], + walk: Walk, out: WalkFile[], + seen: string[], keep: (name: string) => boolean, jump: () => string | null ): Promise { - for (let i = 0; i < DIRS_PER_PASS && pending.length; i++) { - jumpQueue(pending, jump()); - const here = pending.shift()!; + for (let i = 0; i < DIRS_PER_PASS && walk.pending.length; i++) { + await jumpQueue(walk, jump()); + const here = walk.pending.shift()!; + // A folder opened by a click is still in the queue its parent filled: it is + // read once, in the place the click put it. + if (walk.walked.has(here.rel)) continue; + walk.walked.add(here.rel); try { for await (const entry of here.dir.values()) { // Names beginning with `.` or `@` are skipped — that is `.thumbnails`, // `.git`, and the `@eaDir` a Synology writes beside every frame. if (/^[.@]/.test(entry.name)) continue; - if (entry.kind === 'directory') pending.push({ dir: entry as FileSystemDirectoryHandle, rel: `${here.rel}${entry.name}/` }); - else if (keep(entry.name)) out.push({ handle: entry as FileSystemFileHandle, rel: `${here.rel}${entry.name}` }); + if (entry.kind === 'directory') { + const rel = `${here.rel}${entry.name}/`; + walk.pending.push({ dir: entry as FileSystemDirectoryHandle, rel }); + seen.push(rel); + } else if (keep(entry.name)) { + out.push({ handle: entry as FileSystemFileHandle, rel: `${here.rel}${entry.name}` }); + } } } catch { // A subfolder that will not open is skipped; the rest of the roll stands. } } - return pending.length === 0; + return walk.pending.length === 0; }