Archilyzer · Source

archilyzer

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

commit 649150050c70aa8362692c7ab621a8bff220ae18
parent 282802734c9e1cdb64b2a3e4600ff3b8bf7ab7d6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:43:48 -0400

umtool: timed notes, take notes, notes components

components/notes/: TimedNotes/TimedVideo (n or Mark at the playhead, tick strip,
resolved to the entry on screen), AnchoredNotes (row and take notes),
NoteThread, NotesProvider (one handle per notes.json per page), GeneratedBanner.
TakesBench gains take notes and timed notes on every preview; takes.mjs
exports takesWithVerdicts for the notes digest.

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

Diffstat:
Aumtool/components/notes/AnchoredNotes.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/GeneratedBanner.tsx | 15+++++++++++++++
Aumtool/components/notes/NoteThread.tsx | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/NotesProvider.tsx | 34++++++++++++++++++++++++++++++++++
Aumtool/components/notes/TimedNotes.tsx | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/TakesBench.tsx | 30++++++++++++++++++++++++++++--
Mumtool/lib/report/manifest.mjs | 32++++++++++++++++++++++++++++++--
Mumtool/lib/report/moments.mjs | 9++++++++-
Mumtool/lib/report/takes.mjs | 30++++++++++++++++++++++++++++++
Mumtool/lib/report/takes.test.mjs | 23+++++++++++++++++++++++
10 files changed, 612 insertions(+), 5 deletions(-)

diff --git a/umtool/components/notes/AnchoredNotes.tsx b/umtool/components/notes/AnchoredNotes.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { badgeVariants } from "@/components/ui/badge"; +import type { Anchor, Note } from "@/lib/annotations/types"; +import type { UseNotes } from "@/lib/annotations/useNotes"; +import { NoteComposer, NoteThread } from "./NoteThread"; + +// The notes on one THING: a timeline row (`entry`), a take (`take`). A count +// of the open ones, folded; unfolded, every note on it and a box for another. + +type Pin = { kind: "entry"; entry: string } | { kind: "take"; take: string }; + +export const notesOn = (notes: Note[], pin: Pin) => + notes.filter((n) => { + const a = n.anchor as Anchor; + if (pin.kind === "entry") { + return (a.kind === "entry" && a.entry === pin.entry) || (a.kind === "edit" && a.entry === pin.entry); + } + return (a.kind === "take" && a.take === pin.take) || (a.kind === "moment" && a.take === pin.take); + }); + +export default function AnchoredNotes({ + notes, + pin, + startOpen = false, + label = "notes", +}: { + notes: UseNotes; + pin: Pin; + startOpen?: boolean; + label?: string; +}) { + const [open, setOpen] = useState(startOpen); + const mine = notesOn(notes.notes, pin).filter((n) => n.anchor.kind !== "moment"); + const openCount = mine.filter((n) => n.status === "open").length; + const key = pin.kind === "entry" ? pin.entry : pin.take; + return ( + <div data-anchored-notes={key} data-open-notes={openCount} className="text-[11px]"> + <button + type="button" + data-action="toggle-notes" + onClick={() => setOpen((o) => !o)} + className={openCount ? badgeVariants({ variant: "open", size: "sm" }) : "text-[var(--color-sel)] hover:underline"} + > + {openCount ? `${openCount} open ${openCount === 1 ? "note" : "notes"}` : mine.length ? `${label} (${mine.length})` : `+ ${label.replace(/s$/, "")}`} + </button> + {open && ( + <div className="mt-1 space-y-1 rounded bg-[var(--color-panel-2)] p-2"> + {mine.map((n) => ( + <NoteThread + key={n.id} + note={n} + write={notes.write} + busy={notes.busy} + head={ + n.anchor.kind === "edit" ? ( + <span className={badgeVariants({ variant: "info", size: "sm" })}>edit · {n.anchor.field}</span> + ) : null + } + /> + ))} + <NoteComposer + busy={notes.busy} + placeholder={pin.kind === "entry" ? `a note on ${key}` : `a note on this take`} + testId={`note-input-${key}`} + onAdd={(text) => void notes.write({ op: "add", text, anchor: pin })} + /> + {notes.error && <div className="text-[var(--color-bad)]">{notes.error}</div>} + </div> + )} + </div> + ); +} diff --git a/umtool/components/notes/GeneratedBanner.tsx b/umtool/components/notes/GeneratedBanner.tsx @@ -0,0 +1,15 @@ +// One line, wherever a generated manifest can be edited: the project page, +// the clip bench, the On-screen section. Edits are still allowed; each one +// leaves an `edit` note for the agent that runs the generator +// (lib/report/guard.ts). +export default function GeneratedBanner({ generatedBy }: { generatedBy: string | null | undefined }) { + if (!generatedBy) return null; + return ( + <p + data-testid="generated-banner" + className="rounded border border-[var(--color-dirty)] px-2 py-1 text-[11px] text-[var(--color-dirty)]" + > + Generated by <code className="font-mono">{generatedBy}</code>; a rebuild of manifests overwrites edits made here. + </p> + ); +} diff --git a/umtool/components/notes/NoteThread.tsx b/umtool/components/notes/NoteThread.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { badgeVariants } from "@/components/ui/badge"; +import type { Note, NoteOp } from "@/lib/annotations/types"; + +// One note: its text, who wrote it, its status, the replies, and what can be +// done to it. Shared by the timed notes, the row notes and the take notes. + +const STATUS_TONE = { open: "open", resolved: "info", wontfix: "info" } as const; + +export function NoteThread({ + note, + write, + busy, + head, +}: { + note: Note; + write: (op: NoteOp) => Promise<unknown>; + busy: boolean; + /** What the note is on, drawn before its text (a timestamp, an entry id). */ + head?: React.ReactNode; +}) { + const [reply, setReply] = useState<string | null>(null); + const open = note.status === "open"; + return ( + <div + data-note-id={note.id} + data-note-status={note.status} + className={`space-y-0.5 text-[12px] ${open ? "" : "opacity-60"}`} + > + <div className="flex flex-wrap items-baseline gap-1.5"> + {head} + {note.author === "agent" && <span className={badgeVariants({ variant: "meter", size: "sm" })}>agent</span>} + {!open && <span className={badgeVariants({ variant: STATUS_TONE[note.status], size: "sm" })}>{note.status}</span>} + <span className="flex-1 whitespace-pre-wrap text-[var(--color-text)]">{note.text}</span> + <span className="flex gap-1.5 text-[10px]"> + {open ? ( + <> + <button type="button" data-note-action="resolve" disabled={busy} onClick={() => void write({ op: "status", id: note.id, status: "resolved" })} className="text-[var(--color-good)] hover:underline disabled:opacity-40"> + resolve + </button> + <button type="button" data-note-action="wontfix" disabled={busy} onClick={() => void write({ op: "status", id: note.id, status: "wontfix" })} className="text-[var(--color-dim)] hover:underline disabled:opacity-40"> + won&rsquo;t fix + </button> + </> + ) : ( + <button type="button" data-note-action="reopen" disabled={busy} onClick={() => void write({ op: "status", id: note.id, status: "open" })} className="text-[var(--color-sel)] hover:underline disabled:opacity-40"> + reopen + </button> + )} + <button type="button" data-note-action="reply" disabled={busy} onClick={() => setReply((r) => (r === null ? "" : null))} className="text-[var(--color-sel)] hover:underline disabled:opacity-40"> + reply + </button> + {note.author === "operator" && ( + <button type="button" data-note-action="delete" disabled={busy} onClick={() => void write({ op: "delete", id: note.id })} className="text-[var(--color-dim)] hover:text-[var(--color-bad)] disabled:opacity-40"> + delete + </button> + )} + </span> + </div> + {note.replies.map((r, i) => ( + <div key={i} data-note-reply={i} className="ml-4 flex flex-wrap items-baseline gap-1.5 text-[11px]"> + {r.author === "agent" && <span className={badgeVariants({ variant: "meter", size: "sm" })}>agent</span>} + <span className="whitespace-pre-wrap text-[var(--color-dim)]">{r.text}</span> + </div> + ))} + {reply !== null && ( + <input + autoFocus + value={reply} + data-note-reply-input="" + onChange={(e) => setReply(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && reply.trim()) { + void write({ op: "reply", id: note.id, text: reply }); + setReply(null); + } + if (e.key === "Escape") setReply(null); + }} + placeholder="reply — Enter to send" + className="ml-4 w-[calc(100%-1rem)] rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" + /> + )} + </div> + ); +} + +/** A one-line composer: Enter adds, Escape cancels. */ +export function NoteComposer({ + onAdd, + onCancel, + busy, + placeholder, + head, + testId, +}: { + onAdd: (text: string) => void; + onCancel?: () => void; + busy: boolean; + placeholder: string; + head?: React.ReactNode; + testId?: string; +}) { + const [text, setText] = useState(""); + const add = () => { + if (!text.trim()) return; + onAdd(text); + setText(""); + }; + return ( + <div className="flex flex-wrap items-center gap-2"> + {head} + <input + autoFocus + value={text} + data-testid={testId} + onChange={(e) => setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") add(); + if (e.key === "Escape") onCancel?.(); + }} + placeholder={placeholder} + className="min-w-[14rem] flex-1 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" + /> + <button + type="button" + disabled={busy || !text.trim()} + onClick={add} + className="rounded border border-[var(--color-good)] px-2 py-0.5 text-[11px] text-[var(--color-good)] disabled:opacity-40" + > + add + </button> + </div> + ); +} diff --git a/umtool/components/notes/NotesProvider.tsx b/umtool/components/notes/NotesProvider.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { notesQuery, useNotes, type NotesTarget, type UseNotes } from "@/lib/annotations/useNotes"; + +// ONE handle on a notes.json per page. +// +// Every write carries the token its handle last read, so two handles on the +// same file in one page -- the timeline's row notes and the final video's +// timed notes, say -- would 409 each other on every other write. A page wraps +// itself in <NotesProvider target>, and every notes component under it shares +// that handle; a component outside any provider (or under one for another +// file) opens its own. + +const Ctx = createContext<{ key: string; notes: UseNotes } | null>(null); + +export function NotesProvider({ target, children }: { target: NotesTarget; children: React.ReactNode }) { + const notes = useNotes(target); + return <Ctx.Provider value={{ key: notesQuery(target), notes }}>{children}</Ctx.Provider>; +} + +/** The page's shared handle for `target`, or a handle of this component's own. */ +export function useSharedNotes(target: NotesTarget | null): UseNotes { + const ctx = useContext(Ctx); + const shared = !!(ctx && target && ctx.key === notesQuery(target)); + const own = useNotes(shared ? null : target); + return shared ? ctx!.notes : own; +} + +/** True when a write's response says the server wrote notes too (an edit on a generated manifest). */ +export const wroteNotes = (j: unknown): boolean => { + const e = (j as { editNotes?: { added?: number; updated?: number; deleted?: number } | null })?.editNotes; + return !!e && (e.added ?? 0) + (e.updated ?? 0) + (e.deleted ?? 0) > 0; +}; diff --git a/umtool/components/notes/TimedNotes.tsx b/umtool/components/notes/TimedNotes.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { badgeVariants } from "@/components/ui/badge"; +import type { Anchor, MomentResolved, Note } from "@/lib/annotations/types"; +import type { NotesTarget, UseNotes } from "@/lib/annotations/useNotes"; +import { useSharedNotes } from "./NotesProvider"; +import { NoteComposer, NoteThread } from "./NoteThread"; + +// Notes at a SECOND of a rendered video, in the notes.json of whatever the +// video belongs to (a report-video project, an article). The song tool's +// MomentMarks keeps its own store; this is the same idea bound to +// lib/annotations, so an agent reads it back with everything else. +// +// `n` (with the video focused) or "Mark" opens a note at the playhead. When +// the video is a report project's build, the second is RESOLVED at write time +// (/api/report/moment: the entry on screen, its title and quote, the source +// second and its archive link) and stored in the note -- a rebuild moves +// entries, and the agent must see what was on screen when the key was pressed. +// A file with no build schedule keeps `t` alone. + +const ts = (t: number) => { + const m = Math.floor(t / 60); + const s = t - m * 60; + return `${m}:${s.toFixed(1).padStart(4, "0")}`; +}; + +type MomentAnchor = Extract<Anchor, { kind: "moment" }>; +const isMomentOn = (file: string) => (n: Note): n is Note & { anchor: MomentAnchor } => + n.anchor.kind === "moment" && n.anchor.file === file; + +export default function TimedNotes({ + notes, + file, + video, + take, + resolveProject, +}: { + notes: UseNotes; + /** The file's path as the anchor stores it: project-relative (`takes/<id>/preview.mp4`), or the article's `video.mp4`. */ + file: string; + video: HTMLVideoElement | null; + /** The take this file is a preview of, stored on the anchor. */ + take?: string; + /** Resolve each mark against this project's build schedule. */ + resolveProject?: string | null; +}) { + const [duration, setDuration] = useState<number | null>(null); + const [pending, setPending] = useState<number | null>(null); + const marks = notes.notes.filter(isMomentOn(file)).sort((a, b) => a.anchor.t - b.anchor.t); + + const mark = useCallback(() => { + if (!video) return; + setPending(Number(video.currentTime.toFixed(2))); + }, [video]); + + useEffect(() => { + if (!video) return; + const dur = () => setDuration(Number.isFinite(video.duration) ? video.duration : null); + const key = (e: KeyboardEvent) => { + if ((e.key === "n" || e.key === "N") && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + mark(); + } + }; + dur(); + video.addEventListener("loadedmetadata", dur); + video.addEventListener("durationchange", dur); + video.addEventListener("keydown", key); + return () => { + video.removeEventListener("loadedmetadata", dur); + video.removeEventListener("durationchange", dur); + video.removeEventListener("keydown", key); + }; + }, [video, mark]); + + const seek = (t: number) => { + if (!video) return; + video.currentTime = t; + video.focus(); + }; + + const add = async (t: number, text: string) => { + let resolved: (MomentResolved & { entry?: string | null }) | null = null; + if (resolveProject) { + const q = new URLSearchParams({ project: resolveProject, file, t: String(t) }); + if (duration) q.set("duration", String(duration)); + resolved = await fetch(`/api/report/moment?${q}`, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null); + } + const anchor: MomentAnchor = { kind: "moment", file, t }; + if (take) anchor.take = take; + if (resolved?.entry) { + anchor.entry = resolved.entry; + const { entry: _e, ...rest } = resolved as Record<string, unknown>; + delete rest.schedule; + anchor.resolved = rest as MomentResolved; + } + await notes.write({ op: "add", text, anchor }); + }; + + return ( + <div data-timed-notes={file} data-marks={marks.length} className="space-y-1"> + {/* the tick strip: where the marks are, under the player */} + {duration ? ( + <div className="relative h-2 rounded bg-[var(--color-panel-2)]" data-tick-strip=""> + {marks.map((n) => ( + <button + key={n.id} + type="button" + title={`${ts(n.anchor.t)} — ${n.text}`} + onClick={() => seek(n.anchor.t)} + data-tick={n.anchor.t} + className={`absolute top-0 h-2 w-1 -translate-x-1/2 rounded ${n.status === "open" ? "bg-[var(--color-dirty)]" : "bg-[var(--color-dim)]"}`} + style={{ left: `${Math.min(100, (n.anchor.t / duration) * 100)}%` }} + /> + ))} + </div> + ) : null} + <div className="flex flex-wrap items-center gap-2 text-[11px]"> + <button + type="button" + data-action="mark" + disabled={!video} + onClick={mark} + className="rounded border border-[var(--color-sel)] px-2 py-0.5 text-[11px] text-[var(--color-sel)] disabled:opacity-40" + > + Mark <kbd>n</kbd> + </button> + {marks.length > 0 && ( + <span className="text-[var(--color-dim)]"> + {marks.filter((m) => m.status === "open").length} open of {marks.length} + </span> + )} + {notes.error && <span className="text-[var(--color-bad)]">{notes.error}</span>} + </div> + {pending !== null && ( + <div className="rounded bg-[var(--color-panel-2)] p-2"> + <NoteComposer + busy={notes.busy} + placeholder="what is wrong here" + testId="timed-note-input" + head={<span className="num text-[11px] text-[var(--color-meter)]">{ts(pending)}</span>} + onCancel={() => setPending(null)} + onAdd={(text) => { + const t = pending; + setPending(null); + void add(t, text); + }} + /> + </div> + )} + {marks.length > 0 && ( + <ul className="space-y-1"> + {marks.map((n) => { + const r = n.anchor.resolved; + return ( + <li key={n.id} data-mark-at={n.anchor.t} data-mark-entry={n.anchor.entry ?? ""}> + <NoteThread + note={n} + write={notes.write} + busy={notes.busy} + head={ + <> + <button + type="button" + onClick={() => seek(n.anchor.t)} + className="num text-[11px] text-[var(--color-sel)] underline" + title="seek here" + > + {ts(n.anchor.t)} + </button> + {n.anchor.entry && ( + <span className="font-mono text-[11px] text-[var(--color-dim)]" title={r?.quote ?? undefined}> + {n.anchor.entry} + {r?.title ? ` · ${r.title}` : ""} + </span> + )} + {r?.approx && <span className={badgeVariants({ variant: "info", size: "sm" })}>approx</span>} + {r?.url && ( + <a href={r.url} target="_blank" rel="noreferrer" className="text-[11px] text-[var(--color-sel)] hover:underline"> + source + </a> + )} + </> + } + /> + </li> + ); + })} + </ul> + )} + </div> + ); +} + +/** + * A video with its timed notes under it: what the project's final cut, a + * take's preview or an article's report video mounts. `notes` defaults to the + * page's shared handle for `target` (NotesProvider), else one of its own. + */ +export function TimedVideo({ + target, + file, + src, + take, + resolveProject, + notes: given, + poster, + testId, + className = "aspect-video w-full rounded border border-[var(--color-line)] bg-black", +}: { + target: NotesTarget; + file: string; + src: string; + take?: string; + resolveProject?: string | null; + notes?: UseNotes; + poster?: string; + testId?: string; + className?: string; +}) { + const own = useSharedNotes(given ? null : target); + const notes = given ?? own; + const [el, setEl] = useState<HTMLVideoElement | null>(null); + return ( + <div className="space-y-1"> + <video ref={setEl} data-testid={testId} src={src} poster={poster} controls preload="metadata" playsInline className={className} /> + <TimedNotes notes={notes} file={file} video={el} take={take} resolveProject={resolveProject} /> + </div> + ); +} diff --git a/umtool/components/projects/TakesBench.tsx b/umtool/components/projects/TakesBench.tsx @@ -1,6 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import AnchoredNotes from "@/components/notes/AnchoredNotes"; +import { NotesProvider, useSharedNotes } from "@/components/notes/NotesProvider"; +import TimedNotes from "@/components/notes/TimedNotes"; import { badgeVariants, type BadgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { fmtAgo } from "@/lib/format"; @@ -128,6 +131,7 @@ export default function TakesBench({ }; return ( + <NotesProvider target={{ project }}> <div className="space-y-6"> {groups.map((g) => { const tally = VERDICTS.map((v) => [v.id, g.takes.filter((t) => verdicts[t.id]?.verdict === v.id).length] as const) @@ -166,6 +170,7 @@ export default function TakesBench({ {g.takes.map((t) => ( <TakeCard key={t.id} + project={project} take={t} src={previewSrc(project, t)} verdict={verdicts[t.id] ?? null} @@ -194,10 +199,12 @@ export default function TakesBench({ ); })} </div> + </NotesProvider> ); } function TakeCard({ + project, take: t, src, verdict, @@ -209,6 +216,7 @@ function TakeCard({ onUnmute, save, }: { + project: string; take: Take; src: string; verdict: TakeVerdict | null; @@ -220,6 +228,16 @@ function TakeCard({ onUnmute: () => void; save: (patch: { verdict?: Verdict | null; note?: string }) => Promise<void>; }) { + const notes = useSharedNotes({ project }); + // The element, for the timed notes; the parent's callback (audio routing) + // still gets it. Stable, so it runs once per mount, not once per render. + const [el, setEl] = useState<HTMLVideoElement | null>(null); + const parentRef = useRef(videoRef); + parentRef.current = videoRef; + const bindVideo = useCallback((node: HTMLVideoElement | null) => { + parentRef.current(node); + setEl(node); + }, []); const [note, setNote] = useState(verdict?.note ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); @@ -254,7 +272,7 @@ function TakeCard({ > {t.previewSize != null ? ( <video - ref={videoRef} + ref={bindVideo} src={src} controls preload="metadata" @@ -327,6 +345,14 @@ function TakeCard({ /> </div> {error && <span className="text-[11px] text-[var(--color-bad)]">{error}</span>} + {/* The conversation about this take: notes on the take as a whole, and + notes at a second of its preview, resolved to the entry on screen. + Both land in the project's notes.json, which the agent that made + the takes reads back (`umtool notes`) beside verdicts.json. */} + <AnchoredNotes notes={notes} pin={{ kind: "take", take: t.id }} label="take notes" /> + {t.previewSize != null && ( + <TimedNotes notes={notes} file={`takes/${t.id}/${t.preview}`} video={el} take={t.id} resolveProject={project} /> + )} </div> </article> ); diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -928,6 +928,10 @@ export async function moveEntry(dir, id, toIndex, { token = null, at = null } = /** * Remove one entry. Refused when something still points at it -- a post * attached to a clip, a claim -- in the build's own words. + * + * @param {string} dir + * @param {string} id + * @param {{ token?: string | null, at?: number | null }} [opts] */ export async function removeEntry(dir, id, { token = null, at = null } = {}) { return structural(dir, "remove", token, (m) => { @@ -937,7 +941,11 @@ export async function removeEntry(dir, id, { token = null, at = null } = {}) { }); } -/** Copy one entry to just after itself, under a fresh id. A copied claim is dropped (one claim, one entry). */ +/** Copy one entry to just after itself, under a fresh id. A copied claim is dropped (one claim, one entry). * + * @param {string} dir + * @param {string} id + * @param {{ token?: string | null, at?: number | null }} [opts] + */ export async function duplicateEntry(dir, id, { token = null, at = null } = {}) { return structural(dir, "duplicate", token, (m) => { const i = entryIndex(m.timeline, id, at); @@ -1016,6 +1024,11 @@ const TEASER_PATCH_KEYS = ["lines", "beat", "dip", "tail", "tailWait"]; * Patch one teaser: its lines, beat, dip, tail and tail wait. A key given as * null (or "") is removed; a key not given is kept. Checked with the build's * validateTeaser (which checks the dip too) against the patched entry. + * + * @param {string} dir + * @param {string} id + * @param {Record<string, unknown>} patch + * @param {{ token?: string | null, at?: number | null }} [opts] */ export async function updateTeaser(dir, id, patch, { token = null, at = null } = {}) { if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new Error("a teaser patch is an object"); @@ -1048,6 +1061,10 @@ const POST_TEXT_KEYS = ["platform", "author", "handle", "date", "text", "url", " * should be stored: an empty optional string is dropped rather than written. * `attachTo` and `hide` are kept from the stored post unless the value names * them. Checked with the build's validatePosts. + * + * @param {string} dir + * @param {Record<string, any>} post + * @param {{ token?: string | null }} [opts] */ export async function upsertPost(dir, post, { token = null } = {}) { if (!post || typeof post !== "object" || Array.isArray(post)) throw new Error("a post is an object"); @@ -1073,7 +1090,11 @@ export async function upsertPost(dir, post, { token = null } = {}) { }); } -/** Remove one post by id. */ +/** Remove one post by id. * + * @param {string} dir + * @param {string} id + * @param {{ token?: string | null }} [opts] + */ export async function removePost(dir, id, { token = null } = {}) { return structural(dir, "post", token, (m) => { const i = (m.posts ?? []).findIndex((p) => p?.id === id); @@ -1089,6 +1110,10 @@ export async function removePost(dir, id, { token = null } = {}) { * verdict's label and colour. Only on a manifest whose deck is on (the * fact-check is drawn by the deck); null removes it. Checked by the build's * validateChrome against the rest of the render block. + * + * @param {string} dir + * @param {Record<string, unknown> | null} factcheck + * @param {{ token?: string | null }} [opts] */ export async function updateFactcheck(dir, factcheck, { token = null } = {}) { if (factcheck === undefined) throw new Error("factcheck must be an object, or null to remove it"); @@ -1114,6 +1139,9 @@ export async function updateFactcheck(dir, factcheck, { token = null } = {}) { * snapshot, byte for byte. The manifest as it is now is kept first * (`undo-saved`), and the restored snapshot is renamed `undone-<op>` so the * next undo goes one step further back rather than round in a circle. + * + * @param {string} dir + * @param {{ token?: string | null }} [opts] */ export async function undoStructural(dir, { token = null } = {}) { return withManifestLock(async () => { diff --git a/umtool/lib/report/moments.mjs b/umtool/lib/report/moments.mjs @@ -136,7 +136,14 @@ export function resolveMoment({ schedule, manifest, variant = null, t, duration return out; } -/** What a moment on `rel` at `t` resolves to, or `{ entry: null }` with no schedule. */ +/** + * What a moment on `rel` at `t` resolves to, or `{ entry: null }` with no schedule. + * + * @param {string} projectDir + * @param {string} rel + * @param {number} t + * @param {number | null} [duration] + */ export async function resolveMomentOnFile(projectDir, rel, t, duration = null) { const s = await scheduleForFile(projectDir, rel); if (!s) return { entry: null, schedule: null }; diff --git a/umtool/lib/report/takes.mjs b/umtool/lib/report/takes.mjs @@ -290,3 +290,33 @@ export async function setTakeVerdict(projectDir, takeId, patch) { return { entry: map[takeId] ?? null, verdicts: map }; }); } + +/** + * Every take with its verdict, for an agent reading the conversation back + * (`umtool notes`, the notes digest): label, group, summary and changes from + * take.json, and the operator's verdict and note from verdicts.json. A verdict + * on a take that no longer exists is kept, with `missing: true`, rather than + * dropped -- the agent may have deleted the take it was about. + * + * @param {string} projectDir + * @returns {Promise<Array<{ id: string, group: string | null, label: string | null, summary: string, changes: string[], verdict: "like" | "maybe" | "no" | null, note: string, at: string, missing?: true }>>} + */ +export async function takesWithVerdicts(projectDir) { + const [listing, verdicts] = await Promise.all([listTakes(projectDir), readVerdicts(projectDir)]); + const out = listing.takes.map((t) => ({ + id: t.id, + group: t.group, + label: t.label, + summary: t.summary, + changes: t.changes, + verdict: verdicts[t.id]?.verdict ?? null, + note: verdicts[t.id]?.note ?? "", + at: verdicts[t.id]?.at ?? "", + })); + const known = new Set(out.map((t) => t.id)); + for (const [id, v] of Object.entries(verdicts)) { + if (known.has(id)) continue; + out.push({ id, group: null, label: null, summary: "", changes: [], verdict: v.verdict, note: v.note, at: v.at, missing: true }); + } + return out; +} diff --git a/umtool/lib/report/takes.test.mjs b/umtool/lib/report/takes.test.mjs @@ -237,3 +237,26 @@ test("concurrent verdicts on different takes are all kept", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("takesWithVerdicts joins take.json and verdicts.json, keeping a verdict on a vanished take", async () => { + const { takesWithVerdicts } = await import("./takes.mjs"); + const dir = await mkdtemp(path.join(tmpdir(), "umtool-takes-digest-")); + try { + await mkdir(path.join(dir, "takes", "a"), { recursive: true }); + await writeFile( + path.join(dir, "takes", "a", "take.json"), + JSON.stringify({ id: "a", group: "g", order: 1, label: "A", kind: "reference", preview: "p.mp4", summary: "s" }), + ); + await writeFile( + path.join(dir, "takes", "verdicts.json"), + JSON.stringify({ a: { verdict: "like", note: "yes", at: "x" }, gone: { verdict: "no", note: "", at: "y" } }), + ); + const rows = await takesWithVerdicts(dir); + assert.deepEqual(rows.map((r) => [r.id, r.label, r.verdict, r.note, r.missing ?? false]), [ + ["a", "A", "like", "yes", false], + ["gone", null, "no", "", true], + ]); + } finally { + await rm(dir, { recursive: true }); + } +});