/** * /api/timeline?from=1940&to=2026&class=event&limit=200 * * Returns events sorted by date_start from wiki/entities/events/*.md frontmatter. * No DB required — pure filesystem read of YAML frontmatter. * * Optional filters: * - from / to: ISO dates or year strings (e.g. "1947" or "1947-06-24") * - class: "event" | "uap_object" | "operation" (default: event) * - limit: 1..500 (default 200) * - q: substring match in canonical_name or narrative_summary */ import fs from "node:fs/promises"; import path from "node:path"; import matter from "gray-matter"; import { WIKI } from "@/lib/wiki"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const CLASS_FOLDER: Record = { event: "events", uap_object: "uap-objects", operation: "operations", }; interface TimelineEntry { entity_class: string; entity_id: string; canonical_name: string; date_start: string | null; date_end: string | null; primary_location?: string | null; narrative_summary?: string | null; summary_status: "none" | "synthesized" | "curated" | "red_teamed"; total_mentions?: number; href: string; } function json(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json", "cache-control": "public, max-age=60" }, }); } function pickDate(fm: Record): string | null { const cand = fm.date_start ?? fm.date ?? fm.event_date ?? fm.observation_date ?? fm.start_date ?? null; if (!cand) return null; return String(cand).trim(); } function dateSortable(s: string | null): string { if (!s) return "9999"; // Pad year-only ("1947" → "1947-00-00") and accept "1947-06-24" const m = s.match(/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/); if (!m) return s; return `${m[1]}-${m[2] ?? "00"}-${m[3] ?? "00"}`; } export async function GET(req: Request) { const u = new URL(req.url); const cls = (u.searchParams.get("class") ?? "event") as keyof typeof CLASS_FOLDER; const folder = CLASS_FOLDER[cls]; if (!folder) return json({ error: "bad_class", class: cls }, 400); const from = u.searchParams.get("from") ?? ""; const to = u.searchParams.get("to") ?? ""; const q = (u.searchParams.get("q") ?? "").toLowerCase().trim(); const limit = Math.min(Math.max(Number(u.searchParams.get("limit") ?? 200), 1), 500); // Default: only show events with curated/synthesised narrative — never stubs. // Opt-in `?include_unsynthesized=1` returns everything (admin / debug). const includeUnsynthesized = u.searchParams.get("include_unsynthesized") === "1"; const dir = path.join(WIKI, "entities", folder); let files: string[] = []; try { files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")); } catch { return json({ entries: [], count: 0 }); } const entries: TimelineEntry[] = []; for (const f of files) { try { const raw = await fs.readFile(path.join(dir, f), "utf-8"); const parsed = matter(raw); const fm = parsed.data as Record; const date_start = pickDate(fm); if (!date_start) continue; const sortable = dateSortable(date_start); if (from && sortable < dateSortable(from)) continue; if (to && sortable > dateSortable(to)) continue; const canonical = String(fm.canonical_name ?? f.replace(/\.md$/, "")); const narrativeRaw = fm.narrative_summary; const narrative = typeof narrativeRaw === "string" ? narrativeRaw : ""; const statusRaw = String(fm.summary_status ?? (narrative ? "synthesized" : "none")); const summary_status = ( ["none", "synthesized", "curated", "red_teamed"].includes(statusRaw) ? statusRaw : "none" ) as TimelineEntry["summary_status"]; // Default: hide events without a real narrative. if (!includeUnsynthesized && summary_status === "none") continue; // Always hide generic concept-entities (categories, not real events). if (fm.is_generic === true) continue; if (q && !canonical.toLowerCase().includes(q) && !narrative.toLowerCase().includes(q)) { continue; } entries.push({ entity_class: cls, entity_id: f.replace(/\.md$/, ""), canonical_name: canonical, date_start, date_end: (fm.date_end as string) ?? null, primary_location: (fm.primary_location as string) ?? null, narrative_summary: narrative ? narrative.slice(0, 280) : null, summary_status, total_mentions: typeof fm.total_mentions === "number" ? fm.total_mentions : undefined, href: `/e/${folder}/${f.replace(/\.md$/, "")}`, }); } catch { /* skip malformed */ } } entries.sort((a, b) => dateSortable(a.date_start).localeCompare(dateSortable(b.date_start))); return json({ count: entries.length, limit, from: from || null, to: to || null, class: cls, entries: entries.slice(0, limit), }); }