disclosure-bureau/web/lib/cases.ts
Luiz Gustavo 4fb3e12e15
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 35s
CI / Scripts — Python smoke (push) Failing after 6s
CI / Web — npm audit (push) Failing after 39s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 6s
ux(bureau): search + agency/decade filters + sort, URL-synced
The audit flagged /bureau as the hardest page to navigate: 80+ case cards
in a flat grid with no way to narrow them down. Now the reader gets a
filterable browser.

- New lib/cases.ts (server) — shared loader for the case library; pulls
  cases off disk, derives the originating agency from the slug prefix
  (DoW, FBI, NASA, CIA, DoE, ODNI, DoD, Other) and the incident year via
  a tight 19xx/20xx regex that requires non-digit boundaries so embedded
  ID numbers don't get mistaken for years (e.g. doc-2070-... no longer
  pretends to be a 2070 incident).
- New CaseLibraryBrowser (client) — search input (matches title + opening
  in either language), agency chips with live counts, decade chips, sort
  dropdown (Most recent / Year ↑ / Year ↓ / A–Z), result count, and a
  clear-filters affordance. All four state pieces sync to the URL so a
  filtered view is bookmarkable.
- /bureau page rewritten: magazine hero ("89 declassified cases,
  narrated.") + dynamic case count + lead, then the browser.
- Cards in the browser carry an eyebrow ("YEAR · AGENCY") so the reader
  sees provenance at a glance.
- CaseLibrary (homepage) refactored to consume the shared loader; behaviour
  on / unchanged.

Verified live: agency=FBI filter narrows to 23 cases; sort=year-desc places
modern cases first; chip counts reflect filtered population.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 01:21:39 -03:00

159 lines
5.5 KiB
TypeScript

/**
* 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);
}