import type { DocumentModel, HeadingNode, Lang, LocusSchemeId, } from "@/lib/schemas/model" /** * Locus & citation config (DATA-ARCHITECTURE.md §1.4). * * Pure, reader-independent string logic shared by the ingest step (to fill * `Anchor.locus`) and the client (citation readout + the citation half of the * AI cache key, and copy-with-citation §3). Isomorphic — no DOM/Node APIs. * * Two citation forms: * - `citationAt` → readout / cache key: "SZ 12 (§ 1)" * - `copyCitation` → clipboard (§3): "(SZ 1–3, § 1)" */ const EN_DASH = "–" const ROMAN: ReadonlyArray = [ [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"], [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"], ] function toRoman(n: number): string { let out = "" let rem = n for (const [value, sym] of ROMAN) { while (rem >= value) { out += sym rem -= value } } return out } export interface ParsedLocus { /** Canonical display locus: "12", "A1", "IV 389", "VII 3", "980a21". */ locus: string /** Roman volume for vol-based schemes (AT, Akademie) — enables range collapse. */ vol?: string /** Numeric page where meaningful (ordering / range collapse). */ page?: number } interface SchemeDef { /** Citation prefix incl. trailing space, e.g. "SZ ", "AA ", "" for Bekker. */ prefix: string hasSections: boolean /** AT & Akademie collapse a shared volume in ranges ("AT VII 25–26"). */ volBased: boolean parse: (anchorId: string) => ParsedLocus } /** Numeric print page of a `pageNNN` anchor (`page012` → 12), else null (§4 sync). */ export function pageNumberOf(anchorId: string): number | null { const m = /^page0*(\d+)$/i.exec(anchorId) return m ? Number(m[1]) : null } function parsePage(anchorId: string): ParsedLocus { const page = pageNumberOf(anchorId) return page === null ? { locus: anchorId } : { locus: String(page), page } } function parseKantAB(anchorId: string): ParsedLocus { const m = /^([AB])0*(\d+)$/i.exec(anchorId) if (!m) return { locus: anchorId } const letter = m[1]!.toUpperCase() const page = Number(m[2]) return { locus: `${letter}${page}`, page } } function parseVolPage(prefix: RegExp, anchorId: string): ParsedLocus { const m = prefix.exec(anchorId) if (!m) return { locus: anchorId } const vol = toRoman(Number(m[1])) const page = Number(m[2]) return { locus: `${vol} ${page}`, vol, page } } const SCHEMES: Record = { "sz-niemeyer": { prefix: "SZ ", hasSections: true, volBased: false, parse: parsePage, }, ideen: { prefix: "Ideen ", hasSections: true, volBased: false, parse: parsePage, }, "kant-AB": { prefix: "KrV ", hasSections: false, volBased: false, parse: parseKantAB, }, "kant-akademie": { prefix: "AA ", hasSections: false, volBased: true, parse: (id) => parseVolPage(/^Ak(\d+)p0*(\d+)$/i, id), }, bekker: { prefix: "", hasSections: false, volBased: false, parse: (id) => ({ locus: id }), }, at: { prefix: "AT ", hasSections: false, volBased: true, parse: (id) => parseVolPage(/^AT(\d+)p0*(\d+)$/i, id), }, } export function localeFor(lang: Lang): string { switch (lang) { case "de": return "de-DE" case "grc": return "el" // Modern Greek segmentation rules apply to polytonic too. case "la": return "la" } } /** Display locus for an anchor id (used by ingest to fill `Anchor.locus`). */ export function anchorLocus(scheme: LocusSchemeId, anchorId: string): string { return SCHEMES[scheme].parse(anchorId).locus } /** * Numeric reading-order key for a text within its work (§2), derived from its * first anchor. Page-bearing schemes sort by page; Bekker by its number + column * ("980a21" → 98000). Volume is ignored — a single work sits in one volume / * edition. Texts with no anchors (front matter, e.g. a Vorbemerkung) sort first * and are handled by the caller, not here. */ export function anchorSortKey(scheme: LocusSchemeId, anchorId: string): number { const p = SCHEMES[scheme].parse(anchorId) if (p.page !== undefined) return p.page const m = /^(\d+)([a-z])/i.exec(anchorId) if (m) return Number(m[1]) * 100 + (m[2]!.toLowerCase().charCodeAt(0) - 97) return 0 } /** Whether this text's scheme appends `(§ n)` sections (German § texts). */ export function schemeHasSections(scheme: LocusSchemeId): boolean { return SCHEMES[scheme].hasSections } /** Index of the nearest preceding anchor at `offset` (for the rail highlight). */ export function anchorIndexAt(model: DocumentModel, offset: number): number { let idx = model.anchors.length > 0 ? 0 : -1 for (let i = 0; i < model.anchors.length; i++) { if (model.anchors[i]!.offset <= offset) idx = i else break } return idx } /** Id of the nearest preceding heading at `offset` (for the TOC highlight). */ export function headingIdAt(model: DocumentModel, offset: number): string | null { const flat: HeadingNode[] = [] flattenHeadings(model.headings, flat) let best: string | null = null let bestStart = -1 for (const h of flat) { if (h.start <= offset && h.start > bestStart) { best = h.headingId bestStart = h.start } } return best } export type GoToTarget = | { kind: "anchor"; anchorId: string } | { kind: "heading"; headingId: string; start: number } /** * Resolve a Go To query (§2) to a scroll target. Accepts a `§ n` section, a * page / Bekker / vol-page locus, or a raw anchor id — matched flexibly against * this text's own scheme and headings. */ export function resolveGoTo(model: DocumentModel, raw: string): GoToTarget | null { const q = raw.trim() if (!q) return null // Explicit "§ n" → the heading carrying that section mark. const sec = /^§\s*(\d+[a-z]?)$/i.exec(q) if (sec) { const want = `§ ${sec[1]!.toLowerCase()}` const flat: HeadingNode[] = [] flattenHeadings(model.headings, flat) const hit = flat.find((h) => h.sectionMark?.toLowerCase() === want) return hit ? { kind: "heading", headingId: hit.headingId, start: hit.start } : null } const def = SCHEMES[model.locusScheme] const ql = q.toLowerCase() const asNum = Number(q) const hit = model.anchors.find((a) => { if (a.id.toLowerCase() === ql) return true const parsed = def.parse(a.id) if (parsed.locus.toLowerCase() === ql) return true if (!Number.isNaN(asNum) && parsed.page === asNum) return true return false }) return hit ? { kind: "anchor", anchorId: hit.id } : null } function flattenHeadings(nodes: readonly HeadingNode[], out: HeadingNode[]): void { for (const n of nodes) { out.push(n) flattenHeadings(n.children, out) } } /** Nearest preceding anchor (offset ≤ pos); falls back to the first anchor. */ function nearestAnchor( model: DocumentModel, offset: number, ): DocumentModel["anchors"][number] | undefined { let best: DocumentModel["anchors"][number] | undefined for (const a of model.anchors) { if (a.offset <= offset) best = a else break // anchors are monotonic in document order } return best ?? model.anchors[0] } /** Nearest preceding `§` section mark (German texts), if any. */ function nearestSection(model: DocumentModel, offset: number): string | undefined { const flat: HeadingNode[] = [] flattenHeadings(model.headings, flat) let best: string | undefined let bestStart = -1 for (const h of flat) { if (h.sectionMark && h.start <= offset && h.start > bestStart) { best = h.sectionMark bestStart = h.start } } return best } function citeSingle(scheme: LocusSchemeId, p: ParsedLocus): string { return `${SCHEMES[scheme].prefix}${p.locus}` } function citeRange(scheme: LocusSchemeId, a: ParsedLocus, b: ParsedLocus): string { const def = SCHEMES[scheme] if (def.volBased && a.vol !== undefined && a.vol === b.vol) { return `${def.prefix}${a.vol} ${a.page}${EN_DASH}${b.page}` } return `${def.prefix}${a.locus}${EN_DASH}${b.locus}` } /** Readout / cache-key citation at a point, e.g. "SZ 12 (§ 1)". */ export function citationAt(model: DocumentModel, offset: number): string { const a = nearestAnchor(model, offset) if (!a) return "" const def = SCHEMES[model.locusScheme] let out = citeSingle(model.locusScheme, def.parse(a.id)) if (def.hasSections) { const sec = nearestSection(model, offset) if (sec) out += ` (${sec})` } return out } /** Clipboard citation for a (possibly multi-locus) selection (§3): "(SZ 1–3, § 1)". */ export function copyCitation( model: DocumentModel, start: number, end: number, ): string { const a = nearestAnchor(model, start) const b = nearestAnchor(model, end) if (!a || !b) return "" const scheme = model.locusScheme const def = SCHEMES[scheme] const body = a.id === b.id ? citeSingle(scheme, def.parse(a.id)) : citeRange(scheme, def.parse(a.id), def.parse(b.id)) let sec = "" if (def.hasSections) { const s = nearestSection(model, start) if (s) sec = `, ${s}` } return `(${body}${sec})` }