Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit d6edb1bb6cf4da337a959a2364d06c0faf231529
parent 803bdb283911bbd617593cf29ec1f04f9af244dc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 23:18:49 -0400

umtool: notes capability on the kind registry; the timeline's token race

- kinds.mjs declares notes: true on report-video and kindTakesNotes(); targets.mjs
  asks it instead of naming the kind (projects.spec: no kind leaks outside the
  registry)
- the timeline list waits for its token instead of sending none, and the route
  no longer reads a JSON null as the token "null"; the structure editors adopt
  each write's token at once
- video-notes-fixture has the deck on, which is where the built cut is shown

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/timeline/route.ts | 4+++-
Mumtool/components/projects/StructureEditors.tsx | 3+++
Mumtool/components/projects/TimelineEditor.tsx | 13++++++++-----
Mumtool/e2e/fixtures/make-fixture.mjs | 7+++++++
Mumtool/lib/annotations/targets.mjs | 5+++--
Mumtool/lib/projects/kinds.mjs | 6++++++
6 files changed, 30 insertions(+), 8 deletions(-)

diff --git a/umtool/app/api/report/timeline/route.ts b/umtool/app/api/report/timeline/route.ts @@ -86,7 +86,9 @@ export async function POST(request: Request) { const r = await resolveReport(String(body.project ?? "")); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); const dir = r.project.dir; - const token = body.token === undefined ? null : String(body.token); + // A string, or no guard at all: `String(null)` would be the token "null", + // which matches no file and refuses every write. + const token = typeof body.token === "string" ? body.token : null; const id = String(body.id ?? ""); const at = atOf(body.at); diff --git a/umtool/components/projects/StructureEditors.tsx b/umtool/components/projects/StructureEditors.tsx @@ -61,6 +61,9 @@ export default function StructureEditors({ project }: { project: string }) { if (res.json.stale) await load(); return false; } + // Adopt the new token now: the reload the announcement starts may land + // after the next save is pressed. + if (typeof res.json.token === "string") setDoc((d) => (d ? { ...d, token: res.json.token as string } : d)); announceManifestChanged(project); if (wroteNotes(res.json)) announceNotesChanged(project); router.refresh(); diff --git a/umtool/components/projects/TimelineEditor.tsx b/umtool/components/projects/TimelineEditor.tsx @@ -29,7 +29,9 @@ const rowKey = (li: HTMLLIElement) => ({ id: li.dataset.entry ?? "", at: Number( export function TimelineList({ project, count, children }: { project: string; count: number; children: React.ReactNode }) { const router = useRouter(); - const [token, setToken] = useState<string | null>(null); + // The manifest's write token, in a ref as well as state: a key pressed before + // the first read came back must wait for it, not send no token at all. + const tokenRef = useRef<string | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const [over, setOver] = useState<number | null>(null); @@ -38,7 +40,8 @@ export function TimelineList({ project, count, children }: { project: string; co const loadToken = useCallback(async () => { const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" }); const j = await r.json().catch(() => ({})); - if (r.ok) setToken(j.token ?? null); + if (r.ok) tokenRef.current = typeof j.token === "string" ? j.token : null; + return tokenRef.current; }, [project]); useEffect(() => { void loadToken(); @@ -53,20 +56,20 @@ export function TimelineList({ project, count, children }: { project: string; co async (op: string, args: Record<string, unknown>) => { setBusy(true); setError(null); - const res = await timelineOp(project, token, op, args); + const res = await timelineOp(project, tokenRef.current ?? (await loadToken()), op, args); setBusy(false); if (!res.ok) { setError(refusalOf(res)); if (res.json.stale) await loadToken(); return false; } - setToken(typeof res.json.token === "string" ? res.json.token : null); + tokenRef.current = typeof res.json.token === "string" ? res.json.token : null; announceManifestChanged(project); if (wroteNotes(res.json)) announceNotesChanged(project); router.refresh(); return true; }, - [project, token, loadToken, router], + [project, loadToken, router], ); const onKeyDown = (e: React.KeyboardEvent) => { diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1852,6 +1852,13 @@ const VNOTES = writeProject("video-notes-fixture", { ]), generatedBy: "polemics/video/make-videos.py", }); +{ + // The deck on: the On-screen section shows the built cut (and its timed + // notes) only under it. + const m = JSON.parse(readFileSync(path.join(VNOTES, "video.manifest.json"), "utf8")); + m.render.chrome = { engine: "hyperframes", layout: "deck" }; + writeFileSync(path.join(VNOTES, "video.manifest.json"), JSON.stringify(m, null, 2) + "\n"); +} mkdirSync(path.join(VNOTES, "out", "sourced"), { recursive: true }); twoSeconds(path.join(VNOTES, "out", "video-notes-fixture.mp4"), 300); writeFileSync(path.join(VNOTES, "out", "sourced", "schedule.json"), JSON.stringify(NOTES_SCHEDULE, null, 2)); diff --git a/umtool/lib/annotations/targets.mjs b/umtool/lib/annotations/targets.mjs @@ -15,6 +15,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { NOTES_FILENAME, REPORTS_ROOT, SEGMENT_RE, SITES_DIR, corpusNotesFile, inside, isCorpusNotesFile } from "../paths.mjs"; import { projectRefs, resolveProject } from "../projects/core.mjs"; +import { kindTakesNotes } from "../projects/kinds.mjs"; import { sourceFor, tildify } from "../articles/sources.mjs"; import { readNotes, writeOp } from "./store.mjs"; @@ -76,7 +77,7 @@ export async function projectTarget(spec, { reportsRoot = REPORTS_ROOT } = {}) { * @param {{ reportsRoot?: string }} [opts] */ export async function projectTargetFor(p, { reportsRoot = REPORTS_ROOT } = {}) { - if (p.kind !== "report-video") throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`); + if (!kindTakesNotes(p.kind)) throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`); const [realRoot, realDir] = await Promise.all([ realpath(/* turbopackIgnore: true */ reportsRoot).catch(() => null), realpath(/* turbopackIgnore: true */ p.dir).catch(() => null), @@ -160,7 +161,7 @@ export async function listNotesFiles({ sitesDir = SITES_DIR, reportsRoot = REPOR } } for (const p of await projectRefs(reportsRoot)) { - if (p.kind !== "report-video") continue; + if (!kindTakesNotes(p.kind)) continue; const file = path.join(/* turbopackIgnore: true */ p.dir, NOTES_FILENAME); if (!(await exists(file))) continue; out.push({ kind: "video-project", id: p.id, file, ...(await readNotes(file)) }); diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -104,6 +104,9 @@ export const PROJECT_KINDS = [ // `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, }, { id: "song", @@ -182,6 +185,9 @@ if (process.env.E2E_UMTOOL_EXTRA_KINDS) { 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; + /** What a client component needs, with none of what it must not have. */ export const kindMeta = (k) => ({ id: k.id,