import type { ResolvedPart, ResolvedText } from './types'; import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types'; import type { ProperText } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; import { resolveActiveOctave, isAtLeast } from '../calendar'; import { splitAntiphon } from './antiphon'; function toResolvedText(proper: ProperText): ResolvedText { return { text: proper.text, status: proper.status, citation: proper.citation }; } /** Shared by every hour's resolver — looks up a common proper by id and * shapes it as a ResolvedText. */ export function resolveCommon(id: string): ResolvedText { return toResolvedText(getCommonProper(id)); } // Named temporal winners with real standing of their own, unconditionally // override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which // shares this set (imported from here, not duplicated) since it's the // same "does this temporal identity carry its own real office" question. export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-the-king']); /** * Which identity's own propers actually supply the office's content * (collect, Benedictus antiphon, psalmody override) — usually just * `day.winner`, but *not* on a day within an active octave where the * temporal day itself has no real standing of its own (`ordinary-feria`) * and the winner stayed temporal — an ordinary day within St. Lawrence's * own octave, e.g.: live-verified (Tridentine 1910, 2026-08-12) that the * chapter/responsory/hymn/versicle, psalms' antiphons, Benedictus * antiphon, and day collect all come from the octave's own feast * ("{ex Commune aut Festo}" / "{ex Proprio Sanctorum}"), not from the * plain temporal day underneath it. * * Deliberately gated on `ordinary-feria` specifically, not just "an * octave is active": the Christmas Octave's own stacked octaves * (Christmas + Stephen + John + Holy Innocents, e.g. on Dec 30) are the * live-verified counterexample — that day's own temporal identity * ("Dominica Infra Octavam Nativitatis", `privileged-feria-minor`) is * itself a real, named standing, and *keeps* the office * ("{ex Proprio de Tempore}"); the four octaves there each become their * own separate "Commemoratio Octavæ ..." block instead (not yet modeled * — see TODO.md), rather than any one of them taking over content the * way Lawrence's octave does. The dividing line is real standing, not * merely "is an octave active." * * `day.winner`/`day.commemorations` themselves stay exactly as * calendar/index.ts computed them either way — this is purely a * content-lookup detail, not a recomputation of who "wins." Picks the * oldest-started active octave with an authored saint record * (activeOctavesFor's own ordering) — matches calendar/day-label.ts's * same choice for the display label, for the same reason. */ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner { if (day.winner.kind === 'sanctoral' || ALWAYS_OVERRIDE_TEMPORAL_IDS.has(day.winner.id)) { return day.winner; } if (day.temporalCategory !== 'ordinary-feria') { return day.winner; } // When more than one octave is active at once (St. Lawrence's and the // Assumption's genuinely overlap every Aug 16-17), resolveActiveOctave // picks the one that actually governs the day — see its own doc // comment for the precedence rule. Falls through to the plain temporal // default if that octave turns out to be a temporal-only one with no // saint record (e.g. Christmas's or Pentecost's own octave id) — no // sanctoral content to synthesize from those. const activeOctave = resolveActiveOctave(day.date); if (activeOctave) { const saint = getSaintRecord(activeOctave.id); if (saint) { return { kind: 'sanctoral', id: saint.id, name: saint.name, rank: saint.rank }; } } return day.winner; } /** Like calendar/index.ts's own isSundayOrFeast, but resolveOfficeWinner- * aware — an octave day counts as a "feast" for this purpose too. * Live-verified: Prime's capitulum/Preces stay in the Sunday/feast form * throughout St. Lawrence's octave (2026-08-12), not just his own actual * day (2026-08-10) — both show the identical "ex Psalterio secundum * diem" 1 Tim 1:17 text. Deliberately a separate function, not a * replacement for the plain isSundayOrFeast: Lauds' own Cross-suffrage * gate (hours/lauds.ts) needs the *narrower*, non-office-aware version, * since Marian Saturday and an octave day both need to land on the * ferial side there despite not being Sunday or a sanctoral `day.winner` * either. */ export function isSundayOrFeastOffice(day: LiturgicalDay): boolean { return day.weekday === 'sunday' || resolveOfficeWinner(day).kind === 'sanctoral'; } /** * Whether today's office winner (resolveOfficeWinner) is a strong enough * feast to carry its own proper minor-hour content (antiphon + chapter) * — duplex-majus+, the same threshold Lauds' own psalmody override uses * (hours/lauds.ts's getPsalmodyOverrideFor), kept in sync deliberately: * as each remaining duplex-majus+ saint gets a Lauds override authored, * the same id is what Prime/Terce/Sext/None would look for too, so it's * one shared backlog (see TODO.md), not per-hour lists that can drift. * Returns the id to look up (`${hourId}-antiphon-${id}` etc.), or * `undefined` when nothing eligible is happening today. */ export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined { const winner = resolveOfficeWinner(day); if (winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus')) { return winner.id; } return undefined; } /** A Little Hour's (or Prime's) plain per-weekday antiphon, overridden by * a duplex-majus+ feast's own proper (`${hourId}-antiphon-${id}`) when * authored — same honest "not gated behind whether content exists, just * eligible to override at all" fallback as every other override in this * codebase: an eligible feast without one authored yet just falls * through to the plain weekday default silently. */ export function resolveMinorHourAntiphon( hourId: string, day: LiturgicalDay, weekdayDefault: Partial>, ): Partial> { const overrideId = getMinorHourOverrideId(day); if (overrideId) { const proper = getCommonProper(`${hourId}-antiphon-${overrideId}`); if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { return proper.text; } } return weekdayDefault; } /** Same idea as resolveMinorHourAntiphon, for the chapter * (`${hourId}-capitulum-${id}`) — falls back to `fallbackId` (the plain * per-annum/per-weekday one already in place for that hour) when no * override is eligible, or none has been authored yet for one that is. */ export function resolveMinorHourChapter(hourId: string, day: LiturgicalDay, fallbackId: string): ResolvedText { const overrideId = getMinorHourOverrideId(day); if (overrideId) { const proper = resolveCommon(`${hourId}-capitulum-${overrideId}`); if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { return proper; } } return resolveCommon(fallbackId); } /** * The day's own collect — real for the vast majority of days (a temporal * winner always resolves, since all 52 Sunday collects are authored and * ferias inherit the governing Sunday's via calendar/temporal-id.ts), and * honestly "missing" for the handful of days a saint wins outright until * that saint's own propers are authored (`saints/.yml`'s `propers` * field is still `null` for all of them today) — same "resolve as * pending" convention as everywhere else, not a special case to handle. * Uses resolveOfficeWinner, not the raw `day.winner` — see its own doc * comment for why those two differ on an octave day. */ export function getDayCollect(day: LiturgicalDay): ResolvedText { const winner = resolveOfficeWinner(day); if (winner.kind === 'temporal') { return toResolvedText(getTemporalProper(`${winner.id}-collect`)); } const saint = getSaintRecord(winner.id); if (saint?.propers) { return resolveCommon(`${saint.propers}-collect`); } return { text: {}, status: { la: 'missing', en: 'missing' } }; } /** A sanctoral commemoration's own rendering: the fuller Ant+V/R+collect * bundle (`${propers}-commemoration`, e.g. st-clare-commemoration.yml) * when authored — live-verified this is the real shape a commemoration * takes, not a bare collect — falling back to just the collect alone * (still labeled, unlike the old unlabeled bare-collect rendering this * replaces) when only that's been authored, and to an honestly labeled * "missing" block when neither has. Always labeled ("Commemoration of St. * X") so an unauthored one reads as "this saint's commemoration isn't * written up yet," not as a mystery blank prayer. */ function sanctoralCommemorationPart(commemoration: Extract): ResolvedPart { const label = `Commemoration of ${commemoration.name}`; const saint = getSaintRecord(commemoration.id); if (saint?.propers) { const combined = resolveCommon(`${saint.propers}-commemoration`); if (combined.status.la !== 'missing' || combined.status.en !== 'missing') { return { kind: 'preces', text: combined, label }; } return { kind: 'preces', text: resolveCommon(`${saint.propers}-collect`), label }; } return { kind: 'preces', text: { text: {}, status: { la: 'missing', en: 'missing' } }, label }; } /** * The day's own collect, plus one more per commemoration (calendar/ * types.ts's LiturgicalDay.commemorations) — Lauds/Vespers say all of * these in sequence, unlike the Little Hours' single getDayCollect. * * Simplification, not yet corrected: real practice compresses this into * one "Orémus" and lets only the *last* collect close with the full * doxology, with earlier ones trailing straight into the next ("And:"). * Each collect file (data/propers/temporal/*-collect.yml, the per-saint * *-collect.yml files) already bakes in its own "Orémus."/"Per Dóminum... * Amen." for the single-collect case every other hour uses today, and * stripping that back out per-collect to chain them properly would need * text surgery this doesn't attempt — so on a commemorated day, each * collect here renders as its own complete, separate block instead. An * octave commemoration never contributes anything of its own here — once * resolveOfficeWinner is in play, its content is already the primary * collect above, so a separate entry would just be a redundant repeat. */ export function getDayCollects(day: LiturgicalDay): ResolvedPart[] { const parts: ResolvedPart[] = [{ kind: 'prayer', text: getDayCollect(day) }]; for (const commemoration of day.commemorations) { if (commemoration.kind === 'temporal') { parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) }); } else if (commemoration.kind === 'sanctoral') { parts.push(sanctoralCommemorationPart(commemoration)); } } return parts; } /** * Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a * per-saint `${propers}-antiphon` (already authored during the sanctoral * pull, sourced from each saint's own raw [Ant 1] — see e.g. * st-lawrence-antiphon.yml) for a sanctoral winner, `${id}-benedictus- * antiphon` (not authored yet for any temporal id — resolves "missing", * same pending convention as everywhere else) for a temporal one. Uses * resolveOfficeWinner, not the raw `day.winner` — on an octave day this * is the octave's own feast's antiphon (live-verified: St. Lawrence's * "In cratícula", not the commemorated St. Clare's, and not the plain * temporal day's), not a *commemorated* saint's, which is a separate, * weaker standing (see getDayCollects/sanctoralCommemorationPart). */ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText { const winner = resolveOfficeWinner(day); if (winner.kind === 'temporal') { return toResolvedText(getTemporalProper(`${winner.id}-benedictus-antiphon`)); } const saint = getSaintRecord(winner.id); if (saint?.propers) { return resolveCommon(`${saint.propers}-antiphon`); } return { text: {}, status: { la: 'missing', en: 'missing' } }; } export function verifiedText(text: Partial>): ResolvedText { const status: Partial> = {}; for (const lang of Object.keys(text)) { status[lang] = 'verified'; } return { text, status }; } const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const; /** Joins a hymn's body with its (seasonally-variable) final doxology * stanza, per language — status is the worse of the two per language. */ export function appendDoxology(body: ResolvedText, doxology: ResolvedText): ResolvedText { const text: Partial> = { ...body.text }; const status: Partial> = { ...body.status }; for (const lang of Object.keys(doxology.text)) { const doxText = doxology.text[lang]; if (doxText) { text[lang] = text[lang] ? `${text[lang]}\n\n${doxText}` : doxText; } const bodyStatus = status[lang] ?? 'missing'; const doxStatus = doxology.status[lang] ?? 'missing'; status[lang] = STATUS_RANK[doxStatus] > STATUS_RANK[bodyStatus] ? doxStatus : bodyStatus; } return { text, status }; } /** Splits a bilingual antiphon (one string per language, each with an * embedded "*") into its incipit and full forms, per language — each * prefixed "Ant. " inline, the same way "V."/"R." are baked directly into * versicle text rather than rendered as a separate UI marker. */ export function splitNamedAntiphon(text: Partial>): { incipit: ResolvedText; full: ResolvedText; } { const incipitText: Partial> = {}; const fullText: Partial> = {}; for (const [lang, t] of Object.entries(text)) { if (!t) { continue; } const split = splitAntiphon(t); incipitText[lang] = `Ant. ${split.incipit}`; fullText[lang] = `Ant. ${split.full}`; } return { incipit: verifiedText(incipitText), full: verifiedText(fullText) }; }