// 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//page.json ReportPageView one report, its citations resolved // /reports//citations.{json,csv} the report's citations, for download // /reports//feed.{xml,json} its timeline as RSS 2.0 and JSON Feed 1.1 (./feeds.ts), // for a report with entries on a site with a public URL // /reports//stills/… a source citation's still, as the report names it // /reports//history/history.json ReportHistoryView its revisions (lib/report/revisions.ts) // /reports//history/repo/ a dumb-HTTP clone of its revision history // /m/index.json MomentIndexView every moment page to build // /m//moment.json MomentPageView one moment: clip, cues, record, "cited in" // /media/clips///-.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 — and nothing large twice: a document (with its archive links) is in a // report view's `sources` once, and its citations name it by id. // // `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, CitationOrigin, CitationVerification, PostCitation, Source, SourceArchive, SpanCitation, } from "../citations/schema"; import { quoteTokens } from "../citations/verify"; import { formatTimestamp } from "../vtt"; import type { CitedIn } from "./citedIn"; import type { ReportHistoryRef } from "./revisions"; import { effectiveUpdated, entriesNewestFirst } from "./entries"; import type { Claim, Report, ReportKind, ReportSlidesSpec, SlideSpec } 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 report's exports (publish/reportExports.ts): the whole report as files a // reader can save and host again — the article (html, pdf, md), its slides // (lib/report/slides.ts: `slides` is slides.html, `slides-pdf` one 16:9 page // per slide; none for a report whose `slides.hide` says so) and the evidence // pack. Their names, and where compose publishes them beside the report's page. export const REPORT_EXPORT_FORMATS = ["html", "pdf", "md", "slides", "slides-pdf", "zip"] as const; export type ReportExportFormat = (typeof REPORT_EXPORT_FORMATS)[number]; export const REPORT_EXPORT_FILENAMES: Readonly> = { html: "report.html", pdf: "report.pdf", md: "report.md", slides: "slides.html", "slides-pdf": "slides.pdf", zip: "evidence-pack.zip", }; export function reportExportDownloadPath(reportId: string, format: ReportExportFormat): string { return `/reports/${reportId}/${REPORT_EXPORT_FILENAMES[format]}`; } // A report's feeds of its timeline (./feeds.ts): RSS 2.0 and JSON Feed 1.1, // published beside its page only for a report with entries on a site with a // public URL (a feed's links are absolute). export const REPORT_FEED_FORMATS = ["rss", "json"] as const; export type ReportFeedFormat = (typeof REPORT_FEED_FORMATS)[number]; export const REPORT_FEED_FILENAMES: Readonly> = { rss: "feed.xml", json: "feed.json", }; export const REPORT_FEED_MIME: Readonly> = { rss: "application/rss+xml", json: "application/feed+json", }; export function reportFeedPath(reportId: string, format: ReportFeedFormat): string { return `/reports/${reportId}/${REPORT_FEED_FILENAMES[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; // What `originalUrl` is, when "Original" would not say: "archive.org" for an // archive.org record, "YouTube" for an archive.org mirror of a YouTube // upload (whose originalUrl is the upload at the cited second), "Original // (may be gone)" for a Wayback Machine copy (lib/wayback.ts). originalLabel?: string; // Where a reader can fetch the recording itself to check it, derived from // the record's provenance (lib/archiveOrg.ts archiveOrgCitationLinks): the // archive.org page and the item's torrent; a Wayback copy's capture page // (lib/wayback.ts waybackCitationLinks). Absent for a record with none. downloads?: { label: string; url: string }[]; // The record in this site's corpus (`/?v=/&t=`): 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; // The document's colour (`#rrggbb`): its sentences' rail and its box's edge. accent?: 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; // Where the evidence came from relative to the document under review: it // gave it ("subject") or the report's author found it ("added"). origin?: CitationOrigin; }; 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; }; // A sentence of a document. The document itself (its byline and its archive // links — a long article may carry hundreds) is in the view's `sources` map // ONCE, never copied into each citation of it: a report citing one article // eighty times would otherwise carry its archive list eighty times. export type SourceCitationView = CitationViewCommon & { kind: "source"; // The document's id in the view's `sources`, and its title for the card's // "from " (sourceAnchor links to the document's block on the page). sourceId: string; sourceTitle: string; // The document's colour, for the sentence's rail (SourceView.accent). sourceAccent?: string; // 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 claim's finding in one line, for the overview of what the check found. gist?: string; // A short mark on the claim (e.g. that the document gives no source), // shown as a pill on its card. flag?: string; // The id of the `source` citation holding the document's own sentence. sourceQuote?: string; // The document's archive links that sit in this claim's sentence (their // `context` overlaps it: claimArchives), at most CLAIM_ARCHIVES_MAX. The // document's full list is shown once, with the document. archives?: SourceArchive[]; findings?: string; citations: string[]; // How the claim reads as a slide (report.json `slide`; lib/report/slides.ts). slide?: SlideSpec; }; // A timeline entry (report.json `entries[]`). Its id is its anchor on the // page (`#<id>`); its body is markdown citing inline, rendered as a section's. export type EntryView = { id: string; // When it was added, and last changed: as the document gives them. date: string; updated?: string; title: string; body: string; }; export type SectionView = { id: string; title: string; body?: string; claims: ClaimView[]; // How the section reads as a slide (report.json `slide`). slide?: SlideSpec; }; export type ReportPageView = { format: typeof REPORT_PAGE_FORMAT; version: typeof REPORT_VIEWS_VERSION; id: string; kind: ReportKind; // Its own line above the title; `<series>: <title>` where one line names // the report (reportFullTitle). series?: string; title: string; subtitle?: string; // A video of the report, shown under the title: site-root paths. video?: ReportVideoView; summary?: string; // How it was checked (markdown, cites nothing). method?: string; published?: string; // When it last changed: report.json `updated`, or its newest timeline // entry's date when that is later (lib/report/entries.ts effectiveUpdated). updated?: string; // The document under review: its id in `sources`. subject?: string; // Every document the report's `source` citations quote, and the subject, // by id — each once. sources: Record<string, 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>; // The timeline, newest first (lib/report/entries.ts); absent when none. entries?: EntryView[]; sections: SectionView[]; // The report as files, and its citations as data, when compose published // them (each a site-root path). downloads?: ReportDownloads; // Its timeline's feeds, when compose published them: ABSOLUTE URLs (a // feed is read off the site, and the page's <link rel="alternate"> must // name it so). feeds?: ReportFeeds; // Its newest revision and the history page (lib/report/revisions.ts), when // compose published a history. history?: ReportHistoryRef; // How the report reads as slides (report.json `slides`; lib/report/slides.ts). slides?: ReportSlidesSpec; }; export type ReportVideoView = { src: string; poster?: string; caption?: string }; export type ReportFeeds = Record<ReportFeedFormat, string>; // What a report page offers to download: its exports (html, pdf, md, the // evidence pack as zip) and its citations (json, csv). Each key is present // only when the file is published. export type ReportDownloads = Partial<Record<ReportExportFormat | "json" | "csv", string>>; export type VerdictCount = { verdict: Verdict; count: number }; export type ReportIndexEntry = { id: string; kind: ReportKind; // Its own line above the title; `<series>: <title>` where one line names // the report (reportFullTitle). series?: string; 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; // The timeline entry's title, when cited in one. entryTitle?: 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?: ReportDownloads; history?: ReportHistoryRef; // The report's feeds (absolute URLs), carried only by a report with entries. feeds?: ReportFeeds; }; 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.note ? { note: s.note } : {}), ...(s.accent ? { accent: s.accent } : {}), 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, origin: c.origin, }); 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, sourceId: c.source, sourceTitle: s.title, sourceAccent: s.accent, 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; } } // Each document once: the subject, then every document a cited source // citation quotes. const sourceViews: Record<string, SourceView> = {}; const subjectId = report.subject && sources[report.subject.source] ? report.subject.source : undefined; if (subjectId) sourceViews[subjectId] = sourceView(subjectId, sources[subjectId]); for (const c of Object.values(citations)) { if (c.kind === "source" && !sourceViews[c.sourceId]) sourceViews[c.sourceId] = sourceView(c.sourceId, sources[c.sourceId]); } // A claim's archive links: those of the document its sentence is from (the // subject when it names none) whose context overlaps the sentence. const archivesFor = (claim: Claim): SourceArchive[] | undefined => { const sq = claim.sourceQuote ? citations[claim.sourceQuote.citation] : undefined; const doc = sq?.kind === "source" ? sourceViews[sq.sourceId] : subjectId ? sourceViews[subjectId] : undefined; if (!doc || doc.archives.length === 0) return undefined; const found = claimArchives(sq?.quote ?? claim.text, doc.archives); return found.length > 0 ? found : undefined; }; return defined({ format: REPORT_PAGE_FORMAT, version: REPORT_VIEWS_VERSION, id: report.id, kind: report.kind, series: report.series, title: report.title, subtitle: report.subtitle, video: report.video ? defined({ src: reportAssetPath(report.id, report.video.src), poster: report.video.poster ? reportAssetPath(report.id, report.video.poster) : undefined, caption: report.video.caption, }) : undefined, summary: report.summary, method: report.method, published: report.published, updated: effectiveUpdated(report), subject: subjectId, sources: sourceViews, verdicts: resolveVerdicts(report.verdicts), citations, entries: report.entries && report.entries.length > 0 ? entriesNewestFirst(report.entries).map((e) => defined({ id: e.id, date: e.date, updated: e.updated, title: e.title, body: e.body }), ) : undefined, sections: report.sections.map((s) => defined({ id: s.id, title: s.title, body: s.body, slide: s.slide, claims: (s.claims ?? []).map((c) => defined({ id: c.id, title: c.title, text: c.text, verdict: c.verdict, gist: c.gist, flag: c.flag, sourceQuote: c.sourceQuote?.citation, archives: archivesFor(c), findings: c.findings, citations: c.citations ?? [], slide: c.slide, }), ), }), ), downloads: resolve.downloads, feeds: report.entries && report.entries.length > 0 ? resolve.feeds : undefined, history: resolve.history, slides: report.slides, }); } // ─── A claim's archive links ─── // A document's archive link belongs to a claim when the words around it (its // `context`, as the document had them) overlap the claim's sentence: at least // CLAIM_ARCHIVE_MIN_SHARED distinct words of four letters or more in common, // and those at least CLAIM_ARCHIVE_MIN_OVERLAP of the shorter side's. Words // are normalised as quote verification normalises them (verify.ts // quoteTokens); short words (the, and, of) are left out, they match anything. // The best CLAIM_ARCHIVES_MAX are kept, in the document's order. export const CLAIM_ARCHIVE_MIN_OVERLAP = 0.6; export const CLAIM_ARCHIVE_MIN_SHARED = 3; export const CLAIM_ARCHIVES_MAX = 5; const contentWords = (text: string) => new Set(quoteTokens(text).filter((t) => t.length >= 4)); export function archiveOverlap(sentence: string, context: string | undefined): number { if (!context) return 0; const a = contentWords(sentence); const b = contentWords(context); let shared = 0; for (const t of b) if (a.has(t)) shared++; if (shared < CLAIM_ARCHIVE_MIN_SHARED) return 0; return shared / Math.min(a.size, b.size); } export function claimArchives(sentence: string, archives: readonly SourceArchive[]): SourceArchive[] { return archives .map((a, i) => ({ a, i, score: archiveOverlap(sentence, a.context) })) .filter((x) => x.score >= CLAIM_ARCHIVE_MIN_OVERLAP) .sort((x, y) => y.score - x.score || x.i - y.i) .slice(0, CLAIM_ARCHIVES_MAX) .sort((x, y) => x.i - y.i) .map((x) => x.a); } // ─── Reading a view ─── // The anchor (fragment id, without `#`) of a document's block on a report page. export function sourceAnchor(sourceId: string): string { return `source-${sourceId}`; } // 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. 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); } return VERDICTS.filter((v) => counts.has(v)).map((verdict) => ({ verdict, count: counts.get(verdict)! })); } // A report named on one line -- a page title, a link, a heading elsewhere: // `<series>: <title>`, or the title alone. export function reportFullTitle(r: { series?: string; title: string }): string { return r.series ? `${r.series}: ${r.title}` : r.title; } // A date as the header shows it: `2026-10-04T12:00:00Z` → `2026-10-04`; a // partial date (`2026-10`) as given. function headerDate(v: string): string { return /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v; } // A timeline entry's date as the page shows it: the day, as the header's // dates (a date-time's time is left to its `datetime` attribute). export function entryDateLabel(v: string): string { return headerDate(v); } // The header's date line, before its revision: the published date, then // "updated <date>" when it differs. export function reportDateParts(view: Pick<ReportPageView, "published" | "updated">): string[] { return [ view.published ? headerDate(view.published) : null, view.updated && view.updated !== view.published ? `updated ${headerDate(view.updated)}` : null, ].filter((x): x is string => !!x); } // The revision on the header's date line: "revision N", or "edited since // revision N" when the report changed after its newest revision. export function reportRevisionLabel(revision: number, current = true): string { return current ? `revision ${revision}` : `edited since revision ${revision}`; } // The document under review named under its title: "<author> · <publisher> · // <date>", the parts it has. export function subjectMetaParts(s: Pick<SourceView, "author" | "publisher" | "date">): string[] { return [s.author?.trim(), s.publisher?.trim(), s.date ? headerDate(s.date) : undefined].filter((x): x is string => !!x); } // ─── The report page's tiers: the quick take, what the check found, every claim ─── // The order "what the check found" groups claims in: what the check changed // about the document first, what it confirmed last. export const FOUND_VERDICT_ORDER: readonly Verdict[] = ["CONTRADICTED", "NOT_FOUND", "PARTLY", "UNTESTABLE", "CORROBORATED"]; export type FoundGroup = { verdict: Verdict; claims: ClaimView[] }; // A fact-check's claims by verdict, in FOUND_VERDICT_ORDER; a verdict no // claim carries is left out, and so is a claim with no verdict. export function foundGroups(view: Pick<ReportPageView, "sections">): FoundGroup[] { const claims = view.sections.flatMap((s) => s.claims); return FOUND_VERDICT_ORDER.map((verdict) => ({ verdict, claims: claims.filter((c) => c.verdict === verdict) })).filter( (g) => g.claims.length > 0, ); } // 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, series: view.series, 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 the // timeline entry, else none (the summary — the page's top). export function citedInHref(entry: Pick<CitedIn, "reportId" | "sectionId" | "claimId" | "entryId">): string { const anchor = entry.claimId ?? entry.sectionId ?? entry.entryId; 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; const entry = e.entryId ? report.entries?.find((x) => x.id === e.entryId) : undefined; out.push( defined({ ...e, href: citedInHref(e), reportTitle: reportFullTitle(report), sectionTitle: section?.title, entryTitle: entry?.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 };