// EXPORT A SITE'S REPORTS AS FILES — so a report survives a takedown as files // anyone can save and host again (plans/report-sites.md, "Exports"). // `archilyzer reports export ` and the editor's `reports-export` job // run `exportSiteReports`; `reports prepare` runs it at its end. // // What it reads: the site's published reports, resolved exactly as compose // resolves them (./composeReports.ts resolveSiteReports — verified against the // corpus, the prepared media matched), and the media `reports prepare` left in // the site's report-media cache (./reportMedia.ts) with each report's stills. // // What it writes, per report, to // `.export-index/sites//report-exports//` (staging, never // served; ./reportExportFiles.ts names it): // // report.html ONE self-contained file (lib/report/exportHtml.ts): // inline CSS, no script, every still and post // screenshot a data: URI, recompressed here to WebP (or // JPEG) at most EXPORT_IMAGE_MAX_WIDTH wide; clips are // linked on the site, never inlined // report.pdf that HTML printed by headless Chromium (A4), its folded // evidence open. Where // Playwright or its browser is missing the PDF is skipped // with a note — never a failure // report.md plain Markdown, numbered references // (lib/report/exportMarkdown.ts) // slides.html the report as slides (lib/report/slides.ts), ONE // self-contained file like report.html // (lib/report/exportSlidesHtml.ts): the site's slide // stylesheet inline, a few lines of script for the keys, // every picture a data: URI. None for a report whose // `slides.hide` says so // slides.pdf those slides printed by headless Chromium, one // 1280 × 720 page per slide; skipped like report.pdf // evidence-pack.zip `/report.html` (and slides.html) pointing at its own media/ // (the clips play offline, the stills and screenshots // as files), report.md, citations.json and .csv. Packed // by the system `zip`, deterministically (sorted names, // fixed times and modes, no extra attributes — the same // report checked at the same `now` packs to the same // bytes); a host without `zip` fails the format, naming it // export.json the manifest: each file's size and sha256, the sha256 // of the report.json it was made from, the footer, notes // // Each run replaces the report's export dir whole: a format not asked for this // time is gone, never left from an older version of the report. Compose // publishes from here only what was made from the report.json as it is now, // each file within the publish limit (an evidence pack over 24 MiB stays // local). // // THE FOOTER (lib/report/exportHtml.ts ReportExportFooter) names which document // this is: `Revision N`, the report's date and the sha256 of its report.json // (`exportFooterFor`). It never names a commit: the revision's commit holds // report.md, whose footer would have to name the commit holding it. The // history page maps the sha256 to its commit (./reportHistory.ts). // // THE REVISION. Each report's export commits a revision to the report's own // history (./reportHistory.ts, `sites//reports//history-git/`) // when its report.json changed since the newest revision: report.json, // report.md and exports.json (the sha256 of every file made and of the // citations JSON and CSV), with the change summary as the message. An export // of an unchanged report commits nothing and belongs to the newest revision. // export.json records the revision and its commit. import { chmod, mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { execa } from "execa"; import { publishFileSizeProblem } from "../lib/builtExport"; import { writeFileAtomic, writeJsonAtomic } from "../lib/jsonFile-server"; import { getPaths, type Paths } from "../lib/paths"; import { getSite, listSiteIds, type Site } from "../lib/site"; import { parseMomentKey, type SpanMoment } from "../lib/citations/moments"; import type { Report } from "../lib/report/schema"; import { reportExportHtml, type ExportClip, type ReportExportFooter } from "../lib/report/exportHtml"; import { reportExportMarkdown } from "../lib/report/exportMarkdown"; import { reportSlidesHtml, slidesImagePaths } from "../lib/report/exportSlidesHtml"; import { hasSlides } from "../lib/report/slides"; import { resolveAccent } from "../lib/accent"; import { REPORT_EXPORT_FILENAMES, REPORT_EXPORT_FORMATS, type ReportExportFormat, type ReportPageView, type SpanCitationView, } from "../lib/report/views"; import { importPlaywright } from "../social/playwrightRuntime"; import { citationSet, citationsCsv, clipPath, ComposeReportsError, formatComposeReportsProblems, resolveSiteReports, type ResolvedSiteReports, } from "./composeReports"; import { REPORT_EXPORT_MANIFEST_FILENAME, REPORT_EXPORT_MANIFEST_FORMAT, REPORT_EXPORT_MANIFEST_VERSION, reportExportDir, reportExportsDir, sha256Hex, type ReportExportFileEntry, type ReportExportManifest, } from "./reportExportFiles"; import { siteReportDir, siteReportFile, type ReportMediaIndex } from "./reportMedia"; import { commitReportRevision, pendingRevision, reportHistoryGitDir, revisionExports, } from "./reportHistory"; // A still or a screenshot in report.html: at most this wide, recompressed. export const EXPORT_IMAGE_MAX_WIDTH = 1200; export const EXPORT_WEBP_QUALITY = 80; // The evidence pack's file times: fixed, so the same files pack the same. export const EVIDENCE_PACK_MTIME = new Date("2000-01-01T00:00:00Z"); export type ReportExportProblem = { // The report, absent for a problem of the whole run. report?: string; format?: ReportExportFormat; message: string; }; // Prints HTML to PDF — A4, or a page size (`size`, slides.pdf's 1280 × 720). // One is opened per run and closed at its end. export type PdfPageSize = { width: string; height: string }; export const SLIDES_PDF_PAGE: PdfPageSize = { width: "1280px", height: "720px" }; export type PdfPrinter = { print: (html: string, size?: PdfPageSize) => Promise; close: () => Promise; }; // A printer, or why there is none (the PDF is then skipped with that note). export type OpenPdfPrinter = () => Promise; export type ExportSiteReportsOptions = { siteId: string; paths?: Paths; // One published report; default every one. reportId?: string; // Default: all of REPORT_EXPORT_FORMATS. formats?: readonly ReportExportFormat[]; // Export even where a citation's media was not prepared (its clip is then // neither linked nor packed), as compose's `--allow-missing-media`. allowMissingMedia?: boolean; onLog?: (line: string) => void; signal?: AbortSignal; // `social.x.visibility` and friends; default the live settings. settings?: { social?: { x?: { visibility?: unknown } } }; now?: () => Date; // Default: headless Chromium through Playwright (openPlaywrightPdfPrinter). openPdfPrinter?: OpenPdfPrinter; // Default: `zip` on PATH. zipBin?: string; }; export type ReportExportResult = { reportId: string; dir: string; manifest: ReportExportManifest; }; export type ExportSiteReportsResult = { exported: ReportExportResult[]; problems: ReportExportProblem[]; }; const mib = (n: number) => `${(n / 1024 / 1024).toFixed(1)} MiB`; const firstLine = (e: unknown) => String((e as Error)?.message ?? e).split("\n")[0].slice(0, 200); export function parseReportExportFormats(v: string): ReportExportFormat[] | null { const parts = v.split(",").map((s) => s.trim()).filter(Boolean); if (parts.length === 0) return null; for (const p of parts) if (!(REPORT_EXPORT_FORMATS as readonly string[]).includes(p)) return null; return REPORT_EXPORT_FORMATS.filter((f) => parts.includes(f)); } // THE FOOTER: the revision this export belongs to (when the report has a // history), the report's own date and the sha256 of its report.json — never // the commit (see the header). The HTML, PDF and Markdown all print // reportExportFooterLine of what this returns, leaving out what is absent. export function exportFooterFor( view: Pick, reportSha256: string, revision?: number, ): ReportExportFooter { const date = view.updated ?? view.published; return { ...(revision !== undefined ? { revision } : {}), ...(date ? { date } : {}), reportSha256 }; } // ─── PDF ─── export const openPlaywrightPdfPrinter: OpenPdfPrinter = async () => { let chromium; try { ({ chromium } = await importPlaywright()); } catch { return { missing: "Playwright is not available on this host" }; } let browser; try { browser = await chromium.launch({ headless: true }); } catch (e) { return { missing: `headless Chromium did not start (${firstLine(e)})` }; } const b = browser; return { print: async (html, size) => { const context = await b.newContext({}); try { const page = await context.newPage(); if (!page.setContent || !page.pdf) throw new Error("this Playwright cannot print a page"); await page.setContent(html, { waitUntil: "load" }); return await page.pdf(size ? { ...size, printBackground: true } : { format: "A4", printBackground: true }); } finally { await context.close(); } }, close: () => b.close(), }; }; // ─── Images ─── const IMAGE_MIME: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", }; async function ffmpegImage(ffmpegBin: string, file: string, codec: "webp" | "jpeg", signal?: AbortSignal): Promise { const scale = `scale='min(${EXPORT_IMAGE_MAX_WIDTH},iw)':-2`; const tail = codec === "webp" ? ["-c:v", "libwebp", "-quality", String(EXPORT_WEBP_QUALITY), "-compression_level", "6", "-f", "webp"] : ["-pix_fmt", "yuvj420p", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2pipe"]; const r = await execa( ffmpegBin, ["-hide_banner", "-loglevel", "error", "-i", file, "-frames:v", "1", "-vf", scale, "-map_metadata", "-1", "-fflags", "+bitexact", "-flags", "+bitexact", ...tail, "pipe:1"], { reject: false, encoding: "buffer", timeout: 60_000, cancelSignal: signal }, ).catch(() => null); if (!r || r.exitCode !== 0 || !(r.stdout instanceof Uint8Array) || r.stdout.length === 0) return null; return r.stdout; } // An image as a data: URI for report.html: recompressed to WebP (else JPEG) at // most EXPORT_IMAGE_MAX_WIDTH wide when that is smaller than the file, else // the file as it is. Null when the file cannot be read. export async function exportImageDataUri(file: string, ffmpegBin: string, signal?: AbortSignal): Promise { let raw: Buffer; try { raw = await readFile(file); } catch { return null; } let best: { mime: string; data: Uint8Array } = { mime: IMAGE_MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream", data: raw, }; for (const codec of ["webp", "jpeg"] as const) { const out = await ffmpegImage(ffmpegBin, file, codec, signal); if (out && out.length < best.data.length) best = { mime: codec === "webp" ? "image/webp" : "image/jpeg", data: out }; if (out) break; } return `data:${best.mime};base64,${Buffer.from(best.data).toString("base64")}`; } // The image paths a view names (a source sentence's still, a post's shot). function viewImagePaths(view: ReportPageView): string[] { const out = new Set(); for (const c of Object.values(view.citations)) { if (c.kind === "source" && c.image) out.add(c.image); if (c.kind === "post" && c.shot) out.add(c.shot); } return [...out].sort(); } // The pictures only the slides show (a clip's poster on a claim's slide): // inlined when this host has the file, else the slide shows its quote alone — // never a problem, a poster may be a link elsewhere. function slidesOnlyImagePaths(view: ReportPageView): string[] { if (!hasSlides(view)) return []; const named = new Set(viewImagePaths(view)); return slidesImagePaths(view).filter((p) => p.startsWith("/") && !named.has(p)); } // The slides' stylesheet, the one the sites draw them with: inlined into slides.html. export async function readSlidesCss(paths: Pick = getPaths()): Promise { return readFile(path.join(/* turbopackIgnore: true */ paths.monorepoRoot, "common", "styles", "slides.css"), "utf8"); } // What slides.html is drawn with: the stylesheet, and the site's accent on the light base. type SlidesLook = { css: string; accent: string }; // ─── Where a view's files are on this host ─── export type ReportFiles = { // A site-root path the view names → the file on disk. local: (sitePath: string) => string | undefined; // A span citation's prepared clip: its site-root path and file. clip: (c: SpanCitationView) => { sitePath: string; file: string; kind: "video" | "audio" } | undefined; }; function within(base: string, rel: string): string | undefined { const b = path.resolve(base); const f = path.resolve(b, rel); return f.startsWith(b + path.sep) ? f : undefined; } function reportFiles(paths: Paths, siteId: string, view: ReportPageView, resolved: ResolvedSiteReports): ReportFiles { const own = `/reports/${view.id}/`; return { local: (p) => { if (p.startsWith(own)) return within(siteReportDir(paths, siteId, view.id), p.slice(own.length)); if (p.startsWith("/media/")) return within(resolved.cacheDir, p.slice("/media/".length)); return undefined; }, clip: (c) => { const entry = resolved.mediaOf.get(c.moment); if (!entry || entry.kind === "post") return undefined; const file = within(resolved.cacheDir, entry.file); if (!file) return undefined; return { sitePath: clipPath(parseMomentKey(c.moment) as SpanMoment, entry.kind), file, kind: entry.kind }; }, }; } // A site-root path's place in the evidence pack: the site's media under // media/, the report's own files (its stills) under media/report/. function packPathOf(view: ReportPageView, sitePath: string): string | undefined { const own = `/reports/${view.id}/`; if (sitePath.startsWith(own)) return `media/report/${sitePath.slice(own.length)}`; if (sitePath.startsWith("/media/")) return sitePath.slice(1); return undefined; } // ─── The evidence pack ─── async function zipAvailable(zipBin: string): Promise { const r = await execa(zipBin, ["-h"], { reject: false }).catch(() => null); return !!r && r.exitCode === 0; } // `names` (relative to `cwd`) zipped into `outFile`, the same bytes for the // same files: names sorted, every time and mode fixed, no extra attributes, // times read in UTC. Media is stored, text deflated. export async function zipDeterministic(cwd: string, names: readonly string[], outFile: string, zipBin = "zip"): Promise { const sorted = [...names].sort(); for (const n of sorted) { await chmod(path.join(cwd, n), 0o644); await utimes(path.join(cwd, n), EVIDENCE_PACK_MTIME, EVIDENCE_PACK_MTIME); } await rm(outFile, { force: true }); const r = await execa( zipBin, ["-X", "-D", "-q", "-n", ".mp4:.m4a:.png:.jpg:.jpeg:.webp:.gif:.avif:.pdf:.zip", outFile, "-@"], { cwd, input: `${sorted.join("\n")}\n`, env: { TZ: "UTC" }, reject: false }, ); if (r.exitCode !== 0) throw new Error(`zip failed (exit ${r.exitCode}): ${String(r.stderr ?? "").trim().slice(0, 300)}`); } async function buildEvidencePack(o: { report: Report; view: ReportPageView; files: ReportFiles; site: Pick; footer: ReportExportFooter; markdown: string; outFile: string; zipBin: string; // The slides' look, when the pack carries slides.html. slides?: SlidesLook; }): Promise { const tmp = await mkdtemp(path.join(os.tmpdir(), "report-evidence-pack-")); try { const top = o.view.id; const names: string[] = []; const put = async (rel: string, data: string | Uint8Array) => { const f = path.join(tmp, top, rel); await mkdir(path.dirname(f), { recursive: true }); await writeFile(f, data); names.push(`${top}/${rel}`); }; const images = new Map(); for (const p of [...viewImagePaths(o.view), ...(o.slides ? slidesOnlyImagePaths(o.view) : [])]) { const local = o.files.local(p); const rel = packPathOf(o.view, p); if (!local || !rel) continue; const data = await readFile(local).catch(() => null); if (!data) continue; await put(rel, data); images.set(p, rel); } const clips = new Map(); for (const c of Object.values(o.view.citations)) { if (c.kind !== "video" && c.kind !== "audio") continue; const clip = o.files.clip(c); if (!clip || clips.has(c.moment)) continue; const rel = packPathOf(o.view, clip.sitePath); const data = rel ? await readFile(clip.file).catch(() => null) : null; if (!rel || !data) continue; await put(rel, data); clips.set(c.moment, { href: rel, kind: clip.kind, play: true }); } const html = reportExportHtml(o.view, { siteUrl: o.site.siteUrl || undefined, footer: o.footer, image: (p) => images.get(p), clip: (c) => clips.get(c.moment), }); await put(REPORT_EXPORT_FILENAMES.html, html); if (o.slides) { await put( REPORT_EXPORT_FILENAMES.slides, reportSlidesHtml(o.view, { ...o.slides, siteUrl: o.site.siteUrl || undefined, footer: o.footer, image: (p) => images.get(p), }), ); } await put(REPORT_EXPORT_FILENAMES.md, o.markdown); await put("citations.json", `${JSON.stringify(citationSet(o.report, o.view), null, 2)}\n`); await put("citations.csv", citationsCsv(o.view)); const tmpZip = path.join(tmp, "pack.zip"); await zipDeterministic(tmp, names, tmpZip, o.zipBin); await writeFileAtomic(o.outFile, await readFile(tmpZip)); } finally { await rm(tmp, { recursive: true, force: true }); } } // ─── One report ─── async function exportOneReport(o: { paths: Paths; site: Site; report: Report; view: ReportPageView; resolved: ResolvedSiteReports; formats: readonly ReportExportFormat[]; printer: () => Promise; zipBin: string; now: Date; log: (line: string) => void; signal?: AbortSignal; }): Promise<{ result?: ReportExportResult; problems: ReportExportProblem[] }> { const { paths, site, report, view } = o; const reportJson = await readFile(siteReportFile(paths, site.siteId, report.id)).catch(() => null); if (!reportJson) return { problems: [{ report: report.id, message: "its report.json cannot be read" }] }; return writeReportExports({ ...o, dir: reportExportDir(paths, site.siteId, report.id), reportSha256: sha256Hex(reportJson), files: reportFiles(paths, site.siteId, view, o.resolved), ffmpegBin: paths.ffmpegBin, history: { gitDir: reportHistoryGitDir(paths, site.siteId, report.id), reportJson }, }); } // ONE REPORT'S EXPORTS, from its view and where its files are: every format // asked for and the manifest, into `dir` (emptied first). The site run above // calls it for each report; the report-site e2e stage calls it for its // fixture. `report` is the document as resolved (its verification // computed): the evidence pack's citations.json is its citation set. // `history` names the report's revision store and the report.json's bytes // (their sha256 is `reportSha256`): the footer names the revision, and a // changed report.json is committed as the next one. export async function writeReportExports(o: { dir: string; site: Pick & { accent?: unknown }; report: Report; view: ReportPageView; reportSha256: string; files: ReportFiles; ffmpegBin: string; formats: readonly ReportExportFormat[]; printer: () => Promise; zipBin: string; now: Date; log: (line: string) => void; signal?: AbortSignal; history?: { gitDir: string; reportJson: Uint8Array }; // The slides' stylesheet; default common/styles/slides.css (readSlidesCss). slidesCss?: string; }): Promise<{ result: ReportExportResult; problems: ReportExportProblem[] }> { const { site, report, view, files } = o; const problems: ReportExportProblem[] = []; const sha = o.reportSha256; let pending: Awaited> | null = null; if (o.history) { if (sha256Hex(o.history.reportJson) !== sha) throw new Error(`report ${report.id}: the history's report.json is not the one exported`); try { pending = await pendingRevision(o.history.gitDir, sha); } catch (e) { problems.push({ report: report.id, message: `its revision history cannot be read: ${firstLine(e)}` }); } } const footer = exportFooterFor(view, sha, pending?.revision); const siteUrl = site.siteUrl || undefined; // The one-file export's images, as data: URIs. const dataUris = new Map(); for (const p of viewImagePaths(view)) { const local = files.local(p); const uri = local ? await exportImageDataUri(local, o.ffmpegBin, o.signal) : null; if (uri) dataUris.set(p, uri); else problems.push({ report: report.id, format: "html", message: `the image ${p} cannot be read` }); } const htmlOpts = { siteUrl, footer, image: (p: string) => dataUris.get(p), clip: (c: SpanCitationView) => { const clip = files.clip(c); const href = clip && siteUrl ? `${siteUrl.replace(/\/+$/, "")}${clip.sitePath}` : undefined; return clip && href ? { href, kind: clip.kind } : undefined; }, }; const html = reportExportHtml(view, htmlOpts); // The slides, when the report has them and a format asks for them. const wantsSlides = o.formats.some((f) => f === "slides" || f === "slides-pdf" || f === "zip"); const slidesLook: SlidesLook | undefined = hasSlides(view) && wantsSlides ? { css: o.slidesCss ?? (await readSlidesCss()), accent: resolveAccent(site.accent).light } : undefined; if (slidesLook) { for (const p of slidesOnlyImagePaths(view)) { const local = files.local(p); const uri = local ? await exportImageDataUri(local, o.ffmpegBin, o.signal) : null; if (uri) dataUris.set(p, uri); } } const slidesHtml = slidesLook ? reportSlidesHtml(view, { ...slidesLook, siteUrl, footer, image: (p) => dataUris.get(p) }) : undefined; // The PDF prints what the page folds away (the evidence the document under // review gave itself) open. const printHtml = reportExportHtml(view, { ...htmlOpts, print: true }); const markdown = reportExportMarkdown(view, { siteUrl, footer }); const dir = o.dir; await rm(dir, { recursive: true, force: true }); await mkdir(dir, { recursive: true }); const entries: ReportExportManifest["files"] = {}; const notes: string[] = []; const record = async (f: ReportExportFormat) => { const name = REPORT_EXPORT_FILENAMES[f]; const data = await readFile(path.join(dir, name)); const localOnly = publishFileSizeProblem(name, data.length); const entry: ReportExportFileEntry = { file: name, bytes: data.length, sha256: sha256Hex(data), ...(localOnly ? { localOnly } : {}) }; entries[f] = entry; o.log(` + ${report.id}/${name} (${mib(entry.bytes)})${localOnly ? ` — local only: ${localOnly}` : ""}`); }; for (const f of o.formats) { if (o.signal?.aborted) throw new Error("export cancelled"); const out = path.join(dir, REPORT_EXPORT_FILENAMES[f]); switch (f) { case "html": await writeFileAtomic(out, html); await record(f); break; case "md": await writeFileAtomic(out, markdown); await record(f); break; case "slides": if (!slidesHtml) { o.log(` - ${report.id}/slides.html: the report publishes no slides`); break; } await writeFileAtomic(out, slidesHtml); await record(f); break; case "slides-pdf": { if (!slidesHtml) { o.log(` - ${report.id}/slides.pdf: the report publishes no slides`); break; } const printer = await o.printer(); if ("missing" in printer) { notes.push(`slides.pdf skipped: ${printer.missing}`); o.log(` - ${report.id}/slides.pdf skipped: ${printer.missing}`); break; } try { await writeFileAtomic(out, Buffer.from(await printer.print(slidesHtml, SLIDES_PDF_PAGE))); await record(f); } catch (e) { notes.push(`slides.pdf skipped: printing failed (${firstLine(e)})`); o.log(` - ${report.id}/slides.pdf skipped: printing failed (${firstLine(e)})`); } break; } case "pdf": { const printer = await o.printer(); if ("missing" in printer) { notes.push(`report.pdf skipped: ${printer.missing}`); o.log(` - ${report.id}/report.pdf skipped: ${printer.missing}`); break; } try { await writeFileAtomic(out, Buffer.from(await printer.print(printHtml))); await record(f); } catch (e) { notes.push(`report.pdf skipped: printing failed (${firstLine(e)})`); o.log(` - ${report.id}/report.pdf skipped: printing failed (${firstLine(e)})`); } break; } case "zip": if (!(await zipAvailable(o.zipBin))) { problems.push({ report: report.id, format: "zip", message: `the evidence pack needs the \`zip\` program (Info-ZIP), and "${o.zipBin}" cannot be run on this host — install zip, or export without zip (--formats html,pdf,md)`, }); break; } try { await buildEvidencePack({ report, view, files, site, footer, markdown, outFile: out, zipBin: o.zipBin, slides: slidesLook }); await record(f); } catch (e) { problems.push({ report: report.id, format: "zip", message: `the evidence pack could not be packed: ${firstLine(e)}` }); } break; } } // The revision: committed when report.json changed since the newest. let revision: ReportExportManifest["revision"]; if (o.history && pending) { try { const made: Record = {}; for (const f of REPORT_EXPORT_FORMATS) { if (entries[f]) made[REPORT_EXPORT_FILENAMES[f]] = await readFile(path.join(dir, REPORT_EXPORT_FILENAMES[f])); } made["citations.json"] = `${JSON.stringify(citationSet(report, view), null, 2)}\n`; made["citations.csv"] = citationsCsv(view); const r = await commitReportRevision({ gitDir: o.history.gitDir, site, reportJson: o.history.reportJson, markdown, exports: revisionExports(report.id, sha, made), now: o.now, }); revision = { revision: r.revision, commit: r.commit, date: r.date, summary: r.summary, committed: r.committed }; o.log( r.committed ? ` + ${report.id}: revision ${r.revision} (${r.commit.slice(0, 12)}): ${r.summary.join("; ")}` : ` = ${report.id}: unchanged since revision ${r.revision} (${r.commit.slice(0, 12)})`, ); } catch (e) { problems.push({ report: report.id, message: `its revision could not be committed: ${firstLine(e)}` }); } } const manifest: ReportExportManifest = { format: REPORT_EXPORT_MANIFEST_FORMAT, version: REPORT_EXPORT_MANIFEST_VERSION, siteId: site.siteId, reportId: report.id, reportSha256: sha, exportedAt: o.now.toISOString(), footer, ...(revision ? { revision } : {}), files: entries, notes, }; await writeJsonAtomic(path.join(dir, REPORT_EXPORT_MANIFEST_FILENAME), manifest); return { result: { reportId: report.id, dir, manifest }, problems }; } // ─── The run ─── export async function exportSiteReports(opts: ExportSiteReportsOptions): Promise { const paths = opts.paths ?? getPaths(); const log = opts.onLog ?? (() => {}); const { siteId } = opts; if (!listSiteIds(paths).includes(siteId)) throw new Error(`no site "${siteId}" (no sites/${siteId}/site.json)`); const site = getSite(siteId, paths); const published = site.reports ?? []; if (opts.reportId && !published.includes(opts.reportId)) { throw new Error(`site "${siteId}" does not publish a report "${opts.reportId}" (only a published report is exported)`); } const formats = opts.formats ?? REPORT_EXPORT_FORMATS; let resolved: ResolvedSiteReports; try { resolved = await resolveSiteReports({ paths, site, allowMissingMedia: opts.allowMissingMedia, settings: opts.settings, now: opts.now, log, }); } catch (e) { if (e instanceof ComposeReportsError) { return { exported: [], problems: formatComposeReportsProblems(e.problems).map((message) => ({ message: `${message} (as compose would refuse it)` })), }; } throw e; } // A report no longer published keeps no exports. if (!opts.reportId) { for (const name of await readdir(reportExportsDir(paths, siteId)).catch(() => [] as string[])) { if (!published.includes(name)) await rm(path.join(reportExportsDir(paths, siteId), name), { recursive: true, force: true }); } } // Opened on the first PDF, once for the run. const opened: { printer?: Promise } = {}; const openPrinter = () => (opened.printer ??= (opts.openPdfPrinter ?? openPlaywrightPdfPrinter)()); const now = opts.now?.() ?? new Date(); const exported: ReportExportResult[] = []; const problems: ReportExportProblem[] = []; log(`${siteId}: exporting ${opts.reportId ?? `${resolved.reports.length} report(s)`} as ${formats.join(", ")}.`); try { for (let i = 0; i < resolved.reports.length; i++) { const report = resolved.reports[i]; if (opts.reportId && report.id !== opts.reportId) continue; if (opts.signal?.aborted) throw new Error("export cancelled"); const r = await exportOneReport({ paths, site, report, view: resolved.views[i], resolved, formats, printer: openPrinter, zipBin: opts.zipBin ?? "zip", now, log, signal: opts.signal, }); if (r.result) exported.push(r.result); problems.push(...r.problems); } } finally { const p = await opened.printer; if (p && !("missing" in p)) await p.close().catch(() => {}); } log(`${exported.length} report(s) exported; ${problems.length} problem(s).`); return { exported, problems }; } export function formatReportExportProblems(problems: readonly ReportExportProblem[]): string[] { return problems.map((p) => `${p.report ?? "site"}${p.format ? ` (${p.format})` : ""}: ${p.message}`); } // The end of `reports prepare`: export the reports when their media is // complete; a prepare with problems leaves the exports as they were (compose // would refuse the site anyway). export async function exportAfterPrepare( index: ReportMediaIndex, opts: ExportSiteReportsOptions, ): Promise { if (index.problems.length > 0) { opts.onLog?.("exports: not made — the evidence media is not complete."); return null; } return exportSiteReports(opts); }