disclosure-bureau/web/lib/cases.ts

160 lines
5.5 KiB
TypeScript
Raw Normal View History

/**
* 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<string, string>; body: string } {
const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/);
if (!m) return { fm: {}, body: md };
const fm: Record<string, string> = {};
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<CaseFile[]> {
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);
}