Archilyzer · Source

archilyzer

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

commit 1efe8fa5ee3f48136e33c8fd90e48151416c6502
parent f28e7a02191214c4287a49576397b5b702cdc604
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 23:42:33 -0400

Merge umtool/articles-a (Track A: /sites, article reader, notes, evidence, workspace files)

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

Diffstat:
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 | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/[site]/[report]/page.tsx | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/[site]/page.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/sites/page.tsx | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/AppNav.tsx | 6+++++-
Aumtool/components/articles/ArticleBody.tsx | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleReader.tsx | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleTable.tsx | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/ArticleVideo.tsx | 13+++++++++++++
Aumtool/components/articles/EvidencePanel.tsx | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/articles/EvidenceWalk.tsx | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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/docs/sites.md | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-evidence.spec.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-notes.spec.ts | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/sites-fixture.mjs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/e2e/sites.spec.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/warm.ts | 17+++++++++++++++++
Aumtool/lib/articles/article.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/evidence.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/files.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/links.mjs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/sites.ts | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/articles/workspace.mjs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/decisions.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/projects.ts | 8+++++---
33 files changed, 3503 insertions(+), 8 deletions(-)

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,55 @@ +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 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 = await readArticleNotes(site.siteId, reportId); + 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} /> + </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 ArticleVideo from "@/components/articles/ArticleVideo"; +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 videoSlot = r.video ? ( + <ArticleVideo 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} + videoSlot={videoSlot} + /> + </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/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/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,394 @@ +"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 ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody"; +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, + videoSlot, + viewError, +}: { + site: string; + report: string; + view: ReportPageView; + initialNotes: NotesRead; + initialFilter?: Filter; + initialNote?: string | null; + meta: ReactNode; + videoSlot?: ReactNode; + viewError?: string | null; +}) { + const notesApi = useNotes({ article: `${site}/${report}` }, { initial: initialNotes }); + const { notes, write, busy, error } = notesApi; + 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" }); + } + }} + > + <ArticleBody view={view} video={videoSlot} /> + </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.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]} /> + {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,13 @@ +// THE ARTICLE'S OWN VIDEO SLOT -- the report's video.mp4, as the published page +// plays it. A plain <video> for now; the integration swaps in the shared timed +// notes player (components/notes/TimedNotes.tsx, release track B) here, with +// moment anchors `{ kind: "moment", file: "video.mp4", t }` on the article's +// notes. Keep this the only place the article page renders its video. +export default function ArticleVideo({ src, poster, caption }: { src: string; poster?: string; caption?: string }) { + return ( + <figure data-article-video className="space-y-1"> + <video controls preload="metadata" src={src} poster={poster} className="aspect-video w-full rounded bg-black" /> + {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,162 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import CopyButton from "@/components/CopyButton"; +import type { Evidence } from "@/lib/articles/evidence"; + +// 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 }>( + function EvidencePanel({ ev, error, number, autoPlay = false }, 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} + </div> + ); + }, +); + +export default EvidencePanel; diff --git a/umtool/components/articles/EvidenceWalk.tsx b/umtool/components/articles/EvidenceWalk.tsx @@ -0,0 +1,126 @@ +"use client"; + +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, +}: { + site: string; + report: string; + items: WalkItem[]; + initial: number; + initialNotes: NotesRead; +}) { + 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} /> + </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/docs/sites.md b/umtool/docs/sites.md @@ -0,0 +1,85 @@ +# /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 in one slot +(`components/articles/ArticleVideo.tsx`). + +| 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. + +## 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-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/sites-fixture.mjs b/umtool/e2e/fixtures/sites-fixture.mjs @@ -5,14 +5,167 @@ // // 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. -import { mkdirSync } from "node:fs"; +// +// 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 }) { +export function makeSitesFixture({ dest, channels }) { const sites = path.join(dest, "sites"); mkdirSync(sites, { recursive: true }); - return { sites }; + + // ---- 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/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/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,99 @@ +// 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 { 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 (p.kind !== "report-video") 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/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" : "report-video", + 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" : "report-video", + 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/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); }