Archilyzer · Source

archilyzer

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

commit b2d1ae21259ae52ebe3eb532708881175b936c47
parent 94e9c6d78429a46f19b69d9362a35245b060bcb8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 03:01:06 -0400

common: report and moment page views (lib/report/views.ts) — the JSON compose writes and the export pages read; buildReportPageView, paths, placeholders

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

Diffstat:
Acommon/lib/report/views.test.ts | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/views.ts | 563+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 789 insertions(+), 0 deletions(-)

diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts @@ -0,0 +1,226 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isRefId } from "../citations/schema"; +import { parseMomentKey } from "../citations/moments"; +import { buildCitedIn } from "./citedIn"; +import { isReportId, type Report } from "./schema"; +import { validateReport } from "./validate"; +import { + MOMENT_PLACEHOLDER_KEY, + REPORT_PLACEHOLDER_ID, + buildReportPageView, + citedInViews, + evidenceClipPath, + momentViewPath, + orderedCitations, + reportAssetPath, + reportIndexEntry, + sectionCitations, + spanLabel, + verdictTally, + type RecordView, +} from "./views"; + +const report: Report = { + format: "archilyzer-report", + version: 1, + id: "demo", + kind: "factcheck", + title: "A demo fact-check", + summary: "It starts [here](cite:v1).", + subject: { source: "s0" }, + verdicts: { PARTLY: { label: "Half" } }, + sources: { + s0: { + kind: "article", + title: "An article", + url: "https://example.org/a", + archives: [{ label: "archive", url: "https://archive.example.org/a" }], + saved: "sources/s0/page.html", + }, + }, + citations: { + v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 3126, end: 3151.004, quote: "one" }, + a1: { kind: "audio", channel: "demo-podcast", id: "ep-1", start: 10.5, end: 20, quote: "two", speaker: "Guest" }, + p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" }, + s1: { kind: "source", source: "s0", quote: "four", image: "stills/s1.png" }, + w1: { kind: "page", url: "https://example.org/p", title: "A page", quote: "five" }, + never: { kind: "page", url: "https://example.org/never", quote: "never cited" }, + }, + sections: [ + { + id: "one", + title: "One", + claims: [ + { + id: "c1", + text: "Claim one.", + verdict: "CONTRADICTED", + sourceQuote: { citation: "s1" }, + findings: "See [this](cite:w1) and `[code](cite:never)`.", + citations: ["v1", "a1"], + }, + ], + }, + { + id: "two", + title: "Two", + body: "A body citing [a post](cite:p1).", + claims: [{ id: "c2", text: "Claim two.", verdict: "PARTLY", citations: ["a1"] }], + }, + ], +}; + +const record = (channel: string, id: string): RecordView => ({ channel, id, title: `${channel} ${id}` }); + +const view = buildReportPageView(report, { + record: (c) => record(c.channel, c.id), + poster: (c) => (c.kind === "video" ? "/poster.png" : undefined), + post: () => ({ author: "@someone", text: "three, and more" }), + downloads: { json: "/reports/demo/citations.json" }, +}); + +test("the fixture report here is sound", () => { + assert.deepEqual(validateReport(report), []); +}); + +test("numbers by first appearance in reading order; an uncited citation is left out", () => { + const numbers = Object.fromEntries(Object.values(view.citations).map((c) => [c.id, c.number])); + // summary v1; claim c1: source s1, findings w1, list v1 a1; section two body p1. + assert.deepEqual(numbers, { v1: 1, s1: 2, w1: 3, a1: 4, p1: 5 }); + assert.equal(view.citations.never, undefined); + assert.deepEqual( + orderedCitations(view).map((c) => c.id), + ["v1", "s1", "w1", "a1", "p1"], + ); +}); + +test("span and post citations link to their moment page, keyed at two decimals", () => { + const v1 = view.citations.v1; + assert.equal(v1.kind, "video"); + if (v1.kind !== "video") return; + assert.equal(v1.moment, "demo-channel/abc123/3126.00-3151.00"); + assert.equal(v1.href, "/m/demo-channel/abc123/3126.00-3151.00/"); + assert.equal(v1.end, 3151); + assert.equal(v1.poster, "/poster.png"); + assert.deepEqual(v1.record, record("demo-channel", "abc123")); + const p1 = view.citations.p1; + assert.equal(p1.kind, "post"); + if (p1.kind !== "post") return; + assert.equal(p1.href, "/m/demo-social/123/"); + assert.equal(p1.author, "@someone"); + assert.equal(p1.text, "three, and more"); + const a1 = view.citations.a1; + assert.equal(a1.kind === "audio" && a1.poster, undefined); +}); + +test("a source citation carries its document (never the saved copy) and its published still", () => { + const s1 = view.citations.s1; + assert.equal(s1.kind, "source"); + if (s1.kind !== "source") return; + assert.equal(s1.image, "/reports/demo/stills/s1.png"); + assert.equal(s1.href, "https://example.org/a"); + assert.equal("saved" in s1.source, false); + assert.equal(view.subject?.title, "An article"); + assert.equal(view.subject && "saved" in view.subject, false); + assert.ok(!JSON.stringify(view).includes("sources/s0/page.html")); +}); + +test("a page citation links out; the verdict overrides are laid over the defaults", () => { + const w1 = view.citations.w1; + assert.equal(w1.kind === "page" && w1.href, "https://example.org/p"); + assert.equal(view.verdicts.PARTLY.label, "Half"); + assert.equal(view.verdicts.PARTLY.color, "#e3b23c"); + assert.equal(view.verdicts.CONTRADICTED.label, "Contradicted"); +}); + +test("the view keeps the claim structure, with the source sentence as an id", () => { + assert.deepEqual(view.sections[0].claims[0], { + id: "c1", + text: "Claim one.", + verdict: "CONTRADICTED", + sourceQuote: "s1", + findings: "See [this](cite:w1) and `[code](cite:never)`.", + citations: ["v1", "a1"], + }); + assert.deepEqual(view.sections[1].claims[0].citations, ["a1"]); + assert.deepEqual(view.downloads, { json: "/reports/demo/citations.json" }); +}); + +test("a section's citations, in number order; code is not citing", () => { + assert.deepEqual( + sectionCitations(view, view.sections[0]).map((c) => c.id), + ["v1", "s1", "w1", "a1"], + ); + assert.deepEqual( + sectionCitations(view, view.sections[1]).map((c) => c.id), + ["a1", "p1"], + ); +}); + +test("the tally counts verdicts in the vocabulary's order; the index entry carries it", () => { + assert.deepEqual(verdictTally(view), [ + { verdict: "PARTLY", count: 1 }, + { verdict: "CONTRADICTED", count: 1 }, + ]); + const entry = reportIndexEntry(view); + assert.equal(entry.href, "/reports/demo/"); + assert.equal(entry.citationCount, 5); + assert.equal(entry.claimCount, 2); + assert.deepEqual(entry.tally, verdictTally(view)); + assert.equal(reportIndexEntry({ ...view, kind: "sweep" }).tally, undefined); +}); + +test("cited-in entries resolve to the claim's anchor with titles and the citation's number", () => { + const index = buildCitedIn([report]); + const entries = citedInViews(index["demo-podcast/ep-1/10.50-20.00"], [view]); + assert.deepEqual( + entries.map((e) => [e.href, e.claimText, e.verdict, e.verdictStyle?.label, e.number]), + [ + ["/reports/demo/#c1", "Claim one.", "CONTRADICTED", "Contradicted", 4], + ["/reports/demo/#c2", "Claim two.", "PARTLY", "Half", 4], + ], + ); + const summary = citedInViews(index["demo-channel/abc123/3126.00-3151.00"], [view]); + assert.equal(summary[0].href, "/reports/demo/"); + assert.equal(citedInViews(index["demo-social/123"], []).length, 0); +}); + +test("paths: the clip, the moment view and a report asset", () => { + assert.equal( + evidenceClipPath({ kind: "span", channel: "demo-channel", id: "-abc", start: 1, end: 2.5 }), + "/media/clips/demo-channel/-abc/1.00-2.50.mp4", + ); + assert.equal(momentViewPath("demo-social/123"), "/m/demo-social/123/moment.json"); + assert.equal(reportAssetPath("demo", "./stills/x.png"), "/reports/demo/stills/x.png"); +}); + +test("the placeholder params can never be a report or a moment", () => { + assert.equal(isReportId(REPORT_PLACEHOLDER_ID), false); + assert.equal(parseMomentKey(MOMENT_PLACEHOLDER_KEY), null); + assert.equal(isRefId("x"), true); +}); + +test("a span reads as clock times", () => { + assert.equal(spanLabel(3126, 3151), "52:06–52:31"); + assert.equal(spanLabel(3725.9, 3730), "1:02:05–1:02:10"); +}); + +test("a citation with no moment key, or a source the report lacks, fails the build loudly", () => { + const bad: Report = { + ...report, + summary: "[x](cite:bad)", + citations: { bad: { kind: "video", channel: "../up", id: "abc", start: 1, end: 2, quote: "q" } }, + sections: [], + }; + assert.throws(() => buildReportPageView(bad, { record: (c) => record(c.channel, c.id) }), /no moment key/); + const noSource: Report = { + ...report, + summary: "[x](cite:s)", + sources: {}, + subject: undefined, + citations: { s: { kind: "source", source: "gone", quote: "q" } }, + sections: [], + }; + assert.throws(() => buildReportPageView(noSource, { record: (c) => record(c.channel, c.id) }), /does not define/); +}); diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -0,0 +1,563 @@ +// REPORT AND MOMENT PAGE VIEWS — the JSON the reports stage of compose writes +// under the export app's public dir, and the export app's pages read. One +// definition, so the writer and the reader cannot disagree: +// +// /reports/index.json ReportIndexView the report index (and a cited site's home) +// /reports/<reportId>/page.json ReportPageView one report, its citations resolved +// /reports/<reportId>/citations.{json,csv} the report's citations, for download +// /reports/<reportId>/stills/… a source citation's still, as the report names it +// /m/index.json MomentIndexView every moment page to build +// /m/<momentKey>/moment.json MomentPageView one moment: clip, cues, record, "cited in" +// /media/clips/<channel>/<id>/<start>-<end>.mp4 a span's evidence clip +// +// A VIEW IS RESOLVED, a document is not: a report names a citation by +// `channel`/`id`; its view carries what a reader sees — the record's title, +// channel title and date, the moment page's href, the per-report number, a +// poster. The components (common/components/citations/) take views as props and +// fetch nothing, so the export site, the editor's previews and umtool's report +// preview render the same thing from the same data. +// +// A view carries ONLY what is cited: no `saved` source copy (never published), +// no cue beyond the moment's bounded context, no record field a page does not +// show. +// +// `buildReportPageView` is the one way a report becomes its view: the numbers +// are lib/report/uses.ts's (first appearance, reading order), the hrefs are +// lib/citations/moments.ts's, the verdict styles the shared vocabulary's with +// the report's overrides laid over. The caller (compose) supplies what only the +// corpus knows — each cited record's metadata, a poster, a post's text — via a +// resolver. A moment page's view is assembled by compose from the cues and the +// clip it prepares; its "cited in" entries come from `citedInViews`. +// +// Pure, no imports but types and pure helpers: the export site's pages and the +// browser can use it. + +import { citationAnchor, extractCiteRefs } from "../citations/inline"; +import { + formatMomentSeconds, + momentKeyOf, + momentPath, + type SpanMoment, +} from "../citations/moments"; +import type { + CitationKind, + CitationVerification, + PostCitation, + Source, + SourceArchive, + SpanCitation, +} from "../citations/schema"; +import { formatTimestamp } from "../vtt"; +import type { CitedIn } from "./citedIn"; +import type { Report, ReportKind } from "./schema"; +import { reportCitationNumbers } from "./uses"; +import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts"; + +export const REPORT_VIEWS_VERSION = 1; + +export const REPORT_INDEX_FORMAT = "archilyzer-report-index"; +export const REPORT_PAGE_FORMAT = "archilyzer-report-page"; +export const MOMENT_INDEX_FORMAT = "archilyzer-moment-index"; +export const MOMENT_PAGE_FORMAT = "archilyzer-moment-page"; + +// ─── Where each view lives (site-root URL paths; a file under the public dir) ─── + +export const REPORTS_INDEX_PATH = "/reports/index.json"; +export const MOMENTS_INDEX_PATH = "/m/index.json"; + +// THE PLACEHOLDER PARAM. A static export refuses a dynamic route whose +// generateStaticParams is empty, so a site with no reports (or no moments) +// builds ONE page under this param, which renders "no reports". It can never +// be a report id (REPORT_ID_RE starts with a letter or digit) nor a moment key +// (a key has two or three segments). The out/ audit expects exactly these. +export const REPORT_PLACEHOLDER_ID = "_none"; +export const MOMENT_PLACEHOLDER_KEY = "_none"; + +export function reportPagePath(reportId: string): string { + return `/reports/${reportId}/`; +} + +export function reportViewPath(reportId: string): string { + return `/reports/${reportId}/page.json`; +} + +export function reportCitationsDownloadPath(reportId: string, format: "json" | "csv"): string { + return `/reports/${reportId}/citations.${format}`; +} + +// A file the report names relative to its own directory (a still, +// `stills/a01.png` — validated by lib/report/validate.ts never to leave it), +// as published. +export function reportAssetPath(reportId: string, relPath: string): string { + return `/reports/${reportId}/${relPath.replace(/^\.\//, "")}`; +} + +export function momentViewPath(key: string): string { + return `${momentPath(key)}moment.json`; +} + +// A span's evidence clip, named by the moment (the pad widens the clip, not +// the name). +export function evidenceClipPath(m: SpanMoment): string { + return `/media/clips/${m.channel}/${m.id}/${formatMomentSeconds(m.start)}-${formatMomentSeconds(m.end)}.mp4`; +} + +// ─── Views ─── + +// A cited record, as a reader sees it. +export type RecordView = { + channel: string; + channelTitle?: string; + id: string; + title?: string; + // The record's own date (`YYYY-MM-DD`). + date?: string; + platform?: string; + // The record on its own platform at the cited time (lib/momentUrl.ts + // platformMomentUrl), or the post's own URL. + originalUrl?: string; + // The record in this site's corpus (`/?v=<channel>/<id>&t=<s>`): a FULL site + // only — a cited site has no corpus to open. + corpusUrl?: string; +}; + +// A document a `source` citation quotes. Never its `saved` copy. +export type SourceView = { + id: string; + kind: Source["kind"]; + title: string; + url?: string; + publisher?: string; + author?: string; + date?: string; + // A line shown under the byline, e.g. which copy of the document was read. + note?: string; + archives: SourceArchive[]; +}; + +type CitationViewCommon = { + id: string; + // The citation's number in its report (absent outside one). + number?: number; + quote: string; + speaker?: string; + date?: string; + label?: string; + note?: string; + verification?: CitationVerification; +}; + +export type SpanCitationView = CitationViewCommon & { + kind: "video" | "audio"; + // The moment's key and page. + moment: string; + href: string; + start: number; + end: number; + record: RecordView; + // A still for the card: a site-root path or an http(s) URL. + poster?: string; +}; + +export type PostCitationView = CitationViewCommon & { + kind: "post"; + moment: string; + href: string; + record: RecordView; + author?: string; + text?: string; + // The captured screenshot of the post: a site-root path. + shot?: string; + thread?: boolean; +}; + +export type SourceCitationView = CitationViewCommon & { + kind: "source"; + source: SourceView; + // The still of the sentence, published (reportAssetPath). + image?: string; + // The document itself, when it has a URL. + href?: string; +}; + +export type PageCitationView = CitationViewCommon & { + kind: "page"; + href: string; + title?: string; + archiveUrl?: string; +}; + +export type CitationView = SpanCitationView | PostCitationView | SourceCitationView | PageCitationView; + +export type ClaimView = { + id: string; + // A short headline shown above the claim (e.g. a quoted phrase). + title?: string; + // The claim, as the document under review states it. + text: string; + verdict?: Verdict; + // The id of the `source` citation holding the document's own sentence. + sourceQuote?: string; + findings?: string; + citations: string[]; +}; + +export type SectionView = { + id: string; + title: string; + body?: string; + claims: ClaimView[]; +}; + +export type ReportPageView = { + format: typeof REPORT_PAGE_FORMAT; + version: typeof REPORT_VIEWS_VERSION; + id: string; + kind: ReportKind; + title: string; + subtitle?: string; + summary?: string; + published?: string; + updated?: string; + subject?: SourceView; + // Every verdict's label and colour, the report's overrides applied. + verdicts: Record<Verdict, VerdictStyle>; + // Every citation the report cites, numbered; one it defines but never cites + // is left out. + citations: Record<string, CitationView>; + sections: SectionView[]; + // The report's citations as files, when compose wrote them. + downloads?: { json?: string; csv?: string }; +}; + +export type VerdictCount = { verdict: Verdict; count: number }; + +export type ReportIndexEntry = { + id: string; + kind: ReportKind; + title: string; + subtitle?: string; + published?: string; + updated?: string; + href: string; + citationCount: number; + claimCount: number; + // A fact-check's tally (verdictTally); absent on a sweep. + tally?: VerdictCount[]; + verdicts?: Record<Verdict, VerdictStyle>; +}; + +export type ReportIndexView = { + format: typeof REPORT_INDEX_FORMAT; + version: typeof REPORT_VIEWS_VERSION; + reports: ReportIndexEntry[]; +}; + +// One line of the transcript around a span. +export type CueLineView = { + start: number; + end?: number; + text: string; + // Whether the line overlaps the cited span. + inSpan: boolean; +}; + +// One place that cites a moment, ready to link. +export type CitedInView = CitedIn & { + href: string; + reportTitle: string; + sectionTitle?: string; + claimTitle?: string; + claimText?: string; + verdict?: Verdict; + // The verdict's label and colour in its report (overrides applied). + verdictStyle?: VerdictStyle; + number?: number; +}; + +export type MomentClipView = { + src: string; + // The record seconds the clip covers: the span with its pad. + start: number; + end: number; + poster?: string; +}; + +export type MomentPostView = { + author?: string; + text: string; + shot?: string; + media?: { src: string; kind: "image" | "video" }[]; +}; + +export type MomentPageView = { + format: typeof MOMENT_PAGE_FORMAT; + version: typeof REPORT_VIEWS_VERSION; + key: string; + kind: Extract<CitationKind, "video" | "audio" | "post">; + record: RecordView; + // The quote, speaker and check of the first citation of the moment. + quote: string; + speaker?: string; + date?: string; + verification?: CitationVerification; + // A span: the cited seconds, the clip, the bounded cue context. + start?: number; + end?: number; + clip?: MomentClipView; + cues?: CueLineView[]; + // A post. + post?: MomentPostView; + citedIn: CitedInView[]; +}; + +export type MomentIndexView = { + format: typeof MOMENT_INDEX_FORMAT; + version: typeof REPORT_VIEWS_VERSION; + moments: string[]; +}; + +// ─── Building a report's view ─── + +export type ReportViewResolver = { + // The cited record's metadata. Compose knows the corpus; a citation of a + // record it cannot resolve is a compose failure, not a missing field here. + record: (c: SpanCitation | PostCitation) => RecordView; + poster?: (c: SpanCitation) => string | undefined; + post?: (c: PostCitation) => { author?: string; text?: string; shot?: string } | undefined; + downloads?: { json?: string; csv?: string }; +}; + +function sourceView(id: string, s: Source): SourceView { + return { + id, + kind: s.kind, + title: s.title, + ...(s.url ? { url: s.url } : {}), + ...(s.publisher ? { publisher: s.publisher } : {}), + ...(s.author ? { author: s.author } : {}), + ...(s.date ? { date: s.date } : {}), + ...((s as { note?: string }).note ? { note: (s as { note?: string }).note } : {}), + archives: s.archives ?? [], + }; +} + +const defined = <T extends object>(o: T): T => + Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; + +// A report's page view. Throws on a citation whose moment cannot be keyed, or +// a source citation naming a source the report does not define — both are +// validation problems (lib/report/validate.ts) a published report cannot have. +export function buildReportPageView(report: Report, resolve: ReportViewResolver): ReportPageView { + const numbers = reportCitationNumbers(report); + const all = report.citations ?? {}; + const sources = report.sources ?? {}; + const citations: Record<string, CitationView> = {}; + for (const [id, number] of numbers) { + const c = all[id]; + const common = defined({ + id, + number, + quote: c.quote, + speaker: c.speaker, + date: c.date, + label: c.label, + note: c.note, + verification: c.verification, + }); + switch (c.kind) { + case "video": + case "audio": { + const key = momentKeyOf(c); + if (!key) throw new Error(`report ${report.id}: citation ${id} has no moment key`); + citations[id] = defined({ + kind: c.kind, + ...common, + moment: key, + href: momentPath(key), + start: Number(formatMomentSeconds(c.start)), + end: Number(formatMomentSeconds(c.end)), + record: resolve.record(c), + poster: resolve.poster?.(c), + }); + break; + } + case "post": { + const key = momentKeyOf(c); + if (!key) throw new Error(`report ${report.id}: citation ${id} has no moment key`); + const post = resolve.post?.(c); + citations[id] = defined({ + kind: "post" as const, + ...common, + moment: key, + href: momentPath(key), + record: resolve.record(c), + author: post?.author, + text: post?.text, + shot: post?.shot, + thread: c.thread, + }); + break; + } + case "source": { + const s = sources[c.source]; + if (!s) throw new Error(`report ${report.id}: citation ${id} names source ${c.source}, which it does not define`); + citations[id] = defined({ + kind: "source" as const, + ...common, + source: sourceView(c.source, s), + image: c.image ? reportAssetPath(report.id, c.image) : undefined, + href: s.url, + }); + break; + } + case "page": + citations[id] = defined({ + kind: "page" as const, + ...common, + href: c.url, + title: c.title, + archiveUrl: c.archiveUrl, + }); + break; + } + } + const subject = report.subject && sources[report.subject.source]; + return defined({ + format: REPORT_PAGE_FORMAT, + version: REPORT_VIEWS_VERSION, + id: report.id, + kind: report.kind, + title: report.title, + subtitle: report.subtitle, + summary: report.summary, + published: report.published, + updated: report.updated, + subject: subject ? sourceView(report.subject!.source, subject) : undefined, + verdicts: resolveVerdicts(report.verdicts), + citations, + sections: report.sections.map((s) => + defined({ + id: s.id, + title: s.title, + body: s.body, + claims: (s.claims ?? []).map((c) => + defined({ + id: c.id, + title: (c as { title?: string }).title, + text: c.text, + verdict: c.verdict, + sourceQuote: c.sourceQuote?.citation, + findings: c.findings, + citations: c.citations ?? [], + }), + ), + }), + ), + downloads: resolve.downloads, + }); +} + +// ─── Reading a view ─── + +// The report's citations in number order. +export function orderedCitations(view: Pick<ReportPageView, "citations">): CitationView[] { + return Object.values(view.citations) + .filter((c) => c.number !== undefined) + .sort((a, b) => a.number! - b.number!); +} + +// The citations a section cites (its body, then each claim's source sentence, +// findings and list), in number order — a per-section reference list. +export function sectionCitations(view: Pick<ReportPageView, "citations">, section: SectionView): CitationView[] { + const ids = new Set<string>(); + for (const r of extractCiteRefs(section.body)) ids.add(r.id); + for (const claim of section.claims) { + if (claim.sourceQuote) ids.add(claim.sourceQuote); + for (const r of extractCiteRefs(claim.findings)) ids.add(r.id); + for (const id of claim.citations) ids.add(id); + } + return orderedCitations(view).filter((c) => ids.has(c.id)); +} + +// 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[] { + 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); + } + return VERDICTS.filter((v) => counts.has(v)).map((verdict) => ({ verdict, count: counts.get(verdict)! })); +} + +// The report's entry in the index. +export function reportIndexEntry(view: ReportPageView): ReportIndexEntry { + const tally = view.kind === "factcheck" ? verdictTally(view) : undefined; + return defined({ + id: view.id, + kind: view.kind, + title: view.title, + subtitle: view.subtitle, + published: view.published, + updated: view.updated, + href: reportPagePath(view.id), + citationCount: Object.keys(view.citations).length, + claimCount: view.sections.reduce((n, s) => n + s.claims.length, 0), + tally, + verdicts: tally ? view.verdicts : undefined, + }); +} + +// The anchor of a place in a report: the claim, else the section, else none +// (the summary — the page's top). +export function citedInHref(entry: Pick<CitedIn, "reportId" | "sectionId" | "claimId">): string { + const anchor = entry.claimId ?? entry.sectionId; + return `${reportPagePath(entry.reportId)}${anchor ? `#${anchor}` : ""}`; +} + +// A moment's "cited in" entries (lib/report/citedIn.ts buildCitedIn), resolved +// against the reports' views for their titles; an entry whose report has no +// view is dropped. +export function citedInViews(entries: readonly CitedIn[], reports: readonly ReportPageView[]): CitedInView[] { + const byId = new Map(reports.map((r) => [r.id, r])); + const out: CitedInView[] = []; + for (const e of entries) { + const report = byId.get(e.reportId); + if (!report) continue; + const section = e.sectionId ? report.sections.find((s) => s.id === e.sectionId) : undefined; + const claim = e.claimId ? section?.claims.find((c) => c.id === e.claimId) : undefined; + out.push( + defined({ + ...e, + href: citedInHref(e), + reportTitle: report.title, + sectionTitle: section?.title, + claimTitle: claim?.title, + claimText: claim?.text, + verdict: claim?.verdict, + verdictStyle: claim?.verdict ? report.verdicts[claim.verdict] : undefined, + number: report.citations[e.citationId]?.number, + }), + ); + } + return out; +} + +// ─── Labels ─── + +// A span as a reader reads it: `52:06–52:31` (lib/vtt.ts formatTimestamp). +export function spanLabel(start: number, end: number): string { + return `${formatTimestamp(start)}–${formatTimestamp(end)}`; +} + +export const CITATION_KIND_LABELS: Record<CitationKind, string> = { + video: "Video", + audio: "Audio", + post: "Post", + source: "Source", + page: "Web page", +}; + +// The anchor of a citation's reference entry (re-exported so a page needs one +// import for the view and its anchors). +export { citationAnchor };