commit c5a01bff2c05b43577bd4f6abd94ea7b95db68d1
parent 721530d506ba681198278d641dc774b0ded3ce81
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:30:11 -0400
Merge report-r7-editor-tab (the editor's Reports tab: reports + drafts with problems, publish/unpublish/reorder via a locked patchSite, Prepare evidence media)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
14 files changed, 1058 insertions(+), 9 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/common/lib/site.ts b/common/lib/site.ts
@@ -11,7 +11,7 @@ import {
type SocialLink,
} from "./settings";
import { socialLinksForSave } from "./socialLinks";
-import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server";
+import { readJsonFileSync, withJsonFileLock, writeJsonAtomic } from "./jsonFile-server";
import {
isListedSite,
isPrivateSite,
@@ -267,6 +267,33 @@ export async function writeSite(
);
}
+// PATCH SOME KEYS OF A SITE — the writer for a surface that owns a few keys and
+// none of the others (the Reports tab's `reports`). The site is read from disk
+// INSIDE the file lock and the patch is applied to that, so a patch never
+// writes back a copy of the other keys read earlier, and two patches never
+// interleave. A function patch is given the site as it is now (to reorder a
+// list it did not read itself). Validated and written by writeSite. Null when
+// the site has no site.json: a patch never creates a site.
+export async function patchSite(
+ siteId: string,
+ patch:
+ | Partial<Omit<Site, "siteId">>
+ | ((current: Site) => Partial<Omit<Site, "siteId">>),
+ paths: Paths = getPaths(),
+): Promise<Site | null> {
+ if (!isValidSiteId(siteId)) {
+ throw new Error(`Invalid site id: ${String(siteId)}`);
+ }
+ const file = siteConfigFile(paths, siteId);
+ return withJsonFileLock(file, async () => {
+ if (!fs.existsSync(file)) return null;
+ const current = getSite(siteId, paths);
+ const changes = typeof patch === "function" ? patch(current) : patch;
+ await writeSite({ ...current, ...changes, siteId }, paths);
+ return getSite(siteId, paths);
+ });
+}
+
export async function deleteSite(
siteId: string,
paths: Paths = getPaths(),
diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts
@@ -18,7 +18,7 @@ import {
siteToDisk,
type Site,
} from "./siteSchema";
-import { getSite, resolveRelatedSites, siteConfigFile, writeSite } from "./site";
+import { getSite, patchSite, resolveRelatedSites, siteConfigFile, writeSite } from "./site";
import type { Paths } from "./paths";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -409,3 +409,34 @@ test("writeSite → getSite round-trips, and the file holds only non-defaults",
assert.equal("publish" in disk, false);
assert.equal("reports" in disk, false);
});
+
+test("patchSite applies a patch to the site on disk now, keeps every other key, and never creates a site", async () => {
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
+ assert.equal(await patchSite("s", { reports: ["one"] }, paths), null);
+ assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false);
+
+ const site = parseSite("s", {
+ siteTitle: "Mine",
+ archives: false,
+ publish: "cited",
+ reports: ["one", "two"],
+ });
+ await writeSite(site, paths);
+ // Another writer changes a key after this caller last read the site: the
+ // patch is applied to the file, not to the stale copy.
+ await writeSite({ ...site, siteTitle: "Renamed" }, paths);
+ const patched = await patchSite("s", { reports: ["two", "one"] }, paths);
+ assert.deepEqual(patched, { ...site, siteTitle: "Renamed", reports: ["two", "one"] });
+ assert.deepEqual(getSite("s", paths), patched);
+
+ // A function patch sees the site as it is; two patches do not interleave.
+ await Promise.all([
+ patchSite("s", (cur) => ({ reports: [...(cur.reports ?? []), "three"] }), paths),
+ patchSite("s", (cur) => ({ reports: [...(cur.reports ?? []), "four"] }), paths),
+ ]);
+ assert.deepEqual(getSite("s", paths).reports, ["two", "one", "three", "four"]);
+ // The id is the directory's, whatever a patch says.
+ await patchSite("s", { siteId: "other" } as Partial<Site>, paths);
+ assert.equal(getSite("s", paths).siteId, "s");
+ assert.equal(fs.existsSync(siteConfigFile(paths, "other")), false);
+});
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A site has a Reports tab.** `/sites/<site>/reports` lists every report under the site's `reports/` directory — the published ones in their order, then the drafts — with its kind, dates, sections, claims, citations by kind and, for a fact-check, how many claims carry each verdict. Each report's problems, from the same checker the prepare step and the build use, open under it. A draft with no problems can be published, and a published report moved up or down or unpublished; each writes only the site's `reports` list, applied to the list as it is on disk at that moment, so it never overwrites another change to the site. "Prepare evidence media" queues the `reports-prepare` job, and beside it the tab shows the last prepared media (moments by kind, total size, problems by kind) and links the last prepare job. What the site publishes (full or cited) is shown with a link to Settings, where it is changed.
- **A site can say what it publishes, and which reports.** `site.json` takes `publish` — `"full"`, the searchable corpus every site has been (the default, never written), or `"cited"`, only the site's reports and the moments they cite — and `reports`, the ordered ids of its published reports (each a slug; invalid and repeated ids are dropped). The site form has a Publish control and lists the site's reports read-only; saving the form keeps the stored list. A cited site still builds as a full one until the reports pipeline applies the scope. SITE.md documents both keys.
- **A report and its citations now have one written format, checked before anything is built from them.** A cited report is a `report.json` (`archilyzer-report`, version 1): a summary, then sections of claims, each claim with an optional verdict, the reviewed document's own sentence, findings in markdown and the citations it rests on. A citation is one of five kinds — a span of a video, a span of an audio record, a post, a sentence of a source document, or a web page — with a verbatim quote, and is cited from any markdown in the report as `[label](cite:<id>)`. The checker lists every problem at once with where it is: a citation, a source or a `cite:` link that names nothing, a span that ends before it starts or runs past 120 seconds with its context, a still that points outside the report's folder, an id used twice. Each cited span and post has one page address, `/m/<channel>/<id>/<start>-<end>/` or `/m/<channel>/<id>/`. Nothing builds or shows reports yet. The fact-check verdicts (Corroborated, Partly true, Contradicted, Not found, Untestable) and their colours are now kept in one place, which the report video's stamps and tally read too. `REPORT.md` and `CITATIONS.md` list every key.
- **A site's reports can have their evidence media prepared: every cited span cut to a clip, every cited post's capture copied.** `archilyzer reports prepare <site>`, the `reports-prepare` job (`POST /api/ops/reports-prepare`, `pnpm ops reports-prepare`) reads the site's published reports, checks them, and for each cited moment cuts the span (with its context) out of the media already on disk — a fetched clip window, the saved video, or the recording's audio — fitted inside 1280×720 with H.264 and AAC, or as an `.m4a` for an audio span; and copies the screenshot and attached media of each cited post, and of no other post, beside them. Everything lands in the site's build staging (`.export-index/sites/<site>/report-media/`) with an `index.json` naming each moment's file, size, checksum, size in pixels and duration. A clip is cut once and reused while its source file and span are unchanged; a clip or capture no longer cited is removed. Nothing is fetched: a citation whose media is not on disk, a post without a screenshot, a clip over 24 MiB, a citation of a channel outside the site, a post the site may not show, or an invalid or missing report is listed with the citations it affects, and the run fails (exit 1, or a failed job) — a span on a drive that is not mounted is reported as such rather than as missing. The lookup of a span's media on disk is now shared with report-to-video, which finds the same files it did.
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));
+}