117 lines
5.1 KiB
TypeScript
117 lines
5.1 KiB
TypeScript
// 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 — 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()` 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;
|
|
rel: string;
|
|
}
|
|
|
|
interface Pending {
|
|
dir: FileSystemDirectoryHandle;
|
|
rel: string;
|
|
}
|
|
|
|
// 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<string>;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Folders a roll drags along that are never the reader's photographs. A leading
|
|
// `.` or `@` is the `.thumbnails` a camera writes, `.git`, `.Trash`, and the
|
|
// `@eaDir` a Synology puts beside every frame; the spelled-out names are the
|
|
// recycle bins and system folders of Windows, Synology and Linux volumes, which
|
|
// hold deleted or machine-written files rather than frames. Matching is
|
|
// case-insensitive because the same folder is `$RECYCLE.BIN` on one volume and
|
|
// `$Recycle.Bin` on the next.
|
|
export const SYSTEM_DIR = /^[.@]|^\$RECYCLE\.BIN$|^RECYCLER$|^System Volume Information$|^#recycle$|^lost\+found$/i;
|
|
|
|
// The folder a path names, asked from the root one segment at a time. A queue
|
|
// that was written down as paths comes back as handles this way, and a path that
|
|
// will not open answers null rather than a folder that is not there.
|
|
export async function openAt(root: FileSystemDirectoryHandle, rel: string): Promise<FileSystemDirectoryHandle | null> {
|
|
let dir = root;
|
|
try {
|
|
for (const part of rel.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(part);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
// 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<void> {
|
|
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;
|
|
}
|
|
const dir = await openAt(walk.root, rel);
|
|
if (!dir) 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 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(
|
|
walk: Walk,
|
|
out: WalkFile[],
|
|
seen: string[],
|
|
keep: (name: string) => boolean,
|
|
jump: () => string | null
|
|
): Promise<boolean> {
|
|
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 {
|
|
const baseRel = here.rel ? (here.rel.endsWith('/') ? here.rel : `${here.rel}/`) : '';
|
|
for await (const entry of here.dir.values()) {
|
|
if (SYSTEM_DIR.test(entry.name)) continue;
|
|
if (entry.kind === 'directory') {
|
|
const rel = `${baseRel}${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: `${baseRel}${entry.name}` });
|
|
}
|
|
}
|
|
} catch {
|
|
// A subfolder that will not open is skipped; the rest of the roll stands.
|
|
}
|
|
}
|
|
return walk.pending.length === 0;
|
|
}
|