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:
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'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/<reportId>/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'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'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));
+}