/** * cases.ts — server-only loader for the case-file library. * * Reads /data/ufo/case/reports/*.md, parses frontmatter, derives the * magazine title from the body H1, picks the locale-preferred opening * paragraph, and infers two filter dimensions from the slug: * * - agency: which Department / Bureau owns the records (DoW, FBI, NASA, * CIA, DoE, ODNI, DoD, Other). Inferred from the slug prefix. * - year / decade: the incident year mentioned in the slug, when there is * one (most slugs include it). * * The home CaseLibrary and the /bureau browser both consume this. Keeping * it server-only means filesystem access stays out of the client bundle. */ import "server-only"; import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { pickLocaleBody } from "@/lib/bilingual"; export interface CaseFile { slug: string; topic: string; topic_pt_br: string | null; /** Opening paragraph of the body, trimmed and language-picked. */ opening: string; /** Last modification timestamp, used as default sort. */ mtimeMs: number; /** Incident year mentioned in the slug, or null. */ year: number | null; /** Decade label like "1940s", "2020s", derived from `year`. */ decade: string | null; /** Originating bureau / agency. */ agency: Agency; } export type Agency = | "DoW" // Department of War (recent UAP releases) | "FBI" | "NASA" | "CIA" | "DoE" | "ODNI" | "DoD" // generic Department of Defense legacy | "Other"; // named historic cases (Kenneth Arnold, Mantell, green fireballs, …) const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case"; function parseFrontmatter(md: string): { fm: Record; body: string } { const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/); if (!m) return { fm: {}, body: md }; const fm: Record = {}; for (const line of m[1].split("\n")) { const kv = line.match(/^([a-z_]+):\s*(.+)$/); if (!kv) continue; let v = kv[2].trim(); if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); fm[kv[1]] = v; } return { fm, body: m[2] }; } function pickOpening(body: string, locale: "pt-br" | "en"): string { const lines = body.split("\n"); const blocks: string[] = []; let cur: string[] = []; for (const raw of lines) { const line = raw.trim(); if (line.length === 0) { if (cur.length > 0) { blocks.push(cur.join(" ")); cur = []; } continue; } if (line.startsWith("#") || line.startsWith(">") || line.startsWith("|") || line.startsWith("---") || line.startsWith("```")) { if (cur.length > 0) { blocks.push(cur.join(" ")); cur = []; } continue; } cur.push(line); } if (cur.length > 0) blocks.push(cur.join(" ")); if (locale === "pt-br") { const ptIdx = body.indexOf("(PT-BR)"); if (ptIdx >= 0) { const after = body.slice(ptIdx); const m = after.match(/\n\n([^\n#>|`-][^\n]+(?:\n[^\n#>|`-][^\n]+)*)/); if (m) return m[1].replace(/\s+/g, " ").trim().slice(0, 400); } } return (blocks[0] ?? "").replace(/\s+/g, " ").trim().slice(0, 400); } /** Map a slug to its originating agency by prefix / known fragments. */ function inferAgency(slug: string): Agency { if (slug.startsWith("dow-uap-")) return "DoW"; if (slug.startsWith("fbi-")) return "FBI"; if (slug.startsWith("nasa-")) return "NASA"; if (slug.startsWith("cia-")) return "CIA"; if (slug.startsWith("doe-")) return "DoE"; if (slug.startsWith("odni-")) return "ODNI"; // doc-65-hs1-… is the Hoover/FBI bulk dossier; ship it under FBI for the // reader even though the slug doesn't say so. if (slug.startsWith("doc-65-")) return "FBI"; if (slug.startsWith("doc-")) return "DoD"; return "Other"; } /** Pull a 19xx / 20xx that looks like a standalone year token from the slug. * Requires a non-digit boundary on each side, capping the search at the * current year + 1 so embedded ID numbers like "doc-2070-…" don't get * mistaken for a 2070 incident. */ function inferYear(slug: string): number | null { const max = new Date().getFullYear() + 1; const m = slug.match(/(?:^|[^0-9])(19\d{2}|20\d{2})(?:[^0-9]|$)/); if (!m) return null; const y = parseInt(m[1], 10); return y <= max ? y : null; } function decadeOf(year: number | null): string | null { if (year === null) return null; return `${Math.floor(year / 10) * 10}s`; } export async function loadCases(locale: "pt-br" | "en"): Promise { const dir = path.join(CASE_ROOT, "reports"); let files: string[]; try { files = await readdir(dir); } catch { return []; } const items: CaseFile[] = []; for (const f of files) { if (!f.endsWith(".md")) continue; try { const full = path.join(dir, f); const md = await readFile(full, "utf-8"); const st = await stat(full); const { fm, body } = parseFrontmatter(md); const slug = f.replace(/\.md$/, ""); const h1s = [...body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim()); const enTitle = h1s[0] ?? fm.topic ?? f; const ptTitle = h1s[1] ?? h1s[0] ?? fm.topic_pt_br ?? fm.topic ?? f; const year = inferYear(slug); items.push({ slug, topic: enTitle, topic_pt_br: ptTitle, opening: pickOpening(pickLocaleBody(body, locale), locale), mtimeMs: st.mtimeMs, year, decade: decadeOf(year), agency: inferAgency(slug), }); } catch { /* skip broken file */ } } return items.sort((a, b) => b.mtimeMs - a.mtimeMs); }