Archilyzer · Source

archilyzer

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

commit 378dcb4a80c7eee1bef1a0bb9927f0b3d12c64ee
parent 6f38f174bcda2a420c2c07374d5d4c5e98d7b78c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 03:27:31 -0400

editor: a site's Reports tab — reports and drafts with their problems, publish/unpublish/reorder, Prepare evidence media

Lists every report directory under the site's reports/ and every id the site
publishes, validated by the report checker; counts by citation kind and a
fact-check's verdict tally. The published list is written through patchSite.
The last media manifest and prepare job are shown beside the Prepare button;
the publish scope is shown read-only. verdictTally takes a report document too.

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

Diffstat:
Mcommon/lib/report/views.ts | 10+++++++---
Meditor/app/sites/[siteId]/components/SiteTabs.tsx | 7++++++-
Meditor/app/sites/[siteId]/layout.tsx | 6+++---
Aeditor/app/sites/[siteId]/reports/page.tsx | 300+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/PrepareReportMediaButton.tsx | 24++++++++++++++++++++++++
Aeditor/app/sites/components/ReportRowActions.tsx | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/components/SiteForm.tsx | 10++++++++++
Aeditor/app/sites/lib/reportList.test.ts | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/lib/reportList.ts | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/lib/reportListServer.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/lib/reportsActions.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 997 insertions(+), 7 deletions(-)

diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -481,11 +481,15 @@ export function sectionCitations(view: Pick<ReportPageView, "citations">, sectio } // How many claims carry each verdict, in the vocabulary's order; verdicts no -// claim carries are left out. -export function verdictTally(view: Pick<ReportPageView, "sections">): VerdictCount[] { +// claim carries are left out. Takes a page view or a report document as it is +// (whose sections may have no claims) — the editor's report list tallies the +// document. +export function verdictTally(view: { + sections: readonly { claims?: readonly { verdict?: Verdict }[] }[]; +}): VerdictCount[] { const counts = new Map<Verdict, number>(); for (const s of view.sections) { - for (const c of s.claims) if (c.verdict) counts.set(c.verdict, (counts.get(c.verdict) ?? 0) + 1); + for (const c of s.claims ?? []) if (c.verdict) counts.set(c.verdict, (counts.get(c.verdict) ?? 0) + 1); } return VERDICTS.filter((v) => counts.has(v)).map((verdict) => ({ verdict, count: counts.get(verdict)! })); } diff --git a/editor/app/sites/[siteId]/components/SiteTabs.tsx b/editor/app/sites/[siteId]/components/SiteTabs.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useSelectedLayoutSegment } from "next/navigation"; -// Settings | Charts | Search aliases | Tags | Publish. +// Settings | Charts | Search aliases | Tags | Reports | Publish. // // A CLIENT component on purpose. Next 16 layouts are cached during navigation // and DO NOT RERENDER — they cannot read the pathname and would not re-evaluate @@ -32,6 +32,11 @@ export function SiteTabs({ siteId }: { siteId: string }) { label="Tags" /> <Tab + href={`/sites/${siteId}/reports`} + active={segment === "reports"} + label="Reports" + /> + <Tab href={`/sites/${siteId}/publish`} active={segment === "publish"} label="Publish" diff --git a/editor/app/sites/[siteId]/layout.tsx b/editor/app/sites/[siteId]/layout.tsx @@ -6,9 +6,9 @@ import { SiteTabs } from "./components/SiteTabs"; import { getSiteCached } from "./lib/siteCache"; // Shared chrome for every route of one site: the breadcrumb, the site's title -// and the Settings | Charts | Search aliases | Publish switch. A layout now that -// the site has more than one page — Charts, Search aliases and Publish were -// three sidebar entries reading the site from a ?site= param. +// and the Settings | Charts | Search aliases | Tags | Reports | Publish switch. +// A layout now that the site has more than one page — Charts, Search aliases +// and Publish were three sidebar entries reading the site from a ?site= param. // // Next 16 caveats this file is written around (see // node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/layout.md): diff --git a/editor/app/sites/[siteId]/reports/page.tsx b/editor/app/sites/[siteId]/reports/page.tsx @@ -0,0 +1,300 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/site"; +import { readReportMediaIndex } from "yt-dlp-transcript-common/publish/reportMedia"; +import { PrepareReportMediaButton } from "../../components/PrepareReportMediaButton"; +import { ReportRowActions } from "../../components/ReportRowActions"; +import { + publishRefusal, + reportMediaSummary, + type ReportMediaSummary, + type ReportRow, +} from "../../lib/reportList"; +import { latestReportsPrepareJob, readSiteReportRows } from "../../lib/reportListServer"; +import { getSiteCached } from "../lib/siteCache"; + +export const dynamic = "force-dynamic"; +// → "Reports — <id> — Sites" through the layout's title template. +export const metadata: Metadata = { title: "Reports" }; + +// A SITE'S REPORTS: every report directory under `sites/<siteId>/reports/`, +// whether the site publishes it (site.json `reports`, in order) or it is a +// draft, and what is wrong with it — the report's own checker, so this lists +// what prepare and the build would refuse. Publish, unpublish and reorder write +// that one key (lib/reportsActions.ts). The evidence media the published +// reports cite is prepared here too, before a build. +// +// The documents themselves are written elsewhere (a converter, or by hand); +// this tab never edits a report.json. +export default async function SiteReportsPage({ + params, +}: { + params: Promise<{ siteId: string }>; +}) { + const { siteId } = await params; + const site = getSiteCached(siteId); + if (!site) notFound(); + const paths = getPaths(); + const [rows, mediaIndex, lastJob] = await Promise.all([ + readSiteReportRows(paths, site), + readReportMediaIndex(paths, siteId), + latestReportsPrepareJob(paths, siteId), + ]); + const publishedCount = rows.filter((r) => r.position !== null).length; + const cited = isCitedSite(site); + + return ( + <div className="flex flex-col gap-8 max-w-4xl"> + <section className="flex flex-col gap-2" aria-label="Publish scope"> + <p className="text-sm text-muted-foreground"> + A report is a <code>report.json</code> in its own directory under this + site&apos;s <code>reports/</code>. The site publishes the reports listed + here as published, in this order; any other report directory is a + draft. Only a report with no problems can be published. + </p> + <p className="text-sm"> + <span className="font-medium">Publishes:</span>{" "} + {cited + ? "Cited moments only — its reports and the moments they cite" + : "Full archive — the searchable corpus of its channels"}{" "} + <span className="text-muted-foreground"> + (changed on{" "} + <Link href={`/sites/${siteId}`} className="underline"> + Settings + </Link> + ) + </span> + </p> + {cited && publishedCount === 0 && ( + <p role="status" className="text-sm text-warning"> + This site publishes only cited moments and has no published report: + its build would have nothing to show. + </p> + )} + </section> + + <section className="flex flex-col gap-3" aria-label="Reports"> + <h2 className="text-lg font-semibold"> + Reports{" "} + <span className="text-sm font-normal text-muted-foreground"> + {publishedCount} published, {rows.length - publishedCount} draft + {rows.length - publishedCount === 1 ? "" : "s"} + </span> + </h2> + {rows.length === 0 ? ( + <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground"> + No reports yet. Put a report at{" "} + <code>sites/{siteId}/reports/&lt;reportId&gt;/report.json</code>. + </p> + ) : ( + <ul className="flex flex-col gap-3"> + {rows.map((row) => ( + <ReportListItem + key={row.id} + siteId={siteId} + row={row} + publishedCount={publishedCount} + /> + ))} + </ul> + )} + </section> + + <section + className="flex flex-col gap-3 border-t border-border pt-6" + aria-label="Evidence media" + > + <div> + <h2 className="text-lg font-semibold">Evidence media</h2> + <p className="text-sm text-muted-foreground"> + Cuts a clip of every span the published reports cite and copies every + cited post capture, from the media already on disk, ready for the + site&apos;s next build. Nothing is downloaded: a citation whose media + is missing is listed as a problem — fetch its window or persist its + video from the video page, then prepare again. + </p> + </div> + <PrepareReportMediaButton siteId={siteId} /> + <p className="text-sm text-muted-foreground"> + Last prepare job:{" "} + {lastJob ? ( + <Link href={`/jobs/${lastJob.id}`} className="underline font-mono"> + {lastJob.id} + </Link> + ) : ( + "none among recent jobs" + )} + {lastJob && <> ({lastJob.status})</>} + </p> + {mediaIndex ? ( + <MediaSummary summary={reportMediaSummary(mediaIndex)} /> + ) : ( + <p className="text-sm text-muted-foreground"> + Not prepared yet. + </p> + )} + </section> + </div> + ); +} + +const STATUS_TEXT: Record<ReportRow["status"], string> = { + ok: "No problems", + problems: "Has problems", + invalid: "Not a valid report", + missing: "Missing", +}; + +function ReportListItem({ + siteId, + row, + publishedCount, +}: { + siteId: string; + row: ReportRow; + publishedCount: number; +}) { + const dates = [ + row.publishedDate ? `published ${row.publishedDate}` : null, + row.updatedDate ? `updated ${row.updatedDate}` : null, + ].filter(Boolean); + return ( + <li + data-testid="report-row" + data-report={row.id} + className="flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3" + > + <div className="flex flex-wrap items-start justify-between gap-2"> + <div className="flex flex-col gap-0.5"> + <span className="font-medium"> + {row.position !== null && ( + <span className="text-muted-foreground">{row.position + 1}. </span> + )} + {row.title ?? row.id} + </span> + {row.subtitle && ( + <span className="text-sm text-muted-foreground">{row.subtitle}</span> + )} + <span className="text-xs text-muted-foreground"> + <code>{row.id}</code> + {row.kind && <> · {row.kind === "factcheck" ? "fact-check" : "sweep"}</>} + {dates.length > 0 && <> · {dates.join(", ")}</>} + </span> + </div> + <div className="flex flex-col items-end gap-1"> + <span + className={`text-xs ${row.position !== null ? "text-success" : "text-muted-foreground"}`} + > + {row.position !== null ? "Published" : "Draft"} + </span> + <ReportRowActions + siteId={siteId} + reportId={row.id} + position={row.position} + publishedCount={publishedCount} + publishable={publishRefusal(row)} + /> + </div> + </div> + + {(row.status === "ok" || row.status === "problems") && ( + <p className="text-sm"> + {row.sections} section{row.sections === 1 ? "" : "s"} · {row.claims} claim + {row.claims === 1 ? "" : "s"} · {row.citations} citation + {row.citations === 1 ? "" : "s"} + {row.citationsByKind.length > 0 && ( + <span className="text-muted-foreground"> + {" "} + ({row.citationsByKind.map((k) => `${k.count} ${k.kind}`).join(", ")}) + </span> + )} + </p> + )} + + {row.tally && row.verdicts && row.tally.length > 0 && ( + <ul aria-label={`Verdicts of ${row.id}`} className="flex flex-wrap gap-2"> + {row.tally.map((t) => ( + <li + key={t.verdict} + className="flex items-center gap-1.5 rounded border border-border px-2 py-0.5 text-xs" + > + <span + aria-hidden + className="inline-block h-2 w-2 rounded-full" + style={{ backgroundColor: row.verdicts![t.verdict].color }} + /> + {row.verdicts![t.verdict].label} {t.count} + </li> + ))} + </ul> + )} + + {row.problems.length === 0 ? ( + <span className="text-xs text-muted-foreground">{STATUS_TEXT[row.status]}</span> + ) : ( + <details> + <summary className="cursor-pointer select-none text-xs text-warning"> + {STATUS_TEXT[row.status]} — {row.problems.length} problem + {row.problems.length === 1 ? "" : "s"} + </summary> + <ul className="mt-2 flex flex-col gap-1 text-xs"> + {row.problems.map((p, i) => ( + <li key={i}> + {p.path && <code className="text-muted-foreground">{p.path}</code>} + {p.path && ": "} + {p.message} + </li> + ))} + </ul> + </details> + )} + </li> + ); +} + +function MediaSummary({ summary }: { summary: ReportMediaSummary }) { + return ( + <div className="flex flex-col gap-2 rounded-md border border-border bg-card px-4 py-3 text-sm"> + <p> + Prepared {new Date(summary.preparedAt).toLocaleString()}: {summary.moments} moment + {summary.moments === 1 ? "" : "s"} + {summary.momentsByKind.length > 0 && ( + <> ({summary.momentsByKind.map((k) => `${k.count} ${k.kind}`).join(", ")})</> + )} + , {formatBytes(summary.totalBytes)}. + </p> + {summary.problems.length === 0 ? ( + <p className="text-success">No problems.</p> + ) : ( + <details> + <summary className="cursor-pointer select-none text-warning"> + {summary.problems.length} problem{summary.problems.length === 1 ? "" : "s"}:{" "} + {summary.problemsByKind.map((k) => `${k.count} ${k.kind}`).join(", ")} + </summary> + <ul className="mt-2 flex flex-col gap-1 text-xs"> + {summary.problems.map((p, i) => ( + <li key={i}> + <code className="text-muted-foreground">{p.kind}</code>{" "} + {p.moment ? ( + <code>{p.moment}</code> + ) : ( + <> + <code>{p.report ?? "?"}</code> + {p.path && <code className="text-muted-foreground"> {p.path}</code>} + </> + )} + {p.citations && p.citations.length > 0 && ( + <span className="text-muted-foreground"> (cited by {p.citations.join(", ")})</span> + )} + : {p.message} + </li> + ))} + </ul> + </details> + )} + </div> + ); +} diff --git a/editor/app/sites/components/PrepareReportMediaButton.tsx b/editor/app/sites/components/PrepareReportMediaButton.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { reportsPrepareAction } from "../lib/reportsPrepareAction"; +import { cancelJobAction } from "../../jobs/actions"; + +// "Prepare evidence media": queues the site's reports-prepare job and streams +// its log. When the job settles the tab is re-rendered, so the media summary +// beside it shows the manifest the job just wrote. +export function PrepareReportMediaButton({ siteId }: { siteId: string }) { + const router = useRouter(); + return ( + <StreamActionLog + trigger={() => reportsPrepareAction(siteId)} + cancelAction={cancelJobAction} + buttonLabel="Prepare evidence media" + runningLabel="Preparing evidence media…" + onSettled={(started) => { + if (started) router.refresh(); + }} + /> + ); +} diff --git a/editor/app/sites/components/ReportRowActions.tsx b/editor/app/sites/components/ReportRowActions.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + moveReportAction, + publishReportAction, + unpublishReportAction, + type ReportsActionResult, +} from "../lib/reportsActions"; + +// One report's buttons on the Reports tab: Publish for a draft; Up, Down and +// Unpublish for a published report. Each is one write of site.json `reports` +// (lib/reportsActions.ts); the action revalidates the tab, which re-renders +// with the list as written. +export function ReportRowActions({ + siteId, + reportId, + position, + publishedCount, + publishable, +}: { + siteId: string; + reportId: string; + // Its place in the published list, or null for a draft. + position: number | null; + publishedCount: number; + // Why it may not be published, or null when it may. + publishable: string | null; +}) { + const [pending, start] = useTransition(); + const [error, setError] = useState<string | null>(null); + const run = (fn: () => Promise<ReportsActionResult>) => { + setError(null); + start(async () => { + const res = await fn(); + if (!res.ok) setError(res.error); + }); + }; + const btn = + "rounded border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"; + + return ( + <span className="flex flex-wrap items-center gap-2"> + {position === null ? ( + <button + type="button" + disabled={pending || publishable !== null} + title={publishable ?? undefined} + aria-label={`Publish report ${reportId}`} + onClick={() => run(() => publishReportAction(siteId, reportId))} + className={btn} + > + {pending ? "Publishing…" : "Publish"} + </button> + ) : ( + <> + <button + type="button" + disabled={pending || position === 0} + aria-label={`Move report ${reportId} up`} + onClick={() => run(() => moveReportAction(siteId, reportId, -1))} + className={btn} + > + ↑ + </button> + <button + type="button" + disabled={pending || position >= publishedCount - 1} + aria-label={`Move report ${reportId} down`} + onClick={() => run(() => moveReportAction(siteId, reportId, 1))} + className={btn} + > + ↓ + </button> + <button + type="button" + disabled={pending} + aria-label={`Unpublish report ${reportId}`} + onClick={() => run(() => unpublishReportAction(siteId, reportId))} + className={btn} + > + Unpublish + </button> + </> + )} + {error && ( + <span role="alert" className="text-xs text-destructive"> + {error} + </span> + )} + </span> + ); +} diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -2,6 +2,7 @@ import { useState, type CSSProperties } from "react"; import { useActionState } from "react"; +import Link from "next/link"; import { saveSiteAction, type SaveResult } from "../actions"; import type { Site } from "yt-dlp-transcript-common/lib/site"; import { @@ -396,6 +397,15 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { The published reports, in order (report ids under this site&apos;s reports/ directory). Not edited here: saving this form keeps the list as it is. + {!isNew && ( + <> + {" "}Publish, unpublish and reorder them on the{" "} + <Link href={`/sites/${initial.siteId}/reports`} className="underline"> + Reports + </Link>{" "} + tab. + </> + )} </span> </div> <label className="flex items-center gap-2 text-sm"> diff --git a/editor/app/sites/lib/reportList.test.ts b/editor/app/sites/lib/reportList.test.ts @@ -0,0 +1,217 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ReportMediaIndex } from "yt-dlp-transcript-common/publish/reportMedia"; +import { + publishRefusal, + reportMediaSummary, + reportRow, + reportRows, + withReportMoved, + withReportPublished, + withReportUnpublished, + type ReportFileRead, +} from "./reportList"; + +// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" + +function factcheck(id = "demo-factcheck"): Record<string, unknown> { + return { + format: "archilyzer-report", + version: 1, + id, + kind: "factcheck", + title: "A demo fact-check", + subtitle: "One line under it", + published: "2026-10-01", + updated: "2026-10-04", + verdicts: { PARTLY: { label: "Half true" } }, + sources: { s0: { kind: "article", title: "A demo article", url: "https://example.test/a" } }, + citations: { + c01: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "q1" }, + c02: { kind: "video", channel: "demo-channel", id: "abc123", start: 30, end: 40, quote: "q2" }, + p01: { kind: "post", channel: "demo-channel", id: "1234567890", quote: "q3" }, + a01: { kind: "source", source: "s0", quote: "the claim" }, + }, + sections: [ + { + id: "ch1", + title: "Chapter one", + claims: [ + { id: "k1", text: "One.", verdict: "CONTRADICTED", citations: ["c01", "p01"] }, + { id: "k2", text: "Two.", verdict: "PARTLY", sourceQuote: { citation: "a01" }, citations: ["c02"] }, + { id: "k3", text: "Three.", verdict: "CONTRADICTED", citations: ["c01"] }, + ], + }, + { id: "ch2", title: "Chapter two" }, + ], + }; +} + +function sweep(id: string): Record<string, unknown> { + return { + format: "archilyzer-report", + version: 1, + id, + kind: "sweep", + title: "A demo sweep", + citations: { + c01: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "q1" }, + }, + sections: [{ id: "s1", title: "Found", body: "Said [here](cite:c01)." }], + }; +} + +const ok = (value: unknown): ReportFileRead => ({ ok: true, value }); + +test("a sound fact-check: counts, citations by kind in the model's order, the tally under its overrides", () => { + const row = reportRow("demo-factcheck", ok(factcheck()), 0); + assert.equal(row.status, "ok"); + assert.deepEqual(row.problems, []); + assert.equal(row.title, "A demo fact-check"); + assert.equal(row.subtitle, "One line under it"); + assert.equal(row.kind, "factcheck"); + assert.equal(row.publishedDate, "2026-10-01"); + assert.equal(row.updatedDate, "2026-10-04"); + assert.equal(row.sections, 2); + assert.equal(row.claims, 3); + assert.equal(row.citations, 4); + assert.deepEqual(row.citationsByKind, [ + { kind: "video", count: 2 }, + { kind: "post", count: 1 }, + { kind: "source", count: 1 }, + ]); + // The vocabulary's order, not the claims'; only verdicts a claim carries. + assert.deepEqual(row.tally, [ + { verdict: "PARTLY", count: 1 }, + { verdict: "CONTRADICTED", count: 2 }, + ]); + assert.equal(row.verdicts?.PARTLY.label, "Half true"); + assert.equal(publishRefusal(row), null); +}); + +test("a sweep carries no tally", () => { + const row = reportRow("demo-sweep", ok(sweep("demo-sweep")), null); + assert.equal(row.status, "ok"); + assert.equal(row.kind, "sweep"); + assert.equal(row.claims, 0); + assert.equal(row.tally, undefined); + assert.equal(row.verdicts, undefined); +}); + +test("value problems are listed with their paths, and refuse publishing", () => { + const doc = factcheck("demo-factcheck"); + // The id does not match the directory, and a claim names no citation. + const row = reportRow("other-dir", ok({ ...doc, sections: [{ id: "ch1", title: "One", claims: [{ id: "k1", text: "x", citations: ["nope"] }] }] }), null); + assert.equal(row.status, "problems"); + assert.deepEqual( + row.problems.map((p) => p.path), + ["id", "sections[0].claims[0].citations[0]"], + ); + // Still summarised: it parsed. + assert.equal(row.title, "A demo fact-check"); + assert.match(publishRefusal(row) ?? "", /2 problem/); +}); + +test("not a report, not JSON, unreadable and missing are rows with a problem, never thrown", () => { + const shape = reportRow("bad", ok({ format: "something-else" }), null); + assert.equal(shape.status, "invalid"); + assert.ok(shape.problems.length > 0); + assert.equal(shape.title, undefined); + assert.equal(shape.citations, 0); + assert.match(publishRefusal(shape) ?? "", /not a valid report/); + + const notJson = reportRow("bad", { ok: false, reason: "unparseable" }, null); + assert.equal(notJson.status, "invalid"); + assert.deepEqual(notJson.problems, [{ path: "", message: "report.json is not JSON" }]); + assert.equal(reportRow("bad", { ok: false, reason: "unreadable" }, null).problems[0].message, "report.json cannot be read"); + + const missing = reportRow("gone", { ok: false, reason: "absent" }, 2); + assert.equal(missing.status, "missing"); + assert.equal(missing.position, 2); + assert.match(publishRefusal(missing) ?? "", /no report\.json/); +}); + +test("rows: the published list in the site's order, then drafts by id; a listed id with no directory is missing", () => { + const reads = new Map<string, ReportFileRead>([ + ["b-report", ok(sweep("b-report"))], + ["a-report", ok(sweep("a-report"))], + ["z-draft", ok(sweep("z-draft"))], + ["c-draft", ok(sweep("c-draft"))], + ]); + const rows = reportRows(["b-report", "a-report", "listed-only"], ["z-draft", "a-report", "c-draft", "b-report"], reads); + assert.deepEqual( + rows.map((r) => [r.id, r.position, r.status]), + [ + ["b-report", 0, "ok"], + ["a-report", 1, "ok"], + ["listed-only", 2, "missing"], + ["c-draft", null, "ok"], + ["z-draft", null, "ok"], + ], + ); + assert.deepEqual(reportRows([], [], new Map()), []); +}); + +test("the three list edits: publish appends once, unpublish removes, move swaps and stops at the ends", () => { + assert.deepEqual(withReportPublished(["a", "b"], "c"), ["a", "b", "c"]); + assert.deepEqual(withReportPublished(["a", "b"], "a"), ["a", "b"]); + assert.deepEqual(withReportUnpublished(["a", "b", "c"], "b"), ["a", "c"]); + assert.deepEqual(withReportUnpublished(["a"], "x"), ["a"]); + assert.deepEqual(withReportMoved(["a", "b", "c"], "b", -1), ["b", "a", "c"]); + assert.deepEqual(withReportMoved(["a", "b", "c"], "b", 1), ["a", "c", "b"]); + assert.deepEqual(withReportMoved(["a", "b", "c"], "a", -1), ["a", "b", "c"]); + assert.deepEqual(withReportMoved(["a", "b", "c"], "c", 1), ["a", "b", "c"]); + assert.deepEqual(withReportMoved(["a", "b"], "x", 1), ["a", "b"]); + // Never the caller's array. + const list = ["a", "b"]; + withReportMoved(list, "a", 1); + withReportPublished(list, "c"); + assert.deepEqual(list, ["a", "b"]); +}); + +test("the media summary: moments by kind, every byte the build copies, problems by kind", () => { + const index: ReportMediaIndex = { + format: "archilyzer-report-media", + version: 1, + siteId: "demo-site", + preparedAt: "2026-10-05T12:00:00.000Z", + moments: { + "demo-channel/abc123/10.00-20.00": { + kind: "video", file: "a.mp4", bytes: 1000, sha256: "x", width: 1280, height: 720, durationSec: 20, + }, + "demo-channel/abc123/30.00-40.00": { + kind: "video", file: "b.mp4", bytes: 2000, sha256: "x", width: 1280, height: 720, durationSec: 20, + }, + "demo-channel/def456/5.00-9.00": { + kind: "audio", file: "c.m4a", bytes: 300, sha256: "x", width: null, height: null, durationSec: 14, + }, + "demo-channel/1234567890": { + kind: "post", file: "posts/demo-channel/1234567890/shot.png", bytes: 40, sha256: "x", width: 600, height: 400, + durationSec: null, + media: [{ file: "posts/demo-channel/1234567890/m1.jpg", bytes: 5, sha256: "y" }], + }, + }, + problems: [ + { kind: "missing-media", message: "no media", moment: "demo-channel/ghi789/1.00-2.00", citations: ["r#c03"] }, + { kind: "invalid-report", message: "bad", report: "r", path: "id" }, + { kind: "missing-media", message: "no media", moment: "demo-channel/ghi789/3.00-4.00" }, + ], + }; + const s = reportMediaSummary(index); + assert.equal(s.preparedAt, "2026-10-05T12:00:00.000Z"); + assert.equal(s.moments, 4); + assert.deepEqual(s.momentsByKind, [ + { kind: "video", count: 2 }, + { kind: "audio", count: 1 }, + { kind: "post", count: 1 }, + ]); + assert.equal(s.totalBytes, 1000 + 2000 + 300 + 40 + 5); + assert.deepEqual(s.problemsByKind, [ + { kind: "missing-media", count: 2 }, + { kind: "invalid-report", count: 1 }, + ]); + assert.equal(s.problems.length, 3); + + const empty = reportMediaSummary({ ...index, moments: {}, problems: [] }); + assert.deepEqual([empty.moments, empty.totalBytes, empty.momentsByKind, empty.problemsByKind], [0, 0, [], []]); +}); diff --git a/editor/app/sites/lib/reportList.ts b/editor/app/sites/lib/reportList.ts @@ -0,0 +1,211 @@ +// The site's Reports tab, as data: one row per report (a directory under +// `sites/<siteId>/reports/`, or an id the site lists without one), the last +// evidence-media manifest summarised, and the three edits of the published +// list. Pure — the tab's reader (reportListServer.ts) does the disk, and +// reportsActions.ts writes the list through patchSite. +// +// A row's document is parsed and validated by the report's own checker +// (lib/report/validate.ts), with the directory's name as its id, so the tab +// lists the same problems `archilyzer reports prepare` and compose refuse. + +import type { CitationKind } from "yt-dlp-transcript-common/lib/citations/schema"; +import type { Problem } from "yt-dlp-transcript-common/lib/citations/validate"; +import type { ReportKind } from "yt-dlp-transcript-common/lib/report/schema"; +import { parseReport } from "yt-dlp-transcript-common/lib/report/validate"; +import { verdictTally, type VerdictCount } from "yt-dlp-transcript-common/lib/report/views"; +import { resolveVerdicts, type Verdict, type VerdictStyle } from "yt-dlp-transcript-common/lib/report/verdicts"; +import type { + ReportMediaIndex, + ReportMediaProblem, + ReportMediaProblemKind, +} from "yt-dlp-transcript-common/publish/reportMedia"; + +// The document model's kinds, in the order CITATIONS.md documents them. +const CITATION_KIND_ORDER: readonly CitationKind[] = ["video", "audio", "post", "source", "page"]; + +// What reading a report's `report.json` gave (lib/jsonFile-server.ts +// ReadJsonResult). +export type ReportFileRead = + | { ok: true; value: unknown } + | { ok: false; reason: "absent" | "unreadable" | "unparseable" }; + +// `ok`: parses, no problems. `problems`: parses, with value problems (it must +// not be published). `invalid`: not a report at all (not JSON, or the wrong +// shape). `missing`: the site lists it, but there is no report.json. +export type ReportRowStatus = "ok" | "problems" | "invalid" | "missing"; + +export type ReportRow = { + id: string; + // Its place in the site's published list (site.json `reports`), or null for + // a draft. + position: number | null; + status: ReportRowStatus; + title?: string; + subtitle?: string; + kind?: ReportKind; + // The document's own dates. + publishedDate?: string; + updatedDate?: string; + sections: number; + claims: number; + citations: number; + // Non-zero kinds only, in the model's order. + citationsByKind: { kind: CitationKind; count: number }[]; + // A fact-check's verdicts, and their labels and colours (the shared + // vocabulary under the report's overrides); absent on a sweep. + tally?: VerdictCount[]; + verdicts?: Record<Verdict, VerdictStyle>; + problems: Problem[]; +}; + +const EMPTY_COUNTS = { sections: 0, claims: 0, citations: 0, citationsByKind: [] }; + +export function reportRow(id: string, read: ReportFileRead, position: number | null): ReportRow { + if (!read.ok) { + if (read.reason === "absent") { + return { + id, + position, + status: "missing", + ...EMPTY_COUNTS, + problems: [{ path: "", message: `there is no reports/${id}/report.json` }], + }; + } + return { + id, + position, + status: "invalid", + ...EMPTY_COUNTS, + problems: [ + { + path: "", + message: read.reason === "unparseable" ? "report.json is not JSON" : "report.json cannot be read", + }, + ], + }; + } + const parsed = parseReport(read.value, { id }); + if (!parsed.ok) { + return { id, position, status: "invalid", ...EMPTY_COUNTS, problems: parsed.problems }; + } + const report = parsed.value; + const byKind = new Map<CitationKind, number>(); + for (const c of Object.values(report.citations ?? {})) byKind.set(c.kind, (byKind.get(c.kind) ?? 0) + 1); + return { + id, + position, + status: parsed.problems.length === 0 ? "ok" : "problems", + title: report.title, + ...(report.subtitle !== undefined ? { subtitle: report.subtitle } : {}), + kind: report.kind, + ...(report.published !== undefined ? { publishedDate: report.published } : {}), + ...(report.updated !== undefined ? { updatedDate: report.updated } : {}), + sections: report.sections.length, + claims: report.sections.reduce((n, s) => n + (s.claims?.length ?? 0), 0), + citations: Object.keys(report.citations ?? {}).length, + citationsByKind: CITATION_KIND_ORDER.filter((k) => byKind.has(k)).map((kind) => ({ + kind, + count: byKind.get(kind)!, + })), + ...(report.kind === "factcheck" + ? { tally: verdictTally(report), verdicts: resolveVerdicts(report.verdicts) } + : {}), + problems: parsed.problems, + }; +} + +// Every row: the published reports in the site's order, then the drafts (a +// directory the list does not name) by id. `dirIds` are the report +// directories on disk; `reads` holds a read for every id of either list. +export function reportRows( + published: readonly string[], + dirIds: readonly string[], + reads: ReadonlyMap<string, ReportFileRead>, +): ReportRow[] { + const absent: ReportFileRead = { ok: false, reason: "absent" }; + const listed = new Set(published); + const drafts = [...new Set(dirIds)].filter((id) => !listed.has(id)).sort((a, b) => a.localeCompare(b)); + return [ + ...published.map((id, i) => reportRow(id, reads.get(id) ?? absent, i)), + ...drafts.map((id) => reportRow(id, reads.get(id) ?? absent, null)), + ]; +} + +// Why a report may not be published, or null when it may: only a report that +// parses with no problems is published (a report with problems must not be — +// lib/report/validate.ts). +export function publishRefusal(row: Pick<ReportRow, "id" | "status" | "problems">): string | null { + switch (row.status) { + case "ok": + return null; + case "missing": + return `Report "${row.id}" has no report.json.`; + case "invalid": + return `Report "${row.id}" is not a valid report document; fix its problems first.`; + case "problems": + return `Report "${row.id}" has ${row.problems.length} problem(s); fix them first.`; + } +} + +// THE THREE EDITS of the published list, each applied to the list as it is on +// disk when the write happens (patchSite hands it over), never to the copy the +// tab rendered from. + +// Appends `id` (published last). Already published: unchanged. +export function withReportPublished(list: readonly string[], id: string): string[] { + return list.includes(id) ? [...list] : [...list, id]; +} + +export function withReportUnpublished(list: readonly string[], id: string): string[] { + return list.filter((x) => x !== id); +} + +// Moves `id` one place up (-1) or down (1). At an end, or not listed: +// unchanged. +export function withReportMoved(list: readonly string[], id: string, dir: -1 | 1): string[] { + const out = [...list]; + const i = out.indexOf(id); + const j = i + dir; + if (i < 0 || j < 0 || j >= out.length) return out; + [out[i], out[j]] = [out[j], out[i]]; + return out; +} + +// THE LAST PREPARED MEDIA, summarised for the tab. +export type ReportMediaSummary = { + preparedAt: string; + moments: number; + // Non-zero kinds only: video, audio, post. + momentsByKind: { kind: "video" | "audio" | "post"; count: number }[]; + // Everything the build will copy: every clip, every capture and its media. + totalBytes: number; + problemsByKind: { kind: ReportMediaProblemKind; count: number }[]; + problems: ReportMediaProblem[]; +}; + +const MOMENT_KIND_ORDER = ["video", "audio", "post"] as const; + +export function reportMediaSummary(index: ReportMediaIndex): ReportMediaSummary { + const byKind = new Map<string, number>(); + let totalBytes = 0; + const entries = Object.values(index.moments ?? {}); + for (const m of entries) { + byKind.set(m.kind, (byKind.get(m.kind) ?? 0) + 1); + totalBytes += m.bytes ?? 0; + if (m.kind === "post") for (const f of m.media ?? []) totalBytes += f.bytes ?? 0; + } + const problems = index.problems ?? []; + const problemCounts = new Map<ReportMediaProblemKind, number>(); + for (const p of problems) problemCounts.set(p.kind, (problemCounts.get(p.kind) ?? 0) + 1); + return { + preparedAt: index.preparedAt, + moments: entries.length, + momentsByKind: MOMENT_KIND_ORDER.filter((k) => byKind.has(k)).map((kind) => ({ + kind, + count: byKind.get(kind)!, + })), + totalBytes, + problemsByKind: [...problemCounts].map(([kind, count]) => ({ kind, count })), + problems, + }; +} diff --git a/editor/app/sites/lib/reportListServer.ts b/editor/app/sites/lib/reportListServer.ts @@ -0,0 +1,61 @@ +import "server-only"; +import type { Dirent } from "node:fs"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import type { Paths } from "yt-dlp-transcript-common/lib/paths"; +import { readJsonFile } from "yt-dlp-transcript-common/lib/jsonFile-server"; +import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; +import { siteDir, type Site } from "yt-dlp-transcript-common/lib/site"; +import { siteReportFile } from "yt-dlp-transcript-common/publish/reportMedia"; +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { listAllJobs, type JobListEntry } from "yt-dlp-transcript-common/jobs/listJobs"; +import { readJobMeta } from "yt-dlp-transcript-common/jobs/jobMeta"; +import { reportRows, type ReportFileRead, type ReportRow } from "./reportList"; + +// The report directories under `sites/<siteId>/reports/`: a directory whose +// name is a report id. Anything else there (a stray file, a bad name) is not a +// report and is not listed. +async function reportDirIds(paths: Paths, siteId: string): Promise<string[]> { + let entries: Dirent[]; + try { + entries = await readdir(path.join(siteDir(paths, siteId), "reports"), { withFileTypes: true }); + } catch { + return []; + } + return entries.filter((e) => e.isDirectory() && isReportId(e.name)).map((e) => e.name); +} + +// Every report of the site, published (in order) then drafts, each read and +// validated. +export async function readSiteReportRows(paths: Paths, site: Site): Promise<ReportRow[]> { + const published = site.reports ?? []; + const dirIds = await reportDirIds(paths, site.siteId); + const ids = [...new Set([...published, ...dirIds])]; + const reads = new Map<string, ReportFileRead>( + await Promise.all( + ids.map(async (id) => [id, await readJsonFile(siteReportFile(paths, site.siteId, id))] as const), + ), + ); + return reportRows(published, dirIds, reads); +} + +// How far back the tab looks for the site's last prepare job: the newest jobs, +// as /jobs lists them. A prepare older than that is not linked. +const PREPARE_JOB_LOOKBACK = 100; + +// The newest `reports-prepare` job for this site (its spec's slug is the site +// id — reportsPrepareAction.ts), live or from an earlier server lifetime, or +// null. +export async function latestReportsPrepareJob( + paths: Paths, + siteId: string, +): Promise<JobListEntry | null> { + const page = await listAllJobs(paths, { limit: PREPARE_JOB_LOOKBACK }); + const registry = getRegistry(); + for (const entry of page.entries) { + if (entry.kind !== "reports-prepare") continue; + const spec = registry.get(entry.id)?.spec ?? (await readJobMeta(paths, entry.id))?.spec; + if (spec?.slug === siteId) return entry; + } + return null; +} diff --git a/editor/app/sites/lib/reportsActions.ts b/editor/app/sites/lib/reportsActions.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { readJsonFile } from "yt-dlp-transcript-common/lib/jsonFile-server"; +import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; +import { isValidSiteId, patchSite } from "yt-dlp-transcript-common/lib/site"; +import { siteReportFile } from "yt-dlp-transcript-common/publish/reportMedia"; +import { + publishRefusal, + reportRow, + withReportMoved, + withReportPublished, + withReportUnpublished, +} from "./reportList"; + +// THE REPORTS TAB'S WRITES: site.json `reports`, the site's published list. +// Each goes through patchSite, which reads the site inside the file lock and +// applies the edit to the list as it is THEN — never a list the tab read +// earlier — and writes the rest of the site back untouched. + +export type ReportsActionResult = { ok: true } | { ok: false; error: string }; + +type Edit = (list: readonly string[]) => string[]; + +async function editReports(siteId: string, reportId: string, edit: Edit): Promise<ReportsActionResult> { + if (!isValidSiteId(siteId)) return { ok: false, error: `"${siteId}" is not a valid site id` }; + if (!isReportId(reportId)) return { ok: false, error: `"${reportId}" is not a valid report id` }; + let written; + try { + written = await patchSite(siteId, (current) => ({ reports: edit(current.reports ?? []) }), getPaths()); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + if (!written) return { ok: false, error: `No site "${siteId}"` }; + // "layout": the Settings tab lists the reports too. + revalidatePath("/sites/[siteId]", "layout"); + return { ok: true }; +} + +// Publish a draft: appended to the list. Refused unless its report.json parses +// with no problems — read now, not when the tab rendered. +export async function publishReportAction(siteId: string, reportId: string): Promise<ReportsActionResult> { + if (!isValidSiteId(siteId)) return { ok: false, error: `"${siteId}" is not a valid site id` }; + if (!isReportId(reportId)) return { ok: false, error: `"${reportId}" is not a valid report id` }; + const read = await readJsonFile(siteReportFile(getPaths(), siteId, reportId)); + const refusal = publishRefusal(reportRow(reportId, read, null)); + if (refusal) return { ok: false, error: refusal }; + return editReports(siteId, reportId, (list) => withReportPublished(list, reportId)); +} + +// Unpublish: off the list, back to a draft. Its directory is not touched. +export async function unpublishReportAction(siteId: string, reportId: string): Promise<ReportsActionResult> { + return editReports(siteId, reportId, (list) => withReportUnpublished(list, reportId)); +} + +// One place up (-1) or down (1) in the published order. +export async function moveReportAction( + siteId: string, + reportId: string, + dir: -1 | 1, +): Promise<ReportsActionResult> { + if (dir !== -1 && dir !== 1) return { ok: false, error: "A report moves one place up or down." }; + return editReports(siteId, reportId, (list) => withReportMoved(list, reportId, dir)); +}