Archilyzer · Source

archilyzer

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

commit a18beb03ae1c204a9624219e4d3e33d764f250f4
parent 77f8e6e1a9bb05ab6aeddfe8bdababdc63a02901
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 10:19:32 -0400

Merge umtool/articles (every site's articles + media in umtool, operator notes an agent reads back, a better video editor)

/sites, the article reader with anchored notes, cited evidence, workspace
files; notes.json beside report.json / the project manifest, `umtool notes`
for agents; timed notes on cuts, takes and article videos; row and take
notes; the generated-manifest guard with edit notes; structural timeline
edits with auto snapshots and undo.

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

Diffstat:
MAGENTS.md | 15+++++++++++++++
MREPORT.md | 2+-
Mcommon/lib/report/docs.ts | 4+++-
Mcommon/publish/composeReports.test.ts | 28++++++++++++++++++++++++++++
Mcommon/publish/reportMedia.ts | 20+++++++++++++++++++-
Meditor/app/sites/lib/reportListServer.ts | 23+++--------------------
Mpackage.json | 2+-
Aumtool/app/api/notes/context/route.ts | 32++++++++++++++++++++++++++++++++
Aumtool/app/api/notes/route.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/chrome/route.ts | 10+++++++---
Mumtool/app/api/report/claim/route.ts | 14++++++++++----
Mumtool/app/api/report/cut/route.ts | 15+++++++++------
Aumtool/app/api/report/moment/route.ts | 26++++++++++++++++++++++++++
Mumtool/app/api/report/onscreen/route.ts | 13++++++++-----
Mumtool/app/api/report/posts/route.ts | 13++++++++-----
Aumtool/app/api/report/timeline/route.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/window/route.ts | 11+++++++----
Aumtool/app/api/sites/evidence/route.ts | 19+++++++++++++++++++
Aumtool/app/api/sites/media/route.ts | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/sites/workspace/route.ts | 23+++++++++++++++++++++++
Mumtool/app/browse/decisions/page.tsx | 4+++-
Aumtool/app/sites/[site]/[report]/evidence/page.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/[site]/[report]/page.tsx | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/[site]/page.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/page.tsx | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/bin/umtool.mjs | 7+++++++
Mumtool/components/AppNav.tsx | 6+++++-
Aumtool/components/articles/AddToVideo.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleBody.tsx | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleReader.tsx | 413+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleTable.tsx | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleVideo.tsx | 34++++++++++++++++++++++++++++++++++
Aumtool/components/articles/EvidencePanel.tsx | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/EvidenceWalk.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/NoteCards.tsx | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/SiteChips.tsx | 14++++++++++++++
Aumtool/components/articles/WorkspacePanel.tsx | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/anchorDom.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/AnchoredNotes.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/GeneratedBanner.tsx | 15+++++++++++++++
Aumtool/components/notes/NoteThread.tsx | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/NotesProvider.tsx | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/TimedNotes.tsx | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ClipBench.tsx | 18++++++++++++++++--
Mumtool/components/projects/ClipBenchPage.tsx | 1+
Mumtool/components/projects/OnscreenSection.tsx | 56+++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ReportProject.tsx | 31++++++++++++++++++++++++-------
Aumtool/components/projects/StructureEditors.tsx | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/TakesBench.tsx | 30++++++++++++++++++++++++++++--
Aumtool/components/projects/TimelineEditor.tsx | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/timelineApi.ts | 39+++++++++++++++++++++++++++++++++++++++
Mumtool/docs/README.md | 2++
Aumtool/docs/notes.md | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/docs/report-video.md | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/docs/sites.md | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-evidence.spec.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-integration.spec.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-notes.spec.ts | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/fixtures/sites-fixture.mjs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/sites.spec.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/timeline-edit.spec.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/video-notes.spec.ts | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/warm.ts | 17+++++++++++++++++
Aumtool/lib/annotations/anchor.mjs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/anchor.test.mjs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/cli.mjs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/cli.test.mjs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/digest.mjs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/server.ts | 27+++++++++++++++++++++++++++
Aumtool/lib/annotations/shape.mjs | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/store.mjs | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/store.test.mjs | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/targets.mjs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/types.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/useNotes.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/article.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/evidence.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/files.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/links.mjs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/sites.ts | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/sources.mjs | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/sources.test.mjs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/workspace.mjs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/decisions.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/paths.mjs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/lib/paths.ts | 4++++
Mumtool/lib/projects.ts | 8+++++---
Mumtool/lib/projects/kinds.mjs | 12++++++++++++
Aumtool/lib/report/edit-notes.mjs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/edit-notes.test.mjs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/guard.ts | 38++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 382++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/lib/report/moments.mjs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/moments.test.mjs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/sections.mjs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/sections.test.mjs | 42++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/structure.test.mjs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/takes.mjs | 30++++++++++++++++++++++++++++++
Mumtool/lib/report/takes.test.mjs | 23+++++++++++++++++++++++
Mumtool/playwright.config.ts | 4++++
101 files changed, 9264 insertions(+), 74 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -140,6 +140,21 @@ better), whose clock is not the same. This tooling is on `main` as of 2026-08-20. +# Operator notes on articles and videos + +```sh +umtool notes --all --open # from the checkout: node umtool/bin/umtool.mjs notes --all --open +``` + +The operator leaves notes in umtool on articles (`/sites/<site>/<report>`) and on +report-video projects (rows, takes, moments in a cut). They live in a `notes.json` +beside the report's `report.json` (never published) or beside the project's +`video.manifest.json`. `umtool notes <site>/<report>` prints each open note with its +anchor resolved and the **source** file to edit — a draft, not the generated +`report.json` or manifest. Act, regenerate, then `umtool notes reply <id> "<what +changed>" --resolve`. Never hand-edit `notes.json`. See +[umtool/docs/notes.md](umtool/docs/notes.md). + # The runtime container `docker compose up -d` stands up a working archive: the editor plus Caddy, with diff --git a/REPORT.md b/REPORT.md @@ -2,7 +2,7 @@ <!-- GENERATED by common/bin/file-schemas-docs.ts from the schemas and their *_FIELD_DOCS records — do not edit by hand. --> -One cited report, format `"archilyzer-report"`, version 1, persisted to `transcripts/sites/<siteId>/reports/<reportId>/report.json` beside its `stills/` and `sources/<sourceId>/`; a relative path in it is relative to that directory. A site's `reports` list in `site.json` is the published, ordered list — see [SITE.md](SITE.md); a report directory it does not name is a draft. The schema is `common/lib/report/schema.ts`; its citations and sources are the citation model's — see [CITATIONS.md](CITATIONS.md). +One cited report, format `"archilyzer-report"`, version 1, persisted to `transcripts/sites/<siteId>/reports/<reportId>/report.json` beside its `stills/` and `sources/<sourceId>/`; a relative path in it is relative to that directory. A site's `reports` list in `site.json` is the published, ordered list — see [SITE.md](SITE.md); a report directory it does not name is a draft. The schema is `common/lib/report/schema.ts`; its citations and sources are the citation model's — see [CITATIONS.md](CITATIONS.md). A `notes.json` beside it holds the operator's notes on the article (umtool, `umtool notes`; [umtool/docs/notes.md](umtool/docs/notes.md)) and is never published. A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections with no verdicts, or bodies that cite inline. Markdown fields (`summary`, a section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`. diff --git a/common/lib/report/docs.ts b/common/lib/report/docs.ts @@ -32,7 +32,9 @@ export function renderReportMarkdown(): string { "that directory. A site's `reports` list in `site.json` is the published, " + "ordered list — see [SITE.md](SITE.md); a report directory it does not name is a " + "draft. The schema is `common/lib/report/schema.ts`; its citations and sources " + - "are the citation model's — see [CITATIONS.md](CITATIONS.md).", + "are the citation model's — see [CITATIONS.md](CITATIONS.md). A `notes.json` " + + "beside it holds the operator's notes on the article (umtool, `umtool notes`; " + + "[umtool/docs/notes.md](umtool/docs/notes.md)) and is never published.", ); out.push(""); out.push( diff --git a/common/publish/composeReports.test.ts b/common/publish/composeReports.test.ts @@ -54,6 +54,8 @@ const VIDEOS = "demo-channel"; const SOCIAL = "demo-social"; const REPORT = "demo-report"; const NOW_FLOOR = new Date().toISOString(); +// What a report's notes.json says: if it shows up in anything built, a note leaked. +const NOTES_SENTINEL = "operator-note-never-published-7f3a"; const writeJson = (file: string, value: unknown) => { mkdirSync(path.dirname(file), { recursive: true }); @@ -172,6 +174,8 @@ function seedSite(siteId: string, extra: Record<string, unknown> = {}, withRepor writeJson(path.join(dir, "report.json"), report()); writeText(path.join(dir, "stills", "a01.png"), "png-bytes"); writeText(path.join(dir, "sources", "s0", "page.html"), "<p>saved copy, never published</p>"); + // umtool's operator notes live beside report.json and are never published. + writeJson(path.join(dir, "notes.json"), { format: "umtool-notes", version: 1, notes: [{ text: NOTES_SENTINEL }] }); seedMedia(siteId); } @@ -786,3 +790,27 @@ test("reports export: no browser skips the PDF with a note; no zip fails the pac assert.equal(await exportMain({ siteId: "cited", formats: ["zip"], zipBin: path.join(ROOT, "no-such-zip") }, quiet), 1); assert.equal(await exportMain({ siteId: "cited", formats: ["html", "md"] }, quiet), 0); }); + +test("a report's notes.json (umtool's operator notes) is never published, exported or committed to its history", async () => { + const { reportHistoryGitDir } = await import("./reportHistory"); + const { execFileSync } = await import("node:child_process"); + const notes = path.join(paths.sitesDir, "cited", "reports", REPORT, "notes.json"); + assert.ok(existsSync(notes)); + await exportSiteReports({ siteId: "cited", paths, now: () => new Date("2026-10-08T08:00:00Z"), openPdfPrinter: fakePrinter([]), onLog: () => {} }); + await compose("cited"); + const leaks = (root: string) => + filesUnder(root).filter((f) => f.endsWith("notes.json") || readFileSync(path.join(root, f)).includes(NOTES_SENTINEL)); + assert.deepEqual(leaks(paths.exportPublicDir), []); + assert.deepEqual(leaks(reportExportDir(paths, "cited", REPORT)), []); + const gitDir = reportHistoryGitDir(paths, "cited", REPORT); + const revs = execFileSync("git", ["--git-dir", gitDir, "rev-list", "--all"], { encoding: "utf8" }).trim().split("\n").filter(Boolean); + assert.ok(revs.length > 0); + for (const rev of revs) { + const names = execFileSync("git", ["--git-dir", gitDir, "ls-tree", "-r", "--name-only", rev], { encoding: "utf8" }).trim().split("\n"); + assert.deepEqual(names.filter((n) => n.includes("notes")), [], rev); + for (const n of names) { + const blob = execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", `${rev}:${n}`]); + assert.ok(!blob.includes(NOTES_SENTINEL), `${rev}:${n}`); + } + } +}); diff --git a/common/publish/reportMedia.ts b/common/publish/reportMedia.ts @@ -42,6 +42,7 @@ // a channel whose media tier is not reachable (lib/channelMedia.ts) is // reported as unreachable, with the reason, rather than as missing. +import type { Dirent } from "node:fs"; import { readdir, rm } from "node:fs/promises"; import path from "node:path"; import { readJsonFile, writeJsonAtomic } from "../lib/jsonFile-server"; @@ -55,7 +56,7 @@ import { readChannelConfig } from "../controller/channels"; import { momentKey, momentOf, momentProblem, type Moment } from "../lib/citations/moments"; import type { CitationPad } from "../lib/citations/schema"; import { parseReport } from "../lib/report/validate"; -import type { Report } from "../lib/report/schema"; +import { isReportId, type Report } from "../lib/report/schema"; import { isPermanentlyGone } from "../lib/availability"; import { loadAvailability } from "../lib/availability-server"; import { MAX_CLIP_WINDOW_SECONDS } from "../lib/clipWindow"; @@ -93,6 +94,23 @@ export function siteReportFile(paths: Paths, siteId: string, reportId: string): return path.join(siteReportDir(paths, siteId, reportId), "report.json"); } +// Every report directory of a site, published or draft: a directory under +// `sites/<siteId>/reports/` whose name is a report id. Anything else there (a +// stray file, a bad name) is not a report. Sorted. Whether one is PUBLISHED is +// the site's `reports` list (site.json), not anything on disk. +export async function listReportDirs(paths: Paths, siteId: string): Promise<string[]> { + let entries: Dirent[]; + try { + entries = await readdir(path.join(siteDir(paths, siteId), "reports"), { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isDirectory() && isReportId(e.name)) + .map((e) => e.name) + .sort((a, b) => a.localeCompare(b)); +} + export type ReportMediaEntry = | EvidenceMedia | { diff --git a/editor/app/sites/lib/reportListServer.ts b/editor/app/sites/lib/reportListServer.ts @@ -1,12 +1,8 @@ import "server-only"; -import type { Dirent } from "node:fs"; -import { readdir } from "node:fs/promises"; -import path from "node:path"; import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { readJsonFile } from "yt-dlp-transcript-common/lib/jsonFile-server"; -import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; -import { siteDir, type Site } from "yt-dlp-transcript-common/lib/site"; -import { siteReportFile } from "yt-dlp-transcript-common/publish/reportMedia"; +import type { Site } from "yt-dlp-transcript-common/lib/site"; +import { listReportDirs, siteReportFile } from "yt-dlp-transcript-common/publish/reportMedia"; import { publishableReportExports, readReportExportManifest, @@ -18,24 +14,11 @@ import { listAllJobs, type JobListEntry } from "yt-dlp-transcript-common/jobs/li import { readJobMeta } from "yt-dlp-transcript-common/jobs/jobMeta"; import { reportRows, type ReportFileRead, type ReportRow } from "./reportList"; -// The report directories under `sites/<siteId>/reports/`: a directory whose -// name is a report id. Anything else there (a stray file, a bad name) is not a -// report and is not listed. -async function reportDirIds(paths: Paths, siteId: string): Promise<string[]> { - let entries: Dirent[]; - try { - entries = await readdir(path.join(siteDir(paths, siteId), "reports"), { withFileTypes: true }); - } catch { - return []; - } - return entries.filter((e) => e.isDirectory() && isReportId(e.name)).map((e) => e.name); -} - // Every report of the site, published (in order) then drafts, each read and // validated. export async function readSiteReportRows(paths: Paths, site: Site): Promise<ReportRow[]> { const published = site.reports ?? []; - const dirIds = await reportDirIds(paths, site.siteId); + const dirIds = await listReportDirs(paths, site.siteId); const ids = [...new Set([...published, ...dirIds])]; const reads = new Map<string, ReportFileRead>( await Promise.all( diff --git a/package.json b/package.json @@ -22,7 +22,7 @@ "e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e", "wt": "node scripts/worktree.mjs", "e2e:sharded": "node scripts/run-sharded-e2e.mjs", - "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs", + "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs umtool/lib/annotations/*.test.mjs umtool/lib/articles/*.test.mjs", "lint": "pnpm --filter export run lint", "ops": "node scripts/archilyzer-ops.mjs" }, diff --git a/umtool/app/api/notes/context/route.ts b/umtool/app/api/notes/context/route.ts @@ -0,0 +1,32 @@ +import { errorResponse, targetFrom } from "@/lib/annotations/server"; +import { digest } from "@/lib/annotations/digest.mjs"; +import { readNotes } from "@/lib/annotations/store.mjs"; + +export const dynamic = "force-dynamic"; + +// The agent brief: the same markdown `umtool notes <target>` prints, as +// text/plain, so "Copy agent brief" and `curl` hand over the words an agent +// would read on the command line. +// +// /api/notes/context?article=<site>/<report>[&status=open|resolved|all] +// /api/notes/context?project=<id>[&status=…] +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const target = await targetFrom({ article: url.searchParams.get("article"), project: url.searchParams.get("project") }); + const s = url.searchParams.get("status"); + const status = s === "resolved" || s === "all" ? s : "open"; + const read = await readNotes(target.file); + const cli = `umtool notes ${target.id}`; + const body = + !read.doc && !read.error + ? `No notes on ${target.id}.\n` + : `${await digest( + { kind: target.kind as "article" | "video-project", id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) }, + { status, projectDir: "dir" in target ? target.dir : undefined }, + )}\n\nRead these again with \`${cli}\` (from the repo checkout).\n`; + return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/umtool/app/api/notes/route.ts b/umtool/app/api/notes/route.ts @@ -0,0 +1,51 @@ +import { errorResponse, targetFrom } from "@/lib/annotations/server"; +import { readNotes } from "@/lib/annotations/store.mjs"; +import { writeNote } from "@/lib/annotations/targets.mjs"; + +export const dynamic = "force-dynamic"; + +// Notes on an article or a report-video project (lib/annotations/, docs/notes.md). +// +// GET ?article=<site>/<report> | ?project=<id> +// { subject, file, token, doc, source, error? } -- doc null when +// there are no notes; `source` is what a first write would record. +// POST same params, body { token, op: { op: "add" | "edit" | "status" | +// "reply" | "delete" | "delete-reply" | "source", ... } } +// { doc, token, note }. 409 when `token` is stale or the file on +// disk is not a notes doc; 400 for a refused op or target. +// +// Every write from here is the OPERATOR's. An agent writes through +// `umtool notes`, which stamps "agent"; there is no way to claim to be one here. +const NO_STORE = { "cache-control": "no-store" }; + +function params(request: Request) { + const url = new URL(request.url); + return { article: url.searchParams.get("article"), project: url.searchParams.get("project") }; +} + +export async function GET(request: Request) { + try { + const target = await targetFrom(params(request)); + const read = await readNotes(target.file); + const source = read.doc?.source ?? (await target.source()); + return Response.json( + { subject: target.subject, file: target.file, token: read.token, doc: read.doc, source: source ?? null, ...(read.error ? { error: read.error } : {}) }, + { headers: NO_STORE }, + ); + } catch (err) { + return errorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const target = await targetFrom(params(request)); + const body = (await request.json().catch(() => null)) as { token?: unknown; op?: unknown } | null; + if (!body || typeof body.op !== "object" || body.op === null) return Response.json({ error: "body needs an op" }, { status: 400 }); + const token = typeof body.token === "string" ? body.token : null; + const out = await writeNote(target, body.op as Record<string, unknown>, { by: "operator", token }); + return Response.json(out, { headers: NO_STORE }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/umtool/app/api/report/chrome/route.ts b/umtool/app/api/report/chrome/route.ts @@ -1,3 +1,4 @@ +import { withEditNotes } from "@/lib/report/guard"; import { ChromeRefused, StaleToken, manifestToken, updateChrome } from "@/lib/report/manifest.mjs"; import { resolveReport } from "@/lib/report/serve.mjs"; import { DECK_DEFAULTS, deckOn, resolveDeck, validateChrome } from "umtool-report-to-video/deck"; @@ -53,15 +54,18 @@ export async function PUT(request: Request) { if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); try { - const res = await updateChrome(r.project.dir, (body.chrome ?? null) as Record<string, unknown> | null, { - token: body.token === undefined ? null : String(body.token), - }); + const { result: res, editNotes } = await withEditNotes(r.project, () => + updateChrome(r.project.dir, (body.chrome ?? null) as Record<string, unknown> | null, { + token: body.token === undefined ? null : String(body.token), + }), + ); return Response.json( { ok: true, chrome: res.chrome, deck: res.chrome ? resolveDeck({ ...(r.manifest.render ?? {}), chrome: res.chrome }) : null, token: res.token, + editNotes, }, { headers: noStore }, ); diff --git a/umtool/app/api/report/claim/route.ts b/umtool/app/api/report/claim/route.ts @@ -1,3 +1,4 @@ +import { withEditNotes } from "@/lib/report/guard"; import { StaleToken, manifestToken, updateClaim } from "@/lib/report/manifest.mjs"; import { resolveClaim } from "@/lib/report/serve.mjs"; import { readClaimDetail } from "@/lib/projects/report.mjs"; @@ -50,11 +51,16 @@ export async function PUT(request: Request) { } try { - const { entry, token } = await updateClaim(r.project.dir, claimId, patch, { - token: body.token === undefined ? null : String(body.token), - }); + const { + result: { entry, token }, + editNotes, + } = await withEditNotes(r.project, () => + updateClaim(r.project.dir, claimId, patch, { + token: body.token === undefined ? null : String(body.token), + }), + ); const detail = await readClaimDetail(r.project.dir, claimId); - return Response.json({ claim: entry, gaps: detail?.gaps ?? [], token }); + return Response.json({ claim: entry, gaps: detail?.gaps ?? [], token, editNotes }); } catch (err) { // A stale token is a 409 and never a silent overwrite. if (err instanceof StaleToken) { diff --git a/umtool/app/api/report/cut/route.ts b/umtool/app/api/report/cut/route.ts @@ -1,3 +1,4 @@ +import { withEditNotes } from "@/lib/report/guard"; import { StaleToken, updateClip } from "@/lib/report/manifest.mjs"; import { cuesInWindow } from "@/lib/projects/report.mjs"; import { resolveClip } from "@/lib/report/serve.mjs"; @@ -63,14 +64,16 @@ export async function POST(request: Request) { } try { - const res = await updateClip( - project.dir, - clipId, - { cutStart: hit.cutStart, cutEnd: hit.cutEnd }, - { token: body.token === undefined ? null : String(body.token) }, + const { result: res, editNotes } = await withEditNotes(project, () => + updateClip( + project.dir, + clipId, + { cutStart: hit.cutStart, cutEnd: hit.cutEnd }, + { token: body.token === undefined ? null : String(body.token) }, + ), ); return Response.json( - { ok: true, entry: res.entry, token: res.token, score: hit.score, matched: hit.matched }, + { ok: true, entry: res.entry, token: res.token, score: hit.score, matched: hit.matched, editNotes }, { headers: { "cache-control": "no-store" } }, ); } catch (e) { diff --git a/umtool/app/api/report/moment/route.ts b/umtool/app/api/report/moment/route.ts @@ -0,0 +1,26 @@ +import { projectRef } from "@/lib/projects"; +import { resolveMomentOnFile } from "@/lib/report/moments.mjs"; + +export const dynamic = "force-dynamic"; + +// GET ?project=<id>&file=<project-relative mp4>&t=<seconds>[&duration=<seconds>] +// +// What is on screen at `t` of a rendered cut (lib/report/moments.mjs): the +// entry, its onscreen title and quote, the source second and its archive link, +// and `approx` when the file and the build's schedule disagree. A timed note +// stores this at write time. `{ entry: null }` when the file has no schedule +// (a preview made without a build) -- the note then keeps `t` alone. +export async function GET(request: Request) { + const url = new URL(request.url); + const project = await projectRef(url.searchParams.get("project") ?? ""); + if (!project) return Response.json({ error: "no such project" }, { status: 404 }); + const file = url.searchParams.get("file") ?? ""; + if (!file || file.startsWith("/") || file.split("/").some((s) => s === ".." || s === "")) { + return Response.json({ error: "file must be relative to the project" }, { status: 400 }); + } + const t = Number(url.searchParams.get("t")); + if (!Number.isFinite(t) || t < 0) return Response.json({ error: "t must be seconds ≥ 0" }, { status: 400 }); + const d = Number(url.searchParams.get("duration")); + const r = await resolveMomentOnFile(project.dir, file, t, Number.isFinite(d) && d > 0 ? d : null); + return Response.json(r, { headers: { "cache-control": "no-store" } }); +} diff --git a/umtool/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts @@ -1,3 +1,4 @@ +import { withEditNotes } from "@/lib/report/guard"; import { StaleToken, manifestToken, updateOnscreen } from "@/lib/report/manifest.mjs"; import { postRows, scheduleForPreview } from "@/lib/report/onscreen.mjs"; import { resolveReport } from "@/lib/report/serve.mjs"; @@ -86,12 +87,14 @@ export async function PUT(request: Request) { if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); try { - const res = await updateOnscreen( - r.project.dir, - body.onscreen as Record<string, { title?: string; subtitle?: string; claim?: Claim | null } | null>, - { token: body.token === undefined ? null : String(body.token) }, + const { result: res, editNotes } = await withEditNotes(r.project, () => + updateOnscreen( + r.project.dir, + body.onscreen as Record<string, { title?: string; subtitle?: string; claim?: Claim | null } | null>, + { token: body.token === undefined ? null : String(body.token) }, + ), ); - return Response.json({ ok: true, onscreen: res.onscreen, claims: res.claims, token: res.token }, { headers: noStore }); + return Response.json({ ok: true, onscreen: res.onscreen, claims: res.claims, token: res.token, editNotes }, { headers: noStore }); } catch (e) { if (e instanceof StaleToken) { return Response.json( diff --git a/umtool/app/api/report/posts/route.ts b/umtool/app/api/report/posts/route.ts @@ -1,3 +1,4 @@ +import { withEditNotes } from "@/lib/report/guard"; import { PostsRefused, StaleToken, updatePosts } from "@/lib/report/manifest.mjs"; import { resolveReport } from "@/lib/report/serve.mjs"; @@ -28,12 +29,14 @@ export async function PUT(request: Request) { if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); try { - const res = await updatePosts( - r.project.dir, - body.posts as Record<string, { attachTo?: string | null; hide?: boolean }>, - { token: body.token === undefined ? null : String(body.token) }, + const { result: res, editNotes } = await withEditNotes(r.project, () => + updatePosts( + r.project.dir, + body.posts as Record<string, { attachTo?: string | null; hide?: boolean }>, + { token: body.token === undefined ? null : String(body.token) }, + ), ); - return Response.json({ ok: true, posts: res.posts, token: res.token }, { headers: noStore }); + return Response.json({ ok: true, posts: res.posts, token: res.token, editNotes }, { headers: noStore }); } catch (e) { if (e instanceof StaleToken) { return Response.json( diff --git a/umtool/app/api/report/timeline/route.ts b/umtool/app/api/report/timeline/route.ts @@ -0,0 +1,134 @@ +import { invalidateProjects } from "@/lib/projects"; +import { withEditNotes } from "@/lib/report/guard"; +import { + StaleToken, + StructureRefused, + duplicateEntry, + insertEntry, + manifestToken, + moveEntry, + removeEntry, + removePost, + undoStructural, + updateFactcheck, + updateTeaser, + upsertPost, +} from "@/lib/report/manifest.mjs"; +import { resolveReport } from "@/lib/report/serve.mjs"; +import { deckOn } from "umtool-report-to-video/deck"; +import { VERDICTS, resolveFactcheck } from "umtool-report-to-video/factcheck"; + +export const dynamic = "force-dynamic"; + +// The STRUCTURE of the cut: what is in the timeline and in what order, a +// teaser's lines, the posts, the fact-check's labels (lib/report/manifest.mjs, +// "STRUCTURE"). The window route deliberately cannot move an entry; this is +// the different button it points at. +// +// GET ?project=<id> the token, the timeline's ids in order, the +// teasers, the posts and the fact-check block +// POST { project, token, op, ... } one op: +// move { id, at?, toIndex } toIndex is the index AFTER the move +// remove { id, at? } +// duplicate { id, at? } +// insert { afterId: id | null, at?, entry: "<channel>/<video>@<start>-<end>" | { type, … } } +// teaser { id, at?, patch: { lines?, beat?, dip?, tail?, tailWait? } } +// post { post: { id, platform, date, text, url, … } } add or replace +// post-remove { id } +// factcheck { factcheck: { verdicts?, stamp?, tally? } | null } +// undo {} restore the newest auto snapshot +// +// Every op snapshots the manifest first (`revisions/<stamp>-auto-before-<op>`), +// is checked by the build's own validators, and on a GENERATED manifest leaves +// an `edit` note for the agent (lib/report/guard.ts). A stale token is a 409. + +type Body = Record<string, unknown>; +const noStore = { "cache-control": "no-store" }; + +export async function GET(request: Request) { + const url = new URL(request.url); + const r = await resolveReport(url.searchParams.get("project") ?? ""); + if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); + const entries = (r.manifest.timeline ?? []) as Record<string, unknown>[]; + const timeline = entries.map((e) => ({ id: String(e.id), type: String(e.type ?? "entry") })); + // What the structure editors edit: each teaser as written, the posts, and + // the fact-check block with every default filled (the form's placeholders). + const teasers = entries + .map((e, at) => ({ e, at })) + .filter(({ e }) => e.type === "teaser") + .map(({ e, at }) => ({ at, id: String(e.id), lines: e.lines ?? [], beat: e.beat ?? null, dip: e.dip ?? null, tail: e.tail ?? null, tailWait: e.tailWait ?? null })); + const render = (r.manifest.render ?? {}) as Record<string, unknown>; + return Response.json( + { + timeline, + teasers, + posts: Array.isArray(r.manifest.posts) ? r.manifest.posts : [], + deckOn: deckOn(render), + factcheck: (render.chrome as { factcheck?: unknown } | undefined)?.factcheck ?? null, + factcheckResolved: resolveFactcheck(render), + verdicts: VERDICTS, + generatedBy: r.manifest.generatedBy ?? null, + token: await manifestToken(r.project.dir), + }, + { headers: noStore }, + ); +} + +const atOf = (v: unknown) => (v === undefined || v === null || v === "" ? null : Number(v)); + +export async function POST(request: Request) { + let body: Body; + try { + body = await request.json(); + } catch { + return Response.json({ error: "expected JSON" }, { status: 400 }); + } + 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; + // 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); + + const run = (): Promise<Record<string, unknown>> => { + switch (body.op) { + case "move": + return moveEntry(dir, id, Number(body.toIndex), { token, at }); + case "remove": + return removeEntry(dir, id, { token, at }); + case "duplicate": + return duplicateEntry(dir, id, { token, at }); + case "insert": + return insertEntry(dir, body.afterId ? String(body.afterId) : null, body.entry, { token, at }); + case "teaser": + return updateTeaser(dir, id, body.patch as Record<string, unknown>, { token, at }); + case "post": + return upsertPost(dir, body.post as Record<string, unknown>, { token }); + case "post-remove": + return removePost(dir, id, { token }); + case "factcheck": + return updateFactcheck(dir, (body.factcheck ?? null) as Record<string, unknown> | null, { token }); + case "undo": + return undoStructural(dir, { token }); + default: + throw new Error("op must be move, remove, duplicate, insert, teaser, post, post-remove, factcheck or undo"); + } + }; + + try { + const { result, editNotes } = await withEditNotes(r.project, run); + // revisions/ changed: the project page lists them. + invalidateProjects(); + return Response.json({ ok: true, ...result, editNotes }, { headers: noStore }); + } catch (e) { + if (e instanceof StaleToken) { + return Response.json({ error: e.message, expected: e.expected, got: e.got, stale: true }, { status: 409 }); + } + if (e instanceof StructureRefused) { + return Response.json({ error: e.message, errors: e.errors }, { status: 400 }); + } + return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); + } +} diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts @@ -1,3 +1,4 @@ +import { withEditNotes } from "@/lib/report/guard"; import { StaleToken, updateClip } from "@/lib/report/manifest.mjs"; import { resolveClip } from "@/lib/report/serve.mjs"; @@ -63,11 +64,13 @@ export async function PUT(request: Request) { if (!Object.keys(patch).length) return Response.json({ error: "nothing to change" }, { status: 400 }); try { - const res = await updateClip(r.project.dir, clipId, patch, { - token: body.token === undefined ? null : String(body.token), - }); + const { result: res, editNotes } = await withEditNotes(r.project, () => + updateClip(r.project.dir, clipId, patch, { + token: body.token === undefined ? null : String(body.token), + }), + ); return Response.json( - { ok: true, entry: res.entry, before: res.before, token: res.token }, + { ok: true, entry: res.entry, before: res.before, token: res.token, editNotes }, { headers: { "cache-control": "no-store" } }, ); } catch (e) { diff --git a/umtool/app/api/sites/evidence/route.ts b/umtool/app/api/sites/evidence/route.ts @@ -0,0 +1,19 @@ +import { citationEvidence } from "@/lib/articles/evidence"; +import { readReportFile, siteById } from "@/lib/articles/sites"; + +export const dynamic = "force-dynamic"; + +// GET ?site&report&cite -- one citation's evidence (lib/articles/evidence.ts): +// its quote and record, the transcript around it, and what can play it. +export async function GET(request: Request) { + const url = new URL(request.url); + const site = url.searchParams.get("site") ?? ""; + const reportId = url.searchParams.get("report") ?? ""; + const cite = url.searchParams.get("cite") ?? ""; + if (!siteById(site)) return Response.json({ error: `no site ${site}` }, { status: 404 }); + const read = await readReportFile(site, reportId); + if (!read.report) return Response.json({ error: read.problems[0]?.message ?? "no report" }, { status: 404 }); + const ev = await citationEvidence(site, read.report, cite); + if (!ev) return Response.json({ error: `no citation ${cite}` }, { status: 404 }); + return Response.json(ev, { headers: { "cache-control": "no-store" } }); +} diff --git a/umtool/app/api/sites/media/route.ts b/umtool/app/api/sites/media/route.ts @@ -0,0 +1,94 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { reportMediaDir, reportMediaIndexFile, siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia"; +import { rangeResponse } from "@/lib/report/serve.mjs"; +import { CHANNELS_DIR, REPORTS_ROOT, SITES_DIR, inside } from "@/lib/paths"; +import { sitesPaths } from "@/lib/articles/sites"; + +export const dynamic = "force-dynamic"; + +// Article media, READ-ONLY, with byte ranges (lib/report/serve.mjs +// rangeResponse, the one the cut player uses -- a <video> will not seek a +// stream it was not given a 206 for). +// +// ?site&report&file a file in the report's directory: video.mp4, +// poster.jpg, stills/… -- relative, no `..`, a media type, +// and its real path under SITES_DIR (or REPORTS_ROOT: a +// generator may link a take's preview in) +// ?site&moment the site's PREPARED evidence clip for a moment, found +// through report-media/index.json -- never a client path +// ?corpus=<abs> a clip window, saved video, audio or post capture in +// the corpus: lexically under CHANNELS_DIR (its real +// path is on whatever drive the media tier links to) +const TYPES: Record<string, string> = { + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + ".mov": "video/quicktime", + ".m4a": "audio/mp4", + ".mp3": "audio/mpeg", + ".opus": "audio/ogg", + ".ogg": "audio/ogg", + ".wav": "audio/wav", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", +}; +const SEGMENT = /^[a-z0-9][a-z0-9-]{0,63}$/; + +async function serve(request: Request, abs: string): Promise<Response> { + const type = TYPES[path.extname(abs).toLowerCase()]; + if (!type) return new Response("not a media file", { status: 400 }); + const st = await stat(/* turbopackIgnore: true */ abs).catch(() => null); + if (!st?.isFile()) return new Response("not found", { status: 404 }); + return rangeResponse(request, { + abs, + size: st.size, + headers: { "content-type": type, "accept-ranges": "bytes", "cache-control": "private, no-store" }, + }); +} + +async function reportFile(site: string, report: string, rel: string): Promise<string | null> { + if (!SEGMENT.test(site) || !SEGMENT.test(report)) return null; + if (!rel || rel.includes("\0") || rel.startsWith("/") || rel.split("/").some((s) => s === ".." || s === "" || s === ".")) return null; + const dir = siteReportDir(sitesPaths(), site, report); + const abs = path.join(/* turbopackIgnore: true */ dir, rel); + if (!inside(dir, abs)) return null; + const real = await realpath(/* turbopackIgnore: true */ abs).catch(() => null); + if (!real) return null; + const roots = await Promise.all([SITES_DIR, REPORTS_ROOT].map((r) => realpath(/* turbopackIgnore: true */ r).catch(() => r))); + return roots.some((r) => inside(r, real)) ? real : null; +} + +async function preparedFile(site: string, moment: string): Promise<string | null> { + if (!SEGMENT.test(site)) return null; + try { + const index = JSON.parse(await readFile(/* turbopackIgnore: true */ reportMediaIndexFile(sitesPaths(), site), "utf8")); + const entry = index?.moments?.[moment]; + if (!entry || typeof entry.file !== "string") return null; + const dir = reportMediaDir(sitesPaths(), site); + const abs = path.resolve(/* turbopackIgnore: true */ dir, entry.file); + return inside(dir, abs) ? abs : null; + } catch { + return null; + } +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const q = (k: string) => url.searchParams.get(k) ?? ""; + if (url.searchParams.has("corpus")) { + const abs = path.resolve(/* turbopackIgnore: true */ q("corpus")); + if (abs !== q("corpus") || !inside(CHANNELS_DIR, abs)) return new Response("outside the corpus", { status: 400 }); + return serve(request, abs); + } + if (url.searchParams.has("moment")) { + const abs = await preparedFile(q("site"), q("moment")); + return abs ? serve(request, abs) : new Response("no prepared clip for that moment", { status: 404 }); + } + const abs = await reportFile(q("site"), q("report"), q("file")); + return abs ? serve(request, abs) : new Response("no such report file", { status: 404 }); +} diff --git a/umtool/app/api/sites/workspace/route.ts b/umtool/app/api/sites/workspace/route.ts @@ -0,0 +1,23 @@ +import { readFile } from "node:fs/promises"; +import { workspaceFile } from "@/lib/articles/workspace.mjs"; + +export const dynamic = "force-dynamic"; + +// GET ?ws=<name under REPORTS_ROOT>&rel=<listed file> -- one workspace file, +// READ-ONLY, only one that lib/articles/workspace.mjs lists. HTML is served +// under a CSP sandbox (no scripts, no same-origin) for the page's sandboxed +// iframe; markdown and JSON as plain text. +export async function GET(request: Request) { + const url = new URL(request.url); + const f = await workspaceFile(url.searchParams.get("ws") ?? "", url.searchParams.get("rel") ?? ""); + if (!f) return new Response("not a workspace file", { status: 404 }); + const body = await readFile(/* turbopackIgnore: true */ f.real); + const html = f.rel.endsWith(".html"); + return new Response(body, { + headers: { + "content-type": html ? "text/html; charset=utf-8" : f.rel.endsWith(".json") ? "application/json; charset=utf-8" : "text/plain; charset=utf-8", + "cache-control": "no-store", + ...(html ? { "content-security-policy": "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'" } : {}), + }, + }); +} diff --git a/umtool/app/browse/decisions/page.tsx b/umtool/app/browse/decisions/page.tsx @@ -122,7 +122,9 @@ export default async function DecisionsPage({ <section key={id} data-project={id}> <h2 className="mb-1.5 flex items-baseline gap-2"> <Link - href={`/browse/${id}`} + // An article's notes are listed under its page's path + // (lib/decisions.ts noteDecisions), not a project's. + href={id.startsWith("sites/") ? `/${id}` : `/browse/${id}`} className="font-mono text-[13px] text-[var(--color-text)] hover:text-[var(--color-sel)]" > {id} diff --git a/umtool/app/sites/[site]/[report]/evidence/page.tsx b/umtool/app/sites/[site]/[report]/evidence/page.tsx @@ -0,0 +1,60 @@ +import { notFound } from "next/navigation"; +import { reportCitationNumbers } from "yt-dlp-transcript-common/lib/report/uses"; +import BrowseHeader from "@/components/BrowseHeader"; +import EvidenceWalk, { type WalkItem } from "@/components/articles/EvidenceWalk"; +import { readArticleNotes, readReportFile, siteById } from "@/lib/articles/sites"; +import { corpusNotesFile } from "@/lib/paths"; +import { linkedProjects } from "@/lib/articles/links.mjs"; +import { sourceFor } from "@/lib/articles/sources.mjs"; +import type { NotesRead } from "@/lib/annotations/types"; + +export const dynamic = "force-dynamic"; + +// Every citation of one article, one per screen, in the article's own +// numbering. `?c=<citationId>` opens on that citation. +export default async function EvidenceWalkPage({ + params, + searchParams, +}: { + params: Promise<{ site: string; report: string }>; + searchParams: Promise<{ c?: string }>; +}) { + const { site: siteId, report: reportId } = await params; + const { c } = await searchParams; + const site = siteById(siteId); + if (!site) notFound(); + const read = await readReportFile(site.siteId, reportId); + if (!read.report) notFound(); + const r = read.report; + const numbers = reportCitationNumbers(r); + const items: WalkItem[] = [...numbers.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([id, number]) => ({ id, number, kind: r.citations?.[id]?.kind ?? "?", quote: r.citations?.[id]?.quote ?? "" })); + const at = Math.max(0, items.findIndex((i) => i.id === c)); + const [notes, source] = await Promise.all([readArticleNotes(site.siteId, reportId), sourceFor(site.siteId, reportId)]); + const links = await linkedProjects(site.siteId, reportId, { workspace: source?.workspace ?? null }); + const initialNotes: NotesRead = { + subject: { kind: "article", site: site.siteId, report: reportId }, + file: corpusNotesFile(site.siteId, reportId) ?? "", + token: notes.token, + doc: notes.doc, + source: notes.doc?.source ?? null, + }; + return ( + <div className="flex h-full flex-col"> + <BrowseHeader + active="sites" + crumbs={[ + { href: "/sites", label: "sites" }, + { href: `/sites/${site.siteId}`, label: site.siteId }, + { href: `/sites/${site.siteId}/${reportId}`, label: reportId }, + { label: "evidence" }, + ]} + note={`${items.length} citations`} + /> + <EvidenceWalk site={site.siteId} report={reportId} items={items} initial={at} initialNotes={initialNotes} + videoProjects={links.linked.map((p: { id: string; title: string; generatedBy: string | null }) => ({ id: p.id, title: p.title, generatedBy: p.generatedBy }))} + /> + </div> + ); +} diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx @@ -0,0 +1,180 @@ +import path from "node:path"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { readRevisionHead, REPORT_HISTORY_GIT_DIRNAME } from "yt-dlp-transcript-common/publish/reportHistory"; +import { siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia"; +import BrowseHeader from "@/components/BrowseHeader"; +import ArticleReader from "@/components/articles/ArticleReader"; +import WorkspacePanel from "@/components/articles/WorkspacePanel"; +import { badgeVariants } from "@/components/ui/badge"; +import { articleView } from "@/lib/articles/article"; +import { articleWorkspaceListing, openWorkspaceFile } from "@/lib/articles/files"; +import { linkedProjects } from "@/lib/articles/links.mjs"; +import { readArticleNotes, readReportFile, siteById, sitesPaths } from "@/lib/articles/sites"; +import { sourceFor } from "@/lib/articles/sources.mjs"; +import { corpusNotesFile } from "@/lib/paths"; +import type { NotesRead } from "@/lib/annotations/types"; + +export const dynamic = "force-dynamic"; + +// One article: the reader with its notes (default), or `?tab=source` -- the +// workspace files it was written from, its draft marked. +// +// ?status=open|resolved|all the notes filter it opens with +// ?note=<id> the note it opens on (the decisions inbox links here) +// ?ws=&rel= a workspace file open on the source tab + +type Search = { tab?: string; status?: string; note?: string; ws?: string; rel?: string }; + +export default async function ArticlePage({ + params, + searchParams, +}: { + params: Promise<{ site: string; report: string }>; + searchParams: Promise<Search>; +}) { + const { site: siteId, report: reportId } = await params; + const sp = await searchParams; + const site = siteById(siteId); + if (!site) notFound(); + const read = await readReportFile(site.siteId, reportId); + if (!read.report && read.problems[0]?.message === "no report.json") notFound(); + + const [notes, source] = await Promise.all([readArticleNotes(site.siteId, reportId), sourceFor(site.siteId, reportId)]); + const links = await linkedProjects(site.siteId, reportId, { workspace: source?.workspace ?? null }); + const gitDir = path.join(/* turbopackIgnore: true */ siteReportDir(sitesPaths(), site.siteId, reportId), REPORT_HISTORY_GIT_DIRNAME); + const head = await readRevisionHead(gitDir).catch(() => null); + const published = (site.reports ?? []).includes(reportId); + const r = read.report; + const tab = sp.tab === "source" ? "source" : "article"; + const base = `/sites/${site.siteId}/${reportId}`; + const media = (file: string) => + `/api/sites/media?site=${encodeURIComponent(site.siteId)}&report=${encodeURIComponent(reportId)}&file=${encodeURIComponent(file)}`; + + const meta = ( + <div data-article-meta className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-[var(--color-dim)]"> + <Link href={`/sites/${site.siteId}`} className="hover:text-[var(--color-text)]"> + {site.siteTitle || site.siteId} + </Link> + <span data-status={published ? "published" : "draft"} className={badgeVariants({ variant: published ? "on" : "neutral", size: "sm" })}> + {published ? "published" : "draft"} + </span> + {r?.published && <span>published {r.published}</span>} + {r?.updated && <span>updated {r.updated}</span>} + {head && <span data-revisions={head.revision}>{head.revision} revision{head.revision === 1 ? "" : "s"}</span>} + {links.linked.map((p: { id: string }) => ( + <Link key={p.id} href={`/browse/${p.id}`} data-project-link={p.id} className="font-mono text-[var(--color-sel)] hover:underline"> + {p.id} + </Link> + ))} + {links.possible.map((p: { id: string }) => ( + <Link key={p.id} href={`/browse/${p.id}`} className="font-mono hover:underline" title="shares the slug; not linked"> + {p.id} (possible) + </Link> + ))} + <nav className="ml-auto flex gap-2" aria-label="article views"> + <Link href={base} aria-current={tab === "article" ? "page" : undefined} className={tab === "article" ? "text-[var(--color-sel)]" : "hover:text-[var(--color-text)]"}> + article + </Link> + <Link href={`${base}?tab=source`} aria-current={tab === "source" ? "page" : undefined} className={tab === "source" ? "text-[var(--color-sel)]" : "hover:text-[var(--color-text)]"}> + source + </Link> + <Link href={`${base}/evidence`} className="hover:text-[var(--color-text)]"> + evidence + </Link> + </nav> + {source && ( + <div className="w-full font-mono text-[11px]" title={source.how}> + {source.draft && <span>draft {source.draft}</span>} + {source.generator && <span className="ml-3">generator {source.generator}</span>} + </div> + )} + </div> + ); + + const header = ( + <BrowseHeader + active="sites" + crumbs={[{ href: "/sites", label: "sites" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { label: reportId }]} + note={`${notes.doc?.notes.filter((n) => n.status === "open").length ?? 0} open notes`} + /> + ); + + if (!r) { + return ( + <div className="flex h-full flex-col"> + {header} + <main className="deck-main flex-1 p-4"> + <div className="mx-auto max-w-[72ch] space-y-3"> + {meta} + <p className="text-[13px] text-[var(--color-bad)]">report.json does not read as a report:</p> + <ul className="list-disc pl-5 text-[12px] text-[var(--color-dim)]"> + {read.problems.slice(0, 20).map((p, i) => ( + <li key={i}>{p.message}</li> + ))} + </ul> + </div> + </main> + </div> + ); + } + + if (tab === "source") { + const ws = await articleWorkspaceListing(source?.workspace); + const opened = + sp.ws && sp.rel + ? await openWorkspaceFile(sp.ws, sp.rel) + : ws && source?.draft + ? await openWorkspaceFile(ws.name, path.relative(ws.dir, source.draft)) + : null; + const draftRel = ws && source?.draft ? `${ws.name}/${path.relative(ws.dir, source.draft)}` : null; + return ( + <div className="flex h-full flex-col"> + {header} + <main className="deck-main flex-1 p-4"> + <div className="space-y-4"> + {meta} + <WorkspacePanel + workspaces={ws ? [ws] : []} + opened={opened} + highlight={draftRel} + hrefFor={(w, rel) => `${base}?${new URLSearchParams({ tab: "source", ws: w, rel })}`} + /> + </div> + </main> + </div> + ); + } + + const { view, error } = await articleView(r); + const initialNotes: NotesRead = { + subject: { kind: "article", site: site.siteId, report: reportId }, + file: corpusNotesFile(site.siteId, reportId) ?? "", + token: notes.token, + doc: notes.doc, + source: notes.doc?.source ?? source ?? null, + ...(notes.error ? { error: notes.error } : {}), + }; + const status = sp.status === "resolved" || sp.status === "all" ? sp.status : "open"; + const video = r.video + ? { file: r.video.src, src: media(r.video.src), poster: r.video.poster ? media(r.video.poster) : undefined, caption: r.video.caption } + : null; + + return ( + <div className="flex h-full flex-col"> + {header} + <ArticleReader + site={site.siteId} + report={reportId} + view={view} + viewError={error} + initialNotes={initialNotes} + initialFilter={status} + initialNote={sp.note ?? null} + meta={meta} + video={video} + videoProjects={links.linked.map((p: { id: string; title: string; generatedBy: string | null }) => ({ id: p.id, title: p.title, generatedBy: p.generatedBy }))} + /> + </div> + ); +} diff --git a/umtool/app/sites/[site]/page.tsx b/umtool/app/sites/[site]/page.tsx @@ -0,0 +1,123 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import BrowseHeader from "@/components/BrowseHeader"; +import ArticleTable, { articleHref } from "@/components/articles/ArticleTable"; +import SiteChips from "@/components/articles/SiteChips"; +import WorkspacePanel from "@/components/articles/WorkspacePanel"; +import { badgeVariants } from "@/components/ui/badge"; +import { readSiteRow, siteById } from "@/lib/articles/sites"; +import { openWorkspaceFile, siteWorkspaceListings, takeTally } from "@/lib/articles/files"; +import { videoProjects } from "@/lib/articles/links.mjs"; + +export const dynamic = "force-dynamic"; + +// One site: its articles, the videos they play, the umtool projects those +// videos were cut in (with their takes), and the workspace files the articles +// were written from. `?ws=&rel=` opens a workspace file below. + +export default async function SitePage({ + params, + searchParams, +}: { + params: Promise<{ site: string }>; + searchParams: Promise<{ ws?: string; rel?: string }>; +}) { + const { site: siteId } = await params; + const sp = await searchParams; + const site = siteById(siteId); + if (!site) notFound(); + const row = await readSiteRow(site); + + const projects = await videoProjects(); + const linkedIds = [...new Set(row.articles.flatMap((a) => a.projects.linked.map((p) => p.id)))]; + const linked = await Promise.all( + linkedIds.map(async (id) => { + const p = projects.find((x: { id: string }) => x.id === id) as { id: string; dir: string }; + return { id: p.id, tally: await takeTally(p.dir), articles: row.articles.filter((a) => a.projects.linked.some((l) => l.id === id)) }; + }), + ); + const videos = row.articles.filter((a) => a.hasVideo); + const workspaces = await siteWorkspaceListings(site.siteId, row.articles.map((a) => a.id)); + const opened = sp.ws && sp.rel ? await openWorkspaceFile(sp.ws, sp.rel) : null; + const hrefFor = (ws: string, rel: string) => + `/sites/${site.siteId}?${new URLSearchParams({ ws, rel }).toString()}#files`; + const media = (id: string, file: string) => + `/api/sites/media?site=${encodeURIComponent(site.siteId)}&report=${encodeURIComponent(id)}&file=${file}`; + + return ( + <div className="flex h-full flex-col"> + <BrowseHeader + active="sites" + crumbs={[{ href: "/sites", label: "sites" }, { label: row.title }]} + note={`${row.published} published · ${row.drafts} drafts · ${row.openNotes} open notes`} + /> + <main className="deck-main flex-1 space-y-6 p-4"> + <div className="flex flex-wrap items-baseline gap-2"> + <h1 className="text-[16px] font-semibold text-[var(--color-text)]">{row.title}</h1> + <span className="font-mono text-[11px] text-[var(--color-dim)]">{row.siteId}</span> + <SiteChips site={row} /> + </div> + + <section data-section="articles"> + <h2 className="micro mb-1.5">articles</h2> + <ArticleTable articles={row.articles} /> + </section> + + <section data-section="videos"> + <h2 className="micro mb-1.5">report videos {videos.length}</h2> + {videos.length === 0 ? ( + <p className="text-[12px] text-[var(--color-dim)]">none</p> + ) : ( + <div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3"> + {videos.map((a) => ( + <figure key={a.id} data-report-video={a.id} className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-2"> + <video + controls + preload="none" + src={media(a.id, "video.mp4")} + poster={a.hasPoster ? media(a.id, "poster.jpg") : undefined} + className="aspect-video w-full rounded bg-black" + /> + <figcaption className="mt-1 text-[12px]"> + <Link href={articleHref(a)} className="text-[var(--color-text)] hover:text-[var(--color-sel)]"> + {a.title} + </Link> + </figcaption> + </figure> + ))} + </div> + )} + </section> + + <section data-section="projects"> + <h2 className="micro mb-1.5">video projects {linked.length}</h2> + {linked.length === 0 ? ( + <p className="text-[12px] text-[var(--color-dim)]">none linked</p> + ) : ( + <ul className="space-y-1"> + {linked.map((p) => ( + <li key={p.id} data-video-project={p.id} className="flex flex-wrap items-baseline gap-2 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5 text-[12px]"> + <Link href={`/browse/${p.id}`} className="font-mono text-[var(--color-sel)] hover:underline"> + {p.id} + </Link> + <span className="text-[var(--color-dim)]">{p.articles.map((a) => a.title).join(", ")}</span> + <Link href={`/browse/${p.id}/takes`} className="ml-auto text-[var(--color-dim)] hover:text-[var(--color-text)]" data-takes={p.tally.takes}> + {p.tally.takes} takes + </Link> + <span className={badgeVariants({ variant: "neutral", size: "sm" })}>like {p.tally.like}</span> + <span className={badgeVariants({ variant: "neutral", size: "sm" })}>maybe {p.tally.maybe}</span> + <span className={badgeVariants({ variant: "neutral", size: "sm" })}>no {p.tally.no}</span> + </li> + ))} + </ul> + )} + </section> + + <section data-section="files" id="files"> + <h2 className="micro mb-1.5">workspace files</h2> + <WorkspacePanel workspaces={workspaces} opened={opened} hrefFor={hrefFor} /> + </section> + </main> + </div> + ); +} diff --git a/umtool/app/sites/page.tsx b/umtool/app/sites/page.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; +import BrowseHeader from "@/components/BrowseHeader"; +import ArticleTable from "@/components/articles/ArticleTable"; +import SiteChips from "@/components/articles/SiteChips"; +import { badgeVariants } from "@/components/ui/badge"; +import { listSiteRows, type ArticleRow } from "@/lib/articles/sites"; + +export const dynamic = "force-dynamic"; + +// Every site's articles, private sites first. Zero client JS: the filters are +// links that change searchParams (/browse/decisions' idiom), so a filtered +// view is one pasteable URL. + +type Search = { site?: string; status?: string; notes?: string }; + +export default async function SitesPage({ searchParams }: { searchParams: Promise<Search> }) { + const sp = await searchParams; + const all = await listSiteRows(); + + const keep = (a: ArticleRow) => + (!sp.status || a.status === sp.status) && (sp.notes !== "open" || a.openNotes > 0); + const sites = all + .filter((s) => !sp.site || s.siteId === sp.site) + .map((s) => ({ ...s, shown: s.articles.filter(keep) })) + .filter((s) => s.shown.length > 0 || (!sp.status && !sp.notes)); + + const articles = all.flatMap((s) => s.articles); + const open = articles.reduce((n, a) => n + a.openNotes, 0); + const qs = (next: Partial<Search>) => { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries({ ...sp, ...next })) if (v) p.set(k, String(v)); + const s = p.toString(); + return `/sites${s ? `?${s}` : ""}`; + }; + + return ( + <div className="flex h-full flex-col"> + <BrowseHeader active="sites" crumbs={[{ label: "sites" }]} note={`${all.length} sites · ${articles.length} articles · ${open} open notes`} /> + <main className="deck-main flex-1 p-4"> + <div className="mb-3 flex flex-wrap items-center gap-1.5"> + <span className="micro">site</span> + <Chip href={qs({ site: "" })} on={!sp.site} label={`all ${all.length}`} /> + {all.map((s) => ( + <Chip key={s.siteId} href={qs({ site: s.siteId })} on={sp.site === s.siteId} label={s.siteId} /> + ))} + <span className="micro ml-3">status</span> + <Chip href={qs({ status: "" })} on={!sp.status} label="all" /> + <Chip href={qs({ status: "published" })} on={sp.status === "published"} label={`published ${articles.filter((a) => a.status === "published").length}`} /> + <Chip href={qs({ status: "draft" })} on={sp.status === "draft"} label={`draft ${articles.filter((a) => a.status === "draft").length}`} /> + <span className="micro ml-3">notes</span> + <Chip href={qs({ notes: "" })} on={sp.notes !== "open"} label="all" /> + <Chip href={qs({ notes: "open" })} on={sp.notes === "open"} label={`open ${articles.filter((a) => a.openNotes > 0).length}`} /> + </div> + + {sites.length === 0 ? ( + <p className="text-[12px] text-[var(--color-dim)]">{all.length === 0 ? "no sites" : "nothing matches that filter"}</p> + ) : ( + <div className="space-y-6"> + {sites.map((s) => ( + <section key={s.siteId} data-site={s.siteId}> + <h2 className="mb-1.5 flex flex-wrap items-baseline gap-2"> + <Link href={`/sites/${s.siteId}`} className="text-[14px] font-semibold text-[var(--color-text)] hover:text-[var(--color-sel)]"> + {s.title} + </Link> + <span className="font-mono text-[11px] text-[var(--color-dim)]">{s.siteId}</span> + <SiteChips site={s} /> + <span className="micro" data-counts={`${s.published}/${s.drafts}`}> + {s.published} published · {s.drafts} draft{s.drafts === 1 ? "" : "s"} + </span> + </h2> + <ArticleTable articles={s.shown} /> + </section> + ))} + </div> + )} + </main> + </div> + ); +} + +function Chip({ href, on, label }: { href: string; on: boolean; label: string }) { + return ( + <Link href={href} aria-current={on ? "true" : undefined} className={badgeVariants({ variant: on ? "on" : "neutral" })}> + {label} + </Link> + ); +} diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -36,6 +36,9 @@ // umtool diff <project> <snapshot> what changed since that snapshot // umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V] // umtool check-sources [<project>…] prints the re-check chain +// umtool notes [<site>/<report> | <project> | --all] [--open|--resolved|--all-status] [--json] +// umtool notes reply <id> "<text>" [--resolve] | resolve | wontfix | reopen <id> +// the operator's notes, and the agent's answers import process from "node:process"; import { PROJECT_KINDS, @@ -65,6 +68,7 @@ import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs import { openIndex, signRecord } from "../lib/projects/index-db.mjs"; import { pipelineProcessesFor } from "../lib/report/busy.mjs"; import { probeTools } from "../lib/tools.mjs"; +import { notesCommand } from "../lib/annotations/cli.mjs"; import { scaffoldReportVideo } from "../lib/projects/scaffold.mjs"; import { CACHE_DIR, INDEX_DIR, MEDIA_ROOT, MEDIA_TIERED, OLD_CACHE_DIR } from "../lib/paths.mjs"; import { @@ -698,6 +702,8 @@ function usage() { " umtool diff <project> <snapshot> what changed since that snapshot", " umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]", " umtool check-sources [<project>…] prints the re-check chain (never-checked/old when no args)", + " umtool notes [<site>/<report>|<project>|--all] the operator's notes, as markdown (open ones)", + " umtool notes reply <id> \"<text>\" [--resolve] answer one; resolve | wontfix | reopen <id>", "", `reading ${REPORTS_ROOT} (set REPORTS_DIR to move it)`, "", @@ -725,6 +731,7 @@ const COMMANDS = { diff: cmdDiff, export: cmdExport, "check-sources": cmdCheckSources, + notes: async () => process.exit(await notesCommand(argv.slice(argv.indexOf("notes") + 1))), help: usage, }; diff --git a/umtool/components/AppNav.tsx b/umtool/components/AppNav.tsx @@ -6,7 +6,8 @@ import NavGroup from "./NavGroup"; // is waiting, the two benches that are not a project (mix, find), and the song // piles folded under one entry. // -// SEVEN visible entries, and the cap is still NINE. A tenth wraps the header on +// SEVEN visible entries (home, browse, decisions, sites, mix, find, song ▸), +// and the cap is still NINE. A tenth wraps the header on // a laptop, and a nav that wraps stops reading as one row of places and starts // reading as a list. The next tool goes UNDER one of these, not beside them -- // which is exactly what happened to the four judging piles and the sources @@ -30,6 +31,9 @@ export default function AppNav({ active }: { active: string }) { // The worklist across every project, not a sixth pile. It sits beside // browse because that is where every decision it names gets settled. { href: "/browse/decisions", label: "decisions" }, + // Every site's articles -- published and drafts -- with their notes, their + // evidence and the workspace they were written in. + { href: "/sites", label: "sites" }, { href: "/mix", label: "mix" }, // Every occurrence of a word across the corpus. It sits with browse because // what it retrieves is raw material for a build, not a pile to judge. diff --git a/umtool/components/articles/AddToVideo.tsx b/umtool/components/articles/AddToVideo.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { Evidence } from "@/lib/articles/evidence"; + +// "Add to video": the cited span as a new clip at the END of a linked +// report-video project's timeline, through the structure route +// (/api/report/timeline, op insert). That route snapshots the manifest first +// (Undo on the project page restores it) and, on a GENERATED manifest, leaves +// an `edit` note so the agent ports the clip into the generator's inputs. + +export type VideoProjectLink = { id: string; title: string; generatedBy: string | null }; + +export default function AddToVideo({ ev, projects }: { ev: Evidence; projects: VideoProjectLink[] }) { + const [project, setProject] = useState(projects[0]?.id ?? ""); + const [state, setState] = useState<{ busy: boolean; done?: { id: string; project: string }; error?: string }>({ busy: false }); + if (!projects.length || !ev.record || ev.start === undefined || ev.end === undefined) return null; + const chosen = projects.find((p) => p.id === project) ?? projects[0]; + + const add = async () => { + setState({ busy: true }); + try { + const g = await fetch(`/api/report/timeline?project=${encodeURIComponent(chosen.id)}`, { cache: "no-store" }); + const gj = await g.json(); + if (!g.ok) throw new Error(gj.error ?? g.statusText); + const last = gj.timeline.length ? gj.timeline[gj.timeline.length - 1] : null; + const entry: Record<string, unknown> = { + type: "clip", + channel: ev.record!.channel, + video: ev.record!.id, + start: ev.start, + end: ev.end, + quote: ev.quote, + }; + if (/^[A-Za-z0-9_-]{1,60}$/.test(ev.cite)) entry.id = `cite-${ev.cite}`; + const r = await fetch("/api/report/timeline", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: chosen.id, token: gj.token, op: "insert", afterId: last?.id ?? null, at: last ? gj.timeline.length - 1 : null, entry }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error ?? r.statusText); + setState({ busy: false, done: { id: j.id ?? j.result?.id ?? "the clip", project: chosen.id } }); + } catch (err) { + setState({ busy: false, error: err instanceof Error ? err.message : String(err) }); + } + }; + + return ( + <div data-add-to-video className="flex flex-wrap items-center gap-2 border-t border-[var(--color-line)] pt-2"> + {projects.length > 1 ? ( + <select value={chosen.id} onChange={(e) => setProject(e.target.value)} aria-label="video project" className="rounded border border-[var(--color-line)] bg-transparent px-1 py-0.5 font-mono text-[11px]"> + {projects.map((p) => ( + <option key={p.id} value={p.id}> + {p.id} + </option> + ))} + </select> + ) : ( + <span className="font-mono text-[11px] text-[var(--color-dim)]">{chosen.id}</span> + )} + <button type="button" onClick={add} disabled={state.busy} className="rounded border border-[var(--color-line)] px-2 py-0.5 hover:border-[var(--color-sel)] disabled:opacity-50"> + Add to video + </button> + {chosen.generatedBy && <span className="text-[11px] text-[var(--color-dim)]">generated; leaves an edit note</span>} + {state.done && ( + <span role="status" className="text-[11px]"> + added <span className="font-mono">{state.done.id}</span> —{" "} + <Link href={`/browse/${state.done.project}`} className="underline"> + open + </Link> + </span> + )} + {state.error && <span role="alert" className="text-[11px] text-[var(--color-bad)]">{state.error}</span>} + </div> + ); +} diff --git a/umtool/components/articles/ArticleBody.tsx b/umtool/components/articles/ArticleBody.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { createContext, memo, useContext, type AnchorHTMLAttributes, type ReactNode } from "react"; +import { Markdown } from "yt-dlp-transcript-common/components/Markdown"; +import type { CitationView, ReportPageView } from "yt-dlp-transcript-common/lib/report/views"; + +// The article's text, as blocks the notes anchor to (`data-block`: title, +// subtitle, summary, method, each section by id). MEMOISED and never +// re-rendered once mounted: the reader wraps notes' quotes in <mark>s by +// editing this DOM directly (components/articles/anchorDom.ts), which is safe +// only because React has no reason to touch it again. Everything that changes +// -- which citation is open, which note is selected -- goes through the +// context below, whose value never changes. +// +// A citation is a button (its label, which is part of the text) and its +// number (which is not: `data-anchor-skip`). Clicking it opens the evidence +// panel; nothing here links out of umtool. + +export type ArticleActions = { + openCite: (id: string) => void; + noteSection: (section: string, title: string) => void; +}; + +export const ArticleActionsContext = createContext<ArticleActions | null>(null); + +const CitationsContext = createContext<Readonly<Record<string, CitationView>>>({}); + +const CITE_SCHEME = "cite:"; + +function CiteLink({ href, children }: AnchorHTMLAttributes<HTMLAnchorElement>) { + const actions = useContext(ArticleActionsContext); + const citations = useContext(CitationsContext); + const id = href?.startsWith(CITE_SCHEME) ? href.slice(CITE_SCHEME.length).trim() : null; + if (id === null) { + return ( + <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--color-sel)] underline decoration-[var(--color-sel)]/40"> + {children} + </a> + ); + } + const c = citations[id]; + return ( + <> + <button + type="button" + data-cite={id} + onClick={() => actions?.openCite(id)} + title={c ? `${c.quote}${c.speaker ? ` — ${c.speaker}` : ""}` : `citation ${id}`} + className="cursor-pointer rounded-sm text-left text-[var(--color-text)] underline decoration-[var(--color-sel)] decoration-dotted underline-offset-2 hover:bg-[var(--color-panel-2)] data-[has-note=true]:bg-[color-mix(in_srgb,var(--color-dirty)_22%,transparent)]" + > + {children} + </button> + <sup data-anchor-skip className="ml-0.5 font-mono text-[10px] text-[var(--color-sel)]"> + {c?.number ?? "?"} + </sup> + </> + ); +} + +// markdown-to-jsx's elements carry common's class names, which umtool's +// stylesheet does not have; these descendant rules are umtool's. +const MD = + "text-[14px] leading-relaxed text-[var(--color-text)] [&_p]:my-2.5 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-[var(--color-line)] [&_blockquote]:pl-3 [&_blockquote]:text-[var(--color-dim)] [&_strong]:font-semibold [&_em]:italic [&_h3]:mt-4 [&_h3]:font-semibold [&_h4]:mt-3 [&_h4]:font-semibold [&_code]:font-mono [&_code]:text-[12px] [&_hr]:my-4 [&_hr]:border-[var(--color-line)]"; + +function Md({ text }: { text: string }) { + return ( + <Markdown className={MD} linkComponent={CiteLink}> + {text} + </Markdown> + ); +} + +function NoteButton({ section, title }: { section: string; title: string }) { + const actions = useContext(ArticleActionsContext); + return ( + <button + type="button" + data-anchor-skip + aria-label={`note on ${title}`} + onClick={() => actions?.noteSection(section, title)} + className="ml-2 align-middle text-[11px] font-normal text-[var(--color-dim)] opacity-60 hover:text-[var(--color-sel)] hover:opacity-100" + > + + note + </button> + ); +} + +function ArticleBodyInner({ view, video }: { view: ReportPageView; video?: ReactNode }) { + return ( + <CitationsContext.Provider value={view.citations}> + <div data-article-body className="space-y-5"> + <header className="space-y-1"> + {view.series && <div className="micro">{view.series}</div>} + <h1 data-block="title" className="text-[22px] font-semibold leading-tight text-[var(--color-text)]"> + {view.title} + </h1> + {view.subtitle && ( + <p data-block="subtitle" className="text-[15px] text-[var(--color-dim)]"> + {view.subtitle} + </p> + )} + </header> + {/* The report's own video, under its title as the published page has + it. Outside every data-block, so it is never part of a quote. */} + {video} + {view.summary && ( + <section data-block="summary" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-4 py-2"> + <div className="micro pt-1" data-anchor-skip> + summary + <NoteButton section="summary" title="summary" /> + </div> + <Md text={view.summary} /> + </section> + )} + {view.sections.map((s) => ( + <section key={s.id} id={`s-${s.id}`} data-block={s.id} className="scroll-mt-4"> + <h2 className="mt-2 text-[17px] font-semibold text-[var(--color-text)]"> + {s.title} + <NoteButton section={s.id} title={s.title} /> + </h2> + {s.body && <Md text={s.body} />} + {s.claims.map((cl) => ( + <div key={cl.id} data-claim={cl.id} className="my-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"> + {cl.title && <div className="text-[13px] font-semibold text-[var(--color-text)]">{cl.title}</div>} + <div className="flex items-start gap-2"> + <div className="min-w-0 flex-1"> + <Md text={cl.text} /> + </div> + {cl.verdict && ( + <span data-anchor-skip className="mt-2 shrink-0 rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[10px] uppercase text-[var(--color-dim)]"> + {view.verdicts?.[cl.verdict]?.label ?? cl.verdict} + </span> + )} + </div> + {cl.findings && <Md text={cl.findings} />} + {cl.citations.length > 0 && ( + <div data-anchor-skip className="mt-1 flex flex-wrap gap-1"> + {cl.citations.map((id) => ( + <CiteChip key={id} id={id} /> + ))} + </div> + )} + </div> + ))} + </section> + ))} + {view.method && ( + <section data-block="method" className="border-t border-[var(--color-line)] pt-3"> + <div className="micro" data-anchor-skip> + method + <NoteButton section="method" title="method" /> + </div> + <Md text={view.method} /> + </section> + )} + </div> + </CitationsContext.Provider> + ); +} + +function CiteChip({ id }: { id: string }) { + const actions = useContext(ArticleActionsContext); + const c = useContext(CitationsContext)[id]; + return ( + <button + type="button" + data-cite={id} + onClick={() => actions?.openCite(id)} + title={c?.quote ?? id} + className="rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-sel)] hover:border-[var(--color-sel)] data-[has-note=true]:border-[var(--color-dirty)]" + > + {c?.number ?? "?"} {c?.label ?? c?.speaker ?? id} + </button> + ); +} + +const ArticleBody = memo(ArticleBodyInner, (a, b) => a.view === b.view && a.video === b.video); +export default ArticleBody; diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views"; +import CopyButton from "@/components/CopyButton"; +import { locateQuote, quoteAnchor } from "@/lib/annotations/anchor.mjs"; +import type { Anchor, Note, NotesRead } from "@/lib/annotations/types"; +import { useNotes } from "@/lib/annotations/useNotes"; +import { ShareNotes } from "@/components/notes/NotesProvider"; +import ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody"; +import ArticleVideo from "./ArticleVideo"; +import type { VideoProjectLink } from "./AddToVideo"; +import { blockText, selectionIn, unwrapMarks, wrapRange } from "./anchorDom"; +import EvidencePanel, { useEvidence } from "./EvidencePanel"; +import { Composer, NoteCard, anchorLabel } from "./NoteCards"; + +// The article, its notes, and its evidence, in one screen: the text in a +// ~70ch column, the notes in a rail beside it (a drawer below 1100px). +// +// select text → "Note" (or `n`) a note on that quote +// "+ note" on a heading a note on the section +// a citation its evidence in the rail; a note on it there +// "+ whole article" a note on all of it +// +// Notes on a quote are found again in the text as it is NOW (lib/annotations/ +// anchor.mjs: the quote, then its context) and marked; one whose quote is gone +// is still listed, flagged "orphaned", under its section. +// +// Keys (not while typing): j/k move between notes, r resolves, e edits, n +// notes the selection (or the whole article), Esc closes whatever is open. + +type Filter = "open" | "resolved" | "all"; + +const MARK_CLASS = + "rounded-sm bg-[color-mix(in_srgb,var(--color-sel)_22%,transparent)] text-inherit data-[active=true]:bg-[color-mix(in_srgb,var(--color-sel)_48%,transparent)] data-[status=resolved]:bg-transparent data-[status=resolved]:underline data-[status=resolved]:decoration-[var(--color-good)] data-[status=wontfix]:bg-transparent"; + +type ComposerState = { anchor: Anchor; label: string } | null; + +export default function ArticleReader({ + site, + report, + view, + initialNotes, + initialFilter = "open", + initialNote = null, + meta, + video, + videoProjects = [], + viewError, +}: { + site: string; + report: string; + view: ReportPageView; + initialNotes: NotesRead; + initialFilter?: Filter; + initialNote?: string | null; + meta: ReactNode; + /** The report's own video (report.json `video`), played with timed notes. */ + video?: { file: string; src: string; poster?: string; caption?: string } | null; + viewError?: string | null; + /** The article's LINKED video projects: where "Add to video" puts a cited span. */ + videoProjects?: VideoProjectLink[]; +}) { + const notesApi = useNotes({ article: `${site}/${report}` }, { initial: initialNotes }); + const { notes, write, busy, error } = notesApi; + // Stable across note writes: ArticleBody is memoised on it (its marks are + // laid over the rendered DOM). The player reads live notes through + // <ShareNotes>, not through this element. + const videoSlot = useMemo( + () => + video ? ( + <ArticleVideo site={site} report={report} file={video.file} src={video.src} poster={video.poster} caption={video.caption} /> + ) : null, + [site, report, video?.file, video?.src, video?.poster, video?.caption], + ); + const [filter, setFilter] = useState<Filter>(initialFilter); + const [selected, setSelected] = useState<string | null>(initialNote); + const [editing, setEditing] = useState<string | null>(null); + const [composer, setComposer] = useState<ComposerState>(null); + const [pending, setPending] = useState<{ anchor: Anchor; label: string; x: number; y: number } | null>(null); + const [cite, setCite] = useState<string | null>(null); + const [orphans, setOrphans] = useState<Set<string>>(new Set()); + const [hover, setHover] = useState<string | null>(null); + const [drawer, setDrawer] = useState(false); + const bodyRef = useRef<HTMLDivElement>(null); + const evidence = useEvidence(site, report, cite); + + const titles = useMemo(() => { + const t: Record<string, string> = { title: "title", subtitle: "subtitle", summary: "summary", method: "method" }; + for (const s of view.sections) t[s.id] = s.title; + return t; + }, [view]); + const numbers = useMemo(() => Object.fromEntries(Object.entries(view.citations).map(([id, c]) => [id, c.number])), [view]); + const blockOrder = useMemo(() => ["title", "subtitle", "summary", ...view.sections.map((s) => s.id), "method"], [view]); + + const visible = useMemo(() => { + const keep = (n: Note) => filter === "all" || (filter === "open" ? n.status === "open" : n.status !== "open"); + const rank = (n: Note) => { + const a = n.anchor; + if (a.kind === "whole") return -1; + if (a.kind === "text" || a.kind === "section") return blockOrder.indexOf(a.section) + 0.5; + if (a.kind === "cite") return 1000 + (numbers[a.cite] ?? 0); + return 2000; + }; + return notes.filter(keep).sort((a, b) => rank(a) - rank(b) || a.at.localeCompare(b.at)); + }, [notes, filter, blockOrder, numbers]); + + // ---- marks --------------------------------------------------------------- + useLayoutEffect(() => { + const root = bodyRef.current; + if (!root) return; + unwrapMarks(root); + const lost = new Set<string>(); + for (const n of visible) { + const a = n.anchor; + if (a.kind !== "text") continue; + const block = root.querySelector(`[data-block="${CSS.escape(a.section)}"]`); + if (!block) { + lost.add(n.id); + continue; + } + const at = locateQuote(blockText(block).text, a); + if (!at.found) { + lost.add(n.id); + continue; + } + wrapRange(block, at.start, at.end, { "data-note": n.id, "data-status": n.status }, MARK_CLASS); + } + // Citation notes: mark the citation itself. + for (const el of Array.from(root.querySelectorAll("[data-cite][data-has-note]"))) el.removeAttribute("data-has-note"); + for (const n of visible) { + if (n.anchor.kind === "cite") { + for (const el of Array.from(root.querySelectorAll(`[data-cite="${CSS.escape(n.anchor.cite)}"]`))) el.setAttribute("data-has-note", "true"); + } + } + setOrphans((prev) => (prev.size === lost.size && [...lost].every((id) => prev.has(id)) ? prev : lost)); + }, [visible]); + + // The selected / hovered note's marks light up. + useEffect(() => { + const root = bodyRef.current; + if (!root) return; + for (const m of Array.from(root.querySelectorAll("mark[data-note]"))) { + const id = m.getAttribute("data-note"); + m.setAttribute("data-active", String(id === selected || id === hover)); + } + }, [selected, hover, visible]); + + const focusNote = useCallback((id: string, scroll = true) => { + setSelected(id); + if (!scroll) return; + requestAnimationFrame(() => { + const mark = bodyRef.current?.querySelector(`mark[data-note="${CSS.escape(id)}"]`); + mark?.scrollIntoView({ block: "center", behavior: "smooth" }); + document.querySelector(`[data-note-card="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); + }); + }, []); + + // A deep link (`?note=`) lands on its note, whatever the filter says. + useEffect(() => { + if (!initialNote) return; + const n = notes.find((x) => x.id === initialNote); + if (n && filter !== "all" && (filter === "open") !== (n.status === "open")) setFilter("all"); + focusNote(initialNote); + // once, on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---- selection → "Note" --------------------------------------------------- + const readSelection = useCallback(() => { + const root = bodyRef.current; + if (!root) return; + const sel = selectionIn(root); + if (!sel) { + setPending(null); + return; + } + const section = sel.block.getAttribute("data-block") ?? ""; + const q = quoteAnchor(blockText(sel.block).text, sel.start, sel.end); + if (!q.quote) { + setPending(null); + return; + } + const anchor: Anchor = { kind: "text", section, ...q }; + setPending({ anchor, label: anchorLabel(anchor, titles, numbers), x: sel.rect.right, y: sel.rect.top }); + }, [titles, numbers]); + + const openComposer = useCallback((anchor: Anchor) => { + setComposer({ anchor, label: anchorLabel(anchor, titles, numbers) }); + setPending(null); + setDrawer(true); + window.getSelection()?.removeAllRanges(); + }, [titles, numbers]); + + // The body's buttons talk to the reader through a context whose value never + // changes (so the memoised body never re-renders): it reads the latest + // callbacks from a ref. + const latest = useRef({ setCite, openComposer, setDrawer }); + latest.current = { setCite, openComposer, setDrawer }; + const actions = useMemo<ArticleActions>( + () => ({ + openCite: (id) => { + latest.current.setCite(id); + latest.current.setDrawer(true); + }, + noteSection: (section) => latest.current.openComposer({ kind: "section", section }), + }), + [], + ); + + const add = async (anchor: Anchor, text: string) => { + const note = await write({ op: "add", text, anchor }); + if (note) { + setComposer(null); + if (filter === "resolved") setFilter("open"); + focusNote(note.id, false); + } + }; + + // ---- keys ----------------------------------------------------------------- + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if (t && (t.closest("input, textarea, select, [contenteditable=true]") || e.metaKey || e.ctrlKey || e.altKey)) return; + const idx = visible.findIndex((n) => n.id === selected); + if (e.key === "j" || e.key === "k") { + if (!visible.length) return; + e.preventDefault(); + const next = e.key === "j" ? (idx < 0 ? 0 : Math.min(visible.length - 1, idx + 1)) : idx < 0 ? 0 : Math.max(0, idx - 1); + focusNote(visible[next].id); + } else if (e.key === "r" && idx >= 0) { + e.preventDefault(); + void write({ op: "status", id: visible[idx].id, status: "resolved" }); + } else if (e.key === "e" && idx >= 0 && visible[idx].author === "operator") { + e.preventDefault(); + setEditing(visible[idx].id); + } else if (e.key === "n") { + e.preventDefault(); + if (pending) openComposer(pending.anchor); + else if (cite) openComposer({ kind: "cite", cite }); + else openComposer({ kind: "whole" }); + } else if (e.key === "Escape") { + setComposer(null); + setEditing(null); + setPending(null); + setCite(null); + setDrawer(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [visible, selected, pending, cite, write, focusNote, openComposer]); + + const counts = { + open: notes.filter((n) => n.status === "open").length, + resolved: notes.filter((n) => n.status !== "open").length, + all: notes.length, + }; + const citeNotes = cite ? notes.filter((n) => n.anchor.kind === "cite" && n.anchor.cite === cite) : []; + const card = (n: Note) => ( + <NoteCard + key={n.id} + note={n} + label={anchorLabel(n.anchor, titles, numbers)} + orphaned={orphans.has(n.id)} + selected={selected === n.id} + editing={editing === n.id} + busy={busy} + onSelect={() => focusNote(n.id)} + onHover={(on) => setHover(on ? n.id : null)} + onEdit={() => setEditing(n.id)} + onCancelEdit={() => setEditing(null)} + write={write} + /> + ); + + return ( + <div className="flex min-h-0 flex-1"> + <main className="deck-main min-w-0 flex-1 p-4"> + <div className="mx-auto max-w-[72ch] space-y-4"> + {meta} + {viewError && <p className="text-[12px] text-[var(--color-bad)]">citations not shown: {viewError}</p>} + <ArticleActionsContext.Provider value={actions}> + <div + ref={bodyRef} + onMouseUp={() => setTimeout(readSelection, 0)} + onKeyUp={(e) => e.shiftKey && readSelection()} + onMouseOver={(e) => { + const m = (e.target as HTMLElement).closest?.("mark[data-note]"); + setHover(m ? m.getAttribute("data-note") : null); + }} + onClick={(e) => { + const m = (e.target as HTMLElement).closest?.("mark[data-note]"); + const id = m?.getAttribute("data-note"); + if (id && window.getSelection()?.isCollapsed) { + setDrawer(true); + focusNote(id, false); + document.querySelector(`[data-note-card="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); + } + }} + > + <ShareNotes target={{ article: `${site}/${report}` }} notes={notesApi}> + <ArticleBody view={view} video={videoSlot} /> + </ShareNotes> + </div> + </ArticleActionsContext.Provider> + </div> + </main> + + {pending && ( + <button + type="button" + onMouseDown={(e) => e.preventDefault()} + onClick={() => openComposer(pending.anchor)} + style={{ left: Math.min(pending.x + 6, window.innerWidth - 70), top: Math.min(window.innerHeight - 32, Math.max(8, pending.y - 30)) }} + className="fixed z-50 rounded bg-[var(--color-sel)] px-2 py-0.5 text-[12px] font-medium text-[var(--color-ink)] shadow" + > + Note + </button> + )} + + <button + type="button" + onClick={() => setDrawer((d) => !d)} + className="fixed bottom-3 right-3 z-30 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-2 py-1 text-[12px] min-[1100px]:hidden" + > + notes {counts.open} + </button> + + <aside + aria-label="notes" + data-drawer={drawer ? "open" : "closed"} + className={`fixed inset-y-0 right-0 z-40 flex w-[min(380px,92vw)] flex-col border-l border-[var(--color-line)] bg-[var(--color-ink)] transition-transform min-[1100px]:static min-[1100px]:z-auto min-[1100px]:w-[360px] min-[1100px]:shrink-0 min-[1100px]:translate-x-0 ${drawer ? "translate-x-0" : "translate-x-full"}`} + > + <div className="flex flex-wrap items-center gap-1 border-b border-[var(--color-line)] p-2"> + {(["open", "resolved", "all"] as Filter[]).map((f) => ( + <button + key={f} + type="button" + aria-pressed={filter === f} + onClick={() => setFilter(f)} + className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${filter === f ? "border-[var(--color-sel)] text-[var(--color-sel)]" : "border-[var(--color-line)] text-[var(--color-dim)]"}`} + > + {f} {counts[f]} + </button> + ))} + <button + type="button" + onClick={() => openComposer({ kind: "whole" })} + className="ml-auto text-[11px] text-[var(--color-dim)] hover:text-[var(--color-sel)]" + > + + whole article + </button> + <button type="button" aria-label="close notes" onClick={() => setDrawer(false)} className="text-[12px] text-[var(--color-dim)] min-[1100px]:hidden"> + ✕ + </button> + </div> + <div className="flex items-center gap-2 border-b border-[var(--color-line)] px-2 py-1"> + <CopyButton + url={`/api/notes/context?${new URLSearchParams({ article: `${site}/${report}` })}`} + label="copy agent brief" + title={`umtool notes ${site}/${report}`} + className="shrink-0 whitespace-nowrap" + /> + {notesApi.source?.draft && ( + <span className="truncate font-mono text-[10px] text-[var(--color-dim)]" title={notesApi.source.how ?? ""}> + edit {notesApi.source.draft} + </span> + )} + </div> + <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2"> + {error && <p className="text-[12px] text-[var(--color-bad)]">{error}</p>} + + {cite && ( + <section data-evidence-rail className="space-y-2 rounded border border-[var(--color-line)] p-2"> + <div className="flex items-center"> + <span className="micro">citation [{numbers[cite] ?? cite}]</span> + <a href={`/sites/${site}/${report}/evidence?c=${encodeURIComponent(cite)}`} className="ml-auto text-[11px] text-[var(--color-dim)] hover:text-[var(--color-sel)]"> + walk → + </a> + <button type="button" aria-label="close evidence" onClick={() => setCite(null)} className="ml-2 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]"> + ✕ + </button> + </div> + <EvidencePanel ev={evidence.ev} error={evidence.error} number={numbers[cite]} projects={videoProjects} /> + {citeNotes.length > 0 && <ul className="space-y-2">{citeNotes.map(card)}</ul>} + {composer?.anchor.kind === "cite" && composer.anchor.cite === cite ? ( + <Composer label={composer.label} busy={busy} onSave={(text) => add(composer.anchor, text)} onCancel={() => setComposer(null)} /> + ) : ( + <button type="button" onClick={() => openComposer({ kind: "cite", cite })} className="text-[11px] text-[var(--color-dim)] hover:text-[var(--color-sel)]"> + + note on this citation + </button> + )} + </section> + )} + + {composer && !(composer.anchor.kind === "cite" && composer.anchor.cite === cite) && ( + <Composer label={composer.label} busy={busy} onSave={(text) => add(composer.anchor, text)} onCancel={() => setComposer(null)} /> + )} + + {visible.length === 0 ? ( + <p className="text-[12px] text-[var(--color-dim)]">{notes.length === 0 ? "no notes" : `no ${filter} notes`}</p> + ) : ( + <ul data-notes-list className="space-y-2"> + {/* A note on the open citation is listed with its evidence above. */} + {visible.filter((n) => !(cite && n.anchor.kind === "cite" && n.anchor.cite === cite)).map(card)} + </ul> + )} + </div> + </aside> + </div> + ); +} diff --git a/umtool/components/articles/ArticleTable.tsx b/umtool/components/articles/ArticleTable.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; +import { badgeVariants } from "@/components/ui/badge"; +import { fmtAgo } from "@/lib/format"; +import type { ArticleRow } from "@/lib/articles/sites"; + +// One row per article: what it is, where it stands, what is waiting on it, +// and where it came from. Server-rendered, zero JS -- the /browse idiom. + +const posterUrl = (a: ArticleRow) => + `/api/sites/media?site=${encodeURIComponent(a.site)}&report=${encodeURIComponent(a.id)}&file=poster.jpg`; + +const baseName = (p: string) => p.split("/").pop() ?? p; + +export function articleHref(a: { site: string; id: string }) { + return `/sites/${a.site}/${a.id}`; +} + +export default function ArticleTable({ articles }: { articles: ArticleRow[] }) { + if (articles.length === 0) return <p className="text-[12px] text-[var(--color-dim)]">no articles</p>; + return ( + <table className="w-full border-collapse text-[12px]" data-testid="article-table"> + <thead> + <tr className="text-left text-[11px] text-[var(--color-dim)]"> + <th className="w-[72px] py-1 font-normal" /> + <th className="py-1 font-normal">article</th> + <th className="py-1 font-normal">status</th> + <th className="py-1 font-normal">updated</th> + <th className="py-1 text-right font-normal">cites</th> + <th className="py-1 text-right font-normal">notes</th> + <th className="py-1 pl-3 font-normal">video project</th> + <th className="py-1 font-normal">source</th> + </tr> + </thead> + <tbody> + {articles.map((a) => ( + <tr + key={`${a.site}/${a.id}`} + data-article={`${a.site}/${a.id}`} + data-status={a.status} + className="border-t border-[var(--color-line)] align-top" + > + <td className="py-1.5 pr-2"> + {a.hasPoster ? ( + // eslint-disable-next-line @next/next/no-img-element + <img src={posterUrl(a)} alt="" loading="lazy" className="h-9 w-16 rounded object-cover" /> + ) : ( + <div className="h-9 w-16 rounded bg-[var(--color-panel-2)]" /> + )} + </td> + <td className="py-1.5 pr-3"> + <Link href={articleHref(a)} className="text-[var(--color-text)] hover:text-[var(--color-sel)]"> + {a.title} + </Link> + <div className="font-mono text-[11px] text-[var(--color-dim)]"> + {a.id} + {a.series ? ` · ${a.series}` : ""} + {a.problem ? <span className="text-[var(--color-bad)]"> · {a.problems} problem{a.problems === 1 ? "" : "s"}</span> : null} + </div> + </td> + <td className="py-1.5 pr-3"> + <span className={badgeVariants({ variant: a.status === "published" ? "on" : "neutral", size: "sm" })}> + {a.status} + </span> + </td> + <td className="num py-1.5 pr-3 text-[var(--color-dim)]"> + {a.updated ?? (a.mtimeMs ? fmtAgo(a.mtimeMs) : "—")} + </td> + <td className="num py-1.5 pr-3 text-right">{a.citations}</td> + <td className="num py-1.5 text-right"> + {a.openNotes > 0 ? ( + <Link + href={`${articleHref(a)}?status=open`} + data-open-notes={a.openNotes} + className={badgeVariants({ variant: "open", size: "sm" })} + > + {a.openNotes} open + </Link> + ) : ( + <span className="text-[var(--color-dim)]">{a.notes || "—"}</span> + )} + </td> + <td className="py-1.5 pl-3 pr-3"> + {a.projects.linked.map((p) => ( + <Link key={p.id} href={`/browse/${p.id}`} data-project-link={p.id} className="block font-mono text-[11px] text-[var(--color-sel)] hover:underline"> + {p.id} + </Link> + ))} + {a.projects.possible.map((p) => ( + <Link key={p.id} href={`/browse/${p.id}`} title="shares the slug; not linked" className="block font-mono text-[11px] text-[var(--color-dim)] hover:underline"> + {p.id} (possible) + </Link> + ))} + {a.projects.linked.length + a.projects.possible.length === 0 && <span className="text-[var(--color-dim)]">—</span>} + </td> + <td className="py-1.5 font-mono text-[11px] text-[var(--color-dim)]" title={a.source?.how ?? ""}> + {a.source?.draft ? baseName(a.source.draft) : a.source?.generator ? baseName(a.source.generator) : "—"} + </td> + </tr> + ))} + </tbody> + </table> + ); +} diff --git a/umtool/components/articles/ArticleVideo.tsx b/umtool/components/articles/ArticleVideo.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { TimedVideo } from "@/components/notes/TimedNotes"; + +// THE ARTICLE'S OWN VIDEO -- the report's video (report.json `video.src`), as +// the published page plays it, with timed notes under it: `n` or Mark at the +// playhead writes a moment anchor `{ kind: "moment", file: <video.src>, t }` +// to the ARTICLE's notes. The handle is the reader's own, shared through +// <ShareNotes> (components/notes/NotesProvider.tsx), so a mark and a text note +// never race each other's token. No schedule: a report video is a published +// file, so a mark keeps its time only. Keep this the only place the article +// page renders its video. +export default function ArticleVideo({ + site, + report, + file, + src, + poster, + caption, +}: { + site: string; + report: string; + file: string; + src: string; + poster?: string; + caption?: string; +}) { + return ( + <figure data-article-video className="space-y-1"> + <TimedVideo target={{ article: `${site}/${report}` }} file={file} src={src} poster={poster} testId="article-video" showErrors={false} /> + {caption && <figcaption className="text-[12px] text-[var(--color-dim)]">{caption}</figcaption>} + </figure> + ); +} diff --git a/umtool/components/articles/EvidencePanel.tsx b/umtool/components/articles/EvidencePanel.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import CopyButton from "@/components/CopyButton"; +import type { Evidence } from "@/lib/articles/evidence"; +import AddToVideo, { type VideoProjectLink } from "./AddToVideo"; + +// One citation's evidence: what it quotes, who and when, the transcript around +// it with the cited cues marked, and the media that plays it -- the prepared +// clip, a fetched window or a saved file, seeked to the cited second -- or, +// when there is none on disk, the MCP line that fetches it through the editor. + +export type EvidenceHandle = { togglePlay: () => void }; + +const fmt = (s: number) => { + const m = Math.floor(s / 60); + const r = Math.floor(s % 60); + return `${m}:${String(r).padStart(2, "0")}`; +}; + +export function useEvidence(site: string, report: string, cite: string | null) { + const [ev, setEv] = useState<Evidence | null>(null); + const [error, setError] = useState<string | null>(null); + useEffect(() => { + if (!cite) return; + let live = true; + setEv(null); + setError(null); + fetch(`/api/sites/evidence?${new URLSearchParams({ site, report, cite })}`, { cache: "no-store" }) + .then(async (r) => { + const j = await r.json(); + if (!r.ok) throw new Error(j.error ?? r.statusText); + if (live) setEv(j); + }) + .catch((err) => live && setError(err instanceof Error ? err.message : String(err))); + return () => { + live = false; + }; + }, [site, report, cite]); + return { ev, error }; +} + +const EvidencePanel = forwardRef< + EvidenceHandle, + { ev: Evidence | null; error: string | null; number?: number; autoPlay?: boolean; projects?: VideoProjectLink[] } +>( + function EvidencePanel({ ev, error, number, autoPlay = false, projects = [] }, ref) { + const media = useRef<HTMLMediaElement | null>(null); + useImperativeHandle(ref, () => ({ + togglePlay: () => { + const m = media.current; + if (!m) return; + if (m.paused) void m.play().catch(() => {}); + else m.pause(); + }, + })); + + if (error) return <p className="text-[12px] text-[var(--color-bad)]">{error}</p>; + if (!ev) return <p className="text-[12px] text-[var(--color-dim)]">loading…</p>; + + const play = ev.play; + // File time of a record second: the cited span starts `offset` into the file. + const fileT = (t: number) => (play && ev.start !== undefined ? Math.max(0, t - ev.start + play.offset) : 0); + const startAt = play ? (play.kind === "prepared" ? 0 : Math.max(0, play.offset - 3)) : 0; + const seek = (t: number) => { + if (media.current) { + media.current.currentTime = fileT(t); + void media.current.play().catch(() => {}); + } + }; + + return ( + <div data-evidence={ev.cite} className="space-y-2 text-[12px]"> + <blockquote className="border-l-2 border-[var(--color-sel)] pl-2 text-[13px] text-[var(--color-text)]"> + {number !== undefined && <span className="mr-1 font-mono text-[11px] text-[var(--color-sel)]">[{number}]</span>}“{ev.quote}” + </blockquote> + <div className="text-[var(--color-dim)]"> + {[ev.speaker ?? ev.record?.channelTitle, ev.date, ev.label].filter(Boolean).join(" · ")} + {ev.start !== undefined && ev.end !== undefined && ( + <span className="num ml-1"> + @ {fmt(ev.start)}–{fmt(ev.end)} + </span> + )} + </div> + {ev.record?.title && <div className="text-[var(--color-dim)]">{ev.record.title}</div>} + {ev.originalUrl && ( + <a href={ev.originalUrl} target="_blank" rel="noopener noreferrer" className="text-[var(--color-sel)] hover:underline"> + original ↗ + </a> + )} + + {play ? ( + <div data-play={play.kind}> + {play.audio ? ( + <audio + ref={(el) => { + media.current = el; + }} + controls + src={play.url} + autoPlay={autoPlay} + onLoadedMetadata={(e) => { + e.currentTarget.currentTime = startAt; + }} + className="w-full" + /> + ) : ( + <video + ref={(el) => { + media.current = el; + }} + controls + src={play.url} + autoPlay={autoPlay} + onLoadedMetadata={(e) => { + e.currentTarget.currentTime = startAt; + }} + className="aspect-video w-full rounded bg-black" + /> + )} + <div className="micro mt-0.5">{play.label}</div> + </div> + ) : ev.fetchLine ? ( + <div data-play="none" className="space-y-1 rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] p-2"> + <div className="text-[var(--color-dim)]">not on disk — fetch it through the editor:</div> + <code className="block break-all font-mono text-[11px] text-[var(--color-text)]">{ev.fetchLine}</code> + <CopyButton text={ev.fetchLine} label="copy fetch_clip" /> + </div> + ) : null} + + {ev.post && ( + <div data-post className="space-y-1 rounded border border-[var(--color-line)] p-2"> + {ev.post.author && <div className="text-[var(--color-dim)]">{ev.post.author}</div>} + {ev.post.text && <p className="whitespace-pre-wrap text-[var(--color-text)]">{ev.post.text}</p>} + {ev.post.shot && ( + // eslint-disable-next-line @next/next/no-img-element + <img src={ev.post.shot} alt="capture of the post" className="w-full rounded border border-[var(--color-line)]" /> + )} + </div> + )} + + {ev.cues.length > 0 ? ( + <ol data-cues className="space-y-0.5"> + {ev.cues.map((c) => ( + <li key={c.start} data-cited={c.cited ? "true" : undefined}> + <button + type="button" + onClick={() => seek(c.start)} + disabled={!play} + className={`flex w-full gap-2 rounded px-1 text-left ${c.cited ? "bg-[var(--color-panel-2)] text-[var(--color-text)]" : "text-[var(--color-dim)]"} enabled:hover:text-[var(--color-text)]`} + > + <span className="num shrink-0 font-mono text-[10px] leading-5">{fmt(c.start)}</span> + <span>{c.text}</span> + </button> + </li> + ))} + </ol> + ) : ev.cuesNote ? ( + <div className="micro">{ev.cuesNote}</div> + ) : null} + <AddToVideo ev={ev} projects={projects} /> + </div> + ); + }, +); + +export default EvidencePanel; diff --git a/umtool/components/articles/EvidenceWalk.tsx b/umtool/components/articles/EvidenceWalk.tsx @@ -0,0 +1,129 @@ +"use client"; + +import type { VideoProjectLink } from "./AddToVideo"; +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { NotesRead } from "@/lib/annotations/types"; +import { useNotes } from "@/lib/annotations/useNotes"; +import EvidencePanel, { useEvidence, type EvidenceHandle } from "./EvidencePanel"; +import { Composer, NoteCard } from "./NoteCards"; + +// Every citation of one article, one per screen: the evidence on the left, its +// notes on the right. j/k (or ←/→) move, space plays, n notes it, Esc cancels. +// The citation in view is in the URL (`?c=`), so a walk can be resumed. + +export type WalkItem = { id: string; number: number; kind: string; quote: string }; + +export default function EvidenceWalk({ + site, + report, + items, + initial, + initialNotes, + videoProjects = [], +}: { + site: string; + report: string; + items: WalkItem[]; + initial: number; + initialNotes: NotesRead; + videoProjects?: VideoProjectLink[]; +}) { + const [idx, setIdx] = useState(initial); + const [composing, setComposing] = useState(false); + const item = items[idx]; + const { ev, error } = useEvidence(site, report, item?.id ?? null); + const { notes, write, busy, error: notesError } = useNotes({ article: `${site}/${report}` }, { initial: initialNotes }); + const panel = useRef<EvidenceHandle>(null); + + const go = useCallback( + (to: number) => { + const next = Math.max(0, Math.min(items.length - 1, to)); + setIdx(next); + setComposing(false); + const url = new URL(window.location.href); + url.searchParams.set("c", items[next].id); + window.history.replaceState(null, "", url); + }, + [items], + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if (t?.closest("input, textarea, select, [contenteditable=true]") || e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === "j" || e.key === "ArrowRight") { + e.preventDefault(); + go(idx + 1); + } else if (e.key === "k" || e.key === "ArrowLeft") { + e.preventDefault(); + go(idx - 1); + } else if (e.key === " ") { + e.preventDefault(); + panel.current?.togglePlay(); + } else if (e.key === "n") { + e.preventDefault(); + setComposing(true); + } else if (e.key === "Escape") { + setComposing(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [idx, go]); + + if (!item) return <p className="p-4 text-[12px] text-[var(--color-dim)]">no citations</p>; + const mine = notes.filter((n) => n.anchor.kind === "cite" && n.anchor.cite === item.id); + + return ( + <div data-walk={item.id} className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-auto p-4 min-[1100px]:grid-cols-[minmax(0,1.3fr)_minmax(320px,1fr)]"> + <div className="min-w-0 space-y-2"> + <div className="flex items-center gap-2 text-[12px]"> + <button type="button" onClick={() => go(idx - 1)} disabled={idx === 0} className="rounded border border-[var(--color-line)] px-2 disabled:opacity-30"> + ← k + </button> + <span className="num" data-walk-position> + {idx + 1} / {items.length} + </span> + <button type="button" onClick={() => go(idx + 1)} disabled={idx === items.length - 1} className="rounded border border-[var(--color-line)] px-2 disabled:opacity-30"> + j → + </button> + <span className="micro ml-2">{item.kind}</span> + <span className="micro ml-auto">space plays · n notes</span> + </div> + <EvidencePanel key={item.id} ref={panel} ev={ev} error={error} number={item.number} projects={videoProjects} /> + </div> + <div className="min-w-0 space-y-2"> + <div className="micro">notes on [{item.number}]</div> + {notesError && <p className="text-[12px] text-[var(--color-bad)]">{notesError}</p>} + {mine.length > 0 && ( + <ul className="space-y-2"> + {mine.map((n) => ( + <NoteCard key={n.id} note={n} label={`citation [${item.number}]`} busy={busy} write={write} /> + ))} + </ul> + )} + {composing ? ( + <Composer + label={`citation [${item.number}]`} + busy={busy} + onSave={async (text) => { + const note = await write({ op: "add", text, anchor: { kind: "cite", cite: item.id } }); + if (note) setComposing(false); + }} + onCancel={() => setComposing(false)} + /> + ) : ( + <button type="button" onClick={() => setComposing(true)} className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-sel)]"> + + note on this citation + </button> + )} + <div className="pt-2"> + <Link href={`/sites/${site}/${report}`} className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]"> + ← article + </Link> + </div> + </div> + </div> + ); +} diff --git a/umtool/components/articles/NoteCards.tsx b/umtool/components/articles/NoteCards.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { NOTE_TEXT_LIMIT } from "@/lib/annotations/types"; +import type { Anchor, Note, NoteOp } from "@/lib/annotations/types"; +import { fmtAgo } from "@/lib/format"; + +// A note in the rail, and the box that writes one. Shared by the article +// reader and the evidence walk. + +export function anchorLabel(a: Anchor, titles: Record<string, string>, cites: Record<string, number | undefined>): string { + const sec = (id: string) => titles[id] ?? id; + switch (a.kind) { + case "text": + return `${sec(a.section)} · “${a.quote.length > 60 ? `${a.quote.slice(0, 59)}…` : a.quote}”`; + case "section": + return `section · ${sec(a.section)}`; + case "cite": + return `citation [${cites[a.cite] ?? a.cite}]`; + case "whole": + return "whole article"; + case "moment": + return `${a.file} @ ${a.t.toFixed(1)}s`; + default: + return a.kind; + } +} + +export function Composer({ + label, + initial = "", + busy, + onSave, + onCancel, + saveLabel = "save note", +}: { + label: string; + initial?: string; + busy?: boolean; + onSave: (text: string) => void | Promise<void>; + onCancel: () => void; + saveLabel?: string; +}) { + const [text, setText] = useState(initial); + const ref = useRef<HTMLTextAreaElement>(null); + useEffect(() => ref.current?.focus(), []); + const save = () => { + if (text.trim()) void onSave(text); + }; + return ( + <div data-composer className="space-y-1 rounded border border-[var(--color-sel)] bg-[var(--color-panel)] p-2"> + <div className="micro truncate" title={label}> + {label} + </div> + <textarea + ref={ref} + aria-label="note text" + value={text} + maxLength={NOTE_TEXT_LIMIT} + onChange={(e) => setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + rows={4} + className="w-full resize-y rounded border border-[var(--color-line)] bg-[var(--color-ink)] p-1.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" + /> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={save} + disabled={busy || !text.trim()} + className="rounded bg-[var(--color-sel)] px-2 py-0.5 text-[12px] text-[var(--color-ink)] disabled:opacity-40" + > + {saveLabel} + </button> + <button type="button" onClick={onCancel} className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]"> + cancel + </button> + <span className="micro ml-auto">ctrl+enter</span> + </div> + </div> + ); +} + +const STATUS_TONE: Record<Note["status"], string> = { + open: "text-[var(--color-dirty)] border-[var(--color-dirty)]", + resolved: "text-[var(--color-good)] border-[var(--color-good)]", + wontfix: "text-[var(--color-dim)] border-[var(--color-line)]", +}; + +export function NoteCard({ + note, + label, + orphaned, + selected, + editing, + busy, + onSelect, + onHover, + onEdit, + onCancelEdit, + write, +}: { + note: Note; + label: string; + orphaned?: boolean; + selected?: boolean; + editing?: boolean; + busy?: boolean; + onSelect?: () => void; + onHover?: (on: boolean) => void; + onEdit?: () => void; + onCancelEdit?: () => void; + write: (op: NoteOp) => Promise<unknown>; +}) { + const [replying, setReplying] = useState(false); + const mine = note.author === "operator"; + return ( + <li + data-note-card={note.id} + data-status={note.status} + data-orphaned={orphaned ? "true" : undefined} + aria-current={selected ? "true" : undefined} + onMouseEnter={() => onHover?.(true)} + onMouseLeave={() => onHover?.(false)} + onClick={onSelect} + className={`cursor-default space-y-1 rounded border bg-[var(--color-panel)] p-2 text-[12px] ${selected ? "border-[var(--color-sel)]" : "border-[var(--color-line)]"}`} + > + <div className="flex items-center gap-1.5"> + <span className={`rounded border px-1 font-mono text-[10px] uppercase ${STATUS_TONE[note.status]}`}>{note.status}</span> + {note.author === "agent" && <span className="rounded border border-[var(--color-meter)] px-1 font-mono text-[10px] text-[var(--color-meter)]">agent</span>} + {orphaned && ( + <span title="the quoted text is no longer in the article" className="rounded border border-[var(--color-bad)] px-1 font-mono text-[10px] text-[var(--color-bad)]"> + orphaned + </span> + )} + <span className="num ml-auto text-[10px] text-[var(--color-dim)]" title={note.updatedAt}> + {fmtAgo(Date.parse(note.updatedAt))} + </span> + </div> + <div className="truncate text-[11px] text-[var(--color-dim)]" title={label}> + {label} + </div> + {editing ? ( + <Composer + label="edit note" + initial={note.text} + busy={busy} + saveLabel="save" + onSave={async (text) => { + await write({ op: "edit", id: note.id, text }); + onCancelEdit?.(); + }} + onCancel={() => onCancelEdit?.()} + /> + ) : ( + <p data-note-text className="whitespace-pre-wrap text-[var(--color-text)]"> + {note.text} + </p> + )} + {note.replies.length > 0 && ( + <ul className="space-y-1 border-l border-[var(--color-line)] pl-2"> + {note.replies.map((r, i) => ( + <li key={`${r.at}-${i}`} data-reply={r.author} className="text-[12px]"> + <span className={`mr-1 font-mono text-[10px] ${r.author === "agent" ? "text-[var(--color-meter)]" : "text-[var(--color-dim)]"}`}>{r.author}</span> + <span className="whitespace-pre-wrap text-[var(--color-text)]">{r.text}</span> + </li> + ))} + </ul> + )} + {replying ? ( + <Composer + label="reply" + busy={busy} + saveLabel="reply" + onSave={async (text) => { + await write({ op: "reply", id: note.id, text }); + setReplying(false); + }} + onCancel={() => setReplying(false)} + /> + ) : ( + <div className="flex flex-wrap gap-2 pt-0.5 text-[11px]"> + {note.status === "open" ? ( + <> + <Act onClick={() => write({ op: "status", id: note.id, status: "resolved" })} disabled={busy}> + resolve + </Act> + <Act onClick={() => write({ op: "status", id: note.id, status: "wontfix" })} disabled={busy}> + won’t fix + </Act> + </> + ) : ( + <Act onClick={() => write({ op: "status", id: note.id, status: "open" })} disabled={busy}> + reopen + </Act> + )} + <Act onClick={() => setReplying(true)} disabled={busy}> + reply + </Act> + {mine && onEdit && ( + <Act onClick={onEdit} disabled={busy}> + edit + </Act> + )} + <Act + onClick={() => { + if (window.confirm("Delete this note?")) void write({ op: "delete", id: note.id }); + }} + disabled={busy} + > + delete + </Act> + </div> + )} + </li> + ); +} + +function Act({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: React.ReactNode }) { + return ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + onClick(); + }} + disabled={disabled} + className="text-[var(--color-dim)] hover:text-[var(--color-sel)] disabled:opacity-40" + > + {children} + </button> + ); +} diff --git a/umtool/components/articles/SiteChips.tsx b/umtool/components/articles/SiteChips.tsx @@ -0,0 +1,14 @@ +import { badgeVariants } from "@/components/ui/badge"; + +// A site's audience, listing and search, as three short chips. +export default function SiteChips({ site }: { site: { private: boolean; listed: boolean; search: boolean } }) { + return ( + <span className="inline-flex gap-1"> + <span data-audience={site.private ? "private" : "public"} className={badgeVariants({ variant: site.private ? "meter" : "neutral", size: "sm" })}> + {site.private ? "private" : "public"} + </span> + <span className={badgeVariants({ variant: "info", size: "sm" })}>{site.listed ? "listed" : "unlisted"}</span> + <span className={badgeVariants({ variant: "info", size: "sm" })}>{site.search ? "search" : "cited only"}</span> + </span> + ); +} diff --git a/umtool/components/articles/WorkspacePanel.tsx b/umtool/components/articles/WorkspacePanel.tsx @@ -0,0 +1,110 @@ +import Link from "next/link"; +import { Markdown } from "@/lib/markdown"; +import { fmtAgo, fmtBytes } from "@/lib/format"; +import type { OpenedFile, WorkspaceListing } from "@/lib/articles/files"; + +// The files an article was written from, and the one that is open. Zero JS: +// each file is a link that sets `?ws=&rel=` on the page it sits on; markdown +// renders, a draft's JSON is pretty-printed with each top-level key folded, +// and HTML opens in a sandboxed iframe (no scripts, no same origin). + +export default function WorkspacePanel({ + workspaces, + opened, + hrefFor, + highlight, +}: { + workspaces: WorkspaceListing[]; + opened: OpenedFile | null; + hrefFor: (ws: string, rel: string) => string; + /** A file to mark (the article's own draft), as `<ws>/<rel>`. */ + highlight?: string | null; +}) { + if (workspaces.length === 0) return <p className="text-[12px] text-[var(--color-dim)]">no workspace found</p>; + return ( + <div className="grid gap-4 min-[1100px]:grid-cols-[minmax(240px,320px)_1fr]" data-testid="workspace-panel"> + <div className="space-y-3"> + {workspaces.map((w) => ( + <div key={w.name} data-workspace={w.name}> + <div className="mb-1 font-mono text-[11px] text-[var(--color-dim)]">{w.dir}</div> + <ul className="space-y-0.5"> + {w.files.map((f) => { + const on = opened?.ws === w.name && opened.rel === f.rel; + const mine = highlight === `${w.name}/${f.rel}`; + return ( + <li key={f.rel} className="flex items-baseline gap-2 text-[12px]"> + <Link + href={hrefFor(w.name, f.rel)} + aria-current={on ? "true" : undefined} + data-file={f.rel} + className={`truncate font-mono ${on ? "text-[var(--color-sel)]" : mine ? "text-[var(--color-text)]" : "text-[var(--color-dim)] hover:text-[var(--color-text)]"}`} + > + {f.rel} + </Link> + {mine && <span className="micro">this article</span>} + <span className="num ml-auto shrink-0 text-[10px] text-[var(--color-dim)]"> + {fmtBytes(f.bytes)} · {fmtAgo(f.mtimeMs)} + </span> + </li> + ); + })} + </ul> + </div> + ))} + </div> + <div className="min-w-0">{opened ? <Opened file={opened} /> : <p className="text-[12px] text-[var(--color-dim)]">pick a file</p>}</div> + </div> + ); +} + +function Opened({ file }: { file: OpenedFile }) { + const head = <div className="mb-2 font-mono text-[11px] text-[var(--color-dim)]">{file.rel}</div>; + if (file.kind === "error") { + return ( + <div> + {head} + <p className="text-[12px] text-[var(--color-bad)]">{file.message}</p> + </div> + ); + } + if (file.kind === "html") { + return ( + <div> + {head} + <iframe title={file.rel} src={file.url} sandbox="" className="h-[70vh] w-full rounded border border-[var(--color-line)] bg-white" /> + </div> + ); + } + if (file.kind === "json") { + const v = file.value; + const entries = v && typeof v === "object" && !Array.isArray(v) ? Object.entries(v as Record<string, unknown>) : null; + return ( + <div data-opened="json"> + {head} + {entries ? ( + <div className="space-y-1"> + {entries.map(([k, val]) => ( + <details key={k} open={typeof val !== "object" || val === null} className="rounded border border-[var(--color-line)] bg-[var(--color-panel)]"> + <summary className="cursor-pointer px-2 py-1 font-mono text-[12px] text-[var(--color-text)]"> + {k} + {Array.isArray(val) ? <span className="micro ml-2">{val.length} items</span> : null} + </summary> + <pre className="max-h-[50vh] overflow-auto whitespace-pre-wrap px-2 pb-2 font-mono text-[11px] text-[var(--color-dim)]"> + {JSON.stringify(val, null, 2)} + </pre> + </details> + ))} + </div> + ) : ( + <pre className="overflow-auto whitespace-pre-wrap font-mono text-[11px]">{JSON.stringify(v, null, 2)}</pre> + )} + </div> + ); + } + return ( + <div data-opened="md"> + {head} + <Markdown text={file.text} /> + </div> + ); +} diff --git a/umtool/components/articles/anchorDom.ts b/umtool/components/articles/anchorDom.ts @@ -0,0 +1,98 @@ +// The article's TEXT, as the notes see it, and the <mark>s that show them. +// +// A block (`[data-block]`: title, subtitle, summary, method, or a section) has +// one plain text: its text nodes in document order, MINUS anything inside +// `[data-anchor-skip]` -- a citation's superscript number, a "+ note" button -- +// so a quote never carries a "3" from a cite marker and re-anchors the same +// way `umtool notes` reads the section from report.json. lib/annotations/ +// anchor.mjs locates a quote in that text; these turn offsets into DOM and back. +// +// The marks are DOM the app adds AFTER React has rendered the (memoised, +// never re-rendered) article body, and removes before adding them again. + +export const SKIP = "[data-anchor-skip]"; + +export type TextModel = { text: string; nodes: { node: Text; start: number }[] }; + +export function blockText(root: Element): TextModel { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: (n) => { + const skip = n.parentElement?.closest(SKIP); + return skip && root.contains(skip) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; + }, + }); + const nodes: TextModel["nodes"] = []; + let text = ""; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const t = n as Text; + nodes.push({ node: t, start: text.length }); + text += t.data; + } + return { text, nodes }; +} + +/** The text offset of a DOM point inside `root`. */ +export function offsetOf(root: Element, container: Node, offset: number): number { + const { nodes, text } = blockText(root); + const point = document.createRange(); + point.setStart(container, offset); + for (const { node, start } of nodes) { + if (node === container) return start + Math.min(offset, node.data.length); + const r = document.createRange(); + r.selectNodeContents(node); + if (r.compareBoundaryPoints(Range.START_TO_START, point) >= 0) return start; + } + return text.length; +} + +export function unwrapMarks(root: Element) { + const parents = new Set<Node>(); + for (const m of Array.from(root.querySelectorAll("mark[data-note]"))) { + const p = m.parentNode; + if (!p) continue; + while (m.firstChild) p.insertBefore(m.firstChild, m); + p.removeChild(m); + parents.add(p); + } + for (const p of parents) p.normalize(); +} + +/** Wrap [start, end) of a block's text in marks carrying `attrs`. Returns the marks. */ +export function wrapRange(root: Element, start: number, end: number, attrs: Record<string, string>, className: string): HTMLElement[] { + const { nodes } = blockText(root); + const out: HTMLElement[] = []; + for (const { node, start: ns } of nodes) { + const ne = ns + node.data.length; + if (ne <= start || ns >= end) continue; + const a = Math.max(start, ns) - ns; + const b = Math.min(end, ne) - ns; + if (b <= a) continue; + let t: Text = node; + if (a > 0) t = t.splitText(a); + if (b - a < t.data.length) t.splitText(b - a); + // Whitespace between two block elements is not worth a mark. + if (!t.data.trim()) continue; + const mark = document.createElement("mark"); + for (const [k, v] of Object.entries(attrs)) mark.setAttribute(k, v); + mark.className = className; + t.parentNode!.insertBefore(mark, t); + mark.appendChild(t); + out.push(mark); + } + return out; +} + +/** The block a selection lies in, and its offsets, or null (collapsed, or across blocks). */ +export function selectionIn(container: Element): { block: Element; start: number; end: number; rect: DOMRect } | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; + const range = sel.getRangeAt(0); + const el = (n: Node) => (n.nodeType === Node.ELEMENT_NODE ? (n as Element) : n.parentElement); + const a = el(range.startContainer)?.closest("[data-block]"); + const b = el(range.endContainer)?.closest("[data-block]"); + if (!a || a !== b || !container.contains(a)) return null; + const start = offsetOf(a, range.startContainer, range.startOffset); + const end = offsetOf(a, range.endContainer, range.endOffset); + if (end <= start) return null; + return { block: a, start, end, rect: range.getBoundingClientRect() }; +} 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,65 @@ +"use client"; + +import { createContext, useContext, useEffect } 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); + +/** Something on the page wrote to a project's notes server-side (an edit note): re-read them. */ +export const NOTES_CHANGED = "umtool:notes-changed"; +export function announceNotesChanged(project: string) { + window.dispatchEvent(new CustomEvent(NOTES_CHANGED, { detail: { project } })); +} + +/** Re-read `notes` when the page says its project's notes changed under it. */ +export function useNotesRefresh(target: NotesTarget | null, notes: UseNotes) { + const project = target && "project" in target ? target.project : null; + const { reload } = notes; + useEffect(() => { + if (!project) return; + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project === project) void reload(); + }; + window.addEventListener(NOTES_CHANGED, on); + return () => window.removeEventListener(NOTES_CHANGED, on); + }, [project, reload]); +} + +export function NotesProvider({ target, children }: { target: NotesTarget; children: React.ReactNode }) { + const notes = useNotes(target); + useNotesRefresh(target, notes); + return <Ctx.Provider value={{ key: notesQuery(target), notes }}>{children}</Ctx.Provider>; +} + +/** + * Share a handle the page already holds (the article reader's) with the notes + * components under it, so they write with ITS token rather than opening a + * second one on the same file. + */ +export function ShareNotes({ target, notes, children }: { target: NotesTarget; notes: UseNotes; children: React.ReactNode }) { + 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); + useNotesRefresh(shared ? null : target, own); + 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,239 @@ +"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, + showErrors = true, +}: { + 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; + /** Show the handle's errors here. Off when the handle is shared with a page that shows them itself (the article reader). */ + showErrors?: boolean; +}) { + 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> + )} + {showErrors && 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, + showErrors, + 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; + showErrors?: boolean; + 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} showErrors={showErrors} /> + </div> + ); +} diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -1,5 +1,8 @@ "use client"; +import AnchoredNotes from "@/components/notes/AnchoredNotes"; +import GeneratedBanner from "@/components/notes/GeneratedBanner"; +import { useSharedNotes, wroteNotes } from "@/components/notes/NotesProvider"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -256,6 +259,8 @@ export type ClipBenchData = { siblings: Sibling[]; /** Is the cut built with the on-screen panel (`render.chrome`)? */ deckOn: boolean; + /** The manifest's `generatedBy`: edits here are noted for the agent that runs it. */ + generatedBy?: string | null; }; const hms = (t: number) => { @@ -377,6 +382,9 @@ type Session = { }; export default function ClipBench({ data }: { data: ClipBenchData }) { + // The project's notes: this clip's row notes, and the edit notes a save on a + // generated manifest leaves (the save says so, and they are re-read). + const notes = useSharedNotes({ project: data.project }); const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); const [cues, setCues] = useState<Cue[]>(data.cues); @@ -1084,6 +1092,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }); const j = (await r.json()) as Record<string, unknown>; setBusy(null); + if (wroteNotes(j)) void notes.reload(); if (!r.ok) { setNote( j.stale @@ -1134,7 +1143,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { void refresh(); return true; }, - [data.project, clip], + [data.project, clip, notes.reload], ); /** @@ -1484,7 +1493,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // Returned, not just stored: "did anything actually arrive" is a question // the caller has to answer before it claims a fetch worked. return j; - }, [data.project, clip.id]); + }, [data.project, clip.id, notes.reload]); // ---- fetching more -------------------------------------------------------- // @@ -1684,6 +1693,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { } setClip((prev) => fromEntry(prev, j.entry ?? {})); token.current = String(j.token ?? ""); + if (wroteNotes(j)) void notes.reload(); setCutScore(j.score ?? null); setNote(`cut to the quote (match ${(j.score ?? 0).toFixed(2)})`); }, [data.project, clip.id]); @@ -1849,6 +1859,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { data-verdict={verdict} className="flex flex-col gap-2 lg:h-full lg:min-h-0 lg:overflow-hidden" > + <GeneratedBanner generatedBy={data.generatedBy} /> {/* ---- where you are, where you can go, and what will be burned in ---- */} <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"> <span className="num font-mono text-[var(--color-text)]"> @@ -2942,6 +2953,9 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { })} </div> + {/* ---- notes on this clip, for whoever acts on them ---- */} + <AnchoredNotes notes={notes} pin={{ kind: "entry", entry: clip.id }} startOpen={false} /> + {/* ---- on-screen: what the panel under the footage says ---- Beside the header's fields because it is the same sitting: the words you hear are the words a title should summarise. Both diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -105,6 +105,7 @@ export default async function ClipBenchPage({ // Whether the cut is built with the on-screen panel. Decides whether the // bench composes a preview of it; the fields are there either way. deckOn: deckOn(manifest.render ?? {}), + generatedBy: typeof manifest.generatedBy === "string" ? manifest.generatedBy : null, view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ name: w.name, diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -1,5 +1,10 @@ "use client"; +import GeneratedBanner from "@/components/notes/GeneratedBanner"; +import { TimedVideo } from "@/components/notes/TimedNotes"; +import StructureEditors from "./StructureEditors"; +import { MANIFEST_CHANGED } from "./timelineApi"; +import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -772,8 +777,11 @@ export default function OnscreenSection({ project, entries, built, + generatedBy = null, }: { project: string; + /** The manifest's `generatedBy`: its edits are noted for the agent, and the section says so. */ + generatedBy?: string | null; /** * Is the default cut's deliverable on disk? The server already knows, and * asking the video route about a file that is not there is a 404 in the @@ -821,6 +829,9 @@ export default function OnscreenSection({ const [job, setJob] = useState<JobView | null>(null); const [jobError, setJobError] = useState<string | null>(null); const [finalV, setFinalV] = useState<number | null | "none">(null); + // The built file's project-relative path (the video route's x-video): what a + // timed note on it is anchored to. + const [finalRel, setFinalRel] = useState<string | null>(null); // The switch as pressed, while its write is in flight: the box follows the // hand at once and falls back to the manifest's answer if the write fails. const [switching, setSwitching] = useState<boolean | null>(null); @@ -933,6 +944,7 @@ export default function OnscreenSection({ const loadFinal = useCallback(async () => { const r = await fetch(`/api/report/video?${q}&kind=final`, { method: "HEAD", cache: "no-store" }).catch(() => null); const m = r?.ok ? r.headers.get("x-video-mtime") : null; + setFinalRel(r?.ok ? r.headers.get("x-video") : null); setFinalV(m ? Number(m) : "none"); }, [q]); @@ -959,6 +971,28 @@ export default function OnscreenSection({ // `built` and `variant` are read once per load; loadFinal already follows the variant. }, [loadChrome, loadRows, loadFinal, recompose]); + // A structural write elsewhere on the page (the timeline, the teaser and + // posts editors) changed the manifest: re-read the tokens and the rows, + // keeping every unsaved edit here, or the next save would 409. + const formDirtyRef = useRef(formDirty); + formDirtyRef.current = formDirty; + useEffect(() => { + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project !== project) return; + void (async () => { + const r = await fetch(`/api/report/chrome?project=${encodeURIComponent(project)}`, { cache: "no-store" }); + const j = (await r.json().catch(() => null)) as (ChromeDoc & { error?: string }) | null; + if (r.ok && j) { + token.current = j.token; + if (!formDirtyRef.current) setDoc(j); + } + await loadRows(true); + })(); + }; + window.addEventListener(MANIFEST_CHANGED, on); + return () => window.removeEventListener(MANIFEST_CHANGED, on); + }, [project, loadRows]); + // ---- writing ------------------------------------------------------------ const queued = useCallback(<T,>(fn: () => Promise<T>): Promise<T> => { const run = saving.current.catch(() => null).then(fn); @@ -978,6 +1012,7 @@ export default function OnscreenSection({ body: JSON.stringify({ project, chrome, token: token.current }), }); const j = (await r.json()) as Record<string, unknown>; + if (wroteNotes(j)) announceNotesChanged(project); setBusy(null); if (!r.ok) { if (j.stale) { @@ -1072,6 +1107,7 @@ export default function OnscreenSection({ body: JSON.stringify({ project, onscreen: draftMap(), token: token.current }), }); const j = (await r.json()) as Record<string, unknown>; + if (wroteNotes(j)) announceNotesChanged(project); setBusy(null); if (!r.ok) { // The drafts stay exactly as typed. A refused batch is a typo or a @@ -1114,6 +1150,7 @@ export default function OnscreenSection({ body: JSON.stringify({ project, posts: postsDraftMap(), token: token.current }), }); const j = (await r.json()) as Record<string, unknown>; + if (wroteNotes(j)) announceNotesChanged(project); setBusy(null); if (!r.ok) { if (j.stale) { @@ -1626,6 +1663,7 @@ export default function OnscreenSection({ data-onscreen={on ? "on" : "off"} className="space-y-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3" > + <GeneratedBanner generatedBy={generatedBy} /> {/* ---- the switch ---- */} <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <h2 className="micro">on-screen</h2> @@ -2050,12 +2088,12 @@ export default function OnscreenSection({ <div className="space-y-1"> <span className="micro">the built video</span> {typeof finalV === "number" ? ( - <video - data-testid="onscreen-final-video" + <TimedVideo + target={{ project }} + testId="onscreen-final-video" + file={finalRel ?? `out/${variant || "sourced"}/final.mp4`} src={`/api/report/video?${q}&kind=final&v=${finalV}`} - controls - preload="metadata" - className="aspect-video w-full rounded border border-[var(--color-line)] bg-black" + resolveProject={project} /> ) : ( <p data-testid="onscreen-no-final" className="text-[11px] text-[var(--color-dim)]"> @@ -2093,6 +2131,14 @@ export default function OnscreenSection({ <div className="mt-1.5">{postsBlock}</div> </details> )} + + {/* ---- the lists: teasers, posts, fact-check labels ---- */} + <details data-testid="structure-folded"> + <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">teasers, posts and fact-check labels</summary> + <div className="mt-1.5"> + <StructureEditors project={project} /> + </div> + </details> </section> ); } diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx @@ -24,6 +24,9 @@ import OnscreenSection from "./OnscreenSection"; import ReportBuildChain from "./ReportBuildChain"; import SnapshotButton from "./SnapshotButton"; import TagCitedButton from "./TagCitedButton"; +import { RowControls, RowNotes, TimelineList } from "./TimelineEditor"; +import GeneratedBanner from "@/components/notes/GeneratedBanner"; +import { NotesProvider } from "@/components/notes/NotesProvider"; import { decisionsForProject } from "@/lib/projects"; import { badgeVariants, type BadgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -257,7 +260,9 @@ export default async function ReportProject({ )} </div> + <NotesProvider target={{ project: project.id }}> <main className="deck-main flex-1 space-y-4 p-4"> + <GeneratedBanner generatedBy={typeof m.generatedBy === "string" ? m.generatedBy : null} /> {/* --- the author's own account of the cut, first ------------------ */} {readme && ( <section data-readme="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"> @@ -430,6 +435,7 @@ export default async function ReportProject({ because the panel is part of the video that gets delivered. */} <OnscreenSection project={project.id} + generatedBy={typeof m.generatedBy === "string" ? m.generatedBy : null} entries={entries.map((e) => ({ id: e.id, kind: e.kind, segment: !!e.segment }))} built={!!build.built} /> @@ -459,8 +465,8 @@ export default async function ReportProject({ /> </div> )} - <ul className="space-y-1"> - {entries.map((e) => { + <TimelineList project={project.id} count={entries.length}> + {entries.map((e, at) => { // Anything that is not a CLIP renders generically. The timeline's // vocabulary is open -- one real manifest carries `scroll` and // `chart` beside its cards -- and a page that only knows two words @@ -468,17 +474,23 @@ export default async function ReportProject({ if (e.kind !== "clip") { return ( <li - key={e.id} + key={`${e.id}@${at}`} data-entry={e.id} data-kind={e.kind} - className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px]" + data-at={at} + tabIndex={0} + className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px] outline-none focus:border-[var(--color-sel)]" > + <RowControls id={e.id} at={at} /> <span className="font-mono text-[var(--color-dim)]">{e.id}</span> <Pill>{e.style ? `${e.kind} · ${e.style}` : e.kind}</Pill> <span className="text-[var(--color-text)]"> {e.heading ?? e.title ?? e.label ?? ""} </span> {e.seconds != null && <span className="num micro ml-auto">{e.seconds}s</span>} + <div className="basis-full"> + <RowNotes id={e.id} project={project.id} /> + </div> </li> ); } @@ -487,15 +499,18 @@ export default async function ReportProject({ const midSentence = e.endsSentence === false && !e.lockEnd && !e.lock; return ( <li - key={e.id} + key={`${e.id}@${at}`} data-entry={e.id} data-kind="clip" + data-at={at} + tabIndex={0} data-cached={e.cached ? "1" : "0"} data-fetched={e.fetched ? "1" : "0"} data-mid-sentence={midSentence ? "1" : "0"} - className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5" + className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5 outline-none focus:border-[var(--color-sel)]" > <div className="flex flex-wrap items-baseline gap-2 text-[12px]"> + <RowControls id={e.id} at={at} /> <span className="font-mono text-[var(--color-sel)]">{e.id}</span> <span className="num font-mono text-[11px] text-[var(--color-dim)]"> {e.video} {hms(e.start)}–{hms(e.end)} ({(e.end - e.start).toFixed(1)}s) @@ -568,10 +583,11 @@ export default async function ReportProject({ set <code className="font-mono">lock</code> if this window is deliberate </p> )} + <RowNotes id={e.id} project={project.id} /> </li> ); })} - </ul> + </TimelineList> <div className="mt-2"> <Link href={`/browse/${project.id}${showAll ? "" : "?all=1"}`} @@ -771,6 +787,7 @@ export default async function ReportProject({ </section> )} </main> + </NotesProvider> </div> ); } diff --git a/umtool/components/projects/StructureEditors.tsx b/umtool/components/projects/StructureEditors.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider"; +import { buttonVariants } from "@/components/ui/button"; +import { MANIFEST_CHANGED, announceManifestChanged, refusalOf, timelineOp } from "./timelineApi"; + +// The parts of the cut that are lists, edited in place: each teaser's lines +// and timing, the posts, and the fact-check's labels and colours. Every save +// is one structural write (POST /api/report/timeline): checked by the build's +// own validators, snapshotted first, and -- on a generated manifest -- noted +// for the agent. + +type Teaser = { at: number; id: string; lines: unknown[]; beat: number | null; dip: { fade: number; black: number } | null; tail: string | null; tailWait: number | null }; +type Post = Record<string, unknown> & { id: string }; +type Verdict = { label: string; color: string }; +type Doc = { + teasers: Teaser[]; + posts: Post[]; + deckOn: boolean; + factcheck: { verdicts?: Record<string, Partial<Verdict>>; stamp?: Record<string, unknown>; tally?: Record<string, unknown> } | null; + factcheckResolved: { verdicts: Record<string, Verdict>; stamp: { seconds: number; position: string }; tally: { show: boolean; position: string } }; + verdicts: string[]; + token: string | null; +}; + +const input = + "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)]"; +const num = (v: string) => (v.trim() === "" ? null : Number(v)); + +export default function StructureEditors({ project }: { project: string }) { + const router = useRouter(); + const [doc, setDoc] = useState<Doc | null>(null); + const [error, setError] = useState<string | null>(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" }); + const j = await r.json(); + if (r.ok) setDoc(j as Doc); + else setError(String(j.error ?? r.status)); + }, [project]); + + useEffect(() => { + void load(); + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project === project) void load(); + }; + window.addEventListener(MANIFEST_CHANGED, on); + return () => window.removeEventListener(MANIFEST_CHANGED, on); + }, [load, project]); + + const run = async (op: string, args: Record<string, unknown>) => { + setBusy(true); + setError(null); + const res = await timelineOp(project, doc?.token ?? null, op, args); + setBusy(false); + if (!res.ok) { + setError(refusalOf(res)); + 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(); + return true; + }; + + if (!doc) return error ? <p className="text-[11px] text-[var(--color-bad)]">{error}</p> : null; + return ( + <div data-testid="structure-editors" className="space-y-3"> + {error && ( + <p data-testid="structure-error" className="text-[11px] text-[var(--color-bad)]"> + {error} + </p> + )} + {doc.teasers.map((t) => ( + <TeaserEditor key={`${t.id}@${t.at}`} teaser={t} busy={busy} save={(patch) => run("teaser", { id: t.id, at: t.at, patch })} /> + ))} + <PostsEditor posts={doc.posts} busy={busy} save={(post) => run("post", { post })} remove={(id) => run("post-remove", { id })} /> + {doc.deckOn && <FactcheckEditor doc={doc} busy={busy} save={(factcheck) => run("factcheck", { factcheck })} />} + </div> + ); +} + +function TeaserEditor({ teaser: t, busy, save }: { teaser: Teaser; busy: boolean; save: (patch: Record<string, unknown>) => Promise<boolean> }) { + // Plain lines edit as one per row; a teaser whose lines carry settings + // (break, role, replace…) edits as the JSON it is, so nothing is dropped. + const plain = t.lines.every((l) => typeof l === "string"); + const linesText = () => (plain ? (t.lines as string[]).join("\n") : JSON.stringify(t.lines, null, 2)); + const [lines, setLines] = useState(linesText); + const [beat, setBeat] = useState(t.beat == null ? "" : String(t.beat)); + const [tail, setTail] = useState(t.tail ?? ""); + const [tailWait, setTailWait] = useState(t.tailWait == null ? "" : String(t.tailWait)); + const [fade, setFade] = useState(t.dip ? String(t.dip.fade) : ""); + const [black, setBlack] = useState(t.dip ? String(t.dip.black) : ""); + const [local, setLocal] = useState<string | null>(null); + // Unsaved edits win over a reload: the re-read every structural write sets + // off can land after somebody has started typing again. + const dirty = useRef(false); + const edit = <T,>(set: (v: T) => void) => (v: T) => { + dirty.current = true; + set(v); + }; + useEffect(() => { + if (dirty.current) return; + setLines(linesText()); + setBeat(t.beat == null ? "" : String(t.beat)); + setTail(t.tail ?? ""); + setTailWait(t.tailWait == null ? "" : String(t.tailWait)); + setFade(t.dip ? String(t.dip.fade) : ""); + setBlack(t.dip ? String(t.dip.black) : ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t]); + + const submit = () => { + setLocal(null); + let parsed: unknown[]; + try { + parsed = plain ? lines.split("\n").map((l) => l.trim()).filter(Boolean) : JSON.parse(lines); + } catch { + setLocal("the lines are not valid JSON"); + return; + } + const dip = fade.trim() || black.trim() ? { fade: num(fade), black: num(black) } : null; + void save({ lines: parsed, beat: num(beat), tail: tail.trim() || null, tailWait: num(tailWait), dip }).then((ok) => { + if (ok) dirty.current = false; + }); + }; + + return ( + <div data-teaser-editor={t.id} className="space-y-1 rounded border border-[var(--color-line)] p-2"> + <div className="micro">teaser · {t.id}</div> + <textarea data-testid={`teaser-lines-${t.id}`} rows={Math.min(8, Math.max(2, lines.split("\n").length))} value={lines} onChange={(e) => edit(setLines)(e.target.value)} className={`${input} w-full font-mono`} /> + <div className="flex flex-wrap items-center gap-2 text-[11px] text-[var(--color-dim)]"> + <label>beat <input data-testid={`teaser-beat-${t.id}`} value={beat} onChange={(e) => edit(setBeat)(e.target.value)} className={`${input} w-14`} /></label> + <label>tail <input value={tail} onChange={(e) => edit(setTail)(e.target.value)} className={`${input} w-16`} /></label> + <label>tail wait <input value={tailWait} onChange={(e) => edit(setTailWait)(e.target.value)} className={`${input} w-14`} /></label> + <label>dip fade <input value={fade} onChange={(e) => edit(setFade)(e.target.value)} className={`${input} w-14`} /></label> + <label>black <input value={black} onChange={(e) => edit(setBlack)(e.target.value)} className={`${input} w-14`} /></label> + <button type="button" data-testid={`teaser-save-${t.id}`} disabled={busy} onClick={submit} className={buttonVariants({ variant: "primary", size: "sm" })}> + Save teaser + </button> + {local && <span className="text-[var(--color-bad)]">{local}</span>} + </div> + </div> + ); +} + +const POST_FIELDS = ["platform", "author", "handle", "date", "url", "shot", "flag"] as const; + +function PostsEditor({ + posts, + busy, + save, + remove, +}: { + posts: Post[]; + busy: boolean; + save: (post: Post) => Promise<boolean>; + remove: (id: string) => Promise<boolean>; +}) { + const [adding, setAdding] = useState(false); + return ( + <div data-testid="posts-editor" className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="micro">posts — {posts.length}</span> + <button type="button" data-testid="post-add" onClick={() => setAdding((a) => !a)} className="text-[11px] text-[var(--color-sel)] hover:underline"> + + post + </button> + </div> + {adding && <PostRow post={{ id: "", platform: "x" }} fresh busy={busy} save={async (p) => (await save(p)) && (setAdding(false), true)} />} + {posts.map((p) => ( + <PostRow key={p.id} post={p} busy={busy} save={save} remove={() => void remove(p.id)} /> + ))} + </div> + ); +} + +function PostRow({ post, fresh = false, busy, save, remove }: { post: Post; fresh?: boolean; busy: boolean; save: (p: Post) => Promise<boolean>; remove?: () => void }) { + const [d, setD] = useState<Record<string, string>>(() => { + const out: Record<string, string> = { id: post.id, text: String(post.text ?? "") }; + for (const k of POST_FIELDS) out[k] = post[k] == null ? "" : String(post[k]); + return out; + }); + const set = (k: string, v: string) => setD((x) => ({ ...x, [k]: v })); + return ( + <div data-post-row={post.id || "new"} className="space-y-1 rounded border border-[var(--color-line)] p-2 text-[11px]"> + <div className="flex flex-wrap items-center gap-1.5 text-[var(--color-dim)]"> + {fresh ? ( + <label>id <input data-testid="post-id" value={d.id} onChange={(e) => set("id", e.target.value)} className={`${input} w-28 font-mono`} /></label> + ) : ( + <span className="font-mono text-[var(--color-text)]">{post.id}</span> + )} + <select value={d.platform} onChange={(e) => set("platform", e.target.value)} className={`${input} text-[11px]`}> + {["x", "bluesky", "web"].map((p) => ( + <option key={p}>{p}</option> + ))} + </select> + {(["author", "handle", "date", "url", "shot", "flag"] as const).map((k) => ( + <label key={k}> + {k} <input data-testid={`post-${k}`} value={d[k]} onChange={(e) => set(k, e.target.value)} className={`${input} ${k === "url" ? "w-56" : "w-28"}`} /> + </label> + ))} + </div> + <textarea data-testid="post-text" rows={2} value={d.text} onChange={(e) => set("text", e.target.value)} className={`${input} w-full`} /> + <div className="flex gap-2"> + <button + type="button" + data-testid="post-save" + disabled={busy} + onClick={() => { + const p: Post = { id: d.id.trim(), text: d.text }; + for (const k of POST_FIELDS) p[k] = d[k]; + void save(p); + }} + className={buttonVariants({ variant: "primary", size: "sm" })} + > + {fresh ? "Add post" : "Save post"} + </button> + {remove && ( + <button type="button" data-testid="post-remove" disabled={busy} onClick={remove} className={buttonVariants({ variant: "destructive", size: "sm" })}> + Remove + </button> + )} + </div> + </div> + ); +} + +function FactcheckEditor({ doc, busy, save }: { doc: Doc; busy: boolean; save: (f: Record<string, unknown> | null) => Promise<boolean> }) { + const given = doc.factcheck ?? {}; + const [v, setV] = useState<Record<string, { label: string; color: string }>>(() => + Object.fromEntries(doc.verdicts.map((k) => [k, { label: String(given.verdicts?.[k]?.label ?? ""), color: String(given.verdicts?.[k]?.color ?? "") }])), + ); + const [seconds, setSeconds] = useState(given.stamp?.seconds == null ? "" : String(given.stamp.seconds)); + const submit = () => { + const verdicts: Record<string, Record<string, string>> = {}; + for (const [k, o] of Object.entries(v)) { + const one: Record<string, string> = {}; + if (o.label.trim()) one.label = o.label.trim(); + if (o.color.trim()) one.color = o.color.trim(); + if (Object.keys(one).length) verdicts[k] = one; + } + // Only what differs from the defaults is written, as the deck's settings are. + const out: Record<string, unknown> = { ...given }; + if (Object.keys(verdicts).length) out.verdicts = verdicts; + else delete out.verdicts; + const stamp = { ...(given.stamp ?? {}) } as Record<string, unknown>; + if (seconds.trim()) stamp.seconds = Number(seconds); + else delete stamp.seconds; + if (Object.keys(stamp).length) out.stamp = stamp; + else delete out.stamp; + void save(Object.keys(out).length ? out : null); + }; + return ( + <div data-testid="factcheck-editor" className="space-y-1 rounded border border-[var(--color-line)] p-2 text-[11px]"> + <div className="micro">fact-check labels</div> + <div className="grid grid-cols-[max-content_1fr_max-content] items-center gap-x-2 gap-y-1"> + {doc.verdicts.map((k) => { + const def = doc.factcheckResolved.verdicts[k]; + return ( + <div key={k} className="contents"> + <span className="font-mono text-[var(--color-dim)]">{k}</span> + <input data-testid={`fc-label-${k}`} placeholder={def?.label} value={v[k]?.label ?? ""} onChange={(e) => setV((x) => ({ ...x, [k]: { ...x[k], label: e.target.value } }))} className={input} /> + <span className="flex items-center gap-1"> + <input data-testid={`fc-color-${k}`} placeholder={def?.color} value={v[k]?.color ?? ""} onChange={(e) => setV((x) => ({ ...x, [k]: { ...x[k], color: e.target.value } }))} className={`${input} w-20 font-mono`} /> + <span className="inline-block h-3 w-3 rounded" style={{ background: v[k]?.color || def?.color }} /> + </span> + </div> + ); + })} + </div> + <div className="flex items-center gap-2 text-[var(--color-dim)]"> + <label>stamp seconds <input value={seconds} placeholder={String(doc.factcheckResolved.stamp.seconds)} onChange={(e) => setSeconds(e.target.value)} className={`${input} w-14`} /></label> + <button type="button" data-testid="fc-save" disabled={busy} onClick={submit} className={buttonVariants({ variant: "primary", size: "sm" })}> + Save labels + </button> + </div> + </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/components/projects/TimelineEditor.tsx b/umtool/components/projects/TimelineEditor.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import AnchoredNotes from "@/components/notes/AnchoredNotes"; +import { announceNotesChanged, useSharedNotes, wroteNotes } from "@/components/notes/NotesProvider"; +import { buttonVariants } from "@/components/ui/button"; +import { MANIFEST_CHANGED, announceManifestChanged, refusalOf, timelineOp } from "./timelineApi"; + +// Re-ordering the cut, on the project page. +// +// The rows stay the server's (each `<li data-entry data-at>` the page always +// drew); this list adds what moves them. Drag a row by its handle, or focus it +// and press alt+↑ / alt+↓; each row's menu duplicates it, removes it, or +// inserts a clip after it (`<channel>/<video>@<start>-<end>`). Every one is a +// structural write (POST /api/report/timeline): snapshotted first, so "Undo" +// restores the cut as it was before the last burst of edits. + +type Ctx = { + project: string; + busy: boolean; + run: (op: string, args: Record<string, unknown>) => Promise<boolean>; + dragging: React.MutableRefObject<{ id: string; at: number } | null>; +}; +const TimelineCtx = createContext<Ctx | null>(null); + +const rowOf = (el: EventTarget | null) => (el instanceof Element ? (el.closest("li[data-entry][data-at]") as HTMLLIElement | null) : null); +const rowKey = (li: HTMLLIElement) => ({ id: li.dataset.entry ?? "", at: Number(li.dataset.at) }); + +export function TimelineList({ project, count, children }: { project: string; count: number; children: React.ReactNode }) { + const router = useRouter(); + // 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); + const dragging = useRef<{ id: string; at: number } | null>(null); + + 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) tokenRef.current = typeof j.token === "string" ? j.token : null; + return tokenRef.current; + }, [project]); + useEffect(() => { + void loadToken(); + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project === project) void loadToken(); + }; + window.addEventListener(MANIFEST_CHANGED, on); + return () => window.removeEventListener(MANIFEST_CHANGED, on); + }, [loadToken, project]); + + const run = useCallback( + async (op: string, args: Record<string, unknown>) => { + setBusy(true); + setError(null); + 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; + } + tokenRef.current = typeof res.json.token === "string" ? res.json.token : null; + announceManifestChanged(project); + if (wroteNotes(res.json)) announceNotesChanged(project); + router.refresh(); + return true; + }, + [project, loadToken, router], + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; + if (e.target instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; + const li = rowOf(e.target); + if (!li || busy) return; + const { id, at } = rowKey(li); + const to = at + (e.key === "ArrowUp" ? -1 : 1); + if (to < 0 || to >= count) return; + e.preventDefault(); + void run("move", { id, at, toIndex: to }).then((ok) => { + // Keep the moved row focused after the page redraws it. + if (ok) setTimeout(() => (document.querySelector(`li[data-at="${to}"]`) as HTMLElement | null)?.focus(), 400); + }); + }; + + return ( + <TimelineCtx.Provider value={{ project, busy, run, dragging }}> + <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[11px]"> + <button type="button" data-testid="timeline-undo" disabled={busy} onClick={() => void run("undo", {})} className={buttonVariants({ size: "sm" })}> + Undo last edit + </button> + <span className="text-[var(--color-dim)]"> + drag ⠿ or <kbd>alt</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> to move + </span> + {busy && <span className="text-[var(--color-dim)]">saving…</span>} + {error && ( + <span data-testid="timeline-error" className="text-[var(--color-bad)]"> + {error} + </span> + )} + </div> + <ul + className="space-y-1" + data-testid="timeline-list" + data-drop-at={over ?? ""} + onKeyDown={onKeyDown} + onDragOver={(e) => { + if (!dragging.current) return; + const li = rowOf(e.target); + if (!li) return; + e.preventDefault(); + setOver(rowKey(li).at); + }} + onDragLeave={() => setOver(null)} + onDrop={(e) => { + const from = dragging.current; + const li = rowOf(e.target); + dragging.current = null; + setOver(null); + if (!from || !li) return; + e.preventDefault(); + const to = rowKey(li).at; + if (to !== from.at) void run("move", { id: from.id, at: from.at, toIndex: to }); + }} + > + {children} + </ul> + </TimelineCtx.Provider> + ); +} + +/** Inside one row: the drag handle and the row's menu. */ +export function RowControls({ id, at }: { id: string; at: number }) { + const ctx = useContext(TimelineCtx); + const [menu, setMenu] = useState(false); + const [insert, setInsert] = useState<string | null>(null); + if (!ctx) return null; + const { busy, run, dragging } = ctx; + return ( + <> + <span + draggable={!busy} + data-drag-handle={id} + title="drag to move" + onDragStart={(e) => { + dragging.current = { id, at }; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", id); + }} + onDragEnd={() => (dragging.current = null)} + className="cursor-grab select-none text-[13px] leading-none text-[var(--color-dim)]" + > + ⠿ + </span> + <span className="relative"> + <button type="button" data-row-menu={id} onClick={() => setMenu((m) => !m)} className="px-1 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]" aria-label={`actions for ${id}`}> + ⋯ + </button> + {menu && ( + <span className="absolute right-0 z-10 mt-1 flex w-36 flex-col rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-1 text-[11px] shadow"> + <button type="button" data-row-action="duplicate" disabled={busy} onClick={() => (setMenu(false), void run("duplicate", { id, at }))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]"> + duplicate + </button> + <button type="button" data-row-action="insert" disabled={busy} onClick={() => (setMenu(false), setInsert(""))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]"> + insert clip after + </button> + <button type="button" data-row-action="remove" disabled={busy} onClick={() => (setMenu(false), void run("remove", { id, at }))} className="px-1 py-0.5 text-left text-[var(--color-bad)] hover:bg-[var(--color-panel-2)]"> + remove + </button> + </span> + )} + </span> + {insert !== null && ( + <input + autoFocus + data-testid={`insert-after-${id}`} + value={insert} + placeholder="<channel>/<video>@<start>-<end>" + onChange={(e) => setInsert(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setInsert(null); + if (e.key === "Enter" && insert.trim()) { + void run("insert", { afterId: id, at, entry: insert.trim() }).then((ok) => ok && setInsert(null)); + } + }} + className="w-64 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" + /> + )} + </> + ); +} + +/** Under one row: its notes (and the edit notes a generated manifest left on it). */ +export function RowNotes({ id, project }: { id: string; project: string }) { + const notes = useSharedNotes({ project }); + return ( + <div className="mt-1"> + <AnchoredNotes notes={notes} pin={{ kind: "entry", entry: id }} /> + </div> + ); +} diff --git a/umtool/components/projects/timelineApi.ts b/umtool/components/projects/timelineApi.ts @@ -0,0 +1,39 @@ +"use client"; + +// The client's side of POST /api/report/timeline, and the event every part of +// a project page listens to after the manifest changed under it. +// +// The timeline editor, the structure editors and the On-screen section each +// hold a manifest TOKEN; a structural write changes the file, so after one +// every other holder must re-read before its next save would 409. + +export const MANIFEST_CHANGED = "umtool:manifest-changed"; + +export function announceManifestChanged(project: string) { + window.dispatchEvent(new CustomEvent(MANIFEST_CHANGED, { detail: { project } })); +} + +export type TimelineResult = { + ok: boolean; + status: number; + json: Record<string, unknown> & { error?: string; errors?: string[]; stale?: boolean; token?: string }; +}; + +export async function timelineOp(project: string, token: string | null, op: string, args: Record<string, unknown> = {}): Promise<TimelineResult> { + const r = await fetch("/api/report/timeline", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project, token, op, ...args }), + cache: "no-store", + }); + const json = (await r.json().catch(() => ({ error: `HTTP ${r.status}` }))) as TimelineResult["json"]; + return { ok: r.ok, status: r.status, json }; +} + +/** A refusal in words: the build's sentences when there are several. */ +export const refusalOf = (res: TimelineResult) => + res.json.stale + ? "the manifest changed since this page read it — reloaded; try again" + : res.json.errors?.length + ? res.json.errors.join("; ") + : String(res.json.error ?? `HTTP ${res.status}`); diff --git a/umtool/docs/README.md b/umtool/docs/README.md @@ -93,6 +93,8 @@ defects that have already shipped in real videos: a manifest with no `siteOrigin | [mix-from-a-project.md](mix-from-a-project.md) | Deep-linking a clip into `/mix` | | [index.md](index.md) | The LMDB index, and why the filesystem stays the model | | [cli.md](cli.md) | `umtool ls / show / check / build / window / …` | +| [notes.md](notes.md) | Operator notes on articles and videos; `umtool notes`, the agent loop | +| [sites.md](sites.md) | `/sites`: every site's articles, their media, workspaces and notes | | [authoring.md](authoring.md) | Writing a manifest from a sweep report | | [e2e.md](e2e.md) | The fixture, the stubs, the global queue | | [quirks.md](quirks.md) | Everything that cost time to find out | diff --git a/umtool/docs/notes.md b/umtool/docs/notes.md @@ -0,0 +1,112 @@ +# Notes: the operator writes them in umtool, an agent acts on them + +A note is a sentence or two the operator leaves on an **article** (a report on a +site, `/sites/<site>/<report>`) or on a **report-video project** (a timeline row, a +take, a moment in a rendered cut). It is saved where the agent that made the thing +can read it back, act on the right SOURCE file, and reply or resolve. + +```sh +umtool notes --all --open # every notes file with an open note +umtool notes candalyzer/polemic-israel # one article's open notes, as markdown +umtool notes candace/polemic-israel # one video project's (plus every take's verdict) +umtool notes reply n_mgk3x0a1b2 "Changed 'said' to 'claimed' in drafts/israel.json" --resolve +umtool notes resolve | wontfix | reopen n_mgk3x0a1b2 +``` + +Run it from the repo checkout (or with `TRANSCRIPTS_DIR`/`SITES_DIR` and +`REPORTS_DIR` set): like `CHANNELS_DIR`, the sites directory is found by walking up +from the working directory to the checkout. + +## The agent loop + +1. `umtool notes --all --open` — what is waiting. +2. `umtool notes <target>` — each open note with its anchor resolved against what + is on disk now, and the **Source** line naming the file to edit. +3. Edit the SOURCE, not the output. An article's `report.json` is regenerated from + a draft (`<workspace>/polemics/drafts/<slug>.json`) by a generator + (`make-site.py`, `polemics.py`); a generated video manifest (`generatedBy`) is + rewritten by its `make-videos.py`. An edit to the output is lost on the next run. +4. Regenerate. +5. `umtool notes reply <id> "<what changed>" --resolve` — or reply without + `--resolve` to ask a question. The operator sees agent replies badged "agent". + +**Never hand-edit `notes.json`.** The CLI and the app write through one store that +validates, takes a lock, and refuses a stale write; a hand edit races the page and +can lose a reply. If the recorded source is wrong, correct it: +`umtool notes source <target> --draft <path> [--generator <path>] [--how <why>]`. + +The same digest is served as text at `GET /api/notes/context?article=<site>/<report>` +(or `?project=<id>`); the page's "Copy agent brief" copies it. + +## Where notes live + +| Subject | File | +|---|---| +| An article | `transcripts/sites/<site>/reports/<report>/notes.json`, beside `report.json` | +| A report-video project | `<project>/notes.json`, beside `video.manifest.json` | + +The article file is the one corpus file umtool writes, and only through +`isCorpusNotesFile` (`lib/paths.mjs`): exactly `sites/<site>/reports/<id>/notes.json`, +in an existing report directory that is not a symlink out of its site. The +generators overwrite only `report.json`, `video.mp4` and `poster.jpg` in a report +directory, so notes survive a regenerate; compose and the report history never read +it (`common/publish/composeReports.test.ts` holds that), so a note is never published. + +## The file + +```jsonc +{ "format": "umtool-notes", "version": 1, + "subject": { "kind": "article", "site": "candalyzer", "report": "polemic-israel" }, + // | { "kind": "video-project", "project": "candace/polemic-israel" } + "source": { "draft": "~/reports/candace/polemics/drafts/israel.json", + "generator": "~/reports/candace/site/polemics.py", + "how": "draft matched by id polemic-israel; generator names candalyzer" }, + "notes": [ { "id": "n_…", "status": "open", // open | resolved | wontfix + "author": "operator", "text": "…", // operator | agent + "at": "…", "updatedAt": "…", + "anchor": { … }, + "replies": [ { "author": "agent", "text": "…", "at": "…" } ], + "resolvedAt": "…", "resolvedBy": "agent" } ] } +``` + +`source` is filled on the first write. For an article it comes from +`lib/articles/sources.mjs`: a draft whose `id` or file name is the report id (allowing +a `polemic-` prefix either way; the workspace whose generator names the site wins a +tie), and the generator under `<workspace>/polemics` or `<workspace>/site` that names +the site or the report. For a video project it is the manifest and, when it has +`generatedBy`, the generator. + +The last note deleted deletes the file. A `notes.json` that does not parse is never +overwritten — fix it by hand first. + +## Anchors + +| `kind` | Fields | Resolved for the agent as | +|---|---|---| +| `text` | `section` (a section id, or `title`/`subtitle`/`summary`/`method`), `quote`, `prefix`, `suffix` | the section title and the sentence holding the quote | +| `cite` | `cite` (a citation id) | the citation's quote, speaker, date, `<channel>/<id>@start-end` | +| `section` | `section` | the section title | +| `whole` | — | the whole article or project | +| `moment` | `file` (relative mp4), `t`, `take?`, `entry?`, `resolved?` | the time, and the entry, quote and source second it resolved to when written | +| `entry` | `entry` (a timeline entry id) | the entry's title, quote and source span | +| `take` | `take` (a take id) | the take's label and summary, and its verdict | +| `edit` | `entry?`, `field`, `from`, `to` | an edit made in umtool to a GENERATED manifest — port it into the generator's inputs | + +A text anchor is a quote with 32 characters of context either side (the W3C +TextQuoteSelector), never an offset, so it survives a regenerated `report.json`: +`lib/annotations/anchor.mjs` finds the quote exactly, then with whitespace, quotes +and case normalised, then — when the quote itself was rewritten — between its +surviving context. A note it cannot place is **orphaned**: still shown, pinned to its +section, with its original quote. + +## Code + +| Module | What | +|---|---| +| `lib/annotations/shape.mjs` | the contract: constants, validation, the ops (pure, client-safe) | +| `lib/annotations/anchor.mjs` | re-anchoring (pure, client-safe) | +| `lib/annotations/store.mjs` | read, lockfile (`notes.json.lock`, stale after 30 s), token, tmp+rename | +| `lib/annotations/targets.mjs` | article / project → file, subject, source; `listNotesFiles` | +| `lib/annotations/digest.mjs`, `cli.mjs` | the markdown digest and `umtool notes` | +| `app/api/notes/route.ts` | GET / POST (operator-stamped; 409 on a stale token) | +| `lib/annotations/useNotes.ts` | the page's hook | diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -711,6 +711,66 @@ note empty is removed. A `verdicts.json` that does not parse is never overwritten. The rules are `lib/report/takes.mjs`; the routes are `/api/report/takes` (list), `/takes/preview` (ranges) and `/takes/verdict`. +Each take also takes notes (a `take` anchor) and timed notes on its preview; +the verdicts and those notes reach the agent through `umtool notes <project>`, +which lists every take with its verdict and note ([notes.md](notes.md)). + +## Operator notes, timed notes and the generated-manifest guard + +Notes on a project live in `<project>/notes.json` (shape and CLI: +[notes.md](notes.md)): **row notes** on a timeline entry (`entry` anchor, an open +count per row on the project page and in the clip bench), **take notes**, **timed +notes** and **edit notes**. + +**Timed notes.** On the built cut (On-screen → the built video), on every take +preview and on an article's report video: `n` with the video focused, or Mark, +writes a note at the playhead, and the tick strip under the player seeks. On a +project the second is resolved against the build's `schedule.json` (the +deliverable `out/<slug>.mp4` against `out/sourced/`, a variant's file against its +own; a take's preview against `takes/<id>/out/<variant>/`) to the entry on screen: +its title, quote, source second and archive link, stored in the note as +`resolved`. A take's `preview.mp4` is that build's output copied, and its length +equals the schedule's `total` exactly (all 9 takes of candace/polemic-israel), so +a mark is exact when the lengths match within 0.25 s and `approx` when they +differ, when the schedule is estimated, or when the second is a held frame past +the clip's source. `lib/report/moments.mjs`, `GET /api/report/moment`. + +**Generated manifests.** A manifest with `generatedBy` shows one line on the +project page, the clip bench and the On-screen section: "Generated by +`<generatedBy>`; a rebuild of manifests overwrites edits made here." Edits are +still allowed. Every manifest writer ROUTE goes through `withEditNotes` +(`lib/report/guard.ts`), which diffs the manifest before and after and records +each change as an `edit` note `{ entry?, field, from, to }`: repeated saves of one +field coalesce into one note, and putting a field back deletes its note unless it +has replies. The agent ports each change into the generator's inputs (BEATS, +drafts), regenerates and resolves the note. `umtool window` is the agent's own +tool and is not wrapped. + +## Structural edits + +The project page's timeline re-orders by drag or alt+↑/↓; each row's ⋯ menu +duplicates it, removes it, or inserts a clip after it +(`<channel>/<video>@<start>-<end>`). The On-screen section edits a teaser's lines, +beat, tail, tail wait and dip; the posts (add, edit, remove); and each verdict's +label and colour and the stamp seconds. An article's citation can be added to the +end of a linked project's timeline ("Add to video", [sites.md](sites.md)). + +All of it is `POST /api/report/timeline` (`move`, `remove`, `duplicate`, `insert`, +`teaser`, `post`, `post-remove`, `factcheck`, `undo`; `GET` gives the token), with +the writers in `lib/report/manifest.mjs` beside the window writers: the mtime +token, tmp + rename, 2 dp. Each op is checked by the build's own validators +(`deck.mjs`, `factcheck.mjs`) and refused only for what it breaks, and snapshots +the manifest first (`revisions/<stamp>-auto-before-<op>.manifest.json`, at most +one per op every two minutes). **Undo** restores the newest such snapshot byte for +byte (the current state is kept as `undo-saved`). + +A move recomputes `sectionEnter` — a clip enters when its `section` differs from +the previous clip's, the first clip included (`lib/report/sections.mjs`) — and only +in a manifest that already carries the flag; `section` itself never changes. +report-to-video only READS the flag. Checked read-only on all 44 manifests under +~/reports: no mismatch with the stored flags, and 3,490 move-and-back round trips +byte-identical. + ## Exports `umtool export <project> --format toc-bbcode|toc-markdown|description|chapters` diff --git a/umtool/docs/sites.md b/umtool/docs/sites.md @@ -0,0 +1,92 @@ +# /sites — every site's articles + +Every report on every site under `transcripts/sites/` — published and drafts — +read in place: no private site built, no port served. umtool **reads** the sites; +the one file it writes there is a report's `notes.json` (docs/notes.md). + +| page | what | +|---|---| +| `/sites` | every site (private first), every article: status, updated, citations, open notes, poster, video project, source draft. Filters are links: `?site=`, `?status=published\|draft`, `?notes=open` | +| `/sites/<site>` | its articles, its report videos (playable), the umtool projects they were cut in (takes, like/maybe/no), the workspace files they were written from (`?ws=&rel=` opens one) | +| `/sites/<site>/<report>` | the article with its notes (below) | +| `/sites/<site>/<report>?tab=source` | the article's workspace files, its own draft opened | +| `/sites/<site>/<report>/evidence` | every citation, one per screen (`?c=<id>`) | + +## Where an article comes from + +- **Site and reports**: common's `listSites`/`getSite` (site.json, the editor's + defaults), `listReportDirs` (the enumerator the editor's Reports tab uses), each + `report.json` through the report validator. **Published** = named in site.json + `reports`, in that order; every other report directory is a **draft**. +- **Source** (`lib/articles/sources.mjs`): the draft under + `~/reports/<ws>/polemics/drafts/` whose `id` or file name is the report id, + `polemic-` allowed on either side, preferring a workspace whose generator names + the site; the generator is the `*.py`/`*.mts` under `<ws>/polemics` or + `<ws>/site` that names the site or the report. Shown with its reason; written + into `notes.json` `source` on the first note. **Edit the draft, not + report.json** — the generator overwrites report.json. +- **Video project** (`lib/articles/links.mjs`): a report-video manifest with a + top-level `"article": "<site>/<report>"` is linked to that article and nothing + else (build-video.mjs ignores the key). Otherwise a manifest `slug` equal to the + report id (± `polemic-`) in the draft's workspace, when exactly one matches; + several are listed as "possible". + +## Reading and noting + +The article renders in a ~70ch column with its notes in a rail (a drawer below +1100px). Its video sits under the title (`components/articles/ArticleVideo.tsx`) +and takes timed notes: `n` with the video focused, or **Mark**, notes the +playhead as a `moment` anchor on the article's notes ([report-video.md](report-video.md)). + +| to note | do | +|---|---| +| a passage | select it → **Note** (or `n`) | +| a section | **+ note** on its heading | +| a citation | click it → the evidence panel → **+ note on this citation** | +| the article | **+ whole article** | + +A passage note keeps its quote and 32 characters either side; it is found again +in the text as it is now (`lib/annotations/anchor.mjs`: exact, then +whitespace/quote/case-normalised, then by its surviving context) and marked. +One whose quote is gone is listed **orphaned**. Keys, when not typing: `j`/`k` +move, `r` resolves, `e` edits, `n` notes, `Esc` closes. `?status=` and `?note=` +open the rail on a filter or a note (the decisions inbox links to the note). +**Copy agent brief** copies `/api/notes/context` — the `umtool notes` digest. + +Open notes are `open-note` rows in `/browse/decisions` and on the dashboard. + +## Evidence + +A citation's panel: its quote, speaker, date, label and original link; ±6 cues +of its record's `transcript.cues.json`, the cited ones marked (click one to seek); +and media, best first: + +1. the site's **prepared** clip (`.export-index/sites/<site>/report-media/`); +2. a clip **window** the editor fetched (`data/<id>/clips/`); +3. a **saved** video or audio file in `data/<id>/` (through its media-tier link); +4. otherwise the `fetch_clip` MCP line to fetch it through the editor. umtool + never runs yt-dlp, and its own fetch client needs a project manifest. + +A post shows its text and its capture screenshot. The walk +(`/evidence`) is the same panel one citation at a time: `j`/`k` (or ←/→), +`space` plays, `n` notes. + +**Add to video** (in the panel, when the article has a linked video project) +puts the cited span at the end of that project's timeline as a clip +(`/api/report/timeline` insert): the manifest is snapshotted first, so the +project page's **Undo** takes it back, and on a generated manifest an `edit` note +tells the agent to port it into the generator's inputs. + +## Routes (read-only) + +| route | serves | +|---|---| +| `/api/sites/media?site&report&file` | a file in the report dir (video.mp4, poster.jpg, stills/…), byte ranges | +| `/api/sites/media?site&moment` | the prepared clip of a moment, via report-media/index.json | +| `/api/sites/media?corpus=<abs>` | a media file lexically under CHANNELS_DIR | +| `/api/sites/evidence?site&report&cite` | one citation's evidence (JSON) | +| `/api/sites/workspace?ws&rel` | a listed workspace file; HTML under a CSP sandbox | + +`SITES_DIR` (else `TRANSCRIPTS_DIR/sites`, else the checkout's +`transcripts/sites`) is where all of it is read; the e2e suite points it at its +fixture (`e2e/fixtures/sites-fixture.mjs`). diff --git a/umtool/e2e/article-evidence.spec.ts b/umtool/e2e/article-evidence.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from "@playwright/test"; +import { rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WARM_TIMEOUT, warm } from "./warm"; + +// --------------------------------------------------------------------------- +// A citation's evidence: the transcript around it, the window that plays it, +// or -- with nothing on disk -- the fetch_clip line; and the one-per-screen +// walk. +// +// priv/polemic-alpha c1 → sitechan/sv1, a clip window on disk +// c2 → sitechan/sv2, cues only +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json"); + + +test.beforeAll(async ({ playwright }) => { + test.setTimeout(WARM_TIMEOUT); + await warm(playwright, ["/sites/priv/polemic-alpha", "/sites/priv/polemic-alpha/evidence", "/api/sites/evidence?site=priv&report=polemic-alpha&cite=c1", "/api/notes?article=priv/polemic-alpha"]); +}); + +test.beforeEach(() => rmSync(NOTES, { force: true })); +test.afterAll(() => rmSync(NOTES, { force: true })); + +test("a citation opens its evidence: the cited cue marked, the window playing from the span", async ({ page }) => { + await page.goto("/sites/priv/polemic-alpha"); + await page.locator("button[data-cite='c1']").first().click(); + const ev = page.locator("[data-evidence='c1']"); + await expect(ev).toContainText("The vote was rigged and everybody knew."); + await expect(ev.locator("[data-play]")).toHaveAttribute("data-play", "window"); + await expect(ev.locator("[data-cited='true']")).toHaveCount(1); + await expect(ev.locator("[data-cited='true']")).toContainText("The vote was rigged"); + await expect(ev.locator("[data-cues] li")).toHaveCount(6); + const video = ev.locator("video"); + await expect(video).toHaveAttribute("src", /corpus=.*sv1.*clips/); + await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime)).toBeGreaterThanOrEqual(0); +}); + +test("with nothing on disk it says so and offers the fetch_clip line, never yt-dlp", async ({ page }) => { + await page.goto("/sites/priv/polemic-alpha"); + await page.locator("button[data-cite='c2']").first().click(); + const ev = page.locator("[data-evidence='c2']"); + await expect(ev.locator("[data-play]")).toHaveAttribute("data-play", "none"); + await expect(ev).toContainText('fetch_clip {"channel":"sitechan","video":"sv2","start":8,"end":12'); + await expect(ev).not.toContainText("yt-dlp"); +}); + +test("the walk: one citation per screen, j/k, n notes it, the URL follows", async ({ page }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + await page.goto("/sites/priv/polemic-alpha/evidence"); + await expect(page.locator("[data-walk='c1']")).toBeVisible(); + await expect(page.locator("[data-walk-position]")).toHaveText("1 / 2"); + await page.keyboard.press("j"); + await expect(page.locator("[data-walk='c2']")).toBeVisible(); + await expect(page).toHaveURL(/\?c=c2$/); + await page.keyboard.press("n"); + await page.getByLabel("note text").fill("Needs a clip."); + await page.keyboard.press("Control+Enter"); + await expect(page.locator("[data-walk='c2'] [data-note-card]")).toHaveCount(1); + await page.keyboard.press("k"); + await expect(page.locator("[data-walk='c1'] [data-note-card]")).toHaveCount(0); + + // It fits the laptop screen: no horizontal scroll. + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + await page.goto("/sites/priv/polemic-alpha/evidence?c=c2"); + await expect(page.locator("[data-walk='c2'] [data-note-card]")).toContainText("Needs a clip."); +}); diff --git a/umtool/e2e/article-integration.spec.ts b/umtool/e2e/article-integration.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Locator } from "@playwright/test"; +import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WARM_TIMEOUT, warm } from "./warm"; + +// --------------------------------------------------------------------------- +// Where the two tracks meet on the article page: +// +// * the report's own video carries timed notes, written to the ARTICLE's +// notes through the reader's one handle (a mark and a text note do not +// 409 each other); +// * "Add to video" puts a cited span at the end of the linked project's +// timeline; the project is GENERATED, so an `edit` note is left for the +// agent, and the change is undoable from the auto snapshot. +// +// priv/polemic-alpha video.mp4 (4 s); c1 → sitechan/sv1@3-6 +// sitews/polemic-alpha the linked project (generatedBy set), one clip a1 +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIX = path.join(HERE, "..", ".e2e-song"); +const NOTES = path.join(FIX, "sites", "priv", "reports", "polemic-alpha", "notes.json"); +const PROJ = path.join(FIX, "sitews", "polemic-alpha"); +const MANIFEST = path.join(PROJ, "video.manifest.json"); +const SAVED = path.join(PROJ, ".manifest.e2e-integration"); +const PROJ_NOTES = path.join(PROJ, "notes.json"); + +const readJson = (f: string) => JSON.parse(readFileSync(f, "utf8")); + +async function seek(video: Locator, t: number) { + await video.evaluate(async (el: HTMLVideoElement, at: number) => { + if (el.readyState < 1) await new Promise((r) => el.addEventListener("loadedmetadata", r, { once: true })); + el.currentTime = at; + await new Promise((r) => el.addEventListener("seeked", r, { once: true })); + }, t); +} + +function restoreProject() { + if (existsSync(SAVED)) { + copyFileSync(SAVED, MANIFEST); + rmSync(SAVED, { force: true }); + } + rmSync(PROJ_NOTES, { force: true }); + rmSync(`${MANIFEST}.bak`, { force: true }); + const rev = path.join(PROJ, "revisions"); + if (existsSync(rev)) for (const f of readdirSync(rev)) if (f.includes("auto-before")) rmSync(path.join(rev, f), { recursive: true, force: true }); +} + +test.beforeAll(async ({ playwright }) => { + test.setTimeout(WARM_TIMEOUT); + await warm(playwright, ["/sites/priv/polemic-alpha", "/api/notes?article=priv/polemic-alpha", "/api/report/timeline?project=sitews/polemic-alpha"]); +}); +test.beforeEach(() => { + rmSync(NOTES, { force: true }); + copyFileSync(MANIFEST, SAVED); +}); +test.afterEach(() => { + rmSync(NOTES, { force: true }); + restoreProject(); +}); + +test("the article's video takes timed notes, on the article's notes, beside a text note", async ({ page }) => { + await page.goto("/sites/priv/polemic-alpha"); + const video = page.getByTestId("article-video"); + await expect(video).toBeVisible(); + const scope = page.locator("[data-timed-notes='video.mp4']"); + await seek(video, 1); + await scope.locator("[data-action='mark']").click(); + const input = scope.getByTestId("timed-note-input"); + await input.fill("the cut is late here"); + await input.press("Enter"); + await expect(scope.locator("[data-mark-at]")).toHaveCount(1); + + // The rail's handle is the same one: a whole-article note right after does not 409. + await page.getByRole("button", { name: "+ whole article" }).click(); + await page.getByLabel("note text").fill("Retitle it."); + await page.getByRole("button", { name: "save note" }).click(); + await expect.poll(() => (existsSync(NOTES) ? readJson(NOTES).notes.length : 0)).toBe(2); + const doc = readJson(NOTES); + expect(doc.subject).toEqual({ kind: "article", site: "priv", report: "polemic-alpha" }); + const moment = doc.notes.find((n: { anchor: { kind: string } }) => n.anchor.kind === "moment"); + expect(moment.anchor).toMatchObject({ kind: "moment", file: "video.mp4", t: 1 }); + expect(moment.author).toBe("operator"); +}); + +test("Add to video: the cited span lands at the end of the linked project, with an edit note; undo restores it", async ({ page, request }) => { + const before = readJson(MANIFEST).timeline.length; + await page.goto("/sites/priv/polemic-alpha"); + await page.locator("button[data-cite='c1']").first().click(); + const add = page.locator("[data-evidence='c1'] [data-add-to-video]"); + await expect(add).toContainText("sitews/polemic-alpha"); + await add.getByRole("button", { name: "Add to video" }).click(); + await expect(add.getByRole("status")).toContainText("cite-c1"); + + const m = readJson(MANIFEST); + expect(m.timeline.length).toBe(before + 1); + expect(m.timeline.at(-1)).toMatchObject({ type: "clip", id: "cite-c1", channel: "sitechan", video: "sv1", start: 3, end: 6 }); + const notes = readJson(PROJ_NOTES).notes; + expect(notes.some((n: { anchor: { kind: string } }) => n.anchor.kind === "edit")).toBe(true); + + const g = await (await request.get("/api/report/timeline?project=sitews/polemic-alpha")).json(); + const undo = await request.post("/api/report/timeline", { data: { project: "sitews/polemic-alpha", token: g.token, op: "undo" } }); + expect(undo.ok()).toBe(true); + expect(readJson(MANIFEST).timeline.length).toBe(before); +}); diff --git a/umtool/e2e/article-notes.spec.ts b/umtool/e2e/article-notes.spec.ts @@ -0,0 +1,186 @@ +import { test, expect, type Page } from "@playwright/test"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WARM_TIMEOUT, warm } from "./warm"; + +// --------------------------------------------------------------------------- +// Notes on an article: written beside report.json (the one corpus file umtool +// writes), anchored to a quote and found again, resolved, deleted -- and the +// last delete removes the file. +// +// priv/polemic-alpha WRITES sites/priv/reports/polemic-alpha/notes.json +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json"); +const PAGE = "/sites/priv/polemic-alpha"; + + +test.beforeAll(async ({ playwright }) => { + test.setTimeout(WARM_TIMEOUT); + await warm(playwright, ["/sites/priv/polemic-alpha", "/api/notes?article=priv/polemic-alpha", "/sites", "/browse/decisions?kind=open-note", "/api/sites/evidence?site=priv&report=polemic-alpha&cite=c1"]); +}); + +test.beforeEach(() => rmSync(NOTES, { force: true })); +test.afterAll(() => rmSync(NOTES, { force: true })); + +async function select(page: Page, block: string, text: string) { + await page.evaluate( + ([block, text]) => { + const root = document.querySelector(`[data-block="${block}"]`)!; + const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let n = w.nextNode() as Text | null; n; n = w.nextNode() as Text | null) { + const i = n.data.indexOf(text); + if (i < 0) continue; + const r = document.createRange(); + r.setStart(n, i); + r.setEnd(n, i + text.length); + const s = getSelection()!; + s.removeAllRanges(); + s.addRange(r); + return; + } + throw new Error(`no "${text}" in ${block}`); + }, + [block, text], + ); + await page.locator(`[data-block="${block}"]`).dispatchEvent("mouseup"); +} + +test("select text, Note, save: a mark on the quote, a note beside report.json", async ({ page }) => { + await page.goto(PAGE); + await select(page, "first", "Nobody checked the claim"); + await page.getByRole("button", { name: "Note", exact: true }).click(); + await page.getByLabel("note text").fill("Source this sentence."); + await page.getByRole("button", { name: "save note" }).click(); + + await expect(page.locator("mark[data-note]")).toHaveText("Nobody checked the claim"); + const doc = JSON.parse(readFileSync(NOTES, "utf8")); + expect(doc.subject).toEqual({ kind: "article", site: "priv", report: "polemic-alpha" }); + expect(doc.source.draft).toMatch(/sitews\/polemics\/drafts\/alpha\.json$/); + expect(doc.notes[0]).toMatchObject({ status: "open", author: "operator", text: "Source this sentence." }); + expect(doc.notes[0].anchor).toMatchObject({ kind: "text", section: "first", quote: "Nobody checked the claim" }); + expect(doc.notes[0].anchor.prefix).toContain("first stream."); + + // Found again after a reload, and hovering the card lights the mark. + await page.reload(); + const card = page.locator("[data-note-card]"); + await expect(card).toHaveCount(1); + await card.hover(); + await expect(page.locator("mark[data-note]")).toHaveAttribute("data-active", "true"); +}); + +test("section, whole-article and citation notes; resolve, reopen, reply, filters", async ({ page }) => { + await page.goto(PAGE); + await page.getByRole("button", { name: "note on Later" }).click(); + await page.getByLabel("note text").fill("Section note."); + await page.keyboard.press("Control+Enter"); + await expect(page.locator("[data-note-card]")).toHaveCount(1); + + await page.getByRole("button", { name: "+ whole article" }).click(); + await page.getByLabel("note text").fill("Whole note."); + await page.getByRole("button", { name: "save note" }).click(); + await expect(page.locator("[data-note-card]")).toHaveCount(2); + + await page.locator("button[data-cite='c1']").first().click(); + await expect(page.locator("[data-evidence='c1']")).toBeVisible(); + await page.getByRole("button", { name: "+ note on this citation" }).click(); + await page.getByLabel("note text").fill("Right second?"); + await page.getByRole("button", { name: "save note" }).click(); + await expect(page.locator("[data-evidence-rail] [data-note-card]")).toHaveCount(1); + await expect(page.locator("button[data-cite='c1'][data-has-note='true']").first()).toBeVisible(); + + const notes = JSON.parse(readFileSync(NOTES, "utf8")).notes; + expect(notes.map((n: { anchor: { kind: string } }) => n.anchor.kind).sort()).toEqual(["cite", "section", "whole"]); + + // resolve the whole-article note; the open filter drops it + const whole = page.locator("[data-note-card]", { hasText: "Whole note." }); + await whole.getByRole("button", { name: "resolve" }).click(); + await expect(page.locator("[data-note-card]", { hasText: "Whole note." })).toHaveCount(0); + await page.getByRole("button", { name: /^resolved 1$/ }).click(); + const resolved = page.locator("[data-note-card]", { hasText: "Whole note." }); + await expect(resolved).toHaveAttribute("data-status", "resolved"); + await resolved.getByRole("button", { name: "reopen" }).click(); + await page.getByRole("button", { name: /^all 3$/ }).click(); + const reopened = page.locator("[data-note-card]", { hasText: "Whole note." }); + await expect(reopened).toHaveAttribute("data-status", "open"); + await reopened.getByRole("button", { name: "reply" }).click(); + await page.getByLabel("note text").fill("A reply."); + await reopened.getByRole("button", { name: "reply", exact: true }).click(); + await expect(reopened.locator("[data-reply='operator']")).toContainText("A reply."); +}); + +test("keys: j selects, r resolves, n notes the selection", async ({ page }) => { + await page.goto(PAGE); + await select(page, "later", "on a different show"); + await expect(page.getByRole("button", { name: "Note", exact: true })).toBeVisible(); + await page.keyboard.press("n"); + await page.getByLabel("note text").fill("Which show?"); + await page.keyboard.press("Control+Enter"); + await expect(page.locator("mark[data-note]")).toHaveText("on a different show"); + await page.locator("main").click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("j"); + await expect(page.locator("[data-note-card]")).toHaveAttribute("aria-current", "true"); + await page.keyboard.press("r"); + await expect(page.locator("[data-note-card]")).toHaveCount(0); + expect(JSON.parse(readFileSync(NOTES, "utf8")).notes[0].status).toBe("resolved"); +}); + +test("a quote that is gone is still listed, flagged orphaned", async ({ page, request }) => { + const res = await request.post("/api/notes?article=priv/polemic-alpha", { + data: { op: { op: "add", text: "About a sentence the agent rewrote.", anchor: { kind: "text", section: "first", quote: "a sentence that is not in the article", prefix: "", suffix: "" } } }, + }); + expect(res.status()).toBe(200); + await page.goto(PAGE); + await expect(page.locator("[data-note-card][data-orphaned='true']")).toHaveCount(1); + await expect(page.locator("mark[data-note]")).toHaveCount(0); +}); + +test("a stale token is a 409 and the page re-reads; the last delete removes notes.json", async ({ page, request }) => { + await page.goto(PAGE); + await page.getByRole("button", { name: "+ whole article" }).click(); + await page.getByLabel("note text").fill("Mine."); + await page.getByRole("button", { name: "save note" }).click(); + await expect(page.locator("[data-note-card]")).toHaveCount(1); + + // An agent writes in between (no token: the CLI's way). + const id = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].id; + const stale = await request.post("/api/notes?article=priv/polemic-alpha", { data: { token: "absent", op: { op: "status", id, status: "resolved" } } }); + expect(stale.status()).toBe(409); + await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "reply", id, text: "seen" } } }); + + await page.locator("[data-note-card]").getByRole("button", { name: "resolve" }).click(); + await expect(page.getByText(/reloaded; check and try again/)).toBeVisible(); + await expect(page.locator("[data-note-card] [data-reply]")).toContainText("seen"); + + page.on("dialog", (d) => d.accept()); + await page.locator("[data-note-card]").getByRole("button", { name: "delete" }).click(); + await expect(page.locator("[data-note-card]")).toHaveCount(0); + expect(existsSync(NOTES)).toBe(false); +}); + +test("open notes are open decisions, linked to the note; ?note= opens on it", async ({ page, request }) => { + await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "For the inbox.", anchor: { kind: "section", section: "later" } } } }); + const id = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].id; + + await page.goto("/sites?notes=open"); + await expect(page.locator("[data-article='priv/polemic-alpha'] [data-open-notes]")).toHaveAttribute("data-open-notes", "1"); + + await page.goto("/browse/decisions?kind=open-note"); + const row = page.locator("[data-project='sites/priv/polemic-alpha'] [data-decision='open-note']"); + await expect(row).toContainText("For the inbox."); + await row.getByRole("link").click(); + await expect(page).toHaveURL(new RegExp(`/sites/priv/polemic-alpha\\?note=${id}`)); + await expect(page.locator(`[data-note-card='${id}']`)).toHaveAttribute("aria-current", "true"); +}); + +test("writes outside a report are refused", async ({ request }) => { + for (const q of ["article=priv/../pub", "article=priv/nope", "article=priv", "article=../x/y"]) { + const r = await request.post(`/api/notes?${q}`, { data: { op: { op: "add", text: "x", anchor: { kind: "whole" } } } }); + expect([400, 403, 404]).toContain(r.status()); + } + const bad = await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "x", anchor: { kind: "moment", file: "../../x.mp4", t: 1 } } } }); + expect(bad.status()).toBe(400); + expect(existsSync(NOTES)).toBe(false); +}); diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -22,6 +22,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA } from "../../song/paths.mjs"; import { songCapabilities } from "./song-capabilities.mjs"; +import { makeSitesFixture } from "./sites-fixture.mjs"; const CODE = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "song"); const dest = path.resolve(process.argv[2] ?? path.join(process.cwd(), ".e2e-song")); @@ -1814,7 +1815,78 @@ take("intro-a", { group: "opening", order: 5, label: "Cold open", kind: "similar take("bad-kind", { group: "finale", order: 4, label: "Bad", kind: "maybe" }); mkdirSync(path.join(TAKES, "takes", "current", "out"), { recursive: true }); +// -- THE VIDEO-NOTES AND TIMELINE FIXTURES ------------------------------------ +// +// video-notes-fixture is a GENERATED manifest (`generatedBy`) with a built cut +// and its schedule, and a take with its own: timed notes resolve against them, +// and every edit made to it leaves an `edit` note (video-notes.spec.ts). +// timeline-fixture is hand-written, with a teaser, three clips, a post riding +// on the second and the deck on: the structural edits' subject +// (timeline-edit.spec.ts). Both are written by specs; nothing else reads them. +const twoSeconds = (file, hz) => + ff([ + "-f", "lavfi", "-i", "testsrc=size=320x180:rate=15:duration=2", + "-f", "lavfi", "-i", `sine=frequency=${hz}:duration=2`, + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + "-movflags", "+faststart", + file, + ]); +const NOTES_SCHEDULE = { + version: 1, + kind: "deck", + estimated: false, + fps: 15, + transition: 0, + total: 2, + segments: [ + { id: "k1", type: "card", start: 0, duration: 0.5, end: 0.5, title: "Opening" }, + { id: "n01", type: "clip", start: 0.5, duration: 0.7, end: 1.2, title: "The first claim" }, + { id: "n02", type: "clip", start: 1.2, duration: 0.8, end: 2, title: "The second claim" }, + ], +}; +const VNOTES = writeProject("video-notes-fixture", { + ...manifest("video-notes-fixture", "The Video Notes Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "card", id: "k1", heading: "Opening" }, + { type: "clip", id: "n01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "This is a complete sentence." }, + { type: "clip", id: "n02", video: "vid1", start: 9, end: 12, cite: 9, section: 0, lock: true, quote: "Another whole sentence entirely." }, + ]), + 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)); +{ + const dir = path.join(VNOTES, "takes", "alt"); + mkdirSync(path.join(dir, "out", "sourced"), { recursive: true }); + writeFileSync( + path.join(dir, "take.json"), + JSON.stringify({ id: "alt", group: "cut", order: 1, label: "Alternate", kind: "similar", summary: "Tighter.", preview: "preview.mp4", seconds: 2 }, null, 2), + ); + twoSeconds(path.join(dir, "preview.mp4"), 360); + writeFileSync(path.join(dir, "out", "sourced", "schedule.json"), JSON.stringify(NOTES_SCHEDULE, null, 2)); +} +writeProject("timeline-fixture", { + ...manifest("timeline-fixture", "The Timeline Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "teaser", id: "t1", lines: ["THE PROMISE"] }, + { type: "clip", id: "a01", video: "vid1", start: 0, end: 3, cite: 0, section: 1, sectionEnter: true, lock: true, quote: "one" }, + { type: "clip", id: "a02", video: "vid1", start: 9, end: 12, cite: 9, section: 1, lock: true, quote: "two" }, + { type: "clip", id: "a03", video: "vid1", start: 12, end: 15, cite: 12, section: 2, sectionEnter: true, lock: true, quote: "three" }, + ]), + posts: [{ id: "p1", platform: "x", author: "Someone", handle: "@someone", date: "2024-01-02", text: "A post.", url: "https://x.com/someone/status/1", attachTo: "a02" }], +}); + +const { sites: SITES } = makeSitesFixture({ dest, reports, channels: CHANNELS }); + console.log(`fixture at ${dest}`); +console.log(` SITES_DIR=${SITES}`); +console.log(` video-notes-fixture (generated, built, schedule + take alt), timeline-fixture (teaser, a01-a03, post p1)`); if (planned) console.log(` planned clip (used in a build): ${planned}`); console.log(` videos/: alpha (4 cuts, 3 variants), beta (2 cuts), deck (1 cut, 2 variants)`); console.log(` deck: 1 spec error, 1 stale recipe, 1 unjudged variant, 1 judged one`); diff --git a/umtool/e2e/fixtures/sites-fixture.mjs b/umtool/e2e/fixtures/sites-fixture.mjs @@ -0,0 +1,171 @@ +// The fixture's SITES_DIR (`<dest>/sites`), which the e2e server reads instead +// of the real transcripts/sites (playwright.config.ts). The suite WRITES notes +// there -- a report's notes.json is the one corpus file umtool writes -- so it +// must never be the real one. +// +// Called by make-fixture.mjs after the projects and the channels exist, so a +// report here can cite the fixture's channels and link its projects. +// +// sites/priv private, cited-only. Reports: +// polemic-alpha PUBLISHED: summary, two sections, cites sitechan/sv1 +// (a clip window on disk: plays) and sitechan/sv2 (cues, +// no media: the fetch_clip line); video.mp4 + poster.jpg +// polemic-beta a DRAFT (not in site.json's reports) +// sites/pub public. Reports: +// gamma PUBLISHED, one section, no citations +// <dest>/sitews/ the WORKSPACE (a direct child of REPORTS_ROOT, which the +// e2e server takes as <dest>): polemics/drafts/{alpha,beta}.json, +// polemics/make-site.py naming `priv`, polemics/out/alpha.{md,html}, +// NOTES.md +// <dest>/sitews/polemic-alpha/ +// the article's report-video project: a manifest whose slug +// is polemic-alpha, two takes, one verdict +// channels/sitechan/data/{sv1,sv2} the cited records +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const put = (file, value) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n"); +}; + +const ff = (args) => { + const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { encoding: "utf8" }); + if (r.status !== 0) throw new Error(`ffmpeg failed: ${r.stderr || r.status}`); +}; + +const clip = (out, seconds, hz) => { + mkdirSync(path.dirname(out), { recursive: true }); + ff([ + "-f", "lavfi", "-i", `testsrc=size=320x180:rate=15:duration=${seconds}`, + "-f", "lavfi", "-i", `sine=frequency=${hz}:duration=${seconds}`, + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-movflags", "+faststart", out, + ]); +}; + +export const SITE_CUES = { + sv1: [ + [0, 3, "Opening line of the first source."], + [3, 6, "The vote was rigged and everybody knew."], + [6, 9, "Nobody checked it at the time."], + [9, 12, "Later the story changed again."], + [12, 15, "A fifth line for context."], + [15, 18, "And a sixth to close."], + ], + sv2: [ + [0, 4, "The second source starts here."], + [4, 8, "She said it on a different show."], + [8, 12, "Then she said the opposite."], + ], +}; + +const ALPHA_BODY_1 = + "She said [the vote was rigged](cite:c1) in the first stream. Nobody checked the claim at the time, and the clip shows it.\n\nThe second paragraph repeats the claim for the record."; +const ALPHA_BODY_2 = "Two years later [she said the opposite](cite:c2), on a different show."; + +const siteJson = (siteId, title, extra) => ({ + siteId, + siteTitle: title, + groups: [{ id: "default", name: "All channels", selectedByDefault: true, order: 0, inline: true }], + defaultGroupId: "default", + channels: [{ slug: "sitechan", order: 0 }], + ...extra, +}); + +/** + * @param {{ dest: string, reports: string, channels: string }} at + */ +export function makeSitesFixture({ dest, channels }) { + const sites = path.join(dest, "sites"); + mkdirSync(sites, { recursive: true }); + + // ---- the cited records ---------------------------------------------------- + for (const [vid, rows] of Object.entries(SITE_CUES)) { + put(path.join(channels, "sitechan", "data", vid, "transcript.cues.json"), { + title: `Site source ${vid}`, + uploadDate: "20240315", + channel: "Site Channel", + webpageUrl: `https://www.youtube.com/watch?v=${vid}`, + duration: rows[rows.length - 1][1], + cues: rows.map(([start, end, text]) => ({ start, end, text })), + }); + } + // sv1 has a clip window the editor fetched: [0, 12] holds the cited [3, 6]. + clip(path.join(channels, "sitechan", "data", "sv1", "clips", "0.00-12.00.mp4"), 12, 523); + + // ---- the sites ------------------------------------------------------------ + put(path.join(sites, "priv", "site.json"), siteJson("priv", "Private Fixture", { audience: "private", search: false, reports: ["polemic-alpha"] })); + put(path.join(sites, "pub", "site.json"), siteJson("pub", "Public Fixture", { reports: ["gamma"] })); + + const alphaDir = path.join(sites, "priv", "reports", "polemic-alpha"); + put(path.join(alphaDir, "report.json"), { + format: "archilyzer-report", + version: 1, + id: "polemic-alpha", + kind: "sweep", + series: "Polemics", + title: "Alpha: the rigged vote", + subtitle: "What she said, and when", + summary: "She said one thing in 2020 and the opposite later.", + published: "2026-10-01", + updated: "2026-10-07", + video: { src: "video.mp4", poster: "poster.jpg" }, + citations: { + c1: { kind: "video", channel: "sitechan", id: "sv1", start: 3, end: 6, quote: "The vote was rigged and everybody knew.", speaker: "Site Channel", date: "2024-03-15" }, + c2: { kind: "video", channel: "sitechan", id: "sv2", start: 8, end: 12, quote: "Then she said the opposite.", speaker: "Site Channel", date: "2024-03-15" }, + }, + sections: [ + { id: "first", title: "The first claim", body: ALPHA_BODY_1 }, + { id: "later", title: "Later", body: ALPHA_BODY_2 }, + ], + }); + clip(path.join(alphaDir, "video.mp4"), 4, 440); + ff(["-f", "lavfi", "-i", "testsrc=size=320x180:rate=1:duration=1", "-frames:v", "1", path.join(alphaDir, "poster.jpg")]); + + put(path.join(sites, "priv", "reports", "polemic-beta", "report.json"), { + format: "archilyzer-report", + version: 1, + id: "polemic-beta", + kind: "sweep", + title: "Beta: a draft", + summary: "A draft nobody has published.", + sections: [{ id: "only", title: "Only section", body: "The draft's only paragraph." }], + }); + + put(path.join(sites, "pub", "reports", "gamma", "report.json"), { + format: "archilyzer-report", + version: 1, + id: "gamma", + kind: "sweep", + title: "Gamma on the public site", + published: "2026-09-01", + sections: [{ id: "g", title: "Gamma", body: "A public article with nothing cited." }], + }); + + // ---- the workspace they were written in ------------------------------------ + const ws = path.join(dest, "sitews"); + put(path.join(ws, "polemics", "drafts", "alpha.json"), { id: "polemic-alpha", title: "Alpha: the rigged vote", sections: [] }); + put(path.join(ws, "polemics", "drafts", "beta.json"), { id: "polemic-beta", title: "Beta: a draft", sections: [] }); + put(path.join(ws, "polemics", "make-site.py"), 'SITE = "priv"\nfor f in DRAFTS.glob("drafts/*.json"):\n rid = f"polemic-{f.stem}"\n'); + put(path.join(ws, "polemics", "out", "alpha.md"), "# Alpha\n\nThe rendered draft of **alpha**.\n"); + put(path.join(ws, "polemics", "out", "alpha.html"), "<!doctype html><h1>Alpha html</h1><script>document.title='ran'</script>\n"); + put(path.join(ws, "NOTES.md"), "# Workspace notes\n\n- one\n- two\n"); + + // ---- the article's video project ------------------------------------------- + const proj = path.join(ws, "polemic-alpha"); + put(path.join(proj, "video.manifest.json"), { + schemaVersion: 1, + slug: "polemic-alpha", + title: "Alpha: the rigged vote", + generatedBy: "polemics/video/make-videos.py", + provenance: { channelSlug: "sitechan", siteOrigin: "https://priv.example" }, + timeline: [{ type: "clip", id: "a1", video: "sv1", channel: "sitechan", start: 3, end: 6, quote: "The vote was rigged" }], + }); + for (const [id, order] of [["deck", 1], ["tight", 2]]) { + put(path.join(proj, "takes", id, "take.json"), { id, group: "cut", order, label: id, kind: order === 1 ? "reference" : "similar", preview: "preview.mp4" }); + } + put(path.join(proj, "takes", "verdicts.json"), { deck: { verdict: "like", note: "", at: "2026-10-07T00:00:00Z" } }); + + return { sites, workspace: ws, project: proj }; +} diff --git a/umtool/e2e/sites.spec.ts b/umtool/e2e/sites.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { WARM_TIMEOUT, warm } from "./warm"; + +// --------------------------------------------------------------------------- +// /sites: every site's articles, a site's page, its media and workspace files. +// +// priv/polemic-alpha published, a video + poster, linked to the project +// sitews/polemic-alpha (slug in the draft's workspace) +// priv/polemic-beta a draft +// pub/gamma published on the public site +// (e2e/fixtures/sites-fixture.mjs) +// --------------------------------------------------------------------------- + + +test.beforeAll(async ({ playwright }) => { + test.setTimeout(WARM_TIMEOUT); + await warm(playwright, ["/sites", "/sites/priv", "/sites/priv/polemic-alpha", "/api/sites/media?site=priv&report=polemic-alpha&file=poster.jpg", "/api/sites/workspace?ws=sitews&rel=NOTES.md"]); +}); + +test("/sites lists every site, private first, with published and draft articles", async ({ page }) => { + const res = await page.goto("/sites"); + expect(res?.status()).toBe(200); + await expect(page.getByRole("link", { name: "sites", exact: true }).first()).toHaveAttribute("aria-current", "page"); + + const sites = page.locator("[data-site]"); + await expect(sites).toHaveCount(2); + await expect(sites.nth(0)).toHaveAttribute("data-site", "priv"); + await expect(sites.nth(1)).toHaveAttribute("data-site", "pub"); + await expect(page.locator("[data-site='priv'] [data-audience]")).toHaveAttribute("data-audience", "private"); + await expect(page.locator("[data-site='priv'] [data-counts]")).toHaveAttribute("data-counts", "1/1"); + + const alpha = page.locator("[data-article='priv/polemic-alpha']"); + await expect(alpha).toHaveAttribute("data-status", "published"); + await expect(alpha).toContainText("Alpha: the rigged vote"); + await expect(alpha.locator("[data-project-link='sitews/polemic-alpha']")).toBeVisible(); + await expect(alpha).toContainText("alpha.json"); + await expect(alpha.locator("img")).toHaveCount(1); + await expect(page.locator("[data-article='priv/polemic-beta']")).toHaveAttribute("data-status", "draft"); +}); + +test("the filters are links, and compose", async ({ page }) => { + await page.goto("/sites"); + await page.getByRole("link", { name: /^draft \d+$/ }).click(); + await expect(page).toHaveURL(/status=draft/); + await expect(page.locator("[data-article]")).toHaveCount(1); + await expect(page.locator("[data-article='priv/polemic-beta']")).toBeVisible(); + + await page.goto("/sites?site=pub"); + await expect(page.locator("[data-site]")).toHaveCount(1); + await expect(page.locator("[data-article='pub/gamma']")).toBeVisible(); + + await page.goto("/sites?notes=open"); + await expect(page.locator("[data-article]")).toHaveCount(0); +}); + +test("a site's page plays its report videos, lists its project's takes and its workspace", async ({ page, request }) => { + await page.goto("/sites/priv"); + await expect(page.locator("[data-report-video='polemic-alpha'] video")).toHaveAttribute("src", /file=video\.mp4/); + const project = page.locator("[data-video-project='sitews/polemic-alpha']"); + await expect(project.locator("[data-takes]")).toHaveAttribute("data-takes", "2"); + await expect(project).toContainText("like 1"); + + // media: ranged, read-only, and nothing outside the report dir + const v = await request.get("/api/sites/media?site=priv&report=polemic-alpha&file=video.mp4", { headers: { range: "bytes=0-99" } }); + expect(v.status()).toBe(206); + expect(v.headers()["content-type"]).toBe("video/mp4"); + expect((await request.get("/api/sites/media?site=priv&report=polemic-alpha&file=../polemic-beta/report.json")).status()).toBe(404); + expect((await request.get("/api/sites/media?site=priv&report=polemic-alpha&file=report.json")).status()).toBe(400); + expect((await request.get("/api/sites/media?corpus=/etc/passwd")).status()).toBe(400); + + // workspace files: markdown renders, a draft folds, HTML is sandboxed + const files = page.locator("[data-section='files']"); + await files.locator("[data-file='NOTES.md']").click(); + await expect(page.locator("[data-opened='md']")).toContainText("Workspace notes"); + await page.locator("[data-file='polemics/drafts/alpha.json']").click(); + await expect(page.locator("[data-opened='json']")).toContainText("polemic-alpha"); + await page.locator("[data-file='polemics/out/alpha.html']").click(); + await expect(page.locator("iframe[sandbox='']")).toHaveCount(1); + const html = await request.get("/api/sites/workspace?ws=sitews&rel=polemics/out/alpha.html"); + expect(html.headers()["content-security-policy"]).toContain("sandbox"); + expect((await request.get("/api/sites/workspace?ws=sitews&rel=../sites/priv/site.json")).status()).toBe(404); + expect((await request.get("/api/sites/workspace?ws=..&rel=NOTES.md")).status()).toBe(404); +}); + +test("an unknown site or article is a 404", async ({ page }) => { + expect((await page.goto("/sites/nope"))?.status()).toBe(404); + expect((await page.goto("/sites/priv/nope"))?.status()).toBe(404); +}); diff --git a/umtool/e2e/timeline-edit.spec.ts b/umtool/e2e/timeline-edit.spec.ts @@ -0,0 +1,136 @@ +import { test, expect, type Page } from "@playwright/test"; +import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Structural edits to a report video (lib/report/manifest.mjs "STRUCTURE", +// POST /api/report/timeline): +// +// timeline-fixture hand-written: teaser t1, clips a01 a02 a03, post p1 on +// a02. WRITES its manifest and revisions/; every test +// starts from the fixture's manifest. +// +// Re-order with alt+↓ and by dragging, undo, the row menu (duplicate, remove, +// insert after), a refusal in the build's words, and the teaser, posts and +// fact-check editors. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DIR = path.join(HERE, "..", ".e2e-song", "reports", "timeline-fixture"); +const MANIFEST = path.join(DIR, "video.manifest.json"); +const PRISTINE = path.join(DIR, "video.manifest.pristine"); +const PAGE = "/browse/reports/timeline-fixture"; + +type Entry = Record<string, unknown> & { id: string }; +const manifest = () => JSON.parse(readFileSync(MANIFEST, "utf8")) as { timeline: Entry[]; posts?: Entry[]; render: Record<string, unknown> }; +const order = () => manifest().timeline.map((e) => e.id); +const rows = (page: Page) => page.locator("li[data-entry][data-kind]"); +const shownOrder = (page: Page) => rows(page).evaluateAll((els) => els.map((e) => e.getAttribute("data-entry"))); + +test.beforeAll(() => { + if (!existsSync(PRISTINE)) copyFileSync(MANIFEST, PRISTINE); +}); +test.beforeEach(() => { + copyFileSync(PRISTINE, MANIFEST); + rmSync(path.join(DIR, "revisions"), { recursive: true, force: true }); + rmSync(path.join(DIR, "notes.json"), { force: true }); +}); + +async function open(page: Page) { + await page.goto(PAGE); + await expect(page.getByTestId("timeline-undo")).toBeEnabled(); + // The list has read its token once the page is hydrated. + await page.waitForLoadState("networkidle"); +} + +test("alt+↓ moves a row, recomputes sectionEnter, and Undo puts it back byte for byte", async ({ page }) => { + const before = readFileSync(MANIFEST, "utf8"); + await open(page); + await page.locator("li[data-entry='a01'][data-kind]").focus(); + await page.keyboard.press("Alt+ArrowDown"); + await expect.poll(order).toEqual(["t1", "a02", "a01", "a03"]); + await expect.poll(() => shownOrder(page)).toEqual(["t1", "a02", "a01", "a03"]); + const m = manifest(); + expect(m.timeline[1].sectionEnter).toBe(true); + expect("sectionEnter" in m.timeline[2]).toBe(false); + expect(readdirSync(path.join(DIR, "revisions")).some((n) => n.includes("auto-before-move"))).toBe(true); + + await page.getByTestId("timeline-undo").click(); + await expect.poll(() => readFileSync(MANIFEST, "utf8")).toBe(before); + await expect.poll(() => shownOrder(page)).toEqual(["t1", "a01", "a02", "a03"]); +}); + +test("a row dragged by its handle lands where it is dropped", async ({ page }) => { + await open(page); + await page.locator("[data-drag-handle='a03']").dragTo(page.locator("li[data-entry='a01'][data-kind]")); + await expect.poll(order).toEqual(["t1", "a03", "a01", "a02"]); +}); + +test("the row menu duplicates, removes, inserts — and a refusal is in the build's words", async ({ page }) => { + await open(page); + await page.locator("[data-row-menu='a03']").click(); + await page.locator("[data-row-action='duplicate']").click(); + await expect.poll(order).toEqual(["t1", "a01", "a02", "a03", "a03-copy"]); + + await expect(page.locator("[data-row-menu='a03-copy']")).toBeVisible(); + await page.locator("[data-row-menu='a03-copy']").click(); + await page.locator("[data-row-action='remove']").click(); + await expect.poll(order).toEqual(["t1", "a01", "a02", "a03"]); + + await page.locator("[data-row-menu='a03']").click(); + await page.locator("[data-row-action='insert']").click(); + await page.getByTestId("insert-after-a03").fill("testchan/vid1@3-6"); + await page.getByTestId("insert-after-a03").press("Enter"); + await expect.poll(order).toEqual(["t1", "a01", "a02", "a03", "vid1-3"]); + expect(manifest().timeline[4]).toEqual({ type: "clip", id: "vid1-3", channel: "testchan", video: "vid1", start: 3, end: 6 }); + + // p1 rides on a02: removing it is refused, and nothing is written. + const was = readFileSync(MANIFEST, "utf8"); + await page.locator("[data-row-menu='a02']").click(); + await page.locator("[data-row-action='remove']").click(); + await expect(page.getByTestId("timeline-error")).toContainText("attachTo"); + expect(readFileSync(MANIFEST, "utf8")).toBe(was); +}); + +test("the teaser's lines and beat, edited in place; an empty teaser is refused", async ({ page }) => { + await open(page); + await page.getByTestId("structure-folded").locator("summary").click(); + await page.getByTestId("teaser-lines-t1").fill("THE PROMISE\nAND WHAT HAPPENED"); + await page.getByTestId("teaser-beat-t1").fill("1.2"); + await page.getByTestId("teaser-save-t1").click(); + await expect.poll(() => manifest().timeline[0].lines).toEqual(["THE PROMISE", "AND WHAT HAPPENED"]); + expect(manifest().timeline[0].beat).toBe(1.2); + + await page.getByTestId("teaser-lines-t1").fill(""); + await page.getByTestId("teaser-save-t1").click(); + await expect(page.getByTestId("structure-error")).toContainText("lines must be a list"); +}); + +test("a post added, then removed; the fact-check's labels once the deck is on", async ({ page }) => { + // The fact-check is drawn by the deck: turn it on in the fixture first. + const m = manifest(); + m.render.chrome = { engine: "hyperframes", layout: "deck" }; + writeFileSync(MANIFEST, JSON.stringify(m, null, 2) + "\n"); + + await open(page); + await page.getByTestId("structure-folded").locator("summary").click(); + await page.getByTestId("post-add").click(); + const fresh = page.locator("[data-post-row='new']"); + await fresh.getByTestId("post-id").fill("p2"); + await fresh.getByTestId("post-date").fill("2024-02-03"); + await fresh.getByTestId("post-url").fill("https://x.com/someone/status/2"); + await fresh.getByTestId("post-text").fill("Another post."); + await fresh.getByTestId("post-save").click(); + await expect.poll(() => (manifest().posts ?? []).map((p) => p.id)).toEqual(["p1", "p2"]); + + await page.locator("[data-post-row='p2']").getByTestId("post-remove").click(); + await expect.poll(() => (manifest().posts ?? []).map((p) => p.id)).toEqual(["p1"]); + + await page.getByTestId("fc-label-CONTRADICTED").fill("NOPE"); + await page.getByTestId("fc-color-CONTRADICTED").fill("#ff0000"); + await page.getByTestId("fc-save").click(); + await expect + .poll(() => (manifest().render.chrome as { factcheck?: unknown }).factcheck) + .toEqual({ verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } } }); +}); diff --git a/umtool/e2e/video-notes.spec.ts b/umtool/e2e/video-notes.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Notes on a report video (lib/annotations, components/notes): +// +// video-notes-fixture a GENERATED manifest (`generatedBy`) with a built cut, +// its schedule, and one take (`alt`) with its own. WRITES +// its notes.json; every test starts from the fixture's +// manifest and no notes. +// +// What is proved: a timed note at a second of the built cut and of a take's +// preview, resolved to the entry on screen; take notes and row notes; and that +// an edit made here to a generated manifest leaves an `edit` note, coalesced, +// and gone again when the edit is put back. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DIR = path.join(HERE, "..", ".e2e-song", "reports", "video-notes-fixture"); +const MANIFEST = path.join(DIR, "video.manifest.json"); +const PRISTINE = path.join(DIR, "video.manifest.pristine"); +const NOTES = path.join(DIR, "notes.json"); +const PROJECT = "reports/video-notes-fixture"; +const PAGE = `/browse/${PROJECT}`; + +type Note = { id: string; status: string; author: string; text: string; anchor: Record<string, unknown>; replies: unknown[] }; +const notesOnDisk = (): Note[] => (existsSync(NOTES) ? JSON.parse(readFileSync(NOTES, "utf8")).notes : []); + +test.beforeAll(() => { + if (!existsSync(PRISTINE)) copyFileSync(MANIFEST, PRISTINE); +}); +test.beforeEach(() => { + copyFileSync(PRISTINE, MANIFEST); + rmSync(NOTES, { force: true }); + rmSync(path.join(DIR, "revisions"), { recursive: true, force: true }); +}); + +/** Seek a <video> to `t` and wait for it to land. */ +async function seek(video: Locator, t: number) { + await video.evaluate(async (el: HTMLVideoElement, at: number) => { + if (el.readyState < 1) await new Promise((r) => el.addEventListener("loadedmetadata", r, { once: true })); + el.currentTime = at; + await new Promise((r) => el.addEventListener("seeked", r, { once: true })); + }, t); +} + +async function addTimedNote(page: Page, scope: Locator, text: string, via: "button" | "key") { + if (via === "button") await scope.locator("[data-action='mark']").click(); + else { + await scope.locator("video").focus(); + await page.keyboard.press("n"); + } + const input = scope.getByTestId("timed-note-input"); + await input.fill(text); + await input.press("Enter"); +} + +test("the generated banner is on the project page and the clip bench", async ({ page }) => { + await page.goto(PAGE); + await expect(page.getByTestId("generated-banner").first()).toContainText( + "Generated by polemics/video/make-videos.py; a rebuild of manifests overwrites edits made here.", + ); + await page.goto(`${PAGE}/clip/n01`); + await expect(page.getByTestId("generated-banner")).toContainText("polemics/video/make-videos.py"); +}); + +test("a timed note on the built cut resolves to the entry on screen and its source", async ({ page }) => { + await page.goto(PAGE); + const video = page.getByTestId("onscreen-final-video"); + await expect(video).toBeVisible(); + const scope = page.locator("[data-timed-notes='out/video-notes-fixture.mp4']"); + await seek(video, 0.8); + await addTimedNote(page, scope, "the claim card is late", "button"); + + await expect(scope.locator("[data-mark-entry='n01']")).toContainText("the claim card is late"); + await expect(scope.locator("[data-mark-entry='n01']")).toContainText("The first claim"); + await expect(scope.locator("[data-tick]")).toHaveCount(1); + + const [n] = notesOnDisk(); + expect(n.author).toBe("operator"); + expect(n.anchor).toMatchObject({ kind: "moment", file: "out/video-notes-fixture.mp4", t: 0.8, entry: "n01" }); + expect(n.anchor.resolved).toMatchObject({ title: "The first claim", channel: "testchan", video: "vid1", sourceT: 0.3 }); + expect(String((n.anchor.resolved as { url: string }).url)).toBe("https://archive.example/?v=testchan%2Fvid1&t=0"); + expect((n.anchor.resolved as { approx?: boolean }).approx).toBeUndefined(); + + // Delete it: the last note takes the file with it. + await scope.locator(`[data-note-id='${n.id}'] [data-note-action='delete']`).click(); + await expect(scope.locator("[data-mark-at]")).toHaveCount(0); + await expect.poll(() => existsSync(NOTES)).toBe(false); +}); + +test("a take: notes on the take, and a timed note on its preview with `n`", async ({ page }) => { + await page.goto(`${PAGE}/takes`); + const card = page.locator("[data-take='alt']"); + await seek(card.locator("video"), 1.5); + await addTimedNote(page, card, "second claim runs long", "key"); + await expect(card.locator("[data-mark-entry='n02']")).toContainText("second claim runs long"); + + await card.locator("[data-anchored-notes='alt'] [data-action='toggle-notes']").click(); + await card.getByTestId("note-input-alt").fill("prefer this one, but tighter"); + await card.getByTestId("note-input-alt").press("Enter"); + await expect(card.locator("[data-anchored-notes='alt']")).toHaveAttribute("data-open-notes", "1"); + + const notes = notesOnDisk(); + expect(notes.map((n) => n.anchor.kind).sort()).toEqual(["moment", "take"]); + expect(notes.find((n) => n.anchor.kind === "moment")!.anchor).toMatchObject({ file: "takes/alt/preview.mp4", take: "alt", entry: "n02" }); + expect(notes.find((n) => n.anchor.kind === "take")!.anchor).toEqual({ kind: "take", take: "alt" }); + + // Resolve the take note; it stays, shown as resolved. + const takeNote = notes.find((n) => n.anchor.kind === "take")!; + await card.locator(`[data-note-id='${takeNote.id}'] [data-note-action='resolve']`).click(); + await expect(card.locator(`[data-note-id='${takeNote.id}']`)).toHaveAttribute("data-note-status", "resolved"); + await expect(card.locator("[data-anchored-notes='alt']")).toHaveAttribute("data-open-notes", "0"); + expect(notesOnDisk().find((n) => n.id === takeNote.id)!.status).toBe("resolved"); +}); + +test("an edit to a generated manifest leaves one edit note, coalesced, gone when put back", async ({ request }) => { + const token = async () => (await (await request.get(`/api/report/clip?project=${PROJECT}&clip=n01`)).json()).token as string; + const put = async (title: string) => + request.put("/api/report/window", { data: { project: PROJECT, clip: "n01", token: await token(), title } }); + + let r = await (await put("A new title")).json(); + expect(r.editNotes).toMatchObject({ generatedBy: "polemics/video/make-videos.py", added: 1 }); + r = await (await put("A newer title")).json(); + expect(r.editNotes).toMatchObject({ added: 0, updated: 1 }); + const [n] = notesOnDisk(); + expect(notesOnDisk()).toHaveLength(1); + expect(n.anchor).toEqual({ kind: "edit", entry: "n01", field: "title", from: null, to: "A newer title" }); + expect(n.text).toContain("polemics/video/make-videos.py"); + + const doc = JSON.parse(readFileSync(NOTES, "utf8")); + expect(doc.source).toMatchObject({ manifest: expect.stringContaining("video-notes-fixture/video.manifest.json") }); + + r = await (await put("")).json(); + expect(r.editNotes).toMatchObject({ deleted: 1 }); + expect(existsSync(NOTES)).toBe(false); +}); + +test("a row note on the project page, counted on the row and kept across a reload", async ({ page }) => { + await page.goto(PAGE); + const row = page.locator("[data-anchored-notes='n02']"); + await row.locator("[data-action='toggle-notes']").click(); + await page.getByTestId("note-input-n02").fill("check the date on this one"); + await page.getByTestId("note-input-n02").press("Enter"); + await expect(row).toHaveAttribute("data-open-notes", "1"); + await page.reload(); + await expect(page.locator("[data-anchored-notes='n02']")).toHaveAttribute("data-open-notes", "1"); + expect(notesOnDisk()[0].anchor).toEqual({ kind: "entry", entry: "n02" }); +}); diff --git a/umtool/e2e/warm.ts b/umtool/e2e/warm.ts @@ -0,0 +1,17 @@ +import { test, type PlaywrightWorkerArgs } from "@playwright/test"; + +// The e2e server is `next dev`: the first request to a route COMPILES it, and +// the article page pulls in a large graph (common's report views, markdown, +// the evidence resolver). On a loaded machine that first compile alone has +// taken over 30 s -- a test's whole budget -- so each spec file that visits +// these routes compiles them first, in a beforeAll with its own timeout. +export const WARM_TIMEOUT = 180_000; + +export async function warm(playwright: PlaywrightWorkerArgs["playwright"], urls: string[]) { + const ctx = await playwright.request.newContext({ baseURL: test.info().project.use.baseURL }); + try { + for (const u of urls) await ctx.get(u, { timeout: 170_000 }); + } finally { + await ctx.dispose(); + } +} diff --git a/umtool/lib/annotations/anchor.mjs b/umtool/lib/annotations/anchor.mjs @@ -0,0 +1,176 @@ +// Finding a text anchor again in text that may have changed. +// +// A note on an article is anchored by its QUOTE plus 32 characters of context +// either side (the W3C TextQuoteSelector), never by an offset: report.json is +// regenerated from a draft, and an offset into the old text points at nothing +// after the agent edits the paragraph above it. +// +// PURE and client-safe: no node imports. The article page runs it against the +// rendered DOM text of a section; `umtool notes` runs it against the section's +// plain text; the unit test runs it against both. + +export const CONTEXT = 32; + +/** Common-suffix length of `a` and `b` (how much of the prefix still precedes). */ +function suffixMatch(a, b) { + let n = 0; + while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n += 1; + return n; +} +/** Common-prefix length of `a` and `b` (how much of the suffix still follows). */ +function prefixMatch(a, b) { + let n = 0; + while (n < a.length && n < b.length && a[n] === b[n]) n += 1; + return n; +} + +function allIndexes(hay, needle) { + const out = []; + if (!needle) return out; + for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + 1)) out.push(i); + return out; +} + +/** Of several hits, the one whose surroundings best match prefix/suffix. Ties: the first. */ +function best(hay, hits, len, prefix, suffix) { + let top = hits[0]; + let topScore = -1; + for (const i of hits) { + const score = + suffixMatch(hay.slice(Math.max(0, i - prefix.length), i), prefix) + + prefixMatch(hay.slice(i + len, i + len + suffix.length), suffix); + if (score > topScore) { + top = i; + topScore = score; + } + } + return top; +} + +/** + * Whitespace collapsed (and typographic quotes/dashes folded), with a map from + * each normalised index back to the original one. `lower` also lowercases. + */ +export function normalise(text, { lower = false } = {}) { + const chars = []; + const map = []; + let space = false; + for (let i = 0; i < text.length; i += 1) { + let c = text[i]; + if (/\s/.test(c)) { + if (space || chars.length === 0) continue; + space = true; + chars.push(" "); + map.push(i); + continue; + } + space = false; + if (c === "‘" || c === "’") c = "'"; + else if (c === "“" || c === "”") c = '"'; + else if (c === "–" || c === "—") c = "-"; + else if (c === "…") c = "."; + if (lower) c = c.toLowerCase(); + chars.push(c); + map.push(i); + } + if (chars[chars.length - 1] === " ") { + chars.pop(); + map.pop(); + } + map.push(text.length); + return { text: chars.join(""), map }; +} + +const norm = (s, lower) => normalise(s ?? "", { lower }).text; + +/** + * Locate a text anchor. `{ found: true, start, end, how }` with offsets into + * `text`, or `{ found: false }` -- an ORPHANED note, still shown, pinned to its + * section. `how`: "exact", "normalised" (whitespace/quotes/case differ), or + * "context" (the quote itself was edited, but what came before and after it is + * still there, close together). + * + * @param {string} text + * @param {{ quote: string, prefix?: string, suffix?: string }} anchor + */ +export function locateQuote(text, anchor) { + const quote = anchor?.quote ?? ""; + const prefix = anchor?.prefix ?? ""; + const suffix = anchor?.suffix ?? ""; + if (!text || !quote) return { found: false }; + + const exact = allIndexes(text, quote); + if (exact.length) { + const i = best(text, exact, quote.length, prefix, suffix); + return { found: true, start: i, end: i + quote.length, how: "exact" }; + } + + for (const lower of [false, true]) { + const n = normalise(text, { lower }); + const q = norm(quote, lower); + if (!q) continue; + const hits = allIndexes(n.text, q); + if (hits.length) { + const i = best(n.text, hits, q.length, norm(prefix, lower), norm(suffix, lower)); + return { found: true, start: n.map[i], end: n.map[i + q.length - 1] + 1, how: "normalised" }; + } + } + + // The quote was rewritten. If the context on BOTH sides survives, close + // together, the span between them is where it was. + const n = normalise(text, { lower: true }); + const p = norm(prefix, true); + const s = norm(suffix, true); + if (p.length >= 8 && s.length >= 8) { + const q = norm(quote, true); + for (const pi of allIndexes(n.text, p)) { + const from = pi + p.length; + const si = n.text.indexOf(s, from); + if (si === -1) continue; + const span = si - from; + if (span <= 0 || span > Math.max(q.length * 2, q.length + 80)) continue; + // Trim the separator spaces the normalised text keeps around the span. + let a = from; + let b = si; + while (a < b && n.text[a] === " ") a += 1; + while (b > a && n.text[b - 1] === " ") b -= 1; + if (a >= b) continue; + return { found: true, start: n.map[a], end: n.map[b - 1] + 1, how: "context" }; + } + } + return { found: false }; +} + +/** + * The anchor for a selection `[start, end)` of `text`: the quote (trimmed of + * surrounding whitespace) and up to CONTEXT characters either side. + * + * @param {string} text + * @param {number} start + * @param {number} end + * @param {number} [context] + */ +export function quoteAnchor(text, start, end, context = CONTEXT) { + let a = Math.max(0, Math.min(start, end)); + let b = Math.min(text.length, Math.max(start, end)); + while (a < b && /\s/.test(text[a])) a += 1; + while (b > a && /\s/.test(text[b - 1])) b -= 1; + return { + quote: text.slice(a, b), + prefix: text.slice(Math.max(0, a - context), a), + suffix: text.slice(b, b + context), + }; +} + +/** The sentence of `text` around `[start, end)`, for a reader with no page open. */ +export function sentenceAround(text, start, end, max = 400) { + const before = text.slice(0, start); + const after = text.slice(end); + const boundary = /[.?!]\s+|\n/g; + let from = 0; + for (let m = boundary.exec(before); m; m = boundary.exec(before)) from = m.index + m[0].length; + const m = after.search(/[.?!](\s|$)|\n/); + const to = m === -1 ? text.length : end + m + (after[m] === "\n" ? 0 : 1); + const out = text.slice(from, to).replace(/\s+/g, " ").trim(); + return out.length > max ? `${out.slice(0, max - 1)}…` : out; +} diff --git a/umtool/lib/annotations/anchor.test.mjs b/umtool/lib/annotations/anchor.test.mjs @@ -0,0 +1,65 @@ +// Re-anchoring a quote in text that changed. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; +import { locateQuote, normalise, quoteAnchor, sentenceAround } from "./anchor.mjs"; + +const P1 = "She said the vote was rigged in 2020. Nobody checked the claim at the time."; +const P2 = "Two years later she said the vote was rigged again, on a different show."; +const TEXT = `${P1}\n\n${P2}`; + +test("an exact quote is found, and the context picks between two copies", () => { + const second = TEXT.indexOf("the vote was rigged", P1.length); + const a = quoteAnchor(TEXT, second, second + "the vote was rigged".length); + assert.equal(a.quote, "the vote was rigged"); + const r = locateQuote(TEXT, a); + assert.deepEqual([r.found, r.start, r.how], [true, second, "exact"]); + const first = locateQuote(TEXT, quoteAnchor(TEXT, TEXT.indexOf("the vote"), TEXT.indexOf("the vote") + 19)); + assert.equal(first.start, TEXT.indexOf("the vote")); +}); + +test("a paragraph edited ABOVE the quote does not move the note off it", () => { + const start = TEXT.indexOf("on a different show"); + const a = quoteAnchor(TEXT, start, start + "on a different show".length); + const edited = `A new opening paragraph the agent added.\n\n${P1.replace("Nobody checked", "No outlet checked")}\n\n${P2}`; + const r = locateQuote(edited, a); + assert.equal(r.found, true); + assert.equal(edited.slice(r.start, r.end), "on a different show"); +}); + +test("whitespace, typographic quotes and case still match (normalised)", () => { + const a = { quote: "it's the\nclaim", prefix: "", suffix: "" }; + const t = "And then: It’s the claim, again."; + const r = locateQuote(t, a); + assert.equal(r.found, true); + assert.equal(r.how, "normalised"); + assert.equal(t.slice(r.start, r.end), "It’s the claim"); +}); + +test("a rewritten quote is found by its surviving context; a vanished one is orphaned", () => { + const start = TEXT.indexOf("Nobody checked the claim"); + const a = quoteAnchor(TEXT, start, start + "Nobody checked the claim".length); + const rewritten = TEXT.replace("Nobody checked the claim", "No outlet verified it"); + const r = locateQuote(rewritten, a); + assert.equal(r.found, true); + assert.equal(r.how, "context"); + assert.equal(rewritten.slice(r.start, r.end), "No outlet verified it"); + + const gone = locateQuote("A completely different article.", a); + assert.deepEqual(gone, { found: false }); + assert.deepEqual(locateQuote("", a), { found: false }); +}); + +test("normalise maps back to the original offsets", () => { + const n = normalise(" a \n\n b—c "); + assert.equal(n.text, "a b-c"); + assert.deepEqual(n.map.slice(0, 5), [2, 3, 7, 8, 9]); +}); + +test("sentenceAround gives the sentence holding the quote", () => { + const s = TEXT.indexOf("Nobody checked"); + assert.equal(sentenceAround(TEXT, s, s + 6), "Nobody checked the claim at the time."); + const f = TEXT.indexOf("Two years"); + assert.equal(sentenceAround(TEXT, f, f + 3), P2); +}); diff --git a/umtool/lib/annotations/cli.mjs b/umtool/lib/annotations/cli.mjs @@ -0,0 +1,140 @@ +// `umtool notes` -- the agent's side of the notes the operator writes in the +// app. It reads and writes through the same store (./store.mjs) and targets +// (./targets.mjs) the app does, and stamps every write `author: agent`. An +// agent never hand-edits notes.json: this validates, locks, and keeps the +// operator's page from losing a reply (docs/notes.md). +// +// umtool notes [--all] every notes file, with open counts +// umtool notes <site>/<report> | <project> the digest (open notes) +// [--open | --resolved | --all-status] [--json] +// umtool notes reply <id> "<text>" [--resolve] [--in <target>] +// umtool notes resolve | wontfix | reopen <id> [--in <target>] +// umtool notes source <target> [--draft P] [--generator P] [--how T] +import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs"; +import { digest } from "./digest.mjs"; +import { readNotes } from "./store.mjs"; +import { articleTarget, listNotesFiles, projectTarget, resolveTarget, writeNote } from "./targets.mjs"; + +const USAGE = [ + "usage: umtool notes [--all] every notes file and its open count", + " umtool notes <site>/<report> | <project> the notes, as markdown (open ones)", + " [--open | --resolved | --all-status] [--json]", + " umtool notes reply <id> \"<text>\" [--resolve] answer a note (and close it)", + " umtool notes resolve | wontfix | reopen <id>", + " umtool notes source <target> [--draft P] [--generator P] [--how T]", + "", + "Notes are the operator's, written in umtool (/sites, a video project). Act on one", + "by editing its SOURCE file and regenerating; never edit notes.json by hand.", +].join("\n"); + +class CliError extends Error {} + +/** + * @param {string[]} args everything after `notes` + * @param {{ sitesDir?: string, reportsRoot?: string, log?: (s: string) => void }} [opts] + * @returns {Promise<number>} exit code + */ +export async function notesCommand(args, { sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, log = console.log } = {}) { + const flags = new Set(args.filter((a) => a.startsWith("--"))); + const val = (n) => { + const i = args.indexOf(n); + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; + }; + const VALUED = new Set(["--in", "--draft", "--generator", "--how"]); + const pos = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.has(args[i - 1]))); + const json = flags.has("--json"); + const opts = { sitesDir, reportsRoot }; + const print = (v) => log(json ? JSON.stringify(v, null, 2) : v); + + try { + const verb = pos[0]; + if (flags.has("--help") || verb === "help") { + log(USAGE); + return 0; + } + if (verb === "reply" || verb === "resolve" || verb === "wontfix" || verb === "reopen") { + const id = pos[1]; + if (!id) throw new CliError(`which note? \`umtool notes ${verb} <id>\``); + const where = await findNote(id, val("--in"), opts); + let op; + if (verb === "reply") { + const text = pos[2]; + if (!text) throw new CliError('what reply? `umtool notes reply <id> "<text>" [--resolve]`'); + op = { op: "reply", id, text, resolve: flags.has("--resolve") }; + } else { + op = { op: "status", id, status: verb === "reopen" ? "open" : verb === "wontfix" ? "wontfix" : "resolved" }; + } + const r = await writeNote(where.target, op, { by: "agent" }); + if (json) print({ ok: true, target: where.target.id, file: where.target.file, note: r.note }); + else log(`${id} in ${where.target.id}: ${r.note?.status}${verb === "reply" ? `, ${r.note?.replies.length} repl${r.note?.replies.length === 1 ? "y" : "ies"}` : ""}`); + return 0; + } + if (verb === "source") { + const spec = pos[1]; + if (!spec) throw new CliError("which target? `umtool notes source <site>/<report> --draft P`"); + const target = await resolveTarget(spec, opts); + const cur = await readNotes(target.file); + if (!cur.doc) throw new CliError(`${target.id} has no notes; the source is recorded with the first note`); + const source = { ...(cur.doc.source ?? {}) }; + for (const k of ["draft", "generator", "how"]) if (val(`--${k}`) !== undefined) source[k] = val(`--${k}`); + const r = await writeNote(target, { op: "source", source }, { by: "agent" }); + print(json ? { ok: true, source: r.doc?.source ?? null } : `${target.id}: source ${JSON.stringify(r.doc?.source ?? {})}`); + return 0; + } + + const status = flags.has("--all-status") ? "all" : flags.has("--resolved") ? "resolved" : "open"; + if (!verb || flags.has("--all")) { + const files = await listNotesFiles(opts); + if (json) { + print(files.map((f) => ({ kind: f.kind, id: f.id, file: f.file, open: f.doc ? f.doc.notes.filter((n) => n.status === "open").length : null, total: f.doc?.notes.length ?? null, error: f.error }))); + return 0; + } + if (!files.length) { + log(`no notes under ${sitesDir} or ${reportsRoot}`); + return 0; + } + const w = Math.max(...files.map((f) => f.id.length)); + let open = 0; + for (const f of files) { + const o = f.doc ? f.doc.notes.filter((n) => n.status === "open").length : 0; + open += o; + if (status === "open" && !o && !f.error) continue; + log(`${f.id.padEnd(w)} ${f.kind === "article" ? "article" : "video "} ${f.error ? `UNREADABLE: ${f.error}` : `${o} open / ${f.doc.notes.length}`}`); + } + log(`\n${open} open note(s) in ${files.length} file(s). \`umtool notes <id>\` for one.`); + return 0; + } + + const target = await resolveTarget(verb, opts); + const read = await readNotes(target.file); + const entry = { kind: target.kind, id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) }; + if (json) { + print({ ...entry, source: read.doc?.source ?? (await target.source()) ?? null }); + return 0; + } + if (!read.doc && !read.error) { + log(`no notes on ${target.id} (${target.file})`); + return 0; + } + log(await digest(entry, { status, sitesDir, reportsRoot, projectDir: target.dir })); + return 0; + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + return err instanceof CliError || err?.name === "NoteError" || err?.name === "TargetError" ? 2 : 1; + } +} + +/** The notes file holding note `id`: the one named by --in, else a search of every file. */ +async function findNote(id, inSpec, opts) { + if (inSpec) { + const target = await resolveTarget(inSpec, opts); + const r = await readNotes(target.file); + if (!r.doc?.notes.some((n) => n.id === id)) throw new CliError(`no note ${id} in ${target.id}`); + return { target }; + } + const hits = (await listNotesFiles(opts)).filter((f) => f.doc?.notes.some((n) => n.id === id)); + if (!hits.length) throw new CliError(`no note ${id} (see \`umtool notes --all\`)`); + if (hits.length > 1) throw new CliError(`${id} is in ${hits.length} files; say which with --in: ${hits.map((h) => h.id).join(", ")}`); + const hit = hits[0]; + return { target: hit.kind === "article" ? await articleTarget(hit.id, opts) : await projectTarget(hit.id, opts) }; +} diff --git a/umtool/lib/annotations/cli.test.mjs b/umtool/lib/annotations/cli.test.mjs @@ -0,0 +1,103 @@ +// `umtool notes`: the agent's loop, end to end, against a temp SITES_DIR and +// REPORTS_DIR -- read the digest, reply and resolve, reopen, list. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { notesCommand } from "./cli.mjs"; +import { articleTarget, projectTarget, writeNote } from "./targets.mjs"; +import { clearSourcesCache } from "../articles/sources.mjs"; + +const REPORT = { + format: "archilyzer-report", + version: 1, + id: "polemic-x", + kind: "sweep", + title: "On X", + summary: "She said **X** twice. Then she [denied it](cite:c1).", + citations: { c1: { kind: "video", channel: "ch", id: "v1", start: 10, end: 20, quote: "I never said X", speaker: "Her" } }, + sections: [{ id: "s1", title: "The first time", body: "In 2019 she said X on a podcast. Nobody noticed." }], +}; + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "umtool-notes-cli-")); + const sites = path.join(root, "sites"); + const reports = path.join(root, "reports"); + await mkdir(path.join(sites, "priv", "reports", "polemic-x"), { recursive: true }); + await writeFile(path.join(sites, "priv", "site.json"), "{}"); + await writeFile(path.join(sites, "priv", "reports", "polemic-x", "report.json"), JSON.stringify(REPORT)); + await mkdir(path.join(reports, "ws", "polemics", "drafts"), { recursive: true }); + await writeFile(path.join(reports, "ws", "polemics", "drafts", "x.json"), JSON.stringify({ id: "polemic-x" })); + await writeFile(path.join(reports, "ws", "polemics", "make-site.py"), 'SITE = "priv" # drafts\n'); + const proj = path.join(reports, "ws", "polemic-x"); + await mkdir(proj, { recursive: true }); + await writeFile( + path.join(proj, "video.manifest.json"), + JSON.stringify({ schemaVersion: 1, slug: "polemic-x", generatedBy: "polemics/make-site.py", timeline: [{ type: "clip", id: "e1", channel: "ch", video: "v1", start: 10, end: 20, quote: "I never said X", onscreen: { title: "Denial" } }] }), + ); + clearSourcesCache(); + return { root, sites, reports, opts: { sitesDir: sites, reportsRoot: reports } }; +} + +async function run(args, opts) { + const out = []; + const code = await notesCommand(args, { ...opts, log: (s) => out.push(String(s)) }); + return { code, text: out.join("\n") }; +} + +test("the agent loop: digest, reply --resolve, reopen; every write is the agent's", async () => { + const { root, sites, opts } = await fixture(); + const t = await articleTarget("priv/polemic-x", opts); + const a = await writeNote(t, { op: "add", text: "Too strong; say 'claimed'.", anchor: { kind: "text", section: "s1", quote: "she said X", prefix: "In 2019 ", suffix: " on a podcast" } }, { by: "operator" }); + await writeNote(t, { op: "add", text: "Is this the right clip?", anchor: { kind: "cite", cite: "c1" } }, { by: "operator" }); + + const d = await run(["priv/polemic-x"], opts); + assert.equal(d.code, 0); + assert.match(d.text, /# Notes on priv\/polemic-x — On X/); + assert.match(d.text, /edit `.*ws\/polemics\/drafts\/x\.json`; `report\.json` is regenerated by `.*make-site\.py`/); + assert.match(d.text, /“she said X” in “The first time”/); + assert.match(d.text, /> In 2019 she said X on a podcast\./); + assert.match(d.text, /citation `c1` — ch\/v1@10-20 \(Her\)/); + assert.match(d.text, /2 open, 0 closed/); + + const r = await run(["reply", a.note.id, "Changed to 'claimed' in drafts/x.json", "--resolve"], opts); + assert.equal(r.code, 0, r.text); + const doc = JSON.parse(await readFile(path.join(sites, "priv", "reports", "polemic-x", "notes.json"), "utf8")); + const n = doc.notes.find((x) => x.id === a.note.id); + assert.equal(n.status, "resolved"); + assert.equal(n.resolvedBy, "agent"); + assert.deepEqual(n.replies.map((x) => x.author), ["agent"]); + + assert.match((await run(["priv/polemic-x"], opts)).text, /1 open, 1 closed/); + assert.match((await run(["priv/polemic-x", "--resolved"], opts)).text, /Changed to 'claimed'/); + assert.equal((await run(["reopen", a.note.id], opts)).code, 0); + const list = await run(["--all"], opts); + assert.match(list.text, /priv\/polemic-x\s+article\s+2 open \/ 2/); + + // refusals exit 2 and write nothing + assert.equal((await run(["reply", "n_nosuchnote"], opts)).code, 2); + assert.equal((await run(["reply", a.note.id], opts)).code, 2); + assert.equal((await run(["priv/../etc"], opts)).code, 2); + await rm(root, { recursive: true }); +}); + +test("a video project's digest names the generator and lists the takes' verdicts", async () => { + const { root, reports, opts } = await fixture(); + const proj = path.join(reports, "ws", "polemic-x"); + await mkdir(path.join(proj, "takes", "deck"), { recursive: true }); + await writeFile(path.join(proj, "takes", "deck", "take.json"), JSON.stringify({ id: "deck", group: "open", order: 1, label: "Deck first", kind: "similar", preview: "preview.mp4" })); + await writeFile(path.join(proj, "takes", "verdicts.json"), JSON.stringify({ deck: { verdict: "like", note: "keep the beat", at: "x" } })); + const t = await projectTarget("ws/polemic-x", opts); + await writeNote(t, { op: "add", text: "Cut this.", anchor: { kind: "entry", entry: "e1" } }, { by: "operator" }); + await writeNote(t, { op: "add", text: "quote changed", anchor: { kind: "edit", entry: "e1", field: "quote", from: "a", to: "b" } }, { by: "operator" }); + const d = await run(["ws/polemic-x"], opts); + assert.equal(d.code, 0, d.text); + assert.match(d.text, /video\.manifest\.json` is regenerated by `.*ws\/polemics\/make-site\.py`/); + assert.match(d.text, /timeline entry `e1` \(clip\) “Denial”/); + assert.match(d.text, /`e1\.quote`: "a" → "b"/); + assert.match(d.text, /- `deck` “Deck first” \(open, similar\): like — keep the beat/); + await rm(root, { recursive: true }); +}); diff --git a/umtool/lib/annotations/digest.mjs b/umtool/lib/annotations/digest.mjs @@ -0,0 +1,207 @@ +// Notes as an AGENT reads them: markdown, every anchor resolved to something a +// reader with no page open can act on, and the file to edit named first. +// +// `umtool notes <target>` prints this; GET /api/notes/context serves the same +// text, so "Copy agent brief" on a page and an agent's CLI hand over the same +// words. An anchor is resolved against what is on disk NOW: +// +// text the section's title and the sentence holding the quote, found +// again with the same re-anchoring the page uses (ORPHANED when the +// quote is gone -- the note still prints, with its quote) +// cite the citation's quote, speaker, date and `<channel>/<id>@start-end` +// moment the time, and the entry/source it resolved to when it was written +// entry the timeline entry's title and quote +// take the take's label and summary, and the operator's verdict on it +// edit what changed, from → to, to port into the generator's inputs +// +// A video project's digest also lists every take with its verdict and note +// (takes/verdicts.json): the agent that rendered the takes reads them here. +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs"; +import { listTakes, readVerdicts } from "../report/takes.mjs"; +import { locateQuote, sentenceAround } from "./anchor.mjs"; + +const readJson = (file) => readFile(/* turbopackIgnore: true */ file, "utf8").then(JSON.parse, () => null); + +/** Markdown to the plain text a reader sees: links to their labels, emphasis and code marks dropped. */ +export function plainText(md) { + return String(md ?? "") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*>\s?/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/(\*\*|__|\*|_|`)/g, ""); +} + +/** + * The plain text of one block of a report, as the page renders it: a section's + * title, body and claims; or the title/subtitle/summary/method. + */ +export function blockText(report, block) { + if (!report) return { title: block, text: "" }; + if (["title", "subtitle", "summary", "method"].includes(block)) { + return { title: block, text: plainText(report[block] ?? "") }; + } + const s = (report.sections ?? []).find((x) => x.id === block); + if (!s) return null; + const parts = [s.title, plainText(s.body ?? "")]; + for (const c of s.claims ?? []) parts.push(c.title ?? "", plainText(c.text), plainText(c.findings ?? "")); + return { title: s.title, text: parts.filter(Boolean).join("\n") }; +} + +const clip = (s, n = 300) => { + const t = String(s ?? "").replace(/\s+/g, " ").trim(); + return t.length > n ? `${t.slice(0, n - 1)}…` : t; +}; +const quoteLine = (s) => `> ${clip(s, 400)}`; +const hms = (t) => { + const s = Math.max(0, Math.round(Number(t) || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = String(s % 60).padStart(2, "0"); + return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`; +}; + +/** One anchor, resolved, as markdown lines. */ +function anchorLines(a, ctx) { + const { report, manifest, takes, verdicts } = ctx; + switch (a.kind) { + case "whole": + return [ctx.kind === "article" ? "**On:** the whole article" : "**On:** the whole project"]; + case "section": { + const b = blockText(report, a.section); + return [`**On:** section “${b?.title ?? a.section}”${b ? "" : " (section no longer exists)"}`]; + } + case "text": { + const b = blockText(report, a.section); + if (!b) return [`**On:** text in section \`${a.section}\` — ORPHANED (section no longer exists)`, quoteLine(a.quote)]; + const hit = locateQuote(b.text, a); + if (!hit.found) return [`**On:** text in “${b.title}” — ORPHANED (quote no longer in the section)`, quoteLine(a.quote)]; + const exact = b.text.slice(hit.start, hit.end); + return [ + `**On:** “${clip(exact, 200)}” in “${b.title}”${hit.how === "exact" ? "" : ` (found ${hit.how})`}`, + quoteLine(sentenceAround(b.text, hit.start, hit.end)), + ]; + } + case "cite": { + const c = report?.citations?.[a.cite]; + if (!c) return [`**On:** citation \`${a.cite}\` (no longer in the report)`]; + const who = [c.speaker, c.date].filter(Boolean).join(", "); + const where = + c.kind === "video" || c.kind === "audio" + ? `${c.channel}/${c.id}@${c.start}-${c.end}` + : c.kind === "post" + ? `post ${c.channel}/${c.id}` + : c.kind === "page" + ? c.url + : `source ${c.source}`; + return [`**On:** citation \`${a.cite}\`${c.label ? ` “${c.label}”` : ""} — ${where}${who ? ` (${who})` : ""}`, quoteLine(c.quote)]; + } + case "moment": { + const r = a.resolved ?? {}; + const take = a.take ? ` of take \`${a.take}\`` : ""; + const lines = [`**On:** ${hms(a.t)} in \`${a.file}\`${take}${r.approx ? " (approximate)" : ""}`]; + const entry = a.entry ?? r.entry; + if (entry || r.title) lines.push(`entry \`${entry ?? "?"}\`${r.title ? ` “${clip(r.title, 160)}”` : ""}`); + if (r.quote) lines.push(quoteLine(r.quote)); + if (r.channel && r.video) lines.push(`source ${r.channel}/${r.video}${r.sourceT !== undefined ? ` @ ${hms(r.sourceT)}` : ""}${r.url ? ` — ${r.url}` : ""}`); + return lines; + } + case "entry": { + const e = (manifest?.timeline ?? []).find((x) => x.id === a.entry); + if (!e) return [`**On:** timeline entry \`${a.entry}\` (no longer in the manifest)`]; + const title = e.title ?? e.onscreen?.title; + const lines = [`**On:** timeline entry \`${a.entry}\` (${e.type ?? "entry"})${title ? ` “${clip(title, 160)}”` : ""}`]; + if (e.quote) lines.push(quoteLine(e.quote)); + if (e.type === "clip" && e.channel && e.video) lines.push(`source ${e.channel}/${e.video}@${e.start}-${e.end}`); + return lines; + } + case "take": { + const t = takes?.find((x) => x.id === a.take); + const v = verdicts?.[a.take]; + const lines = [`**On:** take \`${a.take}\`${t ? ` “${t.label}” (${t.group})` : " (no longer in takes/)"}${v?.verdict ? ` — verdict: ${v.verdict}` : ""}`]; + if (t?.summary) lines.push(`summary: ${clip(t.summary, 300)}`); + return lines; + } + case "edit": + return [ + `**On:** an edit made in umtool to a GENERATED manifest — port it into the generator's inputs`, + `\`${a.entry ? `${a.entry}.` : ""}${a.field}\`: ${clip(JSON.stringify(a.from), 300)} → ${clip(JSON.stringify(a.to), 300)}`, + ]; + default: + return [`**On:** ${JSON.stringify(a)}`]; + } +} + +/** "Edit X; Y is regenerated by Z" -- the line an agent most needs. */ +export function sourceLine(kind, source) { + if (!source) return kind === "article" ? "**Source:** unknown — find the draft before editing report.json, which a generator may overwrite." : "**Source:** the manifest."; + if (kind === "article") { + if (source.draft && source.generator) return `**Source:** edit \`${source.draft}\`; \`report.json\` is regenerated by \`${source.generator}\`.`; + if (source.draft) return `**Source:** edit \`${source.draft}\`.`; + if (source.generator) return `**Source:** \`report.json\` is written by \`${source.generator}\`; edit its inputs.`; + } else { + if (source.generator) return `**Source:** \`${source.manifest}\` is regenerated by \`${source.generator}\`; edit its inputs (BEATS, drafts), not the manifest.`; + if (source.manifest) return `**Source:** edit \`${source.manifest}\`.`; + } + return `**Source:** ${source.how ?? "unknown"}`; +} + +const STATUS = { open: (n) => n.status === "open", resolved: (n) => n.status !== "open", all: () => true }; + +/** + * The digest of one notes file. + * + * @param {{ kind: "article" | "video-project", id: string, file: string, doc: any, error?: string }} entry + * @param {{ status?: "open" | "resolved" | "all", sitesDir?: string, reportsRoot?: string, projectDir?: string }} [opts] + */ +export async function digest(entry, { status = "open", sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, projectDir } = {}) { + const lines = []; + const doc = entry.doc; + const ctx = { kind: entry.kind, report: null, manifest: null, takes: null, verdicts: null }; + let heading; + if (entry.kind === "article") { + const [site, report] = entry.id.split("/"); + ctx.report = await readJson(path.join(/* turbopackIgnore: true */ sitesDir, site, "reports", report, "report.json")); + heading = `# Notes on ${entry.id}${ctx.report?.title ? ` — ${ctx.report.title}` : ""}`; + } else { + const dir = projectDir ?? path.dirname(/* turbopackIgnore: true */ entry.file); + ctx.manifest = await readJson(path.join(/* turbopackIgnore: true */ dir, "video.manifest.json")); + const t = await listTakes(dir); + ctx.takes = t.takes; + ctx.verdicts = await readVerdicts(dir); + heading = `# Notes on ${entry.id}${ctx.manifest?.title ? ` — ${ctx.manifest.title}` : ""}`; + } + lines.push(heading, ""); + lines.push(`file: \`${entry.file}\``); + if (entry.error) { + lines.push("", `**notes.json does not parse:** ${entry.error}. Nothing here may write it until it is fixed by hand.`); + return lines.join("\n"); + } + lines.push(sourceLine(entry.kind, doc?.source)); + if (doc?.source?.how) lines.push(`(${doc.source.how})`); + const all = doc?.notes ?? []; + const shown = all.filter(STATUS[status] ?? STATUS.open); + const open = all.filter((n) => n.status === "open").length; + lines.push("", `${open} open, ${all.length - open} closed${status === "all" ? "" : `; showing ${status}`}.`); + for (const n of shown) { + lines.push("", `## ${n.id} — ${n.status}${n.status !== "open" && n.resolvedBy ? ` by ${n.resolvedBy}` : ""} (${n.author}, ${n.at.slice(0, 16).replace("T", " ")})`); + lines.push(...anchorLines(n.anchor, ctx)); + lines.push("", n.text); + for (const r of n.replies) lines.push("", `- **${r.author}** (${r.at.slice(0, 16).replace("T", " ")}): ${r.text.replace(/\n/g, "\n ")}`); + } + if (entry.kind === "video-project" && ctx.takes?.length) { + lines.push("", "## Takes (takes/verdicts.json)"); + for (const t of ctx.takes) { + const v = ctx.verdicts?.[t.id]; + lines.push(`- \`${t.id}\` “${t.label}” (${t.group}, ${t.kind}): ${v?.verdict ?? "no verdict"}${v?.note ? ` — ${clip(v.note, 400)}` : ""}`); + } + } + if (shown.length) { + lines.push("", "Act on a note by editing the SOURCE above and regenerating, then:"); + lines.push(`\`umtool notes reply <id> "what you changed" --resolve\` (or \`umtool notes reply <id> "question"\` to ask).`); + } + return lines.join("\n"); +} diff --git a/umtool/lib/annotations/server.ts b/umtool/lib/annotations/server.ts @@ -0,0 +1,27 @@ +import { projectRef } from "@/lib/projects"; +import { articleTarget, projectTargetFor, TargetError } from "./targets.mjs"; + +// The app's side of lib/annotations/targets.mjs: a request's `article` or +// `project` parameter to a target, using the app's memoised project walk. +// Everything about WHERE a note may be written is decided in targets.mjs. + +export type Target = Awaited<ReturnType<typeof articleTarget>> | Awaited<ReturnType<typeof projectTargetFor>>; + +export async function targetFrom(params: { article?: string | null; project?: string | null }): Promise<Target> { + if (params.article && params.project) throw new TargetError("pass article or project, not both"); + if (params.article) return articleTarget(params.article); + if (params.project) { + const p = await projectRef(params.project); + if (!p) throw new TargetError(`no project ${params.project}`, 404); + return projectTargetFor(p); + } + throw new TargetError("pass ?article=<site>/<report> or ?project=<id>"); +} + +/** A thrown error as a response: typed refusals keep their status, the rest are 500s. */ +export function errorResponse(err: unknown): Response { + const status = typeof (err as { status?: unknown })?.status === "number" ? (err as { status: number }).status : null; + const name = (err as Error)?.name; + const code = status ?? (name === "NoteError" ? 400 : 500); + return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: code }); +} diff --git a/umtool/lib/annotations/shape.mjs b/umtool/lib/annotations/shape.mjs @@ -0,0 +1,302 @@ +// notes.json: its constants, its validation and the edits made to it -- PURE +// (no node imports), so the store (./store.mjs, node), the CLI and a client +// component all hold the same rules. ./types.ts gives them types. +// +// { "format": "umtool-notes", "version": 1, +// "subject": { kind: "article", site, report } | { kind: "video-project", project }, +// "source": { draft?, generator?, manifest?, how? }, which file to EDIT +// "notes": [ { id, status, author, text, at, updatedAt, anchor, replies, +// resolvedAt?, resolvedBy? } ] } +// +// docs/notes.md is the prose. + +export const NOTES_FORMAT = "umtool-notes"; +export const NOTES_VERSION = 1; +export const NOTE_TEXT_LIMIT = 8000; +export const NOTE_STATUSES = ["open", "resolved", "wontfix"]; +export const NOTE_AUTHORS = ["operator", "agent"]; +export const ANCHOR_KINDS = ["text", "cite", "section", "whole", "moment", "entry", "take", "edit"]; +export const REPORT_BLOCKS = ["title", "subtitle", "summary", "method"]; + +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const TAKE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; +const NOTE_ID = /^n_[a-z0-9]{4,32}$/; +const isStr = (v) => typeof v === "string"; +const short = (v, max) => isStr(v) && v.length <= max; + +export const isNoteId = (v) => isStr(v) && NOTE_ID.test(v); + +/** A fresh note id: time then randomness, base36. */ +export function newNoteId(now = Date.now()) { + return `n_${now.toString(36)}${Math.random().toString(36).slice(2, 6).padEnd(4, "0")}`; +} + +/** A moment's rel path: relative, no `..`, no empty segment, no backslash. */ +function relFile(v) { + return short(v, 512) && v.length > 0 && !v.startsWith("/") && !/[\\\0]/.test(v) && !v.split("/").some((s) => s === ".." || s === "" || s === "."); +} + +/** A JSON value small enough to keep: an edit's from/to. */ +function smallJson(v) { + if (v === undefined) return true; + try { + return JSON.stringify(v).length <= 8000; + } catch { + return false; + } +} + +const RESOLVED_STR = ["entry", "title", "quote", "channel", "video", "url"]; + +/** + * An anchor as stored, or `{ error }`. Extra keys are dropped; a text anchor's + * prefix/suffix default to "". + * + * @param {unknown} raw + * @returns {{ anchor: Record<string, unknown> } | { error: string }} + */ +export function validateAnchor(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "anchor is not an object" }; + const a = /** @type {Record<string, unknown>} */ (raw); + switch (a.kind) { + case "whole": + return { anchor: { kind: "whole" } }; + case "section": + if (!short(a.section, 128) || !ID.test(a.section)) return { error: "section anchor needs a section id" }; + return { anchor: { kind: "section", section: a.section } }; + case "text": { + if (!short(a.section, 128) || !ID.test(a.section)) return { error: "text anchor needs a section id" }; + if (!short(a.quote, 4000) || !a.quote.trim()) return { error: "text anchor needs a quote" }; + if (a.prefix !== undefined && !short(a.prefix, 256)) return { error: "prefix is not a short string" }; + if (a.suffix !== undefined && !short(a.suffix, 256)) return { error: "suffix is not a short string" }; + return { anchor: { kind: "text", section: a.section, quote: a.quote, prefix: a.prefix ?? "", suffix: a.suffix ?? "" } }; + } + case "cite": + if (!short(a.cite, 128) || !ID.test(a.cite)) return { error: "cite anchor needs a citation id" }; + return { anchor: { kind: "cite", cite: a.cite } }; + case "entry": + if (!short(a.entry, 128) || !ID.test(a.entry)) return { error: "entry anchor needs an entry id" }; + return { anchor: { kind: "entry", entry: a.entry } }; + case "take": + if (!isStr(a.take) || !TAKE_ID.test(a.take)) return { error: "take anchor needs a take id" }; + return { anchor: { kind: "take", take: a.take } }; + case "moment": { + if (!relFile(a.file)) return { error: "moment anchor needs a relative file" }; + const t = Number(a.t); + if (!Number.isFinite(t) || t < 0) return { error: "moment anchor needs t ≥ 0" }; + const out = { kind: "moment", file: a.file, t: Number(t.toFixed(2)) }; + if (a.take !== undefined) { + if (!isStr(a.take) || !TAKE_ID.test(a.take)) return { error: "moment take is not a take id" }; + out.take = a.take; + } + if (a.entry !== undefined) { + if (!short(a.entry, 128) || !ID.test(a.entry)) return { error: "moment entry is not an entry id" }; + out.entry = a.entry; + } + if (a.resolved !== undefined) { + if (!a.resolved || typeof a.resolved !== "object" || Array.isArray(a.resolved)) return { error: "resolved is not an object" }; + const r = /** @type {Record<string, unknown>} */ (a.resolved); + const res = {}; + for (const k of RESOLVED_STR) if (short(r[k], 2000)) res[k] = r[k]; + if (typeof r.sourceT === "number" && Number.isFinite(r.sourceT)) res.sourceT = Number(r.sourceT.toFixed(2)); + if (r.approx === true) res.approx = true; + out.resolved = res; + } + return { anchor: out }; + } + case "edit": { + if (!short(a.field, 128) || !a.field) return { error: "edit anchor needs a field" }; + if (a.entry !== undefined && (!short(a.entry, 128) || !ID.test(a.entry))) return { error: "edit entry is not an entry id" }; + if (!smallJson(a.from) || !smallJson(a.to)) return { error: "edit from/to too large" }; + const out = { kind: "edit", field: a.field, from: a.from ?? null, to: a.to ?? null }; + if (a.entry !== undefined) out.entry = a.entry; + return { anchor: out }; + } + default: + return { error: `anchor kind must be one of ${ANCHOR_KINDS.join(", ")}` }; + } +} + +/** @returns {{ subject: Record<string, string> } | { error: string }} */ +export function validateSubject(raw) { + if (!raw || typeof raw !== "object") return { error: "subject is not an object" }; + const s = /** @type {Record<string, unknown>} */ (raw); + if (s.kind === "article" && isStr(s.site) && TAKE_ID.test(s.site) && isStr(s.report) && TAKE_ID.test(s.report)) { + return { subject: { kind: "article", site: s.site, report: s.report } }; + } + if (s.kind === "video-project" && short(s.project, 512) && s.project.length > 0) { + return { subject: { kind: "video-project", project: s.project } }; + } + return { error: "subject must be an article {site, report} or a video-project {project}" }; +} + +function cleanSource(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const out = {}; + for (const k of ["draft", "generator", "manifest", "how"]) if (short(raw[k], 1000) && raw[k]) out[k] = raw[k]; + return Object.keys(out).length ? out : undefined; +} + +function cleanText(v) { + if (!isStr(v)) throw new NoteError("text must be a string"); + const t = v.replace(/\s+$/, ""); + if (!t.trim()) throw new NoteError("text is empty"); + if (t.length > NOTE_TEXT_LIMIT) throw new NoteError(`text is over ${NOTE_TEXT_LIMIT} characters`); + return t; +} + +/** A refused edit: the caller's fault, a 400. */ +export class NoteError extends Error { + constructor(message) { + super(message); + this.name = "NoteError"; + } +} + +/** + * A parsed notes.json, checked. `{ doc }` or `{ error }`. A note that is not + * the contract's shape is an error for the whole file -- the file is never + * "repaired" by dropping it, because the next write would erase it. + */ +export function parseNotesDoc(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "not an object" }; + if (raw.format !== NOTES_FORMAT) return { error: `format is not ${NOTES_FORMAT}` }; + if (raw.version !== NOTES_VERSION) return { error: `version ${raw.version} is not ${NOTES_VERSION}` }; + const subject = validateSubject(raw.subject); + if ("error" in subject) return subject; + if (!Array.isArray(raw.notes)) return { error: "notes is not a list" }; + const notes = []; + for (const [i, n] of raw.notes.entries()) { + const where = `notes[${i}]`; + if (!n || typeof n !== "object") return { error: `${where} is not an object` }; + if (!isNoteId(n.id)) return { error: `${where}.id is not a note id` }; + if (!NOTE_STATUSES.includes(n.status)) return { error: `${where}.status` }; + if (!NOTE_AUTHORS.includes(n.author)) return { error: `${where}.author` }; + if (!isStr(n.text) || !isStr(n.at) || !isStr(n.updatedAt)) return { error: `${where} text/at/updatedAt` }; + const a = validateAnchor(n.anchor); + if ("error" in a) return { error: `${where}.anchor: ${a.error}` }; + if (!Array.isArray(n.replies)) return { error: `${where}.replies is not a list` }; + const replies = []; + for (const r of n.replies) { + if (!r || !NOTE_AUTHORS.includes(r.author) || !isStr(r.text) || !isStr(r.at)) return { error: `${where}.replies` }; + replies.push({ author: r.author, text: r.text, at: r.at }); + } + const note = { id: n.id, status: n.status, author: n.author, text: n.text, at: n.at, updatedAt: n.updatedAt, anchor: a.anchor, replies }; + if (isStr(n.resolvedAt)) note.resolvedAt = n.resolvedAt; + if (NOTE_AUTHORS.includes(n.resolvedBy)) note.resolvedBy = n.resolvedBy; + notes.push(note); + } + const doc = { format: NOTES_FORMAT, version: NOTES_VERSION, subject: subject.subject }; + const source = cleanSource(raw.source); + if (source) doc.source = source; + doc.notes = notes; + return { doc }; +} + +export function emptyDoc(subject, source) { + const doc = { format: NOTES_FORMAT, version: NOTES_VERSION, subject }; + const s = cleanSource(source); + if (s) doc.source = s; + doc.notes = []; + return doc; +} + +function find(doc, id) { + const note = doc.notes.find((n) => n.id === id); + if (!note) throw new NoteError(`no note ${id}`); + return note; +} + +function setStatus(note, status, by, now) { + if (!NOTE_STATUSES.includes(status)) throw new NoteError(`status must be one of ${NOTE_STATUSES.join(", ")}`); + note.status = status; + note.updatedAt = now; + if (status === "open") { + delete note.resolvedAt; + delete note.resolvedBy; + } else { + note.resolvedAt = now; + note.resolvedBy = by; + } +} + +/** + * Apply one op to a doc, in place. Returns the note touched (null on delete). + * `by` is who is writing: the app stamps "operator", the CLI "agent". + * + * { op: "add", text, anchor } a new open note + * { op: "edit", id, text?, anchor? } rewrite it (its author only) + * { op: "status", id, status } open | resolved | wontfix + * { op: "reply", id, text, resolve? } a threaded reply, optionally resolving + * { op: "delete", id } remove the note + * { op: "delete-reply", id, index } remove one reply + * { op: "source", source } correct which file to edit + * + * @param {Record<string, any>} doc + * @param {Record<string, any>} op + * @param {"operator" | "agent"} by + * @param {string} [now] + */ +export function applyOp(doc, op, by, now = new Date().toISOString()) { + if (!NOTE_AUTHORS.includes(by)) throw new NoteError("author must be operator or agent"); + switch (op?.op) { + case "add": { + const a = validateAnchor(op.anchor); + if ("error" in a) throw new NoteError(a.error); + let id = newNoteId(); + while (doc.notes.some((n) => n.id === id)) id = newNoteId(); + const note = { id, status: "open", author: by, text: cleanText(op.text), at: now, updatedAt: now, anchor: a.anchor, replies: [] }; + doc.notes.push(note); + return note; + } + case "edit": { + const note = find(doc, op.id); + if (note.author !== by) throw new NoteError(`only the ${note.author} edits this note; reply instead`); + if (op.text !== undefined) note.text = cleanText(op.text); + if (op.anchor !== undefined) { + const a = validateAnchor(op.anchor); + if ("error" in a) throw new NoteError(a.error); + note.anchor = a.anchor; + } + note.updatedAt = now; + return note; + } + case "status": { + const note = find(doc, op.id); + setStatus(note, op.status, by, now); + return note; + } + case "reply": { + const note = find(doc, op.id); + note.replies.push({ author: by, text: cleanText(op.text), at: now }); + note.updatedAt = now; + if (op.resolve) setStatus(note, "resolved", by, now); + return note; + } + case "delete": { + const i = doc.notes.findIndex((n) => n.id === op.id); + if (i === -1) throw new NoteError(`no note ${op.id}`); + doc.notes.splice(i, 1); + return null; + } + case "delete-reply": { + const note = find(doc, op.id); + const i = Number(op.index); + if (!Number.isInteger(i) || i < 0 || i >= note.replies.length) throw new NoteError("no such reply"); + if (note.replies[i].author !== by) throw new NoteError(`only the ${note.replies[i].author} deletes that reply`); + note.replies.splice(i, 1); + note.updatedAt = now; + return note; + } + case "source": { + const s = cleanSource(op.source); + if (s) doc.source = s; + else delete doc.source; + return null; + } + default: + throw new NoteError("op must be add, edit, status, reply, delete, delete-reply or source"); + } +} + +export const openNotes = (doc) => (doc ? doc.notes.filter((n) => n.status === "open") : []); diff --git a/umtool/lib/annotations/store.mjs b/umtool/lib/annotations/store.mjs @@ -0,0 +1,157 @@ +// notes.json on disk: read, lock, apply one op, write. Shared by the app's +// /api/notes and `umtool notes`, so the operator's page and an agent's CLI go +// through ONE writer with one set of rules (./shape.mjs). +// +// Three writers can race on one file -- the page, an agent's `umtool notes +// reply`, a second agent -- and they are different PROCESSES, so the in-process +// queues lib/state.ts and lib/report/manifest.mjs use are not enough here: +// +// * a LOCKFILE (`notes.json.lock`, created O_EXCL) serialises +// read-modify-write across processes; one left by a dead writer is stale +// after 30 s and is taken over; +// * the write is tmp + rename, so a reader never sees half a file; +// * every write from the page carries the TOKEN it read (the file's mtime in +// ns, or "absent"); a stale one is a 409, never a silent overwrite of a +// reply an agent wrote in between; +// * a notes.json that exists and does not parse is NEVER overwritten (the +// takes.mjs rule) -- writing over it would erase every note in it; +// * the last note deleted deletes the file: an empty notes.json says nothing. +// +// WHERE a write may land is the caller's to decide BEFORE it gets here +// (lib/annotations/targets.mjs: isCorpusNotesFile for an article, a project +// directory for a video) -- `writeOp` takes the file it is given. +import { open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { NoteError, applyOp, emptyDoc, parseNotesDoc } from "./shape.mjs"; + +export { NoteError }; +export const LOCK_STALE_MS = 30_000; +const LOCK_WAIT_MS = 10_000; + +/** A write whose token no longer matches the file: someone wrote in between. */ +export class StaleNotes extends Error { + constructor(expected, got) { + super(`the notes changed since you read them (${got} vs ${expected})`); + this.name = "StaleNotes"; + this.status = 409; + } +} + +/** A notes.json that exists and is not one: refused, never overwritten. */ +export class NotesUnreadable extends Error { + constructor(file, why) { + super(`${path.basename(file)} does not parse (${why}); not overwriting it`); + this.name = "NotesUnreadable"; + this.status = 409; + } +} + +/** The file's identity for a write guard: mtime in ns plus size, or "absent". */ +export async function notesToken(file) { + const st = await stat(/* turbopackIgnore: true */ file, { bigint: true }).catch(() => null); + return st ? `${st.mtimeNs}-${st.size}` : "absent"; +} + +/** + * `{ doc, token }` -- doc null when there is no file. `{ error }` too when the + * file is there and is not a notes doc (the page shows it; writes refuse). + * + * @param {string} file + * @returns {Promise<{ doc: import("./types").NotesDoc | null, token: string, error?: string }>} + */ +export async function readNotes(file) { + const token = await notesToken(file); + let text; + try { + text = await readFile(/* turbopackIgnore: true */ file, "utf8"); + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") return { doc: null, token: "absent" }; + return { doc: null, token, error: String(/** @type {Error} */ (err).message ?? err) }; + } + let raw; + try { + raw = JSON.parse(text); + } catch (err) { + return { doc: null, token, error: `not JSON: ${/** @type {Error} */ (err).message}` }; + } + const r = parseNotesDoc(raw); + if ("error" in r) return { doc: null, token, error: r.error }; + return { doc: r.doc, token }; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Run `fn` holding `<file>.lock`. A lock older than LOCK_STALE_MS is a dead + * writer's and is removed; otherwise wait (up to 10 s) and retry. + * + * @template T + * @param {string} file + * @param {() => Promise<T>} fn + * @param {{ waitMs?: number, staleMs?: number }} [opts] + * @returns {Promise<T>} + */ +export async function withNotesLock(file, fn, { waitMs = LOCK_WAIT_MS, staleMs = LOCK_STALE_MS } = {}) { + const lock = `${file}.lock`; + const deadline = Date.now() + waitMs; + let delay = 15; + for (;;) { + try { + const h = await open(/* turbopackIgnore: true */ lock, "wx"); + await h.writeFile(`${process.pid} ${new Date().toISOString()}\n`).catch(() => {}); + await h.close(); + break; + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code !== "EEXIST") throw err; + const st = await stat(/* turbopackIgnore: true */ lock).catch(() => null); + if (st && Date.now() - st.mtimeMs > staleMs) { + await unlink(/* turbopackIgnore: true */ lock).catch(() => {}); + continue; + } + if (Date.now() > deadline) throw new Error(`${path.basename(lock)} is held; try again`); + await sleep(delay); + delay = Math.min(delay * 2, 250); + } + } + try { + return await fn(); + } finally { + await unlink(/* turbopackIgnore: true */ lock).catch(() => {}); + } +} + +/** + * Apply one op to the notes at `file` and write it back. `init` is the + * subject (and source) a NEW file starts with; an existing file keeps its own + * subject, and its `source` unless the op is `source`. `token` (when given) + * must match the file as it is now, or StaleNotes. `by` is "operator" (the + * app) or "agent" (the CLI). + * + * Returns the doc as written (null when the file was deleted), the new token, + * and the note the op touched. + * + * @param {string} file + * @param {{ subject: Record<string, string>, source?: Record<string, string> | null }} init + * @param {Record<string, any>} op + * @param {{ by: "operator" | "agent", token?: string | null }} opts + * @returns {Promise<{ doc: import("./types").NotesDoc | null, token: string, note: import("./types").Note | null }>} + */ +export async function writeOp(file, init, op, { by, token = null }) { + return withNotesLock(file, async () => { + const current = await readNotes(file); + if (current.error) throw new NotesUnreadable(file, current.error); + if (token !== null && token !== undefined && token !== current.token) throw new StaleNotes(token, current.token); + const doc = /** @type {any} */ (current.doc ?? emptyDoc(init.subject, init.source ?? undefined)); + const note = applyOp(doc, op, by); + if (doc.notes.length === 0) { + await unlink(/* turbopackIgnore: true */ file).catch((err) => { + if (err.code !== "ENOENT") throw err; + }); + return { doc: null, token: "absent", note }; + } + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile(/* turbopackIgnore: true */ tmp, JSON.stringify(doc, null, 2) + "\n", "utf8"); + await rename(/* turbopackIgnore: true */ tmp, file); + return { doc, token: await notesToken(file), note }; + }); +} diff --git a/umtool/lib/annotations/store.test.mjs b/umtool/lib/annotations/store.test.mjs @@ -0,0 +1,206 @@ +// notes.json: the store, the corpus write predicate and the targets. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { corpusNotesFile, isCorpusNotesFile } from "../paths.mjs"; +import { NoteError, applyOp, emptyDoc, parseNotesDoc, validateAnchor } from "./shape.mjs"; +import { NotesUnreadable, StaleNotes, readNotes, withNotesLock, writeOp } from "./store.mjs"; +import { articleTarget, listNotesFiles, writeNote } from "./targets.mjs"; + +const SUBJECT = { kind: "article", site: "s1", report: "r1" }; + +async function tmp() { + return mkdtemp(path.join(tmpdir(), "umtool-notes-")); +} + +test("add, reply, resolve, reopen, delete: round-trip, and the last delete removes the file", async () => { + const dir = await tmp(); + const file = path.join(dir, "notes.json"); + const a = await writeOp(file, { subject: SUBJECT, source: { draft: "~/d.json" } }, { op: "add", text: "fix this", anchor: { kind: "whole" } }, { by: "operator" }); + assert.equal(a.doc.notes.length, 1); + assert.equal(a.doc.source.draft, "~/d.json"); + const id = a.note.id; + assert.match(id, /^n_[a-z0-9]+$/); + + const back = await readNotes(file); + assert.deepEqual(back.doc, a.doc); + assert.equal(back.token, a.token); + + const r = await writeOp(file, { subject: SUBJECT }, { op: "reply", id, text: "done in drafts/x.json", resolve: true }, { by: "agent", token: a.token }); + assert.equal(r.note.status, "resolved"); + assert.equal(r.note.resolvedBy, "agent"); + assert.equal(r.note.replies[0].author, "agent"); + + const o = await writeOp(file, { subject: SUBJECT }, { op: "status", id, status: "open" }, { by: "operator" }); + assert.equal(o.note.status, "open"); + assert.equal(o.note.resolvedAt, undefined); + + const d = await writeOp(file, { subject: SUBJECT }, { op: "delete", id }, { by: "operator" }); + assert.equal(d.doc, null); + await assert.rejects(stat(file), /ENOENT/); + await rm(dir, { recursive: true }); +}); + +test("a stale token is a 409 and the file is untouched", async () => { + const dir = await tmp(); + const file = path.join(dir, "notes.json"); + const a = await writeOp(file, { subject: SUBJECT }, { op: "add", text: "one", anchor: { kind: "whole" } }, { by: "operator", token: "absent" }); + await writeOp(file, { subject: SUBJECT }, { op: "add", text: "two (agent)", anchor: { kind: "whole" } }, { by: "agent" }); + const before = await readFile(file, "utf8"); + await assert.rejects( + writeOp(file, { subject: SUBJECT }, { op: "add", text: "three", anchor: { kind: "whole" } }, { by: "operator", token: a.token }), + (err) => err instanceof StaleNotes && err.status === 409, + ); + assert.equal(await readFile(file, "utf8"), before); + // "absent" against a file that exists is stale too + await assert.rejects( + writeOp(file, { subject: SUBJECT }, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator", token: "absent" }), + StaleNotes, + ); + await rm(dir, { recursive: true }); +}); + +test("an unparseable notes.json is never overwritten", async () => { + const dir = await tmp(); + const file = path.join(dir, "notes.json"); + await writeFile(file, "{ not json"); + assert.match((await readNotes(file)).error, /not JSON/); + await assert.rejects(writeOp(file, { subject: SUBJECT }, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator" }), NotesUnreadable); + assert.equal(await readFile(file, "utf8"), "{ not json"); + // a parseable file with a bad note is refused the same way + await writeFile(file, JSON.stringify({ format: "umtool-notes", version: 1, subject: SUBJECT, notes: [{ id: "bad" }] })); + await assert.rejects(writeOp(file, { subject: SUBJECT }, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator" }), NotesUnreadable); + await rm(dir, { recursive: true }); +}); + +test("lock contention: parallel writers all land; a held lock waits; a stale lock is taken over", async () => { + const dir = await tmp(); + const file = path.join(dir, "notes.json"); + await Promise.all( + Array.from({ length: 12 }, (_, i) => + writeOp(file, { subject: SUBJECT }, { op: "add", text: `n${i}`, anchor: { kind: "whole" } }, { by: i % 2 ? "agent" : "operator" }), + ), + ); + assert.equal((await readNotes(file)).doc.notes.length, 12); + + // Another process holds it (a fresh lock file): we wait, then give up. + await writeFile(`${file}.lock`, "999999 now\n"); + await assert.rejects(withNotesLock(file, async () => 1, { waitMs: 120 }), /held/); + // The same lock, 31 s old: a dead writer's, taken over. + const old = new Date(Date.now() - 31_000); + await utimes(`${file}.lock`, old, old); + assert.equal(await withNotesLock(file, async () => 2, { waitMs: 120 }), 2); + await assert.rejects(stat(`${file}.lock`), /ENOENT/); + await rm(dir, { recursive: true }); +}); + +test("ops refuse what the contract does not allow", () => { + const doc = emptyDoc(SUBJECT); + assert.throws(() => applyOp(doc, { op: "add", text: " ", anchor: { kind: "whole" } }, "operator"), NoteError); + assert.throws(() => applyOp(doc, { op: "add", text: "x", anchor: { kind: "nope" } }, "operator"), NoteError); + assert.throws(() => applyOp(doc, { op: "add", text: "x".repeat(8001), anchor: { kind: "whole" } }, "operator"), NoteError); + const n = applyOp(doc, { op: "add", text: "x", anchor: { kind: "whole" } }, "operator"); + assert.throws(() => applyOp(doc, { op: "edit", id: n.id, text: "agent rewrites it" }, "agent"), /reply instead/); + assert.throws(() => applyOp(doc, { op: "status", id: n.id, status: "done" }, "agent"), NoteError); + assert.throws(() => applyOp(doc, { op: "reply", id: "n_missing0", text: "x" }, "agent"), /no note/); + assert.throws(() => applyOp(doc, { op: "frobnicate" }, "agent"), NoteError); + assert.throws(() => applyOp(doc, { op: "add", text: "x", anchor: { kind: "whole" } }, "someone"), NoteError); + // source: set, then cleared + applyOp(doc, { op: "source", source: { draft: "~/x.json", junk: 1 } }, "agent"); + assert.deepEqual(doc.source, { draft: "~/x.json" }); + assert.ok(parseNotesDoc(JSON.parse(JSON.stringify(doc))).doc); +}); + +test("anchors: every kind validates; bad ones refuse", () => { + const ok = [ + { kind: "whole" }, + { kind: "section", section: "s-2" }, + { kind: "text", section: "summary", quote: "the claim", prefix: "before ", suffix: " after" }, + { kind: "cite", cite: "c12" }, + { kind: "moment", file: "takes/deck/preview.mp4", t: 12.345, take: "deck", entry: "e3", resolved: { title: "T", sourceT: 81.234, approx: true, bogus: 1 } }, + { kind: "entry", entry: "clip-4" }, + { kind: "take", take: "cold-open" }, + { kind: "edit", entry: "e3", field: "quote", from: "a", to: "b" }, + ]; + for (const a of ok) assert.ok("anchor" in validateAnchor(a), JSON.stringify(a)); + assert.equal(validateAnchor(ok[4]).anchor.t, 12.35); + assert.deepEqual(validateAnchor(ok[4]).anchor.resolved, { title: "T", sourceT: 81.23, approx: true }); + assert.equal(validateAnchor({ kind: "text", section: "s", quote: "q" }).anchor.prefix, ""); + const bad = [ + null, + { kind: "text", section: "s" }, + { kind: "moment", file: "../x.mp4", t: 1 }, + { kind: "moment", file: "/abs.mp4", t: 1 }, + { kind: "moment", file: "a.mp4", t: -1 }, + { kind: "take", take: "Bad Id" }, + { kind: "section", section: "has space" }, + { kind: "edit", field: "" }, + ]; + for (const a of bad) assert.ok("error" in validateAnchor(a), JSON.stringify(a)); +}); + +async function sitesFixture() { + const root = await tmp(); + const sites = path.join(root, "sites"); + await mkdir(path.join(sites, "s1", "reports", "r1"), { recursive: true }); + await writeFile(path.join(sites, "s1", "site.json"), "{}"); + await writeFile(path.join(sites, "s1", "reports", "r1", "report.json"), "{}"); + const outside = path.join(root, "outside", "r2"); + await mkdir(outside, { recursive: true }); + await symlink(outside, path.join(sites, "s1", "reports", "r2")); + return { root, sites }; +} + +test("isCorpusNotesFile: exactly sites/<site>/reports/<id>/notes.json, and nothing else", async () => { + const { root, sites } = await sitesFixture(); + const opt = { sitesDir: sites }; + const good = path.join(sites, "s1", "reports", "r1", "notes.json"); + assert.equal(corpusNotesFile("s1", "r1", opt), good); + assert.equal(await isCorpusNotesFile(good, opt), true); + // traversal, spelled several ways + assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r1", "..", "r1", "notes.json").replace(/\/r1\/notes/, "/../r1/r1/notes"), opt), false); + assert.equal(await isCorpusNotesFile(`${sites}/s1/reports/../reports/r1/notes.json`, opt), false); + assert.equal(await isCorpusNotesFile(`${sites}/s1/reports//r1/notes.json`, opt), false); + assert.equal(await isCorpusNotesFile("s1/reports/r1/notes.json", opt), false); + // wrong name, wrong depth, wrong middle segment, bad ids + assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r1", "report.json"), opt), false); + assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r1", "x", "notes.json"), opt), false); + assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "stills", "r1", "notes.json"), opt), false); + assert.equal(await isCorpusNotesFile(path.join(sites, "S1", "reports", "r1", "notes.json"), opt), false); + assert.equal(corpusNotesFile("s1", "../r1", opt), null); + // a report dir that is a symlink out of the site + assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r2", "notes.json"), opt), false); + // a report that does not exist: a note never creates its directory + assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r9", "notes.json"), opt), false); + // a notes.json that is itself a symlink + await writeFile(path.join(root, "elsewhere.json"), "{}"); + await symlink(path.join(root, "elsewhere.json"), good); + assert.equal(await isCorpusNotesFile(good, opt), false); + await rm(root, { recursive: true }); +}); + +test("articleTarget: writes land beside report.json with the discovered source; refusals are typed", async () => { + const { root, sites } = await sitesFixture(); + const reports = path.join(root, "reports"); + await mkdir(path.join(reports, "ws", "polemics", "drafts"), { recursive: true }); + await writeFile(path.join(reports, "ws", "polemics", "drafts", "r1.json"), JSON.stringify({ id: "r1" })); + await writeFile(path.join(reports, "ws", "polemics", "make-site.py"), 'OUT = "sites/s1/reports"\nfor d in drafts: pass\n'); + const opt = { sitesDir: sites, reportsRoot: reports }; + + const t = await articleTarget("s1/r1", opt); + const w = await writeNote(t, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator" }); + assert.equal(w.doc.source.draft.endsWith(path.join("ws", "polemics", "drafts", "r1.json")), true); + assert.equal(w.doc.source.generator.endsWith("make-site.py"), true); + const list = await listNotesFiles(opt); + assert.deepEqual(list.map((l) => [l.kind, l.id, l.doc.notes.length]), [["article", "s1/r1", 1]]); + + await assert.rejects(articleTarget("s1/r9", opt), (e) => e.status === 404); + await assert.rejects(articleTarget("s1/r2", opt), (e) => e.status === 403); + await assert.rejects(articleTarget("s1/../r1", opt), (e) => e.status === 400); + await assert.rejects(articleTarget("s1", opt), (e) => e.status === 400); + await rm(root, { recursive: true }); +}); diff --git a/umtool/lib/annotations/targets.mjs b/umtool/lib/annotations/targets.mjs @@ -0,0 +1,185 @@ +// What a note is ON, and therefore which notes.json it lives in -- decided +// here, once, for the app's /api/notes and for `umtool notes`. +// +// article `SITES_DIR/<site>/reports/<report>/notes.json`, beside +// report.json. The generators that write a report dir +// overwrite only report.json, video.mp4 and poster.jpg, so it +// survives a regenerate; the compose stage and the report +// history never read it (common/publish tests hold that), so +// it is never published. The ONE corpus file umtool writes, +// and only through isCorpusNotesFile. +// video-project `<project>/notes.json`, beside video.manifest.json. A +// report-video project under REPORTS_ROOT, found by the same +// walk `umtool ls` uses. +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"; + +const exists = (p) => stat(/* turbopackIgnore: true */ p).then(() => true, () => false); + +/** A refused target: the caller's fault (400/404). */ +export class TargetError extends Error { + constructor(message, status = 400) { + super(message); + this.name = "TargetError"; + this.status = status; + } +} + +/** + * An article target, checked: the site and report ids, the report directory + * on disk, and the notes path through isCorpusNotesFile. + * + * @param {string} spec `<site>/<report>` + * @param {{ sitesDir?: string, reportsRoot?: string }} [opts] + */ +export async function articleTarget(spec, { sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT } = {}) { + const [site, report, ...rest] = String(spec ?? "").split("/"); + if (rest.length || !SEGMENT_RE.test(site ?? "") || !SEGMENT_RE.test(report ?? "")) { + throw new TargetError(`not an article: ${JSON.stringify(spec)} (want <site>/<report>)`); + } + const file = corpusNotesFile(site, report, { sitesDir }); + if (!file || !(await exists(path.dirname(/* turbopackIgnore: true */ file)))) throw new TargetError(`no report ${site}/${report}`, 404); + if (!(await isCorpusNotesFile(file, { sitesDir }))) throw new TargetError(`refusing to write ${file}`, 403); + return { + kind: "article", + id: `${site}/${report}`, + file, + subject: { kind: "article", site, report }, + // Lazy: the scan reads every workspace's generators, and a read of an + // existing file never needs it. + source: async () => sourceFor(site, report, { reportsRoot }), + }; +} + +/** + * A video-project target: a report-video project under REPORTS_ROOT, by id, + * unique name or directory. + * + * @param {string} spec + * @param {{ reportsRoot?: string }} [opts] + */ +export async function projectTarget(spec, { reportsRoot = REPORTS_ROOT } = {}) { + const r = await resolveProject(String(spec ?? ""), reportsRoot); + if (r.ambiguous) throw new TargetError(`${spec} names ${r.ambiguous.length} projects: ${r.ambiguous.map((p) => p.id).join(", ")}`); + if (!r.project) throw new TargetError(`no project ${spec}`, 404); + return projectTargetFor(r.project, { reportsRoot }); +} + +/** + * The target for a project ref already in hand (the app's memoised walk). + * + * @param {{ id: string, dir: string, kind: string }} p + * @param {{ reportsRoot?: string }} [opts] + */ +export async function projectTargetFor(p, { reportsRoot = REPORTS_ROOT } = {}) { + 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), + ]); + if (!realRoot || !realDir || !inside(realRoot, realDir) || realRoot === realDir) { + throw new TargetError(`${p.id} is not under the reports root`, 403); + } + const file = path.join(/* turbopackIgnore: true */ p.dir, NOTES_FILENAME); + return { + kind: "video-project", + id: p.id, + dir: p.dir, + file, + subject: { kind: "video-project", project: p.id }, + source: async () => projectSource(p.dir, reportsRoot), + }; +} + +/** + * A report-video project's source: its manifest, and -- when the manifest is + * generated -- the generator, resolved against the project's workspace (the + * first directory under REPORTS_ROOT) when that file exists. + */ +export async function projectSource(dir, reportsRoot = REPORTS_ROOT) { + const manifest = path.join(/* turbopackIgnore: true */ dir, "video.manifest.json"); + const out = { manifest: tildify(manifest) }; + let generatedBy = null; + try { + const m = JSON.parse(await readFile(/* turbopackIgnore: true */ manifest, "utf8")); + if (typeof m.generatedBy === "string" && m.generatedBy.trim()) generatedBy = m.generatedBy.trim(); + } catch { + // no manifest, or not JSON: the manifest path is still the place to look + } + if (!generatedBy) { + out.how = "hand-edited manifest"; + return out; + } + const rel = path.relative(/* turbopackIgnore: true */ reportsRoot, dir); + const ws = rel && !rel.startsWith("..") ? path.join(/* turbopackIgnore: true */ reportsRoot, rel.split(path.sep)[0]) : null; + const candidates = [ws && path.join(/* turbopackIgnore: true */ ws, generatedBy), path.join(/* turbopackIgnore: true */ dir, generatedBy)].filter(Boolean); + let gen = null; + for (const c of candidates) { + if (await exists(c)) { + gen = c; + break; + } + } + out.generator = gen ? tildify(gen) : generatedBy; + out.how = `manifest is generated by ${generatedBy}; edit its inputs, then regenerate`; + return out; +} + +/** + * Resolve what the CLI was handed: `<site>/<report>` when that report exists, + * else a project. + */ +export async function resolveTarget(spec, opts = {}) { + const parts = String(spec ?? "").split("/"); + if (parts.length === 2 && parts.every((s) => SEGMENT_RE.test(s))) { + const dir = path.join(/* turbopackIgnore: true */ opts.sitesDir ?? SITES_DIR, parts[0], "reports", parts[1]); + if (await exists(dir)) return articleTarget(spec, opts); + } + return projectTarget(spec, opts); +} + +/** + * Every notes.json there is: articles under SITES_DIR, projects under + * REPORTS_ROOT. `{ target, file, doc, error? }` each; sorted by id. + * + * @param {{ sitesDir?: string, reportsRoot?: string }} [opts] + */ +export async function listNotesFiles({ sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT } = {}) { + const out = []; + for (const site of await readdir(/* turbopackIgnore: true */ sitesDir).catch(() => [])) { + if (!SEGMENT_RE.test(site)) continue; + for (const report of await readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ sitesDir, site, "reports")).catch(() => [])) { + if (!SEGMENT_RE.test(report)) continue; + const file = path.join(/* turbopackIgnore: true */ sitesDir, site, "reports", report, NOTES_FILENAME); + if (!(await exists(file))) continue; + out.push({ kind: "article", id: `${site}/${report}`, file, ...(await readNotes(file)) }); + } + } + for (const p of await projectRefs(reportsRoot)) { + 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, projectKind: p.kind, file, ...(await readNotes(file)) }); + } + return out.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id)); +} + +/** + * One write to a target's notes. A file that does not exist yet starts with + * the target's subject and its discovered source (which the agent may correct + * later with a `source` op); an existing file keeps both. + * + * @param {{ file: string, subject: Record<string, string>, source: () => Promise<Record<string, string> | null> }} target + * @param {Record<string, any>} op + * @param {{ by: "operator" | "agent", token?: string | null }} opts + */ +export async function writeNote(target, op, opts) { + const now = await readNotes(target.file); + const source = now.doc || now.error ? undefined : ((await target.source()) ?? undefined); + return writeOp(target.file, { subject: target.subject, source }, op, opts); +} diff --git a/umtool/lib/annotations/types.ts b/umtool/lib/annotations/types.ts @@ -0,0 +1,98 @@ +// The notes shapes, with NO server imports (the lib/note-types.ts rule): a +// client component may take a value from here without dragging node:fs into +// the browser bundle. The store that reads and writes them is ./store.mjs, +// shared by the app and `umtool notes`; docs/notes.md is the prose. + +// The values are ./shape.mjs's (pure, shared with the store and the CLI); +// this file adds the types. +export { + ANCHOR_KINDS, + NOTE_AUTHORS, + NOTE_STATUSES, + NOTE_TEXT_LIMIT, + NOTES_FORMAT, + NOTES_VERSION, + REPORT_BLOCKS, +} from "./shape.mjs"; +export { CONTEXT as ANCHOR_CONTEXT } from "./anchor.mjs"; + +export type NoteStatus = "open" | "resolved" | "wontfix"; +export type NoteAuthor = "operator" | "agent"; + +/** What a moment resolved to when it was written, for the agent reading it back. */ +export type MomentResolved = { + entry?: string; + title?: string; + quote?: string; + channel?: string; + video?: string; + sourceT?: number; + url?: string; + /** The schedule did not match this file exactly (a preview, not out/). */ + approx?: boolean; +}; + +export type Anchor = + | { kind: "text"; section: string; quote: string; prefix: string; suffix: string } + | { kind: "cite"; cite: string } + | { kind: "section"; section: string } + | { kind: "whole" } + | { kind: "moment"; file: string; t: number; take?: string; entry?: string; resolved?: MomentResolved } + | { kind: "entry"; entry: string } + | { kind: "take"; take: string } + | { kind: "edit"; entry?: string; field: string; from: unknown; to: unknown }; + +export type AnchorKind = Anchor["kind"]; + +export type NoteReply = { author: NoteAuthor; text: string; at: string }; + +export type Note = { + id: string; + status: NoteStatus; + author: NoteAuthor; + text: string; + at: string; + updatedAt: string; + anchor: Anchor; + replies: NoteReply[]; + resolvedAt?: string; + resolvedBy?: NoteAuthor; +}; + +export type NotesSubject = + | { kind: "article"; site: string; report: string } + | { kind: "video-project"; project: string }; + +/** Which file an agent should edit to act on a note. Filled by umtool, correctable. */ +export type NotesSource = { draft?: string; generator?: string; manifest?: string; how?: string }; + +export type NotesDoc = { + format: "umtool-notes"; + version: 1; + subject: NotesSubject; + source?: NotesSource; + notes: Note[]; +}; + +/** What GET /api/notes returns. `token` goes back on every write (409 when stale). */ +export type NotesRead = { + subject: NotesSubject; + file: string; + token: string; + doc: NotesDoc | null; + source: NotesSource | null; + error?: string; +}; + +/** One write. The server stamps author "operator" on everything the UI sends. */ +export type NoteOp = + | { op: "add"; text: string; anchor: Anchor } + | { op: "edit"; id: string; text?: string; anchor?: Anchor } + | { op: "status"; id: string; status: NoteStatus } + | { op: "reply"; id: string; text: string; resolve?: boolean } + | { op: "delete"; id: string } + | { op: "delete-reply"; id: string; index: number } + | { op: "source"; source: NotesSource }; + +export const openCount = (doc: NotesDoc | null | undefined): number => + doc ? doc.notes.filter((n) => n.status === "open").length : 0; diff --git a/umtool/lib/annotations/useNotes.ts b/umtool/lib/annotations/useNotes.ts @@ -0,0 +1,92 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { NoteOp, NotesRead, Note, NotesDoc } from "./types"; + +// The page's handle on one notes.json: read it, write one op at a time with the +// token it read, and on a 409 re-read and say so rather than retrying blind -- +// an agent may have replied in between, and the operator should see that reply +// before their edit lands on top of it. + +export type NotesTarget = { article: string } | { project: string }; + +export function notesQuery(target: NotesTarget): string { + return "article" in target ? `article=${encodeURIComponent(target.article)}` : `project=${encodeURIComponent(target.project)}`; +} + +export type UseNotes = { + doc: NotesDoc | null; + notes: Note[]; + source: NotesRead["source"]; + error: string | null; + loading: boolean; + busy: boolean; + /** Apply one op. Resolves to the note touched (null on delete), or null after an error (shown in `error`). */ + write: (op: NoteOp) => Promise<Note | null>; + reload: () => Promise<void>; +}; + +export function useNotes(target: NotesTarget | null, { initial }: { initial?: NotesRead | null } = {}): UseNotes { + const [read, setRead] = useState<NotesRead | null>(initial ?? null); + const [error, setError] = useState<string | null>(initial?.error ?? null); + const [loading, setLoading] = useState(!initial && !!target); + const [busy, setBusy] = useState(false); + const token = useRef<string | null>(initial?.token ?? null); + const query = target ? notesQuery(target) : null; + + const reload = useCallback(async () => { + if (!query) return; + setLoading(true); + try { + const res = await fetch(`/api/notes?${query}`, { cache: "no-store" }); + const j = await res.json(); + if (!res.ok) throw new Error(j.error ?? res.statusText); + token.current = j.token; + setRead(j); + setError(j.error ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { + if (!initial) void reload(); + // `initial` is the server render's read; only a changed target re-reads. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reload]); + + const write = useCallback( + async (op: NoteOp): Promise<Note | null> => { + if (!query) return null; + setBusy(true); + try { + const res = await fetch(`/api/notes?${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: token.current, op }), + }); + const j = await res.json(); + if (res.status === 409) { + await reload(); + setError(`${j.error ?? "the notes changed"} -- reloaded; check and try again`); + return null; + } + if (!res.ok) throw new Error(j.error ?? res.statusText); + token.current = j.token; + setRead((r) => (r ? { ...r, doc: j.doc, token: j.token, source: j.doc?.source ?? r.source } : r)); + setError(null); + return j.note ?? null; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setBusy(false); + } + }, + [query, reload], + ); + + return { doc: read?.doc ?? null, notes: read?.doc?.notes ?? [], source: read?.source ?? null, error, loading, busy, write, reload }; +} diff --git a/umtool/lib/articles/article.ts b/umtool/lib/articles/article.ts @@ -0,0 +1,191 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { + buildReportPageView, + REPORT_PAGE_FORMAT, + REPORT_VIEWS_VERSION, + type RecordView, + type ReportPageView, +} from "yt-dlp-transcript-common/lib/report/views"; +import type { Report } from "yt-dlp-transcript-common/lib/report/schema"; +import { platformMomentUrl } from "yt-dlp-transcript-common/lib/momentUrl"; +import { readAllPosts } from "yt-dlp-transcript-common/lib/posts-server"; +import type { Post } from "yt-dlp-transcript-common/lib/posts"; +import { readCues } from "@/lib/projects/report.mjs"; +import { CHANNELS_DIR } from "@/lib/paths"; + +// A report's PAGE VIEW, built the way the export site builds it +// (common/lib/report/views.ts buildReportPageView) but resolved against what +// umtool can read without the LMDB index or a compose run: each cited record's +// own files on disk. compose's resolveSiteReports is not reused: it verifies, +// prepares and THROWS on any problem, and half of what this page is for is +// reading drafts that have problems. +// +// A record resolves from its transcript.cues.json (title, date, the uploader's +// display name, webpageUrl -- through lib/projects/report.mjs readCues, which is +// memoised on the file's mtime), else its metadata.info.json, else the +// citation's own label, speaker and date. A post resolves from the channel's +// posts. Nothing here fails the page: a record that cannot be read is a card +// with less on it. + +const isoDay = (d: unknown): string | undefined => { + const s = typeof d === "string" ? d : ""; + if (/^\d{8}$/.test(s)) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`; + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); + return undefined; +}; + +export const recordDir = (channel: string, id: string) => + path.join(/* turbopackIgnore: true */ CHANNELS_DIR, channel, "data", id); + +type RecordMeta = { title?: string; date?: string; channelTitle?: string; webpageUrl?: string }; + +const metaMemo = new Map<string, { key: string; value: RecordMeta | null }>(); + +/** What a record says about itself; null when its directory holds neither file. */ +export async function recordMeta(channel: string, id: string): Promise<RecordMeta | null> { + if (!/^[A-Za-z0-9_.@-]+$/.test(channel) || !/^[A-Za-z0-9_.@-]+$/.test(id)) return null; + const dir = recordDir(channel, id); + const cues = (await readCues(path.join(/* turbopackIgnore: true */ dir, "transcript.cues.json"))) as + | { title?: string; uploadDate?: string; webpageUrl?: string; channel?: string } + | null; + if (cues && (cues.title || cues.webpageUrl)) { + return { title: cues.title, date: isoDay(cues.uploadDate), channelTitle: cues.channel, webpageUrl: cues.webpageUrl }; + } + const file = path.join(/* turbopackIgnore: true */ dir, "metadata.info.json"); + const st = await stat(/* turbopackIgnore: true */ file).catch(() => null); + if (!st) return null; + const key = `${Math.round(st.mtimeMs)}-${st.size}`; + const hit = metaMemo.get(file); + if (hit?.key === key) return hit.value; + let value: RecordMeta | null = null; + try { + const m = JSON.parse(await readFile(/* turbopackIgnore: true */ file, "utf8")); + value = { + title: typeof m.title === "string" ? m.title : undefined, + date: isoDay(m.upload_date), + channelTitle: typeof m.uploader === "string" ? m.uploader : typeof m.channel === "string" ? m.channel : undefined, + webpageUrl: typeof m.webpage_url === "string" ? m.webpage_url : undefined, + }; + } catch { + value = null; + } + metaMemo.set(file, { key, value }); + return value; +} + +// A channel's posts, by id. A big X archive is thousands of posts, so one read +// per channel per minute. +const postsMemo = new Map<string, { at: number; value: Promise<Map<string, Post>> }>(); +export function channelPosts(channel: string): Promise<Map<string, Post>> { + const hit = postsMemo.get(channel); + if (hit && Date.now() - hit.at < 60_000) return hit.value; + const value = readAllPosts(path.join(/* turbopackIgnore: true */ CHANNELS_DIR, channel)) + .then((list) => new Map(list.map((p) => [p.id, p]))) + .catch(() => new Map<string, Post>()); + postsMemo.set(channel, { at: Date.now(), value }); + return value; +} + +/** The capture screenshot of a post, if the editor took one. */ +export async function postShotFile(channel: string, id: string): Promise<string | null> { + if (!/^[A-Za-z0-9_.@-]+$/.test(channel) || !/^[A-Za-z0-9_.@-]+$/.test(id)) return null; + const file = path.join(/* turbopackIgnore: true */ CHANNELS_DIR, channel, "posts-media", id, "shot.png"); + return (await stat(/* turbopackIgnore: true */ file).catch(() => null))?.isFile() ? file : null; +} + +export const corpusMediaUrl = (abs: string) => `/api/sites/media?corpus=${encodeURIComponent(abs)}`; + +type Cite = NonNullable<Report["citations"]>[string]; + +async function recordViewOf( + c: Cite, + metaOf: (channel: string, id: string) => Promise<RecordMeta | null> = recordMeta, +): Promise<RecordView | undefined> { + if (c.kind === "video" || c.kind === "audio") { + const m = await metaOf(c.channel, c.id); + return { + channel: c.channel, + id: c.id, + ...(m?.channelTitle || c.speaker ? { channelTitle: m?.channelTitle ?? c.speaker } : {}), + ...(m?.title || c.label ? { title: m?.title ?? c.label } : {}), + ...(m?.date || c.date ? { date: m?.date ?? c.date } : {}), + ...(m?.webpageUrl ? { originalUrl: platformMomentUrl(m.webpageUrl, null, c.start) ?? m.webpageUrl } : {}), + }; + } + if (c.kind === "post") { + const post = (await channelPosts(c.channel)).get(c.id); + return { + channel: c.channel, + id: c.id, + ...(post?.authorName || post?.author ? { channelTitle: post.authorName ?? post.author } : {}), + ...(post?.createdAt ? { date: post.createdAt.slice(0, 10) } : c.date ? { date: c.date } : {}), + ...(post?.platform ? { platform: post.platform } : {}), + ...(post?.url ? { originalUrl: post.url } : {}), + }; + } + return undefined; +} + +/** + * The page view of a report, or -- when the report's citations cannot be built + * into one (a draft naming a source it does not define) -- the same view with + * no citations, and the reason. + */ +export async function articleView(report: Report): Promise<{ view: ReportPageView; error: string | null }> { + const records = new Map<Cite, RecordView>(); + const posts = new Map<Cite, { author?: string; text?: string; shot?: string }>(); + // A fact-check cites the same few records hundreds of times: read each once, + // all at the same time (recordMeta memoises on the file, not on a promise). + const metas = new Map<string, Promise<RecordMeta | null>>(); + const metaOf = (channel: string, id: string) => { + const k = `${channel}/${id}`; + if (!metas.has(k)) metas.set(k, recordMeta(channel, id)); + return metas.get(k)!; + }; + await Promise.all( + Object.values(report.citations ?? {}).map(async (c) => { + const r = await recordViewOf(c, metaOf); + if (r) records.set(c, r); + if (c.kind === "post") { + const post = (await channelPosts(c.channel)).get(c.id); + const shot = await postShotFile(c.channel, c.id); + posts.set(c, { + ...(post ? { author: post.authorName ?? post.author, text: post.text } : {}), + ...(shot ? { shot: corpusMediaUrl(shot) } : {}), + }); + } + }), + ); + try { + const view = buildReportPageView(report, { + record: (c) => records.get(c as Cite) ?? { channel: c.channel, id: c.id }, + post: (c) => posts.get(c as Cite), + }); + return { view, error: null }; + } catch (err) { + const view = { + format: REPORT_PAGE_FORMAT, + version: REPORT_VIEWS_VERSION, + id: report.id, + kind: report.kind, + ...(report.series ? { series: report.series } : {}), + title: report.title, + ...(report.subtitle ? { subtitle: report.subtitle } : {}), + ...(report.summary ? { summary: report.summary } : {}), + ...(report.method ? { method: report.method } : {}), + ...(report.published ? { published: report.published } : {}), + ...(report.updated ? { updated: report.updated } : {}), + sources: {}, + verdicts: {}, + citations: {}, + sections: report.sections.map((s) => ({ + id: s.id, + title: s.title, + ...(s.body ? { body: s.body } : {}), + claims: (s.claims ?? []).map((cl) => ({ ...cl, citations: cl.citations ?? [] })), + })), + } as unknown as ReportPageView; + return { view, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/umtool/lib/articles/evidence.ts b/umtool/lib/articles/evidence.ts @@ -0,0 +1,182 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { Report } from "yt-dlp-transcript-common/lib/report/schema"; +import { momentKeyOf } from "yt-dlp-transcript-common/lib/citations/moments"; +import { evidenceSpan, resolveEvidenceSource } from "yt-dlp-transcript-common/lib/evidenceClip-server"; +import { reportMediaDir, reportMediaIndexFile } from "yt-dlp-transcript-common/publish/reportMedia"; +import { readCues } from "@/lib/projects/report.mjs"; +import { CHANNELS_DIR } from "@/lib/paths"; +import { channelPosts, corpusMediaUrl, postShotFile, recordDir, recordMeta } from "./article"; +import { sitesPaths } from "./sites"; + +// What a citation's EVIDENCE panel shows: the cited seconds with the transcript +// around them, and something to play -- found, never fetched. +// +// Playback, best first: +// 1. prepared the site's own evidence clip (.export-index/sites/<site>/ +// report-media/, what the build publishes), when prepare has run; +// 2. window a clip window the editor fetched into data/<id>/clips/; +// 3. saved a saved video or audio file in data/<id>/ (through its media +// tier link -- read, never written); +// 4. otherwise nothing to play, and the line that fetches it through the +// editor: the MCP `fetch_clip` tool. umtool's own fetch client +// (api/report/fetch) is a manifest's, so it cannot ask for a +// window no project names. Never yt-dlp. + +export const CONTEXT_CUES = 6; + +export type EvidenceCue = { start: number; end: number; text: string; cited: boolean }; + +export type EvidencePlay = { + kind: "prepared" | "window" | "saved" | "audio"; + url: string; + /** Seconds into the file where the cited span starts. */ + offset: number; + audio: boolean; + label: string; +}; + +export type Evidence = { + cite: string; + kind: string; + quote: string; + speaker?: string; + date?: string; + label?: string; + originalUrl?: string; + record?: { channel: string; id: string; title?: string; channelTitle?: string }; + start?: number; + end?: number; + cues: EvidenceCue[]; + cuesNote?: string; + play: EvidencePlay | null; + fetchLine?: string; + post?: { author?: string; text?: string; url?: string; shot?: string }; +}; + +type Cite = NonNullable<Report["citations"]>[string]; + +/** The ±CONTEXT_CUES cues around [start, end], the overlapping ones marked. */ +export async function cueContext(channel: string, id: string, start: number, end: number) { + const file = path.join(/* turbopackIgnore: true */ recordDir(channel, id), "transcript.cues.json"); + const doc = (await readCues(file)) as { cues?: { start: number; end: number; text?: string }[] } | null; + const cues = doc?.cues ?? []; + if (!cues.length) return { cues: [] as EvidenceCue[], note: doc ? "no cues" : "no transcript.cues.json" }; + const EPS = 0.05; + let first = cues.findIndex((c) => c.end > start + EPS); + if (first < 0) first = cues.length - 1; + let last = first; + while (last + 1 < cues.length && cues[last + 1].start < end - EPS) last += 1; + const from = Math.max(0, first - CONTEXT_CUES); + const to = Math.min(cues.length - 1, last + CONTEXT_CUES); + return { + cues: cues.slice(from, to + 1).map((c, i) => ({ + start: c.start, + end: c.end, + text: String(c.text ?? "").replace(/\s+/g, " ").trim(), + cited: from + i >= first && from + i <= last, + })), + }; +} + +async function preparedClip(siteId: string, c: Cite): Promise<EvidencePlay | null> { + if (c.kind !== "video" && c.kind !== "audio") return null; + const key = momentKeyOf(c); + if (!key) return null; + try { + const index = JSON.parse(await readFile(/* turbopackIgnore: true */ reportMediaIndexFile(sitesPaths(), siteId), "utf8")); + const entry = index?.moments?.[key]; + if (!entry || (entry.kind !== "video" && entry.kind !== "audio") || typeof entry.file !== "string") return null; + const abs = path.join(/* turbopackIgnore: true */ reportMediaDir(sitesPaths(), siteId), entry.file); + let from = evidenceSpan(c).from; + try { + const side = JSON.parse(await readFile(/* turbopackIgnore: true */ abs.replace(/\.(mp4|m4a)$/, ".json"), "utf8")); + if (typeof side?.span?.from === "number") from = side.span.from; + } catch { + // no sidecar: the citation's own pad is the best guess + } + return { + kind: "prepared", + url: `/api/sites/media?site=${encodeURIComponent(siteId)}&moment=${encodeURIComponent(key)}`, + offset: Math.max(0, c.start - from), + audio: entry.kind === "audio", + label: "prepared evidence clip", + }; + } catch { + return null; + } +} + +async function corpusClip(c: Cite): Promise<EvidencePlay | null> { + if (c.kind !== "video" && c.kind !== "audio") return null; + const span = { from: c.start, to: c.end }; + for (const audio of c.kind === "audio" ? [true] : [false, true]) { + const hit = await resolveEvidenceSource({ channelsDir: CHANNELS_DIR, slug: c.channel, id: c.id, span, audio }).catch(() => null); + if (!hit) continue; + const kind = hit.kind === "corpus-window" ? "window" : hit.kind === "saved-video" ? "saved" : "audio"; + return { + kind, + url: corpusMediaUrl(hit.path), + offset: Math.max(0, c.start - hit.windowStart), + audio: hit.kind === "audio", + label: kind === "window" ? `clip window ${hit.name}` : kind === "saved" ? `saved ${hit.name}` : `audio ${hit.name}`, + }; + } + return null; +} + +/** The MCP line that fetches this span through the editor. */ +export function fetchClipLine(c: { channel: string; id: string; start: number; end: number }, reason: string): string { + const s = (n: number) => Number(n.toFixed(2)); + return `fetch_clip ${JSON.stringify({ channel: c.channel, video: c.id, start: s(c.start), end: s(c.end), reason })}`; +} + +export async function citationEvidence(siteId: string, report: Report, citeId: string): Promise<Evidence | null> { + const c = report.citations?.[citeId]; + if (!c) return null; + const base: Evidence = { + cite: citeId, + kind: c.kind, + quote: c.quote, + ...(c.speaker ? { speaker: c.speaker } : {}), + ...(c.date ? { date: c.date } : {}), + ...(c.label ? { label: c.label } : {}), + cues: [], + play: null, + }; + if (c.kind === "video" || c.kind === "audio") { + const meta = await recordMeta(c.channel, c.id); + const ctx = await cueContext(c.channel, c.id, c.start, c.end); + const play = (await preparedClip(siteId, c)) ?? (await corpusClip(c)); + return { + ...base, + start: c.start, + end: c.end, + record: { channel: c.channel, id: c.id, ...(meta?.title ? { title: meta.title } : {}), ...(meta?.channelTitle ? { channelTitle: meta.channelTitle } : {}) }, + ...(meta?.webpageUrl ? { originalUrl: meta.webpageUrl } : {}), + cues: ctx.cues, + ...(ctx.note ? { cuesNote: ctx.note } : {}), + play, + ...(play ? {} : { fetchLine: fetchClipLine(c, `${siteId}/${report.id} ${citeId}`) }), + }; + } + if (c.kind === "post") { + const post = (await channelPosts(c.channel)).get(c.id); + const shot = await postShotFile(c.channel, c.id); + return { + ...base, + record: { channel: c.channel, id: c.id }, + ...(post?.url ? { originalUrl: post.url } : {}), + post: { + ...(post ? { author: post.authorName ?? post.author, text: post.text, url: post.url } : {}), + ...(shot ? { shot: corpusMediaUrl(shot) } : {}), + }, + }; + } + if (c.kind === "page") return { ...base, originalUrl: c.url }; + if (c.kind === "source") { + const s = report.sources?.[c.source]; + return { ...base, ...(s?.url ? { originalUrl: s.url } : {}), ...(s?.title ? { label: c.label ?? s.title } : {}) }; + } + return base; +} diff --git a/umtool/lib/articles/files.ts b/umtool/lib/articles/files.ts @@ -0,0 +1,77 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { REPORTS_ROOT } from "@/lib/paths"; +import { listTakes, readVerdicts } from "@/lib/report/takes.mjs"; +import { siteWorkspaces, tildify } from "./sources.mjs"; +import { untildify } from "./links.mjs"; +import { workspaceFile, workspaceFiles } from "./workspace.mjs"; + +// The page side of lib/articles/workspace.mjs and the video projects' takes. + +export type WorkspaceListing = { + /** The workspace's name under REPORTS_ROOT (what /api/sites/workspace takes). */ + name: string; + dir: string; + files: { rel: string; kind: "draft" | "out" | "doc"; bytes: number; mtimeMs: number }[]; +}; + +export async function listingOf(dir: string): Promise<WorkspaceListing> { + const files = (await workspaceFiles(dir)).map(({ rel, kind, bytes, mtimeMs }: { rel: string; kind: WorkspaceListing["files"][number]["kind"]; bytes: number; mtimeMs: number }) => ({ rel, kind, bytes, mtimeMs })); + return { name: path.basename(dir), dir: tildify(dir), files }; +} + +/** Every workspace a site's articles were written in. */ +export async function siteWorkspaceListings(siteId: string, reportIds: string[]): Promise<WorkspaceListing[]> { + const dirs: string[] = await siteWorkspaces(siteId, reportIds, { reportsRoot: REPORTS_ROOT }); + return Promise.all(dirs.map(listingOf)); +} + +/** One article's workspace (its source's), or null. */ +export async function articleWorkspaceListing(workspace: string | undefined | null): Promise<WorkspaceListing | null> { + if (!workspace) return null; + const abs = untildify(workspace); + if (path.dirname(abs) !== path.resolve(REPORTS_ROOT)) return null; + return listingOf(abs); +} + +export type OpenedFile = + | { ws: string; rel: string; kind: "md"; text: string } + | { ws: string; rel: string; kind: "json"; value: unknown; text: string } + | { ws: string; rel: string; kind: "html"; url: string } + | { ws: string; rel: string; kind: "error"; message: string }; + +const MAX = 2 * 1024 * 1024; + +/** A workspace file opened for the page, or an error saying why not. */ +export async function openWorkspaceFile(ws: string, rel: string): Promise<OpenedFile> { + const f = await workspaceFile(ws, rel); + if (!f) return { ws, rel, kind: "error", message: "not a listed workspace file" }; + if (rel.endsWith(".html")) { + return { ws, rel, kind: "html", url: `/api/sites/workspace?ws=${encodeURIComponent(ws)}&rel=${encodeURIComponent(rel)}` }; + } + if (f.bytes > MAX) return { ws, rel, kind: "error", message: `${f.bytes} bytes; too big to show` }; + const text = await readFile(/* turbopackIgnore: true */ f.real, "utf8"); + if (rel.endsWith(".json")) { + try { + return { ws, rel, kind: "json", value: JSON.parse(text), text }; + } catch (err) { + return { ws, rel, kind: "error", message: `not JSON: ${(err as Error).message}` }; + } + } + return { ws, rel, kind: "md", text }; +} + +export type TakeTally = { takes: number; like: number; maybe: number; no: number; skipped: number }; + +/** How many takes a video project has, and how they were judged. */ +export async function takeTally(dir: string): Promise<TakeTally> { + const [t, v] = await Promise.all([listTakes(dir), readVerdicts(dir)]); + const rows = Object.values(v) as { verdict: string | null }[]; + return { + takes: t.takes.length, + like: rows.filter((r) => r.verdict === "like").length, + maybe: rows.filter((r) => r.verdict === "maybe").length, + no: rows.filter((r) => r.verdict === "no").length, + skipped: t.skipped.length, + }; +} diff --git a/umtool/lib/articles/links.mjs b/umtool/lib/articles/links.mjs @@ -0,0 +1,100 @@ +// Which umtool report-video project is an article's video. +// +// Two ways, in order: +// +// 1. The manifest says so: a top-level `"article": "<site>/<report>"` in +// video.manifest.json. build-video.mjs never reads top-level keys it does +// not know (it reads `generatedBy` no more than this), so the key costs +// the render nothing. A manifest that names an article is linked to it +// and to nothing else. +// 2. The slug matches: the manifest's `slug` (else the project directory's +// name) is the report id, `polemic-<id>`, or the id without `polemic-` -- +// and the project lives in the same workspace as the article's draft +// (lib/articles/sources.mjs). A UNIQUE match is linked; two or more are +// only "possible", and the page says so rather than picking one. +// +// Plain ESM, so `umtool notes` can name an article's video too. +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { REPORTS_ROOT } from "../paths.mjs"; +import { projectRefs } from "../projects/core.mjs"; +import { kindLinksArticles } from "../projects/kinds.mjs"; +import { idKeys } from "./sources.mjs"; + +const CACHE_MS = 30_000; +/** @type {Map<string, { at: number, value: Promise<any[]> }>} */ +const cache = new Map(); + +/** `~/x` back to an absolute path. */ +export function untildify(p) { + if (typeof p !== "string") return p; + return p === "~" || p.startsWith("~/") ? path.join(/* turbopackIgnore: true */ os.homedir(), p.slice(2)) : p; +} + +/** + * Every report-video project with what linking needs from its manifest: + * `{ id, dir, name, slug, article, generatedBy, title }`. + * + * @param {string} [reportsRoot] + */ +export function videoProjects(reportsRoot = REPORTS_ROOT) { + const hit = cache.get(reportsRoot); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.value; + const value = (async () => { + const out = []; + for (const p of await projectRefs(reportsRoot)) { + if (!kindLinksArticles(p.kind)) continue; + let m = {}; + try { + m = JSON.parse(await readFile(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ p.dir, "video.manifest.json"), "utf8")); + } catch { + // a project with no readable manifest still links by its directory name + } + out.push({ + id: p.id, + dir: p.dir, + name: p.name, + slug: typeof m.slug === "string" && m.slug ? m.slug : p.name, + article: typeof m.article === "string" ? m.article : null, + generatedBy: typeof m.generatedBy === "string" ? m.generatedBy : null, + title: typeof m.title === "string" ? m.title : p.name, + }); + } + return out.sort((a, b) => a.id.localeCompare(b.id)); + })(); + cache.set(reportsRoot, { at: Date.now(), value }); + value.catch(() => cache.delete(reportsRoot)); + return value; +} + +export function clearLinksCache() { + cache.clear(); +} + +const inside = (root, p) => p === root || p.startsWith(root + path.sep); + +/** + * The projects linked to one article: `{ linked, possible, how }`. + * + * @param {string} siteId + * @param {string} reportId + * @param {{ reportsRoot?: string, workspace?: string | null, projects?: any[] }} [opts] + * `workspace` is the article's workspace dir (sourceFor's, `~` allowed); without + * one, slug matches are only ever "possible". + */ +export async function linkedProjects(siteId, reportId, { reportsRoot = REPORTS_ROOT, workspace = null, projects } = {}) { + const all = projects ?? (await videoProjects(reportsRoot)); + const key = `${siteId}/${reportId}`; + const declared = all.filter((p) => p.article === key); + if (declared.length) return { linked: declared, possible: [], how: "manifest names the article" }; + + const keys = idKeys(reportId); + // A manifest that names a DIFFERENT article is never a slug match. + const bySlug = all.filter((p) => !p.article && keys.has(p.slug)); + const ws = workspace ? untildify(workspace) : null; + const inWs = ws ? bySlug.filter((p) => inside(ws, p.dir)) : []; + if (inWs.length === 1) return { linked: inWs, possible: [], how: `slug ${inWs[0].slug} in the article's workspace` }; + const possible = inWs.length > 1 ? inWs : bySlug; + return { linked: [], possible, how: possible.length ? `${possible.length} project(s) share the slug` : "no project" }; +} diff --git a/umtool/lib/articles/sites.ts b/umtool/lib/articles/sites.ts @@ -0,0 +1,177 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite, isListedSite, isPrivateSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site"; +import { listReportDirs, siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia"; +import { parseReport } from "yt-dlp-transcript-common/lib/report/validate"; +import type { Report } from "yt-dlp-transcript-common/lib/report/schema"; +import { CHANNELS_DIR, REPORTS_ROOT, SITES_DIR } from "@/lib/paths"; +import { readNotes } from "@/lib/annotations/store.mjs"; +import { corpusNotesFile } from "@/lib/paths"; +import type { NotesDoc } from "@/lib/annotations/types"; +import { sourceFor } from "./sources.mjs"; +import { linkedProjects, videoProjects } from "./links.mjs"; + +// Every site's articles, as umtool reads them: the site list from common +// (site.json through getSite, so a default is the editor's default), each +// report directory through common's listReportDirs (the editor's report tab +// uses the same enumerator), each report.json through the report document's +// own validator, and published-or-draft from the site's `reports` order. Plus +// what only umtool knows: its notes, its source draft, its video project. +// +// READ-ONLY. The one thing umtool writes under SITES_DIR is a notes.json, and +// that goes through lib/annotations, never here. + +/** common's Paths, with the two roots umtool resolves itself (and e2e confines). */ +export function sitesPaths(): Paths { + return { ...getPaths(), sitesDir: SITES_DIR, channelsDir: CHANNELS_DIR }; +} + +export type ArticleStatus = "published" | "draft"; + +export type ProjectLinkRow = { id: string; title: string; slug: string }; + +export type ArticleRow = { + site: string; + id: string; + title: string; + series: string | null; + kind: string | null; + status: ArticleStatus; + published: string | null; + updated: string | null; + /** report.json's mtime, for "updated" when the report names no date. */ + mtimeMs: number | null; + citations: number; + notes: number; + openNotes: number; + hasVideo: boolean; + hasPoster: boolean; + /** report.json missing, unparseable, or invalid: the first problem, else null. */ + problem: string | null; + problems: number; + source: { draft?: string; generator?: string; how?: string; workspace?: string } | null; + projects: { linked: ProjectLinkRow[]; possible: ProjectLinkRow[] }; +}; + +export type SiteRow = { + siteId: string; + title: string; + private: boolean; + listed: boolean; + search: boolean; + published: number; + drafts: number; + openNotes: number; + articles: ArticleRow[]; +}; + +const exists = (p: string) => stat(/* turbopackIgnore: true */ p).then((s) => s.isFile(), () => false); + +export type ArticleRead = { + report: Report | null; + problems: { path?: string; message: string }[]; + mtimeMs: number | null; +}; + +/** One report.json, read and validated; never throws. */ +export async function readReportFile(siteId: string, reportId: string): Promise<ArticleRead> { + const file = path.join(/* turbopackIgnore: true */ siteReportDir(sitesPaths(), siteId, reportId), "report.json"); + let text: string; + let mtimeMs: number | null = null; + try { + const [t, st] = await Promise.all([readFile(/* turbopackIgnore: true */ file, "utf8"), stat(/* turbopackIgnore: true */ file)]); + text = t; + mtimeMs = Math.round(st.mtimeMs); + } catch { + return { report: null, problems: [{ message: "no report.json" }], mtimeMs: null }; + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (err) { + return { report: null, problems: [{ message: `report.json is not JSON: ${(err as Error).message}` }], mtimeMs }; + } + const parsed = parseReport(raw, { id: reportId }); + if (!parsed.ok) return { report: null, problems: parsed.problems, mtimeMs }; + return { report: parsed.value, problems: parsed.problems, mtimeMs }; +} + +export async function readArticleNotes(siteId: string, reportId: string): Promise<{ doc: NotesDoc | null; token: string; error?: string }> { + const file = corpusNotesFile(siteId, reportId); + if (!file) return { doc: null, token: "absent" }; + return readNotes(file); +} + +async function articleRow(site: Site, id: string, published: Set<string>, projects: Awaited<ReturnType<typeof videoProjects>>): Promise<ArticleRow> { + const [read, notes, source] = await Promise.all([ + readReportFile(site.siteId, id), + readArticleNotes(site.siteId, id), + sourceFor(site.siteId, id, { reportsRoot: REPORTS_ROOT }), + ]); + const dir = siteReportDir(sitesPaths(), site.siteId, id); + const r = read.report; + const links = await linkedProjects(site.siteId, id, { workspace: source?.workspace ?? null, projects }); + const row = (p: { id: string; title: string; slug: string }) => ({ id: p.id, title: p.title, slug: p.slug }); + return { + site: site.siteId, + id, + title: r?.title ?? id, + series: r?.series ?? null, + kind: r?.kind ?? null, + status: published.has(id) ? "published" : "draft", + published: r?.published ?? null, + updated: r?.updated ?? r?.published ?? null, + mtimeMs: read.mtimeMs, + citations: Object.keys(r?.citations ?? {}).length, + notes: notes.doc?.notes.length ?? 0, + openNotes: notes.doc?.notes.filter((n) => n.status === "open").length ?? 0, + hasVideo: !!r?.video?.src && (await exists(path.join(/* turbopackIgnore: true */ dir, r.video.src))), + hasPoster: !!r?.video?.poster && (await exists(path.join(/* turbopackIgnore: true */ dir, r.video.poster))), + problem: read.problems[0]?.message ?? null, + problems: read.problems.length, + source, + projects: { linked: links.linked.map(row), possible: links.possible.map(row) }, + }; +} + +function siteFlags(site: Site) { + return { private: isPrivateSite(site), listed: isListedSite(site), search: site.search !== false }; +} + +/** One site with every article, published (in the site's order) then drafts (by id). */ +export async function readSiteRow(site: Site, projects?: Awaited<ReturnType<typeof videoProjects>>): Promise<SiteRow> { + const all = projects ?? (await videoProjects(REPORTS_ROOT)); + const published = new Set(site.reports ?? []); + const dirs = await listReportDirs(sitesPaths(), site.siteId); + const ids = [...(site.reports ?? []), ...dirs.filter((d) => !published.has(d))]; + const articles = await Promise.all(ids.map((id) => articleRow(site, id, published, all))); + return { + siteId: site.siteId, + title: site.siteTitle || site.siteId, + ...siteFlags(site), + published: articles.filter((a) => a.status === "published").length, + drafts: articles.filter((a) => a.status === "draft").length, + openNotes: articles.reduce((n, a) => n + a.openNotes, 0), + articles, + }; +} + +/** Every site, private first, then by id. */ +export async function listSiteRows(): Promise<SiteRow[]> { + const projects = await videoProjects(REPORTS_ROOT); + const sites = listSites(sitesPaths()); + const rows = await Promise.all(sites.map((s) => readSiteRow(s, projects))); + return rows.sort((a, b) => Number(b.private) - Number(a.private) || a.siteId.localeCompare(b.siteId)); +} + +/** A site by id, or null (a bad id or no site.json). */ +export function siteById(siteId: string): Site | null { + try { + const paths = sitesPaths(); + if (!listSites(paths).some((s) => s.siteId === siteId)) return null; + return getSite(siteId, paths); + } catch { + return null; + } +} diff --git a/umtool/lib/articles/sources.mjs b/umtool/lib/articles/sources.mjs @@ -0,0 +1,219 @@ +// Which file an agent should EDIT to change an article. +// +// A report.json under transcripts/sites/ is generated: a workspace under +// ~/reports keeps the draft (`<ws>/polemics/drafts/<slug>.json`, the source of +// truth) and a generator script that writes report.json from it +// (`<ws>/polemics/make-site.py`, `<ws>/site/polemics.py`, ...). A note that +// says "fix this sentence" is useless to an agent that edits report.json -- the +// next generator run puts the old sentence back. So every notes.json carries a +// `source` block naming the draft and the generator, found here. +// +// The match is a heuristic, written down with its reason (`how`), and the +// agent may correct it (`umtool notes source`): +// +// draft a drafts/*.json whose `id`, or file name, is the report id -- +// allowing for a `polemic-` prefix on either side (candalyzer's +// polemic-israel is drafts/israel.json with id polemic-israel; +// jeralyzer-private's `blame` is drafts/blame.json with id +// polemic-blame). Several matches: the one whose workspace has a +// generator naming the site wins; still several, none is chosen. +// generator a *.py / *.mts under <ws>/polemics or <ws>/site that names the +// site (or the report id), preferring one that names the report +// id itself, then one that reads the drafts. Backups +// (`make-report.pre-2026-10-05.py`: a second dot) are skipped. +// +// Cheap: one readdir per workspace and one read per generator, cached for 30 s +// like the project walk. +import { readdir, readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { REPORTS_ROOT } from "../paths.mjs"; + +const CACHE_MS = 30_000; +const GEN_DIRS = ["polemics", "site"]; +const GEN_EXT = /^[^.]+\.(py|mts|mjs|sh)$/; +const MAX_GEN_BYTES = 2 * 1024 * 1024; + +/** `~/…` for a path under the home directory: what a note shows an agent. */ +export function tildify(abs) { + const home = os.homedir(); + return abs === home || abs.startsWith(home + path.sep) ? `~${abs.slice(home.length)}` : abs; +} + +/** The ids a report or draft may go by: itself, without `polemic-`, with it. */ +export function idKeys(id) { + const bare = id.replace(/^polemic-/, ""); + return new Set([id, bare, `polemic-${bare}`]); +} + +/** Does `text` name `id` as a whole token (so `jasolyzer` is not `jasolyzer-private`)? */ +export function names(text, id) { + const esc = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?<![A-Za-z0-9_-])${esc}(?![A-Za-z0-9_-])`).test(text); +} + +const isDir = (p) => stat(/* turbopackIgnore: true */ p).then((s) => s.isDirectory(), () => false); + +/** @type {Map<string, { at: number, value: Promise<any[]> }>} */ +const cache = new Map(); + +/** + * Every workspace under `reportsRoot` that has drafts or a generator dir: + * `{ dir, name, drafts: [{ file, slug, id }], generators: [{ file, text }] }`. + * + * @param {string} [reportsRoot] + */ +export function scanWorkspaces(reportsRoot = REPORTS_ROOT) { + const hit = cache.get(reportsRoot); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.value; + const value = scan(reportsRoot); + cache.set(reportsRoot, { at: Date.now(), value }); + value.catch(() => cache.delete(reportsRoot)); + return value; +} + +/** Forget the scan (a test that writes a fixture, then reads it). */ +export function clearSourcesCache() { + cache.clear(); +} + +async function scan(reportsRoot) { + const entries = await readdir(/* turbopackIgnore: true */ reportsRoot, { withFileTypes: true }).catch(() => []); + const out = []; + for (const e of entries) { + if (e.name.startsWith(".") || e.name === "data") continue; + const dir = path.join(/* turbopackIgnore: true */ reportsRoot, e.name); + if (!(e.isDirectory() || (e.isSymbolicLink() && (await isDir(dir))))) continue; + const drafts = []; + const draftsDir = path.join(/* turbopackIgnore: true */ dir, "polemics", "drafts"); + for (const f of await readdir(/* turbopackIgnore: true */ draftsDir).catch(() => [])) { + if (!f.endsWith(".json")) continue; + const file = path.join(/* turbopackIgnore: true */ draftsDir, f); + let id = null; + try { + const j = JSON.parse(await readFile(/* turbopackIgnore: true */ file, "utf8")); + if (j && typeof j.id === "string") id = j.id; + } catch { + // an unreadable draft still matches by its file name + } + drafts.push({ file, slug: f.slice(0, -5), id }); + } + const generators = []; + for (const g of GEN_DIRS) { + const gdir = path.join(/* turbopackIgnore: true */ dir, g); + for (const f of await readdir(/* turbopackIgnore: true */ gdir).catch(() => [])) { + if (!GEN_EXT.test(f)) continue; + const file = path.join(/* turbopackIgnore: true */ gdir, f); + const st = await stat(/* turbopackIgnore: true */ file).catch(() => null); + if (!st?.isFile() || st.size > MAX_GEN_BYTES) continue; + generators.push({ file, text: await readFile(/* turbopackIgnore: true */ file, "utf8").catch(() => "") }); + } + } + if (drafts.length || generators.length) { + drafts.sort((a, b) => a.file.localeCompare(b.file)); + generators.sort((a, b) => a.file.localeCompare(b.file)); + out.push({ dir, name: e.name, drafts, generators }); + } + } + return out.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Is `draft` this report's? */ +function draftMatches(draft, keys) { + return (draft.id !== null && keys.has(draft.id)) || keys.has(draft.slug) || keys.has(`polemic-${draft.slug}`); +} + +/** + * The source of truth for one report, or null when no workspace claims it. + * + * @param {string} siteId + * @param {string} reportId + * @param {{ reportsRoot?: string }} [opts] + * @returns {Promise<{ draft?: string, generator?: string, how: string, workspace?: string } | null>} + */ +export async function sourceFor(siteId, reportId, { reportsRoot = REPORTS_ROOT } = {}) { + const workspaces = await scanWorkspaces(reportsRoot); + const keys = idKeys(reportId); + const namesSite = (ws) => ws.generators.some((g) => names(g.text, siteId)); + + const cands = []; + for (const ws of workspaces) { + for (const d of ws.drafts) { + if (!draftMatches(d, keys)) continue; + const score = (namesSite(ws) ? 4 : 0) + (d.id === reportId ? 2 : 0) + (d.slug === reportId.replace(/^polemic-/, "") ? 1 : 0); + cands.push({ ws, d, score }); + } + } + cands.sort((a, b) => b.score - a.score); + const top = cands[0]; + const unique = top && (cands.length === 1 || cands[1].score < top.score); + const draft = unique ? top : null; + + const pool = draft ? [draft.ws] : workspaces; + let gen = null; + let genScore = 0; + for (const ws of pool) { + for (const g of ws.generators) { + const site = names(g.text, siteId); + const report = names(g.text, reportId); + if (!site && !report) continue; + const base = path.basename(g.file); + const score = + (report ? 3 : 0) + (site ? 2 : 0) + (draft && /\bdrafts\b/.test(g.text) ? 1 : 0) + (/^(make-site|polemics)\./.test(base) ? 0.5 : 0); + if (score > genScore) { + gen = { ws, g }; + genScore = score; + } + } + } + // Without a draft, a generator that only names the SITE is every report's + // generator and says nothing about this one; keep it only if it names the id. + // A bare id (`deleted`, `poker`) is also an English word, so naming it is + // only evidence when the generator names the site too. + if (!draft && gen && !(names(gen.g.text, reportId) && (reportId.includes("-") || names(gen.g.text, siteId)))) gen = null; + if (!draft && !gen) { + if (cands.length > 1) { + return { how: `several drafts match ${reportId}: ${cands.map((c) => tildify(c.d.file)).join(", ")}; none chosen` }; + } + return null; + } + + const how = []; + if (draft) { + const by = draft.d.id === reportId ? `id ${reportId}` : draft.d.id && keys.has(draft.d.id) ? `id ${draft.d.id}` : `file name ${draft.d.slug}`; + how.push(`draft matched by ${by}`); + if (cands.length > 1) how.push(`preferred over ${cands.length - 1} other`); + } + if (gen) { + const named = [siteId, reportId].filter((id) => names(gen.g.text, id)); + how.push(`generator names ${named.join(" and ")}`); + } + const out = { how: how.join("; ") }; + if (draft) out.draft = tildify(draft.d.file); + if (gen) out.generator = tildify(gen.g.file); + out.workspace = tildify((draft?.ws ?? gen?.ws).dir); + return out; +} + +/** + * The workspace directories a site's articles come from: every workspace that + * holds a matched draft for one of `reportIds`, or a generator that names the + * site. Absolute paths, sorted. + * + * @param {string} siteId + * @param {string[]} reportIds + * @param {{ reportsRoot?: string }} [opts] + */ +export async function siteWorkspaces(siteId, reportIds, { reportsRoot = REPORTS_ROOT } = {}) { + const workspaces = await scanWorkspaces(reportsRoot); + const dirs = new Set(); + for (const ws of workspaces) { + if (ws.generators.some((g) => names(g.text, siteId))) dirs.add(ws.dir); + } + for (const id of reportIds) { + const s = await sourceFor(siteId, id, { reportsRoot }); + const ws = s?.draft ? workspaces.find((w) => w.drafts.some((d) => tildify(d.file) === s.draft)) : null; + if (ws) dirs.add(ws.dir); + } + return [...dirs].sort(); +} diff --git a/umtool/lib/articles/sources.test.mjs b/umtool/lib/articles/sources.test.mjs @@ -0,0 +1,69 @@ +// Finding an article's draft and generator, on the three workspace layouts +// the live private sites use. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { clearSourcesCache, names, siteWorkspaces, sourceFor } from "./sources.mjs"; + +async function put(file, text) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, text); +} + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "umtool-sources-")); + // candalyzer: drafts/<bare>.json with id polemic-<bare>; generator site/polemics.py; + // a hand-written fact-check whose generator names it; a backup generator. + await put(path.join(root, "candace/polemics/drafts/israel.json"), JSON.stringify({ id: "polemic-israel" })); + await put(path.join(root, "candace/site/polemics.py"), 'OUT = ".../sites/candalyzer/reports"\nfor f in DRAFTS.glob("drafts/*.json"): rid = f"polemic-{slug}"\n'); + await put(path.join(root, "candace/site/make-report.py"), 'SITE = "candalyzer"\nREPORT = "deconstruction-fact-check"\n'); + await put(path.join(root, "candace/site/make-report.pre-2026-10-05.py"), 'REPORT = "polemic-israel" # candalyzer\n'); + // jasolyzer-private: drafts/<bare>.json, generator polemics/make-site.py + await put(path.join(root, "pirate/polemics/drafts/skg.json"), JSON.stringify({ id: "polemic-skg" })); + await put(path.join(root, "pirate/polemics/make-site.py"), 'SITE = "jasolyzer-private"\nrid = f"polemic-{slug}" # from drafts\n'); + // jeralyzer-private: the report id is BARE (`blame`), the draft id is polemic-blame + await put(path.join(root, "quartering/polemics/drafts/blame.json"), JSON.stringify({ id: "polemic-blame" })); + await put(path.join(root, "quartering/polemics/make-site.py"), 'SITE = "jeralyzer-private"\nrid = slug # drafts\n'); + // a decoy: a public site's workspace with a same-named draft + await put(path.join(root, "decoy/polemics/drafts/blame.json"), JSON.stringify({ id: "blame" })); + await put(path.join(root, "decoy/polemics/make-site.py"), 'SITE = "jeralyzer"\n'); + clearSourcesCache(); + return root; +} + +test("each layout finds its draft and generator", async () => { + const root = await fixture(); + const o = { reportsRoot: root }; + const isr = await sourceFor("candalyzer", "polemic-israel", o); + assert.equal(isr.draft, path.join(root, "candace/polemics/drafts/israel.json")); + assert.equal(isr.generator, path.join(root, "candace/site/polemics.py")); + assert.match(isr.how, /draft matched by id polemic-israel/); + + const fc = await sourceFor("candalyzer", "deconstruction-fact-check", o); + assert.equal(fc.draft, undefined); + assert.equal(fc.generator, path.join(root, "candace/site/make-report.py")); + + const skg = await sourceFor("jasolyzer-private", "polemic-skg", o); + assert.equal(skg.draft, path.join(root, "pirate/polemics/drafts/skg.json")); + assert.equal(skg.generator, path.join(root, "pirate/polemics/make-site.py")); + + // The decoy's draft id is exactly `blame`, but its workspace never names the site. + const blame = await sourceFor("jeralyzer-private", "blame", o); + assert.equal(blame.draft, path.join(root, "quartering/polemics/drafts/blame.json")); + assert.equal(blame.generator, path.join(root, "quartering/polemics/make-site.py")); + assert.match(blame.how, /preferred over 1 other/); + + assert.equal(await sourceFor("candalyzer", "no-such-report", o), null); + assert.deepEqual(await siteWorkspaces("jasolyzer-private", ["polemic-skg"], o), [path.join(root, "pirate")]); + await rm(root, { recursive: true }); +}); + +test("names() matches whole ids only", () => { + assert.equal(names('"jasolyzer-private"', "jasolyzer"), false); + assert.equal(names("sites/jasolyzer/reports", "jasolyzer"), true); + assert.equal(names("polemic-blame", "blame"), false); +}); diff --git a/umtool/lib/articles/workspace.mjs b/umtool/lib/articles/workspace.mjs @@ -0,0 +1,65 @@ +// The files an article was WRITTEN from, for reading beside it: a workspace's +// drafts, the rendered drafts and briefs under polemics/, and the notes a +// workspace keeps at its top level. Read-only -- this lists and serves, it +// never writes a workspace. +// +// polemics/drafts/*.json the drafts (source of truth) +// polemics/out/*.{md,html} what the drafts render to +// polemics/*.md BRIEF.md, NOTES.md, PRIVACY-SWEEP.md, … +// <ws>/{NOTES,BRIEF,LEADS,PLAN,PRIVACY-SWEEP}.md +// <ws>/site/PLAN.md, <ws>/site/MERGE-PLAN.md +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { REPORTS_ROOT } from "../paths.mjs"; + +export const TOP_LEVEL = ["NOTES.md", "BRIEF.md", "LEADS.md", "PLAN.md", "PRIVACY-SWEEP.md"]; +const SITE_LEVEL = ["PLAN.md", "MERGE-PLAN.md"]; +export const WORKSPACE_EXT = /\.(md|html|json)$/; + +const statFile = (p) => stat(/* turbopackIgnore: true */ p).then((s) => (s.isFile() ? s : null), () => null); + +/** + * Every listed file of one workspace: `{ rel, abs, kind, bytes, mtimeMs }`, + * `kind` one of "draft" | "out" | "doc". Sorted: drafts, docs, outputs; by name. + * + * @param {string} wsDir + */ +export async function workspaceFiles(wsDir) { + const out = []; + const add = async (rel, kind) => { + const abs = path.join(/* turbopackIgnore: true */ wsDir, rel); + const st = await statFile(abs); + if (st) out.push({ rel, abs, kind, bytes: st.size, mtimeMs: Math.round(st.mtimeMs) }); + }; + const ls = (rel) => readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ wsDir, rel)).catch(() => []); + for (const f of await ls("polemics/drafts")) if (f.endsWith(".json")) await add(`polemics/drafts/${f}`, "draft"); + for (const f of await ls("polemics/out")) if (/\.(md|html)$/.test(f)) await add(`polemics/out/${f}`, "out"); + for (const f of await ls("polemics")) if (f.endsWith(".md")) await add(`polemics/${f}`, "doc"); + for (const f of TOP_LEVEL) await add(f, "doc"); + for (const f of SITE_LEVEL) await add(`site/${f}`, "doc"); + const rank = { draft: 0, doc: 1, out: 2 }; + return out.sort((a, b) => rank[a.kind] - rank[b.kind] || a.rel.localeCompare(b.rel)); +} + +/** + * A workspace file a client named, or null: `ws` must be a directory directly + * under REPORTS_ROOT and `rel` one of the files workspaceFiles lists for it, + * and its REAL path must stay inside the workspace. + * + * @param {string} ws the workspace's name under REPORTS_ROOT + * @param {string} rel + * @param {{ reportsRoot?: string }} [opts] + */ +export async function workspaceFile(ws, rel, { reportsRoot = REPORTS_ROOT } = {}) { + if (typeof ws !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(ws) || ws === "..") return null; + if (typeof rel !== "string" || rel.includes("\0") || rel.split("/").some((s) => s === ".." || s === "" || s === ".")) return null; + const dir = path.join(/* turbopackIgnore: true */ reportsRoot, ws); + const listed = (await workspaceFiles(dir)).find((f) => f.rel === rel); + if (!listed) return null; + const [realDir, realAbs] = await Promise.all([ + realpath(/* turbopackIgnore: true */ dir).catch(() => null), + realpath(/* turbopackIgnore: true */ listed.abs).catch(() => null), + ]); + if (!realDir || !realAbs || !realAbs.startsWith(realDir + path.sep)) return null; + return { ...listed, real: realAbs }; +} diff --git a/umtool/lib/decisions.ts b/umtool/lib/decisions.ts @@ -4,6 +4,7 @@ import { DEFAULT_TARGET, loudnessVerdict } from "./loudness-types"; import { buildStatus, readManifest } from "./manifest"; import { readSpec, validateSpec } from "./spec"; import { readNotes, type NoteMap } from "./notes"; +import { listNotesFiles } from "./annotations/targets.mjs"; import { acceptedFor, readThumbAccepted, readThumbManifest, thumbNamesFor } from "./thumbs"; // --------------------------------------------------------------------------- @@ -298,3 +299,79 @@ export function decisionsMarkdown(items: Decision[]): string[] { } return out; } + +// --------------------------------------------------------------------------- +// Open NOTES -- on an article (sites/<site>/reports/<id>/notes.json) or on a +// report-video project (<project>/notes.json) -- are open decisions: somebody +// asked for a change and nobody has answered it. One row per open note, kind +// `open-note`, linked to the note on its page. A notes.json that does not +// parse is blocking: nothing can write to it until somebody fixes it by hand. +// +// An ARTICLE is not a project, so its row's `project` is its page's path +// (`sites/<site>/<report>`), which is also where its href points. +// --------------------------------------------------------------------------- + +const firstLine = (s: string, max = 140) => { + const line = s.split("\n").find((l) => l.trim()) ?? ""; + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +}; + +function anchorLabel(a: { kind: string; [k: string]: unknown }): string { + switch (a.kind) { + case "text": + return `“${firstLine(String(a.quote ?? ""), 48)}”`; + case "cite": + return `cite ${a.cite}`; + case "section": + return `section ${a.section}`; + case "moment": + return `${a.file} @ ${Number(a.t).toFixed(1)}s`; + case "entry": + return `entry ${a.entry}`; + case "take": + return `take ${a.take}`; + case "edit": + return `edit ${a.field}${a.entry ? ` on ${a.entry}` : ""}`; + default: + return "whole"; + } +} + +export async function noteDecisions(): Promise<Decision[]> { + const files = await listNotesFiles().catch(() => []); + const out: Decision[] = []; + for (const f of files) { + const article = f.kind === "article"; + const project = article ? `sites/${f.id}` : f.id; + const page = article ? `/sites/${f.id}` : `/browse/${f.id}`; + if (f.error || !f.doc) { + out.push({ + kind: "unreadable-notes", + project, + projectKind: article ? "article" : ((f as { projectKind?: string }).projectKind ?? "project"), + target: "notes.json", + why: `notes.json does not parse (${f.error ?? "unknown"}); nothing will write to it until it is fixed`, + href: page, + severity: "blocking", + at: Date.now(), + }); + continue; + } + for (const n of f.doc.notes) { + if (n.status !== "open") continue; + const replies = n.replies.length ? ` · ${n.replies.length} repl${n.replies.length === 1 ? "y" : "ies"}` : ""; + const at = Date.parse(n.updatedAt); + out.push({ + kind: "open-note", + project, + projectKind: article ? "article" : ((f as { projectKind?: string }).projectKind ?? "project"), + target: anchorLabel(n.anchor as { kind: string }), + why: `${firstLine(n.text)}${replies}`, + href: `${page}?note=${encodeURIComponent(n.id)}`, + severity: "open", + at: Number.isFinite(at) ? at : 0, + }); + } + } + return out; +} diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs @@ -5,6 +5,7 @@ // `umtool ls` and the page it is supposed to describe. lib/paths.ts re-exports // everything here with types; nothing computes a root twice. import { existsSync } from "node:fs"; +import { lstat, realpath } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { SONG_DATA, SONG_REPORTS } from "../song/paths.mjs"; @@ -208,12 +209,74 @@ export const CHANNELS_DIR = path.resolve( : path.join(/* turbopackIgnore: true */ REPO_ROOT, "transcripts", "channels")), ); +// The SITES -- `transcripts/sites/<site>/`, each a site.json and its reports +// (`reports/<id>/report.json`, `video.mp4`, `poster.jpg`). Resolved the way +// CHANNELS_DIR is, and the way common/lib/paths.ts resolves its sitesDir, so +// one `SITES_DIR` confines both. Readable only; the ONE file in it umtool may +// write is a report's notes.json, and only through isCorpusNotesFile below. +export const SITES_DIR = path.resolve( + /* turbopackIgnore: true */ + process.env.SITES_DIR ?? + (process.env.TRANSCRIPTS_DIR + ? path.join(/* turbopackIgnore: true */ process.env.TRANSCRIPTS_DIR, "sites") + : path.join(/* turbopackIgnore: true */ REPO_ROOT, "transcripts", "sites")), +); + export const READ_ROOTS = dedupe( process.env.MIX_ROOTS ? process.env.MIX_ROOTS.split(":").filter(Boolean) - : [SONG_REPORTS, REPORTS_ROOT, SONG_DATA, SONG_SCRATCH, CHANNELS_DIR, MEDIA_ROOT], + : [SONG_REPORTS, REPORTS_ROOT, SONG_DATA, SONG_SCRATCH, CHANNELS_DIR, MEDIA_ROOT, SITES_DIR], ); +/** A site id or a report id: one lowercase url-safe segment (common/lib/report/schema.ts REPORT_ID_RE). */ +export const SEGMENT_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; +export const NOTES_FILENAME = "notes.json"; + +/** + * The ONE corpus path umtool may write: `SITES_DIR/<site>/reports/<id>/notes.json`. + * + * Lexically: exactly four segments under SITES_DIR, the second `reports`, the + * site and report ids in the report-id grammar, the file named notes.json, and + * no `..` or doubled separator anywhere. Then on disk: the report directory's + * REAL path must be the lexical one under the real SITES_DIR -- a report dir + * that is a symlink out of its site (or a site dir that is one) is refused -- + * and it must already exist: a note never creates a report directory. A + * notes.json that exists and is not a plain file (a symlink) is refused too. + * + * WRITE_ROOTS is untouched: nothing else in the corpus becomes writable. + * + * @param {string} abs + * @param {{ sitesDir?: string }} [opts] + * @returns {Promise<boolean>} + */ +export async function isCorpusNotesFile(abs, { sitesDir = SITES_DIR } = {}) { + if (typeof abs !== "string" || !path.isAbsolute(abs) || abs.includes("\0")) return false; + if (path.resolve(/* turbopackIgnore: true */ abs) !== abs) return false; + const root = path.resolve(/* turbopackIgnore: true */ sitesDir); + const rel = path.relative(/* turbopackIgnore: true */ root, abs); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false; + const parts = rel.split(path.sep); + if (parts.length !== 4) return false; + const [site, reports, report, name] = parts; + if (!SEGMENT_RE.test(site) || reports !== "reports" || !SEGMENT_RE.test(report) || name !== NOTES_FILENAME) { + return false; + } + const [realRoot, realDir] = await Promise.all([ + realpath(/* turbopackIgnore: true */ root).catch(() => null), + realpath(/* turbopackIgnore: true */ path.dirname(/* turbopackIgnore: true */ abs)).catch(() => null), + ]); + if (!realRoot || !realDir) return false; + if (realDir !== path.join(/* turbopackIgnore: true */ realRoot, site, "reports", report)) return false; + const st = await lstat(/* turbopackIgnore: true */ abs).catch(() => null); + return !st || st.isFile(); +} + +/** `SITES_DIR/<site>/reports/<report>/notes.json`, or null for a bad id. */ +export function corpusNotesFile(site, report, { sitesDir = SITES_DIR } = {}) { + if (!SEGMENT_RE.test(String(site)) || !SEGMENT_RE.test(String(report))) return null; + return path.join(/* turbopackIgnore: true */ path.resolve(/* turbopackIgnore: true */ sitesDir), site, "reports", report, NOTES_FILENAME); +} + export const WRITE_ROOTS = dedupe( process.env.MIX_WRITE_ROOTS ? process.env.MIX_WRITE_ROOTS.split(":").filter(Boolean) diff --git a/umtool/lib/paths.ts b/umtool/lib/paths.ts @@ -20,7 +20,9 @@ import path from "node:path"; // --------------------------------------------------------------------------- export { CACHE_DIR, + CHANNELS_DIR, cacheFile, + corpusNotesFile, INDEX_DIR, MEDIA_ROOT, MEDIA_ROOTS, @@ -31,8 +33,10 @@ export { SONG_DATA, SONG_REPORTS, SONG_SCRATCH, + SITES_DIR, WRITE_ROOTS, inside, + isCorpusNotesFile, labelFor, mediaMirror, resolveInRoots, diff --git a/umtool/lib/projects.ts b/umtool/lib/projects.ts @@ -5,7 +5,7 @@ import { collapseFolders, foldersFor, walkProjects } from "./projects/walk.mjs"; import { SONG_KIND } from "./projects/song.mjs"; import { openIndex, signRecord } from "./projects/index-db.mjs"; import { BROWSE_ROOT } from "./browse"; -import { decisionsForSong } from "./decisions"; +import { decisionsForSong, noteDecisions } from "./decisions"; import { listMedia, listMediaUnder, type MediaRow } from "./media"; import type { Decision } from "./decisions"; import type { @@ -302,8 +302,10 @@ const RANK: Record<string, number> = { blocking: 0, open: 1, info: 2 }; export async function openDecisions(): Promise<Decision[]> { const refs = await projectRefs(); const per = await Promise.all(refs.map(decisionsForProject)); - return per - .flat() + // Open notes on articles and report videos (lib/decisions.ts noteDecisions): + // an article is not a project, so they are added here, not per project. + const notes = await noteDecisions(); + return [...per.flat(), ...notes] .sort((a, b) => RANK[a.severity] - RANK[b.severity] || b.at - a.at); } diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -104,6 +104,12 @@ 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, + // 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", @@ -182,6 +188,12 @@ 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; + +/** 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, diff --git a/umtool/lib/report/edit-notes.mjs b/umtool/lib/report/edit-notes.mjs @@ -0,0 +1,174 @@ +// An edit made in umtool to a GENERATED manifest, written down for the agent +// that generates it. +// +// A manifest with `generatedBy` (polemics/video/make-videos.py, …) is rebuilt +// from the generator's inputs, and the rebuild overwrites whatever was edited +// here. So edits are still allowed -- the operator is watching the cut and the +// fix belongs there -- and each one becomes an `edit` note in the project's +// notes.json (lib/annotations/): which entry, which field, from what, to what. +// The agent ports it into the generator's inputs (BEATS, drafts) and resolves +// the note, and the next rebuild keeps it. +// +// ONE wrapper does this for every manifest writer (lib/report/guard.ts), by +// diffing the manifest before and after the write -- so a writer added later +// is covered without knowing this exists. +// +// Repeated saves of one field COALESCE: an open edit note on the same entry and +// field keeps its original `from` and takes the new `to`, and an edit that +// returns the field to its `from` deletes the note. Dragging a window five +// times is one note, and dragging it back is none. +import { readNotes } from "../annotations/store.mjs"; +import { writeNote } from "../annotations/targets.mjs"; + +const MAX_VALUE = 3000; + +/** A value small enough to keep in a note; a large one is summarised. */ +function keep(v) { + if (v === undefined) return null; + const s = JSON.stringify(v); + if (s.length <= MAX_VALUE) return v; + return `(${Array.isArray(v) ? `${v.length} items` : "object"}, ${s.length} characters)`; +} + +const ID_LISTS = ["posts", "ledger"]; + +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +/** Key the timeline by `id` (and its variant, since twins share an id). */ +function entryKey(e) { + return e?.variant ? `${e.id}@${e.variant}` : String(e?.id); +} + +/** + * Every change between two manifests, as `{ entry?, field, from, to }`. + * + * timeline entry field { entry: id, field: "<key>" } + * an entry added { entry: id, field: "timeline+", from: null, to: <entry> } + * an entry removed { entry: id, field: "timeline-", from: <entry>, to: null } + * the order { field: "timeline.order", from: [ids], to: [ids] } (survivors only) + * a post, a ledger claim { entry: id, field: "posts.<key>" | "posts+" | "posts-" } (ledger alike) + * anything else { field: "<top>.<key>" } one level down (render.chrome, provenance.siteOrigin) + * + * @param {Record<string, any>} before + * @param {Record<string, any>} after + */ +export function editsBetween(before, after) { + const out = []; + const a = (before?.timeline ?? []).filter((e) => e && e.id != null); + const b = (after?.timeline ?? []).filter((e) => e && e.id != null); + const byA = new Map(a.map((e) => [entryKey(e), e])); + const byB = new Map(b.map((e) => [entryKey(e), e])); + for (const [k, e] of byA) if (!byB.has(k)) out.push({ entry: String(e.id), field: "timeline-", from: keep(e), to: null }); + for (const [k, e] of byB) if (!byA.has(k)) out.push({ entry: String(e.id), field: "timeline+", from: null, to: keep(e) }); + const sa = a.map(entryKey).filter((k) => byB.has(k)); + const sb = b.map(entryKey).filter((k) => byA.has(k)); + if (!same(sa, sb)) out.push({ field: "timeline.order", from: keep(sa), to: keep(sb) }); + for (const [k, x] of byA) { + const y = byB.get(k); + if (!y) continue; + for (const f of new Set([...Object.keys(x), ...Object.keys(y)])) { + // sectionEnter follows the order; the order change already says it. + if (f === "sectionEnter" || same(x[f], y[f])) continue; + out.push({ entry: String(x.id), field: f, from: keep(x[f]), to: keep(y[f]) }); + } + } + + // Lists of things with ids -- the posts, the ledger's claims -- by id. + for (const list of ID_LISTS) { + const pa = new Map((Array.isArray(before?.[list]) ? before[list] : []).map((p) => [String(p?.id), p])); + const pb = new Map((Array.isArray(after?.[list]) ? after[list] : []).map((p) => [String(p?.id), p])); + for (const [id, p] of pa) if (!pb.has(id)) out.push({ entry: id, field: `${list}-`, from: keep(p), to: null }); + for (const [id, p] of pb) { + const q = pa.get(id); + if (!q) { + out.push({ entry: id, field: `${list}+`, from: null, to: keep(p) }); + continue; + } + for (const f of new Set([...Object.keys(q), ...Object.keys(p)])) { + if (!same(q[f], p[f])) out.push({ entry: id, field: `${list}.${f}`, from: keep(q[f]), to: keep(p[f]) }); + } + } + } + + for (const top of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) { + if (top === "timeline" || ID_LISTS.includes(top)) continue; + const x = before?.[top]; + const y = after?.[top]; + if (same(x, y)) continue; + const isObj = (v) => v && typeof v === "object" && !Array.isArray(v); + if (isObj(x) && isObj(y)) { + for (const f of new Set([...Object.keys(x), ...Object.keys(y)])) { + if (!same(x[f], y[f])) out.push({ field: `${top}.${f}`, from: keep(x[f]), to: keep(y[f]) }); + } + } else { + out.push({ field: top, from: keep(x), to: keep(y) }); + } + } + return out; +} + +/** The sentence an edit note carries; the anchor carries the values. */ +export function editText(edit, generatedBy) { + const where = edit.entry ? `${edit.entry} ` : ""; + const what = + edit.field === "timeline+" + ? "added to the timeline" + : edit.field === "timeline-" + ? "removed from the timeline" + : edit.field === "timeline.order" + ? "the timeline was re-ordered" + : edit.field.endsWith("+") + ? `${edit.field.slice(0, -1)} entry added` + : edit.field.endsWith("-") + ? `${edit.field.slice(0, -1)} entry removed` + : `${edit.field} changed`; + return `${where}${what} in umtool. Port it into the inputs of ${generatedBy}; a rebuild of manifests overwrites it.`; +} + +/** + * Write `edits` into a project's notes as `edit` notes, coalescing with open + * ones on the same entry and field (see the top of this file). Returns how many + * notes were added, updated and deleted. Errors are returned, not thrown: the + * manifest write already happened, and a notes file that will not take a note + * must not turn a saved edit into a reported failure. + * + * @param {{ file: string, subject: Record<string, string>, source: () => Promise<any> }} target + * @param {Array<{ entry?: string, field: string, from: unknown, to: unknown }>} edits + * @param {string} generatedBy + */ +export async function recordEdits(target, edits, generatedBy) { + const counts = { added: 0, updated: 0, deleted: 0, errors: /** @type {string[]} */ ([]) }; + for (const edit of edits) { + try { + const { doc } = await readNotes(target.file); + const open = (doc?.notes ?? []).find( + (n) => + n.status === "open" && + n.author === "operator" && + n.anchor.kind === "edit" && + n.anchor.field === edit.field && + (n.anchor.entry ?? null) === (edit.entry ?? null), + ); + if (open) { + const from = /** @type {any} */ (open.anchor).from; + // Put back as it was: the note says nothing -- unless somebody has + // already replied to it, and then it stays for them to resolve. + if (same(from, edit.to) && !open.replies.length) { + await writeNote(target, { op: "delete", id: open.id }, { by: "operator" }); + counts.deleted += 1; + } else { + const anchor = { kind: "edit", field: edit.field, from, to: edit.to, ...(edit.entry ? { entry: edit.entry } : {}) }; + await writeNote(target, { op: "edit", id: open.id, anchor }, { by: "operator" }); + counts.updated += 1; + } + continue; + } + const anchor = { kind: "edit", field: edit.field, from: edit.from, to: edit.to, ...(edit.entry ? { entry: edit.entry } : {}) }; + await writeNote(target, { op: "add", text: editText(edit, generatedBy), anchor }, { by: "operator" }); + counts.added += 1; + } catch (e) { + counts.errors.push(e instanceof Error ? e.message : String(e)); + } + } + return counts; +} diff --git a/umtool/lib/report/edit-notes.test.mjs b/umtool/lib/report/edit-notes.test.mjs @@ -0,0 +1,67 @@ +// Edits to a generated manifest, as notes: the diff, and the coalescing. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { readNotes } from "../annotations/store.mjs"; +import { editsBetween, editText, recordEdits } from "./edit-notes.mjs"; + +const m = () => ({ + generatedBy: "polemics/video/make-videos.py", + render: { fps: 30, chrome: { engine: "hyperframes" } }, + timeline: [ + { type: "clip", id: "a", start: 1, end: 5, quote: "q" }, + { type: "clip", id: "b", start: 10, end: 15 }, + { type: "card", id: "k" }, + ], + posts: [{ id: "p1", text: "t", attachTo: "a" }], + ledger: [{ id: "c1", scope: "x" }], +}); + +test("editsBetween names each change by entry and field", () => { + const before = m(); + const after = m(); + after.timeline[0].quote = "new"; + after.timeline[0].start = 2; + after.timeline = [after.timeline[1], after.timeline[0], { type: "card", id: "k2" }]; + after.posts[0].attachTo = "b"; + after.ledger[0].scope = "y"; + after.render.chrome.layout = "deck"; + const e = editsBetween(before, after); + const keyOf = (x) => `${x.entry ?? ""}|${x.field}`; + assert.deepEqual( + e.map(keyOf).sort(), + ["a|quote", "a|start", "c1|ledger.scope", "k2|timeline+", "k|timeline-", "p1|posts.attachTo", "|render.chrome", "|timeline.order"].sort(), + ); + const quote = e.find((x) => x.field === "quote"); + assert.deepEqual([quote.from, quote.to], ["q", "new"]); + assert.deepEqual(editsBetween(before, m()), []); + assert.match(editText({ entry: "a", field: "quote" }, "gen.py"), /^a quote changed in umtool\. Port it into the inputs of gen\.py/); +}); + +test("recordEdits adds, coalesces, and deletes a note an edit put back", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-editnotes-")); + const target = { + file: path.join(dir, "notes.json"), + subject: { kind: "video-project", project: "x/y" }, + source: async () => ({ manifest: "~/x/y/video.manifest.json" }), + }; + try { + let c = await recordEdits(target, [{ entry: "a", field: "start", from: 1, to: 2 }], "gen.py"); + assert.deepEqual([c.added, c.updated, c.deleted], [1, 0, 0]); + c = await recordEdits(target, [{ entry: "a", field: "start", from: 2, to: 3 }], "gen.py"); + assert.deepEqual([c.added, c.updated], [0, 1]); + let doc = (await readNotes(target.file)).doc; + assert.equal(doc.notes.length, 1); + assert.deepEqual([doc.notes[0].anchor.from, doc.notes[0].anchor.to], [1, 3]); + assert.equal(doc.source.manifest, "~/x/y/video.manifest.json"); + c = await recordEdits(target, [{ entry: "a", field: "start", from: 3, to: 1 }], "gen.py"); + assert.equal(c.deleted, 1); + assert.equal((await readNotes(target.file)).doc, null); + } finally { + await rm(dir, { recursive: true }); + } +}); diff --git a/umtool/lib/report/guard.ts b/umtool/lib/report/guard.ts @@ -0,0 +1,38 @@ +import { projectTargetFor } from "@/lib/annotations/targets.mjs"; +import { readManifest } from "@/lib/projects/report.mjs"; +import { editsBetween, recordEdits } from "./edit-notes.mjs"; + +// THE one wrapper every manifest writer's route goes through. +// +// A manifest with `generatedBy` is rebuilt by its generator, and the rebuild +// overwrites edits made here (the banner on the project page, the bench and +// the On-screen section says so). The edit is still made; what this adds is a +// record of it: the manifest is read before and after the write, and every +// change becomes an `edit` note in the project's notes.json for the agent to +// port into the generator's inputs (lib/report/edit-notes.mjs). A hand-edited +// manifest (no `generatedBy`) is written exactly as before. +// +// The notes are written AFTER the manifest and never fail the request: the +// edit is saved either way, and `editNotes.errors` says when its note is not. + +export type EditNotes = { generatedBy: string; added: number; updated: number; deleted: number; errors: string[] }; + +export async function withEditNotes<T>( + project: { id: string; dir: string; kind: string }, + write: () => Promise<T>, +): Promise<{ result: T; editNotes: EditNotes | null }> { + const before = await readManifest(project.dir); + const result = await write(); + const generatedBy = typeof before?.generatedBy === "string" ? before.generatedBy.trim() : ""; + if (!generatedBy) return { result, editNotes: null }; + const after = await readManifest(project.dir); + const edits = editsBetween(before, after); + if (!edits.length) return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [] } }; + try { + const target = await projectTargetFor(project); + const counts = await recordEdits(target, edits, generatedBy); + return { result, editNotes: { generatedBy, ...counts } }; + } catch (e) { + return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [e instanceof Error ? e.message : String(e)] } }; + } +} diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -30,10 +30,12 @@ import { rolesGaps, } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; -import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck"; +import { normalizeOnscreen, validateChrome, validatePosts, validateTeaser, validateTeasers } from "umtool-report-to-video/deck"; +import { applySectionEnter } from "./sections.mjs"; import { normalizeClaim, validateClaims } from "umtool-report-to-video/factcheck"; import { parseMuteFrom } from "./playback.mjs"; import { DELIVERABLES_MODES } from "./storage.mjs"; +import { createSnapshot, listSnapshots } from "./snapshots.mjs"; // Its own write queue, not lib/state.ts's. // @@ -787,3 +789,381 @@ export async function updateStorage(dir, { deliverables } = {}, { token = null } return { storage: manifest.storage, token: nextToken, changed: true }; }); } + + +// --------------------------------------------------------------------------- +// STRUCTURE: re-ordering the cut, and the edits that add or remove a thing. +// +// Every writer above changes the fields of something already in the manifest. +// These change WHAT IS IN IT -- the order of the timeline, which entries it +// holds, a teaser's lines, the posts, the fact-check's labels -- so they are +// the edits a person wants to take back. Each one snapshots the manifest into +// revisions/ first (`auto-before-<op>`, at most one per op every two minutes, +// so a burst of drags is one step back), and `undoStructural` restores the +// newest of those. +// +// Same four rules as the rest of the file: the mtime token, tmp + rename under +// the lock, 2 dp, and validation by the BUILD's own checks (deck.mjs, +// factcheck.mjs) before anything is written, so a manifest these accept is one +// the build accepts. +// +// An entry is named by its id. A timeline may repeat an id across variants +// (`variant: "sourced"` / `"full"` twins); then the caller passes `at`, the +// index it means, and a stale `at` (the id is not there any more) refuses. +// --------------------------------------------------------------------------- + +export const AUTO_SNAPSHOT_PREFIX = "auto-before-"; +const AUTO_SNAPSHOT_EVERY_MS = 2 * 60 * 1000; +const ENTRY_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** + * Copy the manifest into revisions/ as `auto-before-<op>`, unless one for the + * same op was taken in the last two minutes. Never fatal: an undo point that + * cannot be taken is reported, and the edit still lands. + */ +export async function autoSnapshot(dir, op, { now = Date.now() } = {}) { + const label = `${AUTO_SNAPSHOT_PREFIX}${op}`; + try { + const recent = (await listSnapshots(dir)).find((s) => !s.legacy && s.label === label); + if (recent && now - recent.mtimeMs < AUTO_SNAPSHOT_EVERY_MS) return { skipped: true, rel: recent.rel }; + return { skipped: false, ...(await createSnapshot(dir, { label })) }; + } catch (e) { + return { skipped: true, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** The index of entry `id`: `at` when it names it, else the only entry with that id. */ +export function entryIndex(timeline, id, at = null) { + if (at !== null && at !== undefined) { + const i = Number(at); + if (!Number.isInteger(i) || timeline[i]?.id !== id) { + throw new Error(`timeline[${at}] is not ${id} any more — reload`); + } + return i; + } + const hits = []; + timeline.forEach((e, i) => { + if (e?.id === id) hits.push(i); + }); + if (!hits.length) throw new Error(`no timeline entry with id ${id}`); + if (hits.length > 1) throw new Error(`${id} is in the timeline ${hits.length} times — say which (at)`); + return hits[0]; +} + +/** An id not yet in the timeline: `base`, else `base-2`, `base-3`, … */ +export function freshEntryId(timeline, base) { + const clean = String(base).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "entry"; + const ids = new Set(timeline.map((e) => e?.id)); + if (!ids.has(clean)) return clean; + for (let n = 2; ; n += 1) if (!ids.has(`${clean}-${n}`)) return `${clean}-${n}`; +} + +/** Every reason the build would refuse the manifest's structure, after an edit. */ +function structureErrors(manifest) { + return [ + ...validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render), + ...validateClaims(manifest), + ...validateTeasers(manifest), + ]; +} + +/** The build's sentences, thrown whole so a route can return each one. */ +export class StructureRefused extends Error { + /** @param {string[]} errors */ + constructor(errors) { + super(errors.join("; ")); + this.name = "StructureRefused"; + this.errors = errors; + } +} + +/** + * One structural write: token, read, `mutate` (which throws to refuse), the + * build's checks, the auto snapshot of the file as it still is, then the write. + */ +async function structural(dir, op, token, mutate) { + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); + if (!Array.isArray(manifest.timeline)) manifest.timeline = []; + // Only what THIS edit breaks refuses it: a manifest that already carries a + // problem the build would name (somebody's hand edit) can still be + // re-ordered, and the problem is still the build's to report. + const already = new Set(structureErrors(manifest)); + const result = mutate(manifest); + const errors = structureErrors(manifest).filter((e) => !already.has(e)); + if (errors.length) throw new StructureRefused(errors); + applySectionEnter(manifest.timeline); + const snapshot = await autoSnapshot(dir, op); + const nextToken = await writeManifestAtomic(dir, manifest); + return { ...result, snapshot, token: nextToken }; + }); +} + +/** + * Move one entry to `toIndex` (its index in the timeline AFTER the move). + * `sectionEnter` is recomputed by report-to-video's own rule (sections.mjs), + * and only on a manifest that already uses it. + * + * @param {string} dir + * @param {string} id + * @param {number} toIndex + * @param {{ token?: string | null, at?: number | null }} [opts] + */ +export async function moveEntry(dir, id, toIndex, { token = null, at = null } = {}) { + return structural(dir, "move", token, (m) => { + const from = entryIndex(m.timeline, id, at); + const to = Number(toIndex); + if (!Number.isInteger(to) || to < 0 || to >= m.timeline.length) { + throw new Error(`toIndex must be 0–${m.timeline.length - 1}`); + } + if (to === from) throw new Error(`${id} is already at ${to}`); + const [e] = m.timeline.splice(from, 1); + m.timeline.splice(to, 0, e); + return { id, from, to }; + }); +} + +/** + * 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) => { + const i = entryIndex(m.timeline, id, at); + const [removed] = m.timeline.splice(i, 1); + return { id, at: i, removed }; + }); +} + +/** 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); + const copy = JSON.parse(JSON.stringify(m.timeline[i])); + copy.id = freshEntryId(m.timeline, `${id}-copy`); + delete copy.claim; + m.timeline.splice(i + 1, 0, copy); + return { id: copy.id, at: i + 1, entry: copy }; + }); +} + +/** `<channel>/<video>@<start>-<end>`, in seconds. */ +const CLIP_SPEC_RE = /^([A-Za-z0-9._-]+)\/([^@\s/]+)@(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/; + +/** + * What `insertEntry` will put in the timeline, checked: a clip spec string + * (`<channel>/<video>@<start>-<end>`) becomes a bare clip; an object is an + * entry as written, given a fresh id when it has none (or one already taken). + * + * @param {Array<Record<string, unknown>>} timeline + * @param {unknown} spec + */ +export function entryFromSpec(timeline, spec) { + if (typeof spec === "string") { + const mm = spec.trim().match(CLIP_SPEC_RE); + if (!mm) throw new Error("a clip is <channel>/<video>@<start>-<end> in seconds"); + const [, channel, video, s, e] = mm; + return clipEntry(timeline, { type: "clip", channel, video, start: Number(s), end: Number(e) }); + } + if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error("an entry is an object, or a clip spec string"); + const entry = JSON.parse(JSON.stringify(spec)); + if (JSON.stringify(entry).length > 20000) throw new Error("that entry is too large"); + if (typeof entry.type !== "string" || !entry.type) throw new Error("an entry needs a type"); + if (entry.type === "clip") return clipEntry(timeline, entry); + const base = typeof entry.id === "string" && ENTRY_ID_RE.test(entry.id) ? entry.id : entry.type; + entry.id = freshEntryId(timeline, base); + return entry; +} + +function clipEntry(timeline, e) { + if (typeof e.video !== "string" || !e.video.trim()) throw new Error("a clip needs its video id"); + for (const k of ["start", "end"]) { + const v = Number(e[k]); + if (!Number.isFinite(v) || v < 0) throw new Error(`a clip's ${k} must be a number ≥ 0`); + e[k] = round2(v); + } + if (e.end - e.start < 0.5) throw new Error(`a clip must be at least half a second (${e.start}–${e.end})`); + if (e.channel !== undefined && (typeof e.channel !== "string" || !e.channel)) delete e.channel; + const base = typeof e.id === "string" && ENTRY_ID_RE.test(e.id) ? e.id : `${e.video}-${Math.floor(e.start)}`; + // `type` and `id` first: the manifests are read by humans. + const { type: _t, id: _i, ...rest } = e; + return { type: "clip", id: freshEntryId(timeline, base), ...rest }; +} + +/** + * Insert an entry after `afterId` (null or "": at the start). `spec` is a clip + * spec string or an entry object (entryFromSpec). + * + * @param {string} dir + * @param {string | null} afterId + * @param {unknown} spec + * @param {{ token?: string | null, at?: number | null }} [opts] `at` is afterId's index + */ +export async function insertEntry(dir, afterId, spec, { token = null, at = null } = {}) { + return structural(dir, "insert", token, (m) => { + const i = afterId ? entryIndex(m.timeline, afterId, at) + 1 : 0; + const entry = entryFromSpec(m.timeline, spec); + m.timeline.splice(i, 0, entry); + return { id: entry.id, at: i, entry }; + }); +} + +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"); + const keys = Object.keys(patch); + const bad = keys.filter((k) => !TEASER_PATCH_KEYS.includes(k)); + if (bad.length) throw new Error(`${bad.join(", ")}: not something this writer changes (${TEASER_PATCH_KEYS.join(", ")})`); + if (!keys.length) throw new Error("nothing to change"); + return structural(dir, "teaser", token, (m) => { + const e = m.timeline[entryIndex(m.timeline, id, at)]; + if (e.type !== "teaser") throw new Error(`${id} is a ${e.type ?? "non-teaser"} entry, not a teaser`); + for (const k of keys) { + const v = patch[k]; + if (v === null || v === "" || v === undefined) delete e[k]; + else if (k === "beat" || k === "tailWait") e[k] = round2(Number(v)); + else if (k === "dip") { + e.dip = typeof v === "object" && v ? { fade: round2(Number(v.fade)), black: round2(Number(v.black)) } : v; + } else if (k === "tail") e.tail = String(v); + else e[k] = v; + } + const errors = validateTeaser(e); + if (errors.length) throw new StructureRefused(errors); + return { id, entry: e }; + }); +} + +const POST_TEXT_KEYS = ["platform", "author", "handle", "date", "text", "url", "shot", "flag", "accent", "logo", "siteChannel", "siteUrl", "postId", "variant"]; + +/** + * Add a post, or replace the one with its id. The value is the post as it + * 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"); + if (typeof post.id !== "string" || !ENTRY_ID_RE.test(post.id)) throw new Error("a post needs an id: letters, digits, dashes, underscores"); + return structural(dir, "post", token, (m) => { + if (!Array.isArray(m.posts)) m.posts = []; + const i = m.posts.findIndex((p) => p?.id === post.id); + const prev = i >= 0 ? m.posts[i] : {}; + const next = { id: post.id }; + for (const k of POST_TEXT_KEYS) { + const v = k in post ? post[k] : prev[k]; + if (v === undefined || v === null || (typeof v === "string" && !v.trim())) continue; + next[k] = typeof v === "string" ? v.trim() : v; + } + for (const k of ["attachTo", "hide"]) { + const v = k in post ? post[k] : prev[k]; + if (v === undefined || v === null || v === "" || v === false) continue; + next[k] = v; + } + if (i >= 0) m.posts[i] = next; + else m.posts.push(next); + return { post: next, created: i < 0 }; + }); +} + +/** 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); + if (i < 0) throw new Error(`no post with id ${id}`); + const [removed] = m.posts.splice(i, 1); + if (!m.posts.length) delete m.posts; + return { removed }; + }); +} + +/** + * Set or remove `render.chrome.factcheck`: the stamp, the tally, and each + * 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"); + return structural(dir, "factcheck", token, (m) => { + const render = m.render ?? {}; + if (!render.chrome || typeof render.chrome !== "object") { + throw new Error("the fact-check is drawn by the on-screen deck, and this manifest has none — turn the deck on first"); + } + const chrome = { ...render.chrome }; + if (factcheck === null) delete chrome.factcheck; + else chrome.factcheck = factcheck; + const { chrome: _old, ...rest } = render; + const already = new Set(validateChrome(render.chrome, rest)); + const errors = validateChrome(chrome, rest).filter((e) => !already.has(e)); + if (errors.length) throw new StructureRefused(errors); + m.render = { ...render, chrome }; + return { factcheck: chrome.factcheck ?? null }; + }); +} + +/** + * Take back the newest structural edit: restore the newest `auto-before-<op>` + * 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 () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + const target = (await listSnapshots(dir)).find((s) => !s.legacy && s.label?.startsWith(AUTO_SNAPSHOT_PREFIX)); + if (!target) throw new Error("nothing to undo: no automatic snapshot in revisions/"); + const op = target.label.slice(AUTO_SNAPSHOT_PREFIX.length); + const text = await readFile(path.join(dir, target.rel), "utf8"); + JSON.parse(text); // a snapshot that does not parse is not restored over a manifest that does + let saved = null; + try { + saved = (await createSnapshot(dir, { label: "undo-saved" })).rel; + } catch { + // within the same second as another snapshot: the state is already kept + } + const file = manifestFile(dir); + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile(tmp, text, "utf8"); + await rename(tmp, file); + const undone = target.rel.replace(`-${target.label}.manifest.json`, `-undone-${op}.manifest.json`); + await rename(path.join(dir, target.rel), path.join(dir, undone)).catch(() => {}); + return { restored: target.rel, op, saved, token: await manifestToken(dir) }; + }); +} diff --git a/umtool/lib/report/moments.mjs b/umtool/lib/report/moments.mjs @@ -0,0 +1,155 @@ +// A moment on a rendered cut -> the entry playing there. +// +// A timed note is a second on a FILE (`out/sourced/<slug>.mp4`, a take's +// `takes/<id>/preview.mp4`). What makes it worth an agent's time is what that +// second resolves to: the entry on screen, its onscreen title and quote, and +// the source second with a link into the archive. That join is the build's +// `schedule.json` (build-video.mjs writeChromeSchedule: `out/<variant>/`, or a +// take's own `takes/<id>/out/<variant>/`), which says where each entry starts +// in the cut. +// +// THE SCHEDULE MATCHES THE BUILD'S OUTPUT, and a take's preview.mp4 is that +// output copied: measured on every take of candace/polemic-israel, the +// preview's duration equals the schedule's `total` to the millisecond. So a +// mark is exact when the file it was made on is as long as the schedule says, +// and APPROXIMATE (`approx: true`) when it is not -- a different preset, a +// trimmed preview -- or when the schedule is an estimate, or the second falls +// in a held frame past the clip's own source. +// +// The resolution is written INTO the note at write time (`anchor.resolved`): +// a later rebuild moves entries around, and the agent reading the note must +// see what was on screen when the operator pressed the key. +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { DEFAULT_VARIANT } from "umtool-report-to-video/build-video"; +import { channelFor } from "../projects/report.mjs"; + +const TAKE_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; +const APPROX_TOLERANCE = 0.25; + +/** + * Where a moment's file sits: in a take (`takes/<id>/…`) and/or a variant's + * output dir (`…out/<variant>/…`). Pure. + * + * @param {string} rel project-relative, `/`-separated + */ +export function momentFileInfo(rel) { + const parts = String(rel ?? "").split("/"); + let take = null; + let i = 0; + if (parts[0] === "takes" && TAKE_RE.test(parts[1] ?? "")) { + take = parts[1]; + i = 2; + } + const variant = parts[i] === "out" && parts.length > i + 2 && TAKE_RE.test(parts[i + 1]) ? parts[i + 1] : null; + return { take, variant }; +} + +async function readJson(file) { + try { + return JSON.parse(await readFile(/* turbopackIgnore: true */ file, "utf8")); + } catch { + return null; + } +} + +/** + * The schedule and manifest a moment on `rel` resolves against, or null when + * there is no schedule. A take's own manifest wins over the project's. + * + * @param {string} projectDir + * @param {string} rel + */ +export async function scheduleForFile(projectDir, rel) { + const { take, variant } = momentFileInfo(rel); + const base = take ? path.join(/* turbopackIgnore: true */ projectDir, "takes", take) : projectDir; + let variants = []; + if (variant) variants = [variant]; + else { + const dirs = await readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ base, "out"), { withFileTypes: true }).catch(() => []); + variants = dirs.filter((d) => d.isDirectory() || d.isSymbolicLink()).map((d) => d.name); + // A deliverable names its cut (`<slug>-full.mp4`; the default cut is + // `<slug>.mp4`): that variant's schedule first, then the default's. + const named = (v) => path.basename(String(rel)).endsWith(`-${v}.mp4`); + const rank = (v) => (named(v) ? 0 : v === DEFAULT_VARIANT ? 1 : 2); + variants.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); + } + for (const v of variants) { + const file = path.join(/* turbopackIgnore: true */ base, "out", v, "schedule.json"); + const schedule = await readJson(file); + if (!schedule || !Array.isArray(schedule.segments)) continue; + const manifest = + (take ? await readJson(path.join(/* turbopackIgnore: true */ base, "video.manifest.json")) : null) ?? + (await readJson(path.join(/* turbopackIgnore: true */ projectDir, "video.manifest.json"))); + const st = await stat(/* turbopackIgnore: true */ file).catch(() => null); + return { + schedule, + manifest, + variant: v, + take, + scheduleRel: path.relative(/* turbopackIgnore: true */ projectDir, file).split(path.sep).join("/"), + scheduleMtimeMs: st ? Math.round(st.mtimeMs) : null, + }; + } + return null; +} + +/** + * The entry playing at `t` seconds of a cut. Pure. + * + * During a crossfade both segments are on screen; the incoming one is taken + * from half-way through it. `duration` is the file's own (the player knows it): + * when it differs from the schedule's total the result is `approx`. + * + * @param {{ schedule: any, manifest: any, variant?: string | null, t: number, duration?: number | null }} args + * @returns {{ entry: string | null, title?: string, quote?: string, channel?: string, video?: string, sourceT?: number, url?: string, approx?: boolean }} + */ +export function resolveMoment({ schedule, manifest, variant = null, t, duration = null }) { + const segs = Array.isArray(schedule?.segments) ? schedule.segments : []; + if (!segs.length || !Number.isFinite(t)) return { entry: null }; + const D = Number(schedule.transition) || 0; + let i = 0; + for (let k = 0; k < segs.length; k += 1) if (Number(segs[k].start) <= t - D / 2) i = k; + const seg = segs[i]; + const out = { entry: String(seg.id) }; + let approx = schedule.estimated === true; + if (Number.isFinite(duration) && Number.isFinite(Number(schedule.total)) && Math.abs(duration - Number(schedule.total)) > APPROX_TOLERANCE) { + approx = true; + } + const e = (manifest?.timeline ?? []).find((x) => x?.id === seg.id && (!x.variant || !variant || x.variant === variant)) ?? null; + const title = seg.title ?? e?.onscreen?.title ?? e?.title ?? e?.heading ?? null; + if (title) out.title = String(title); + if (e?.quote) out.quote = String(e.quote); + if (e?.type === "clip") { + const from = Number(e.cutStart ?? e.start); + const to = Number(e.cutEnd ?? e.end); + const into = Math.max(0, t - Number(seg.start)); + if (Number.isFinite(from) && Number.isFinite(to)) { + if (from + into > to + 0.05) approx = true; // a held frame past the clip's own source + out.sourceT = Number(Math.min(to, from + into).toFixed(2)); + const channel = channelFor(manifest, e); + if (channel) out.channel = channel; + out.video = String(e.video); + const origin = manifest?.provenance?.siteOrigin; + if (origin && channel) { + out.url = `${origin}/?v=${encodeURIComponent(`${channel}/${e.video}`)}&t=${Math.floor(out.sourceT)}`; + } + } + } + if (approx) out.approx = true; + return out; +} + +/** + * 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 }; + return { ...resolveMoment({ ...s, t, duration }), schedule: s.scheduleRel }; +} diff --git a/umtool/lib/report/moments.test.mjs b/umtool/lib/report/moments.test.mjs @@ -0,0 +1,81 @@ +// A second on a rendered cut -> the entry, title, quote and source second. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { momentFileInfo, resolveMoment, resolveMomentOnFile } from "./moments.mjs"; + +const manifest = { + provenance: { siteOrigin: "https://arch.test", channelSlug: "chan" }, + timeline: [ + { type: "teaser", id: "tz", lines: ["X"] }, + { type: "clip", id: "c1", video: "v1", start: 100, end: 110, cutStart: 102, cutEnd: 108, quote: "the words", onscreen: { title: "Own title" } }, + { type: "clip", id: "c2", video: "v2", channel: "other", start: 50, end: 60 }, + ], +}; +const schedule = { + transition: 0.5, + total: 20.5, + segments: [ + { id: "tz", start: 0, end: 4.5, title: "Teaser" }, + { id: "c1", start: 4, end: 10.5 }, + { id: "c2", start: 10, end: 20.5, title: "Schedule title" }, + ], +}; + +test("momentFileInfo reads the take and the variant from the path", () => { + assert.deepEqual(momentFileInfo("takes/deck/preview.mp4"), { take: "deck", variant: null }); + assert.deepEqual(momentFileInfo("takes/deck/out/full/x.mp4"), { take: "deck", variant: "full" }); + assert.deepEqual(momentFileInfo("out/sourced/slug.mp4"), { take: null, variant: "sourced" }); + assert.deepEqual(momentFileInfo("video.mp4"), { take: null, variant: null }); +}); + +test("resolveMoment: the entry on screen, the incoming one past half a crossfade", () => { + assert.equal(resolveMoment({ schedule, manifest, t: 1 }).entry, "tz"); + assert.equal(resolveMoment({ schedule, manifest, t: 4.1 }).entry, "tz", "still the outgoing one"); + const c1 = resolveMoment({ schedule, manifest, t: 6, duration: 20.5 }); + assert.deepEqual(c1, { + entry: "c1", + title: "Own title", + quote: "the words", + sourceT: 104, + channel: "chan", + video: "v1", + url: "https://arch.test/?v=chan%2Fv1&t=104", + }); + const c2 = resolveMoment({ schedule, manifest, t: 12 }); + assert.equal(c2.title, "Schedule title"); + assert.equal(c2.channel, "other"); + assert.equal(c2.sourceT, 52); +}); + +test("approx: a file of another length, an estimated schedule, a held frame", () => { + assert.equal(resolveMoment({ schedule, manifest, t: 6, duration: 30 }).approx, true); + assert.equal(resolveMoment({ schedule: { ...schedule, estimated: true }, manifest, t: 6 }).approx, true); + // c1's cut is 6 s; 7.5 s in is a held frame + const held = resolveMoment({ schedule, manifest, t: 4 + 7.5 - 0.01 + 0, duration: 20.5 }); + assert.equal(held.entry, "c2"); + const c1held = resolveMoment({ schedule: { ...schedule, segments: [schedule.segments[0], { id: "c1", start: 4 }] }, manifest, t: 12, duration: 20.5 }); + assert.equal(c1held.sourceT, 108); + assert.equal(c1held.approx, true); +}); + +test("resolveMomentOnFile finds a take's schedule, preferring the default variant", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-moments-")); + try { + await writeFile(path.join(dir, "video.manifest.json"), JSON.stringify(manifest)); + await mkdir(path.join(dir, "takes", "t1", "out", "full"), { recursive: true }); + await mkdir(path.join(dir, "takes", "t1", "out", "sourced"), { recursive: true }); + await writeFile(path.join(dir, "takes", "t1", "out", "sourced", "schedule.json"), JSON.stringify(schedule)); + await writeFile(path.join(dir, "takes", "t1", "out", "full", "schedule.json"), JSON.stringify({ ...schedule, segments: [{ id: "c2", start: 0 }] })); + const r = await resolveMomentOnFile(dir, "takes/t1/preview.mp4", 6, 20.5); + assert.equal(r.entry, "c1"); + assert.equal(r.schedule, "takes/t1/out/sourced/schedule.json"); + assert.deepEqual(await resolveMomentOnFile(dir, "takes/none/preview.mp4", 6), { entry: null, schedule: null }); + } finally { + await rm(dir, { recursive: true }); + } +}); diff --git a/umtool/lib/report/sections.mjs b/umtool/lib/report/sections.mjs @@ -0,0 +1,64 @@ +// `sectionEnter`: the flag on the first clip of each section, which is what +// makes the legacy footer's marker slide from one node to the next +// (report-to-video/build-video.mjs reads it; README "The marker slides"). +// +// report-to-video only ever READS it; the generators author it. This writes +// the rule down for umtool's one writer that re-orders a timeline +// (lib/report/manifest.mjs moveEntry), derived from what the builder reads and +// checked against every real manifest: in the one that carries the flag +// (quartering-employee-count, seven of nineteen clips) a clip carries it +// exactly when its `section` differs from the previous CLIP's -- the first +// clip counts, a card between two clips does not break a section, and a clip +// with no `section` never enters one. Re-applying it to every real manifest +// under ~/reports changes nothing (lib/report/sections.test.mjs has the shape). +// +// `section` itself is the author's: it says which chapter an entry belongs +// to, and a move does not change that. Only the flag follows the order. +// +// A manifest that carries no `sectionEnter` key at all (every deck-era cut: +// the deck draws no footer) is left exactly as it is: `applySectionEnter` +// changes nothing unless some entry already carries the key. + +/** + * The flag each entry should carry, by index: true on a clip whose `section` + * is set and differs from the previous clip's; false everywhere else. + * + * @param {Array<Record<string, unknown>>} timeline + * @returns {boolean[]} + */ +export function sectionEnterFlags(timeline) { + let prev; + return (timeline ?? []).map((e) => { + if (e?.type !== "clip") return false; + const s = e.section; + const enters = s !== undefined && s !== null && s !== prev; + prev = s; + return enters; + }); +} + +/** Does this timeline use the flag at all? */ +export const usesSectionEnter = (timeline) => (timeline ?? []).some((e) => e && Object.hasOwn(e, "sectionEnter")); + +/** + * Recompute `sectionEnter` in place after a re-order. Only on a timeline that + * already uses it; written as `true` or removed (an absent key reads as false, + * and `"sectionEnter": false` is noise a human reads as a decision). Returns + * the ids whose flag changed. + * + * @param {Array<Record<string, unknown>>} timeline + * @returns {string[]} + */ +export function applySectionEnter(timeline) { + if (!usesSectionEnter(timeline)) return []; + const flags = sectionEnterFlags(timeline); + const changed = []; + timeline.forEach((e, i) => { + const was = e.sectionEnter === true; + if (flags[i] === was) return; + if (flags[i]) e.sectionEnter = true; + else delete e.sectionEnter; + changed.push(String(e.id)); + }); + return changed; +} diff --git a/umtool/lib/report/sections.test.mjs b/umtool/lib/report/sections.test.mjs @@ -0,0 +1,42 @@ +// The sectionEnter rule, against the shape of the one manifest that uses it. +import assert from "node:assert/strict"; +import test from "node:test"; +import { applySectionEnter, sectionEnterFlags, usesSectionEnter } from "./sections.mjs"; + +const clip = (id, section, extra = {}) => ({ type: "clip", id, section, ...extra }); + +test("a clip enters a section when its section differs from the previous clip's", () => { + const tl = [ + { type: "card", id: "t0" }, + clip("a", 1), + clip("b", 1), + { type: "card", id: "mid" }, + clip("c", 1), + clip("d", 2), + clip("e"), + clip("f", 2), + { type: "scroll", id: "s" }, + ]; + assert.deepEqual(sectionEnterFlags(tl), [false, true, false, false, false, true, false, true, false]); +}); + +test("applySectionEnter leaves a timeline that never used the flag alone", () => { + const tl = [clip("a", 1), clip("b", 2)]; + assert.deepEqual(applySectionEnter(tl), []); + assert.equal(usesSectionEnter(tl), false); + assert.equal("sectionEnter" in tl[0], false); +}); + +test("applySectionEnter is the identity on a timeline already in order, and fixes a move", () => { + const tl = [clip("a", 1, { sectionEnter: true }), clip("b", 1), clip("c", 2, { sectionEnter: true }), clip("d", 2)]; + assert.deepEqual(applySectionEnter(tl), []); + // d moved to the front: d enters 2, a enters 1, c no longer enters (b was 1, c is 2 -> still enters) + const moved = [tl[3], tl[0], tl[1], tl[2]]; + assert.deepEqual(applySectionEnter(moved).sort(), ["d"]); + assert.equal(moved[0].sectionEnter, true); + assert.equal(moved[3].sectionEnter, true); + // and moving it back removes the flag again (deleted, never written false) + const back = [moved[1], moved[2], moved[3], moved[0]]; + assert.deepEqual(applySectionEnter(back), ["d"]); + assert.equal("sectionEnter" in back[3], false); +}); diff --git a/umtool/lib/report/structure.test.mjs b/umtool/lib/report/structure.test.mjs @@ -0,0 +1,235 @@ +// The structural writers: move, remove, duplicate, insert, the teaser, the +// posts, the fact-check, and undo. Each goes through the token, the lock, the +// build's own checks and an automatic snapshot, and each refusal leaves the +// file byte-for-byte as it was. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + MANIFEST_NAME, + StaleToken, + StructureRefused, + duplicateEntry, + entryFromSpec, + insertEntry, + manifestToken, + moveEntry, + removeEntry, + removePost, + undoStructural, + updateFactcheck, + updateTeaser, + upsertPost, +} from "./manifest.mjs"; + +const base = () => ({ + slug: "t", + provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, + render: { width: 1920, height: 1080, fps: 30, transition: 0.5 }, + timeline: [ + { type: "teaser", id: "tz", lines: ["THE PROMISE"] }, + { type: "clip", id: "c01", video: "v1", start: 10, end: 20, section: 1, sectionEnter: true }, + { type: "clip", id: "c02", video: "v2", start: 30, end: 41, section: 1 }, + { type: "clip", id: "c03", video: "v3", start: 50, end: 55, section: 2, sectionEnter: true }, + ], + posts: [ + { id: "p1", platform: "x", date: "2024-01-02", text: "hello", url: "https://x.com/a/status/1", attachTo: "c02" }, + ], +}); + +async function project(manifest = base()) { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-structure-")); + await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n"); + return dir; +} +const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8"); +const read = async (dir) => JSON.parse(await readRaw(dir)); +const ids = (m) => m.timeline.map((e) => e.id); +const revisions = async (dir) => (await readdir(path.join(dir, "revisions")).catch(() => [])).sort(); + +test("moveEntry: re-orders, recomputes sectionEnter, snapshots once per burst", async () => { + const dir = await project(); + try { + const r = await moveEntry(dir, "c03", 1, { token: await manifestToken(dir) }); + assert.deepEqual([r.from, r.to], [3, 1]); + const m = await read(dir); + assert.deepEqual(ids(m), ["tz", "c03", "c01", "c02"]); + // c03 (section 2) now first: it enters; c01 (section 1, after a 2) enters; c02 does not + assert.equal(m.timeline[1].sectionEnter, true); + assert.equal(m.timeline[2].sectionEnter, true); + assert.equal("sectionEnter" in m.timeline[3], false); + // The file is still the CLI's formatting. + assert.equal(await readRaw(dir), JSON.stringify(m, null, 2) + "\n"); + assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1); + await moveEntry(dir, "c03", 3); + assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1, "throttled"); + await assert.rejects(moveEntry(dir, "c03", 3), /already at 3/); + await assert.rejects(moveEntry(dir, "c03", 9), /toIndex/); + await assert.rejects(moveEntry(dir, "nope", 0), /no timeline entry/); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("a stale token refuses every structural write and leaves the file alone", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + for (const call of [ + () => moveEntry(dir, "c03", 1, { token: "1" }), + () => removeEntry(dir, "c03", { token: "1" }), + () => duplicateEntry(dir, "c03", { token: "1" }), + () => insertEntry(dir, "c03", "chan/vx@1-5", { token: "1" }), + () => updateTeaser(dir, "tz", { beat: 1 }, { token: "1" }), + () => upsertPost(dir, { id: "p2" }, { token: "1" }), + () => removePost(dir, "p1", { token: "1" }), + () => undoStructural(dir, { token: "1" }), + ]) { + await assert.rejects(call(), StaleToken); + } + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("removeEntry: refused while a post rides on the clip; fine once it does not", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(removeEntry(dir, "c02"), (e) => e instanceof StructureRefused && /attachTo/.test(e.message)); + assert.equal(await readRaw(dir), before); + const r = await removeEntry(dir, "c03"); + assert.equal(r.removed.id, "c03"); + assert.deepEqual(ids(await read(dir)), ["tz", "c01", "c02"]); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("duplicateEntry and insertEntry: fresh ids, the clip spec, 2 dp, at the right place", async () => { + const dir = await project(); + try { + const d = await duplicateEntry(dir, "c01"); + assert.equal(d.id, "c01-copy"); + const again = await duplicateEntry(dir, "c01"); + assert.equal(again.id, "c01-copy-2"); + const ins = await insertEntry(dir, "c02", "mychan/abc123@12.3456-20.1"); + assert.deepEqual(ins.entry, { type: "clip", id: "abc123-12", channel: "mychan", video: "abc123", start: 12.35, end: 20.1 }); + const first = await insertEntry(dir, null, { type: "card", heading: "Start" }); + assert.equal(first.at, 0); + assert.equal(first.id, "card"); + const m = await read(dir); + assert.deepEqual(ids(m), ["card", "tz", "c01", "c01-copy-2", "c01-copy", "c02", "abc123-12", "c03"]); + await assert.rejects(insertEntry(dir, "c02", "not a spec"), /channel/); + await assert.rejects(insertEntry(dir, "c02", "chan/v@5-5.2"), /half a second/); + // A teaser that the build would refuse is refused here. + await assert.rejects(insertEntry(dir, "c02", { type: "teaser", lines: [] }), StructureRefused); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("entryFromSpec never reuses an id", () => { + const tl = [{ id: "a" }, { id: "v-1" }]; + assert.equal(entryFromSpec(tl, { type: "card", id: "a" }).id, "a-2"); + assert.equal(entryFromSpec(tl, "c/v@1-3").id, "v-1-2"); +}); + +test("updateTeaser: lines, beat, tail, dip — validated by the build's own check", async () => { + const dir = await project(); + try { + const r = await updateTeaser(dir, "tz", { lines: ["THE PROMISE", "AND WHAT HAPPENED"], beat: 1.2345, tail: "?", tailWait: 1 }); + assert.equal(r.entry.beat, 1.23); + await assert.rejects(updateTeaser(dir, "tz", { lines: [] }), StructureRefused); + await assert.rejects(updateTeaser(dir, "tz", { tail: null }), /tailWait/); + await updateTeaser(dir, "tz", { tail: null, tailWait: null }); + const m = await read(dir); + assert.equal("tail" in m.timeline[0], false); + await assert.rejects(updateTeaser(dir, "c01", { beat: 1 }), /not a teaser/); + await assert.rejects(updateTeaser(dir, "tz", { seconds: 4 }), /not something/); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("upsertPost / removePost: add, replace keeping attachTo, refuse what validatePosts refuses", async () => { + const dir = await project(); + try { + const add = await upsertPost(dir, { id: "p2", platform: "bluesky", date: "2024-03-04", text: " words ", url: "https://bsky.app/x", author: "" }); + assert.equal(add.created, true); + assert.deepEqual(add.post, { id: "p2", platform: "bluesky", date: "2024-03-04", text: "words", url: "https://bsky.app/x" }); + const rep = await upsertPost(dir, { id: "p1", platform: "x", date: "2024-01-02", text: "edited", url: "https://x.com/a/status/1" }); + assert.equal(rep.post.attachTo, "c02"); + await assert.rejects(upsertPost(dir, { id: "p3", platform: "myspace", date: "2024", text: "x", url: "http://no" }), StructureRefused); + await removePost(dir, "p2"); + await removePost(dir, "p1"); + assert.equal("posts" in (await read(dir)), false); + await assert.rejects(removePost(dir, "p1"), /no post/); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("updateFactcheck: needs the deck; labels and colours checked by validateChrome", async () => { + const dir = await project(); + try { + await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 3 } }), /deck on first/); + const m = await read(dir); + m.render.chrome = { engine: "hyperframes", layout: "deck" }; + await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(m, null, 2) + "\n"); + const r = await updateFactcheck(dir, { verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } }, stamp: { seconds: 4 } }); + assert.equal(r.factcheck.stamp.seconds, 4); + await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 99 } }), StructureRefused); + await updateFactcheck(dir, null); + assert.equal("factcheck" in (await read(dir)).render.chrome, false); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("undoStructural restores the newest automatic snapshot, keeps the current state, and walks back", async () => { + const dir = await project(); + try { + const original = await readRaw(dir); + await assert.rejects(undoStructural(dir), /nothing to undo/); + await moveEntry(dir, "c03", 1); + // Age the move's snapshot so the next op is not throttled into it. + const rev = path.join(dir, "revisions"); + for (const n of await readdir(rev)) await utimes(path.join(rev, n), new Date(Date.now() - 600_000), new Date(Date.now() - 600_000)); + await new Promise((r) => setTimeout(r, 1100)); + await removeEntry(dir, "c01"); + const afterRemove = ids(await read(dir)); + assert.deepEqual(afterRemove, ["tz", "c03", "c02"]); + + const u1 = await undoStructural(dir); + assert.equal(u1.op, "remove"); + assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]); + await new Promise((r) => setTimeout(r, 1100)); + const u2 = await undoStructural(dir); + assert.equal(u2.op, "move"); + assert.equal(await readRaw(dir), original, "byte for byte"); + const names = await revisions(dir); + assert.ok(names.some((n) => n.includes("undone-move"))); + assert.ok(names.some((n) => n.includes("undo-saved"))); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("a manifest that already has a build problem can still be re-ordered", async () => { + const m = base(); + m.posts[0].attachTo = "gone"; // already broken before the edit + const dir = await project(m); + try { + await moveEntry(dir, "c03", 1); + assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]); + } finally { + await rm(dir, { recursive: true }); + } +}); 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 }); + } +}); diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts @@ -76,6 +76,10 @@ export default defineConfig({ // var. CHANNELS_DIR has to be said explicitly: it is where a report // video's cue files live, and its default is the real 3 GB corpus. `CHANNELS_DIR=${FIXTURE}/channels ` + + // The sites (/sites, article notes): the fixture's own, never the real + // transcripts/sites -- the one corpus file umtool writes is a report's + // notes.json, and the suite writes them. + `SITES_DIR=${FIXTURE}/sites ` + // The cache (the project index, posters, analyses) is no longer under // SONG_DIR (release 17): its default is the user's ~/.cache, which a // suite must never write. The fixture's own, rebuilt with it every run.