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>
159 lines
5.5 KiB
TypeScript
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);
|
|
}
|