// `archilyzer reports convert --out ` // and `archilyzer reports to-manifest --out ` — // the converters (lib/report/convert*.ts) over files. // // `--channels-dir ` (a `transcripts/channels` tree) lets a conversion // read what the documents do not carry: a cited record's cues, so a /sweep or // /ask citation's one second widens to whole sentences; the channel that keeps // a post cited by its platform link; a post's platform, link and date for a // manifest's `posts`. Without it a span is the second plus 10 s and those // posts are left out, each with a warning. Nothing is fetched. // // The output is checked before it is written: `convert` writes only a report // the report validator passes, `to-manifest` reads only one. Exit 0 written // (warnings on stderr), 1 refused with problems (nothing written), 2 an input // that cannot be read. import { readFile } from "node:fs/promises"; import path from "node:path"; import { writeJsonAtomic } from "../lib/jsonFile-server"; import type { Problem } from "../lib/citations/validate"; import { askToReport, readAskAnswer } from "../lib/report/convertAsk"; import { manifestToReport, reportToManifest } from "../lib/report/convertManifest"; import { sweepToReport } from "../lib/report/convertSweep"; import type { ConvertContext, Converted } from "../lib/report/convertShared"; import { diskCuesOf, diskPostChannelOf, diskPostOf } from "../lib/report/convert-server"; type Out = { log: (s: string) => void; error: (s: string) => void }; export const CONVERT_FROM = ["sweep", "ask", "manifest"] as const; export type ConvertFrom = (typeof CONVERT_FROM)[number]; export function isConvertFrom(v: unknown): v is ConvertFrom { return typeof v === "string" && (CONVERT_FROM as readonly string[]).includes(v); } function printProblems(out: Out, what: string, problems: Problem[]): void { out.error(`${what}: ${problems.length} problem(s):`); for (const p of problems) out.error(` ${p.path || "(root)"}: ${p.message}`); } async function readInput(file: string, out: Out, what: string): Promise { try { return await readFile(file, "utf8"); } catch (err) { out.error(`${what}: cannot read ${file}: ${(err as Error).message}`); return null; } } function parseJson(text: string, file: string, out: Out, what: string): unknown { try { return JSON.parse(text); } catch (err) { out.error(`${what}: ${file} is not JSON: ${(err as Error).message}`); return undefined; } } export async function convertMain( opts: { from: ConvertFrom; input: string; out: string; channelsDir?: string; id?: string; title?: string }, out: Out = console, ): Promise { const what = `reports convert ${opts.from}`; const text = await readInput(opts.input, out, what); if (text === null) return 2; const warnings: string[] = []; const ctx: ConvertContext = opts.channelsDir ? { cuesOf: diskCuesOf(opts.channelsDir, warnings), postChannelOf: diskPostChannelOf(opts.channelsDir) } : {}; const named = { ...ctx, ...(opts.id ? { id: opts.id } : {}), ...(opts.title ? { title: opts.title } : {}) }; let converted: Converted; if (opts.from === "sweep") { converted = await sweepToReport(text, named); } else { const raw = parseJson(text, opts.input, out, what); if (raw === undefined) return 2; if (opts.from === "ask") { const answer = readAskAnswer(raw); if (!answer) { out.error(`${what}: ${opts.input} is not an /ask answer ({ answer, sources }, a saved chat or an assistant message)`); return 2; } converted = await askToReport(answer, named); } else { converted = await manifestToReport(raw, named); const hasStills = Object.values(converted.report.citations ?? {}).some((c) => c.kind === "source" && c.image); if (hasStills && path.resolve(path.dirname(opts.input)) !== path.resolve(path.dirname(opts.out))) { warnings.push( "the report's stills are paths relative to the manifest's directory — copy them beside the report (or write it there)", ); } } } for (const w of [...warnings, ...converted.warnings]) out.error(`warning: ${w}`); if (converted.problems.length) { printProblems(out, `${what}: not written`, converted.problems); return 1; } await writeJsonAtomic(opts.out, converted.report, { mkdir: true }); const r = converted.report; const claims = r.sections.reduce((n, s) => n + (s.claims?.length ?? 0), 0); out.log( `${opts.out}: report ${r.id} (${r.kind}) — ${r.sections.length} section(s), ${claims} claim(s), ${Object.keys(r.citations ?? {}).length} citation(s)`, ); return 0; } export async function toManifestMain( opts: { input: string; out: string; channelsDir?: string; siteOrigin?: string }, out: Out = console, ): Promise { const what = "reports to-manifest"; const text = await readInput(opts.input, out, what); if (text === null) return 2; const raw = parseJson(text, opts.input, out, what); if (raw === undefined) return 2; const result = await reportToManifest(raw, { ...(opts.siteOrigin ? { siteOrigin: opts.siteOrigin } : {}), ...(opts.channelsDir ? { postOf: diskPostOf(opts.channelsDir) } : {}), }); if (!result.ok) { printProblems(out, `${what}: ${opts.input} is not a sound report`, result.problems); return 1; } const warnings = [...result.warnings]; const timeline = result.manifest.timeline as { type: string }[]; if ( timeline.some((e) => e.type === "image") && path.resolve(path.dirname(opts.input)) !== path.resolve(path.dirname(opts.out)) ) { warnings.push("the images' src are paths relative to the report's directory — copy the stills beside the manifest"); } for (const w of warnings) out.error(`warning: ${w}`); await writeJsonAtomic(opts.out, result.manifest, { mkdir: true }); const posts = (result.manifest.posts as unknown[] | undefined)?.length ?? 0; out.log(`${opts.out}: ${timeline.length} timeline entr${timeline.length === 1 ? "y" : "ies"}, ${posts} post(s)`); return 0; }