// The folder walk. // // Two rules do almost all the work here: // // A PROJECT IS A LEAF. Detection stops the descent, which is what keeps out/ // (1,210 files, 3.1 GB across ~/reports) out of the walk entirely. Nothing // here ever sees a clip, a segment, a card PNG or a variant. // // A FOLDER WITH NO PROJECT BENEATH IT DOES NOT EXIST. That is what silently // drops ~40 loose test directories under quartering-uh-song -- alarm-tests, // chop-tests, run-visual-tests, sfx, pipeline -- with no denylist to maintain // and nothing to update when the 41st appears. // // Measured cost on the real tree: ~25 readdirs, no stats of anything inside a // project. The 3.1 GB is never touched. import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { RESERVED_BROWSE, detectKind, skipsDir } from "./kinds.mjs"; /** A single safe path segment: no separators, no traversal, no dotfiles. */ export const isSegment = (v) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) && !v.includes(".."); /** * Four levels from REPORTS_ROOT. `quartering-uh-song/videos/yoshi` is two, so * this is two levels of headroom and a cheap guard against an accident -- a * symlink into a home directory, say -- turning the index into a filesystem * crawl. */ export const MAX_DEPTH = 4; /** * How a project is reachable. * * Both failures were SILENT before. A directory whose name isSegment() dislikes * (a space is enough) simply vanished from the listing; a directory called * `find` was listed with a link that rendered the phrase console instead. Each * is now a state the project carries, an entry in the index and a decision -- * never a disappearance. */ export function routingFor(id) { const segs = id.split("/"); if (!segs.every(isSegment)) return "unroutable"; if (RESERVED_BROWSE.includes(segs[0])) return "shadowed"; return "ok"; } async function readDeclared(dir, names) { if (!names.has("project.json")) return null; try { return JSON.parse(await readFile(path.join(dir, "project.json"), "utf8")); } catch { return null; } } /** * Every project under `root`, and the folders that contain them. * * Symlinked directories ARE followed -- a project symlinked into the tree is a * reasonable thing to do -- but every real path is visited once, so a link that * points at an ancestor terminates instead of spinning. */ export async function walkProjects(root, { maxDepth = MAX_DEPTH } = {}) { const projects = []; const visited = new Set(); const visit = async (abs, rel, depth) => { let real; try { real = await realpath(abs); } catch { return; } if (visited.has(real)) return; visited.add(real); let entries; try { entries = await readdir(abs, { withFileTypes: true }); } catch { return; } const names = new Set(entries.map((e) => e.name)); const hit = detectKind(names, await readDeclared(abs, names)); if (hit) { const id = rel; projects.push({ id, dir: abs, name: path.basename(abs), folder: path.posix.dirname(id) === "." ? "" : path.posix.dirname(id), kind: hit.kind, template: hit.template, routing: routingFor(id), ...(hit.ambiguousWith ? { ambiguousWith: hit.ambiguousWith } : {}), ...(hit.unknownKind ? { unknownKind: true } : {}), }); return; // a project is a leaf } if (depth >= maxDepth) return; for (const e of entries) { if (e.name.startsWith(".")) continue; if (skipsDir(e.name)) continue; let isDir = e.isDirectory(); if (!isDir && e.isSymbolicLink()) { isDir = await stat(path.join(abs, e.name)).then((s) => s.isDirectory(), () => false); } if (!isDir) continue; await visit(path.join(abs, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1); } }; await visit(root, "", 0); projects.sort((a, b) => a.id.localeCompare(b.id)); return projects; } // --------------------------------------------------------------------------- // Folders, derived from the project paths rather than recorded during the walk. // // Deriving them is what makes "a folder with no project beneath it is invisible" // true by construction rather than by a filter somebody has to remember. // --------------------------------------------------------------------------- export function foldersFor(projects) { const nodes = new Map(); const node = (p) => { let n = nodes.get(p); if (!n) { n = { path: p, label: p === "" ? "" : p.split("/").pop(), collapsedFrom: [], projects: [], children: [], }; nodes.set(p, n); } return n; }; node(""); for (const pr of projects) { const parts = pr.id.split("/").slice(0, -1); let acc = ""; node("").children; for (const seg of parts) { const parent = acc; acc = acc ? `${acc}/${seg}` : seg; node(acc); const pn = node(parent); if (!pn.children.includes(acc)) pn.children.push(acc); } node(acc).projects.push(pr.id); } return nodes; } /** * Collapse pass-through folders FOR DISPLAY. * * `quartering-uh-song` holds no projects and exactly one child that matters * (`videos`), so the index shows one heading, `quartering-uh-song / videos`. * * The URL is never collapsed -- /browse/quartering-uh-song/videos/yoshi stays * the one true address. A URL has to mean the same thing in six weeks, and a * display convenience is not allowed to decide what a link is. */ export function collapseFolders(nodes) { const out = new Map(nodes); let changed = true; while (changed) { changed = false; for (const [p, n] of [...out]) { if (p === "") continue; if (n.projects.length !== 0 || n.children.length !== 1) continue; const childPath = n.children[0]; const child = out.get(childPath); if (!child) continue; child.label = `${n.label} / ${child.label}`; child.collapsedFrom = [...n.collapsedFrom, n.path]; // Re-parent: whoever pointed at n now points at the child. for (const other of out.values()) { const i = other.children.indexOf(p); if (i >= 0) other.children[i] = childPath; } out.delete(p); changed = true; break; } } return out; }