// The project registry. // // A KIND is what a thing is; a TEMPLATE is which configuration of that kind it // is. `um-song` is not a kind -- it is the one template of the `song` kind that // exists so far, and the distinction is the whole point: the next thing this // tool has to hold (a supercut, a cover set, a vertical short) will be a new // template of an existing kind at least as often as a new kind. // // Plain ESM and NO `node:fs`, so `umtool` on the command line, a server // component and a client component that only wants the chip labels all read one // definition. The reading each kind does lives in its own module. // // TO ADD A KIND: add an entry here and a view. Nothing else. There is an e2e // spec that fails if a kind id appears as a string literal anywhere outside // lib/projects/ and components/projects/ -- that is the mechanical form of this // rule, and it is what stops `if (kind === "report-video")` appearing in a page. import { BRAND_CHOICES } from "umtool-report-to-video/brand-ids"; import { MANIFEST_NAME, REPORT_DECISION_KINDS, reportDecisions, reportSignature, summariseReport } from "./report.mjs"; import { CUT_NAMES, songSignature, summariseSong } from "./song.mjs"; import { sweepSignature, summariseSweep } from "./sweep.mjs"; /** * Static children of app/browse/. A project whose FIRST path segment is one of * these can never be opened at its own URL -- a static segment beats a dynamic * one, so `/browse/find` renders the phrase console no matter what is on disk. * * Before this it failed SILENTLY, which is the worst available outcome: the * project is listed, the link is there, and it goes somewhere else entirely. * e2e/projects.spec.ts asserts this list equals the real directory listing, so * a tenth tool page cannot quietly invalidate it. */ export const RESERVED_BROWSE = ["at", "decisions", "faces", "find", "sources", "trim"]; /** Directory names the walk never descends into. */ export const SKIP_DIRS = new Set([ "node_modules", // A project is a LEAF, so these are only ever reached inside one -- but a // half-built tree can have them at folder level too, and none of them can // contain a project. "out", "variants", "plan", "thumbs", // The e2e fixture symlinks 39 GB of audio here. "data", // Manifest snapshots (`umtool snapshot`). A project is a leaf, so the walk // never reaches inside one -- but a snapshot directory left behind by a // deleted manifest must not read as a project either. "revisions", // A report's cut clips. Like out/, it may be a link to the media root // (release 17), and the walk follows a link to a directory with a stat -- // which, on a media drive that is unplugged or stalled, is a hang or a // miss, never a project. "clips", ]); /** * Name PREFIXES the walk never descends into, for the same reason as `clips`: * `share-/` (a deliver's zip and its staging) may be a link to the media * root, and none of them can contain a project. */ export const SKIP_PREFIXES = ["share-"]; /** * What a cut move leaves beside the directory it moved (lib/report/storage.mjs): * `out.moved-`, `out.incoming` -- only for the names that move, so a * folder of projects that merely ends in `.incoming` is still walked. */ const MOVE_LEFTOVER = /^(out|clips|share-[^/]*)\.(moved-[^/]*|incoming)$/; /** Whether the walk skips a directory entry by its name. */ export const skipsDir = (name) => SKIP_DIRS.has(name) || SKIP_PREFIXES.some((p) => name.startsWith(p)) || MOVE_LEFTOVER.test(name); const has = (names, n) => names.has(n); const someMatch = (names, re) => [...names].some((n) => re.test(n)); const SWEEP_RE = /(^|[-_])sweep([-_]report)?\.md$|^sweep-report\.md$/i; export const PROJECT_KINDS = [ { id: "report-video", template: "cited-timeline", label: "report video", badge: "video", // The manifest is both the marker and the edit decision list. Nothing else // needs to exist for a directory to be a report video. detect: (names) => has(names, MANIFEST_NAME), stages: [ { id: "windows", label: "windows" }, { id: "fetched", label: "clips fetched" }, { id: "built", label: "built" }, ], decisionKinds: REPORT_DECISION_KINDS, summarise: summariseReport, signature: reportSignature, decisions: reportDecisions, // Views reachable under the project's own URL: // /browse//clip/ the window bench // /browse//claim/ the adjudication bench views: ["clip", "claim"], // What "new project" asks for, beyond a slug and a title. Declared HERE so // the menu renders fields from data and never branches on a kind id. // `brand` offers report-to-video's presets (render.brand); none is the // default and writes the manifest it always did. scaffold: { fields: ["from", "siteOrigin", "seed", "brand"], brands: BRAND_CHOICES }, // Takes a notes.json beside its manifest (lib/annotations/targets.mjs): // timed notes on its cuts, row notes, take notes, edit notes. notes: true, // Its manifest can be an ARTICLE's video (lib/articles/links.mjs): a // top-level `article`, else its slug against the report id. linksArticles: true, }, { id: "song", template: "um-song", label: "song", badge: "song", detect: (names) => has(names, "spec.json") || has(names, "verdicts.json") || CUT_NAMES.some((c) => has(names, `${c}.mp4`)), stages: [ { id: "draft", label: "no cuts" }, { id: "built", label: "some cuts" }, { id: "shipped", label: "all cuts" }, ], // Assembled from lib/decisions.ts, which still owns the song reducer -- it // reads verdicts, plans, recipes, loudness and the accepted cover set, all // of which live in TypeScript beside readSong(). Listed here so the union // of every kind's vocabulary is computable without importing any of it. decisionKinds: [ "unjudged-variant", "missing-cut", "no-recipe", "stale-recipe", "spec-problem", "no-plan", "unattributed", "thumb-unaccepted", "loudness", ], summarise: summariseSong, signature: songSignature, // Wired in lib/projects.ts, because the reducer is TypeScript. decisions: null, // `/browse//wide` -- today's cut page, unchanged. views: CUT_NAMES, scaffold: { fields: [] }, }, { id: "sweep-report", template: "sweep", label: "sweep report", badge: "report", // A report that is not yet a video. It earns a kind on day one because one // exists (~/reports/hasan-bike), and because a kind with no decisions, no // build and no rich read is the cheapest possible proof that the registry // is extensible. detect: (names) => !has(names, MANIFEST_NAME) && someMatch(names, SWEEP_RE), stages: [{ id: "draft", label: "no manifest" }], decisionKinds: [], summarise: summariseSweep, signature: sweepSignature, decisions: null, views: [], // A sweep report is written by the sweep, not scaffolded here. scaffold: null, }, ]; // An escape hatch for the extensibility spec: it injects a fourth kind and // asserts the index, the chips, the CLI and the project page all pick it up // with no code edit anywhere. If adding a kind needs an edit outside this file, // that spec fails loudly. if (process.env.E2E_UMTOOL_EXTRA_KINDS) { try { for (const k of JSON.parse(process.env.E2E_UMTOOL_EXTRA_KINDS)) { PROJECT_KINDS.push({ stages: [{ id: "draft", label: "draft" }], decisionKinds: [], summarise: null, signature: null, decisions: null, views: [], scaffold: null, ...k, detect: (names) => has(names, k.marker), }); } } catch { /* a malformed override must not take the app down */ } } export const kindById = (id) => PROJECT_KINDS.find((k) => k.id === id) ?? null; /** Does a project of this kind keep a notes.json (lib/annotations)? Declared on the kind, never branched on its id. */ export const kindTakesNotes = (id) => kindById(id)?.notes === true; /** Can a project of this kind be an article's video (lib/articles/links.mjs)? Declared on the kind. */ export const kindLinksArticles = (id) => kindById(id)?.linksArticles === true; /** What a client component needs, with none of what it must not have. */ export const kindMeta = (k) => ({ id: k.id, template: k.template, label: k.label, badge: k.badge, stages: k.stages, decisionKinds: k.decisionKinds, scaffold: k.scaffold ?? null, }); export const KIND_META = () => PROJECT_KINDS.map(kindMeta); /** Every decision kind any registered kind can emit. */ export const ALL_DECISION_KINDS = () => [ ...new Set(PROJECT_KINDS.flatMap((k) => k.decisionKinds)), ]; /** * Which kind a directory is, from its entry names alone. * * `project.json` wins outright, so a directory can always declare itself and * nothing on disk has to move to adopt the registry. Otherwise every kind's * detect() runs and TWO matches is an error, never a guess -- a directory that * is two kinds is a bug, and picking one would hide it. */ export function detectKind(names, declared = null) { if (declared?.kind) { const k = kindById(declared.kind); if (k) return { kind: k.id, template: declared.template ?? k.template, declared: true }; // A declared kind nobody registers is still a statement of intent; keep it // so the index can say so rather than silently falling back to a guess. return { kind: declared.kind, template: declared.template ?? "unknown", unknownKind: true }; } const hits = PROJECT_KINDS.filter((k) => { try { return k.detect(names); } catch { return false; } }); if (!hits.length) return null; if (hits.length > 1) { return { kind: hits[0].id, template: hits[0].template, ambiguousWith: hits.map((k) => k.id) }; } return { kind: hits[0].id, template: hits[0].template }; }