// The site's Reports tab, as data: one row per report (a directory under // `sites//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; 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(); 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, ): 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): 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(); 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(); 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, }; }