Something went wrong. Try again.
Original-language reader for Kant, Aristotle & more: select any passage to gloss, translate, or hear it read aloud, with exact citations.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313import 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<readonly [number, string]> = [ [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<LocusSchemeId, SchemeDef> = { "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})`}