5f4cf33776
Deploy / deploy (push) Successful in 51s
Prime/Terce/Sext/None had no per-feast override mechanism at all before this -- their psalm antiphon always fell back to the plain weekday default and their chapter was always a single fixed per-annum/per-weekday text, regardless of what was actually being celebrated, even on a saint's own actual feast day (not just during an octave). Confirmed live (Monastic Tridentinum 1617) that St. Lawrence has his own real antiphon and chapter at every one of these hours, both on his own day and throughout his octave. New shared mechanism in hours/resolve-common.ts: getMinorHourOverrideId (duplex-majus+ threshold, kept in sync with Lauds' own so it's one shared backlog), resolveMinorHourAntiphon, resolveMinorHourChapter -- all resolveOfficeWinner-aware, so an octave day gets the same override as the feast's own actual day. Prime additionally needed isSundayOrFeastOffice (its capitulum/Preces Sunday-vs-ferial choice) made octave-aware -- live-verified Prime's capitulum stays the Sunday/feast form throughout the octave too, so no new file was needed there, just reaching the existing one correctly. Also fills in Lauds' own still-missing Lawrence office bundle (capitulum/responsory/hymn/versicle) -- flagged in TODO.md as worth fixing "even before tackling the other 32" and still not done until now. Authored real content for St. Lawrence across all five hours from live queries. The same ~31-saint backlog (TODO.md) now applies uniformly across Lauds and the four hours fixed here. Also documents a real, found-not-fixed gap: when two octaves overlap with no temporal standing to yield to (St. Lawrence's and the Assumption's genuinely overlap every Aug 16-17), this app currently picks whichever started first, which happens to match the live engine on Aug 17 (Lawrence's own elevated final "Octave Day" outranks the Assumption's ordinary octave-day strength) but only by coincidence, not a real rank comparison -- proposed design written up in TODO.md pending a decision on scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
5.8 KiB
TypeScript
135 lines
5.8 KiB
TypeScript
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
|
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
|
import { resolveDay } from '../calendar';
|
|
import { getDayLabel } from '../calendar/day-label';
|
|
import { getPsalmsFor } from '../psalter/distribution';
|
|
import { getPsalmVerses } from '../psalter';
|
|
import { getMartyrologyEntryFor } from '../martyrology';
|
|
import { getRegulaReadingFor } from '../regula';
|
|
import { getChapterResponsoryId } from './chapter-responsory';
|
|
import { getHymnDoxologyId } from './hymn-doxology';
|
|
import { getOpeningVersicleId } from './opening-versicle';
|
|
import { isDoubleOrHigher } from './antiphon';
|
|
import {
|
|
resolveCommon,
|
|
appendDoxology,
|
|
splitNamedAntiphon,
|
|
resolveOfficeWinner,
|
|
resolveMinorHourAntiphon,
|
|
isSundayOrFeastOffice,
|
|
} from './resolve-common';
|
|
import primeDefinitionData from '../data/hours/prime.yml';
|
|
import antiphonsData from '../data/hours/prime-antiphons.yml';
|
|
|
|
const primeDefinition = primeDefinitionData as HourDefinition;
|
|
const antiphons = antiphonsData as Record<Weekday, Partial<Record<string, string>>>;
|
|
|
|
function resolvePart(part: HourPart, date: string, day: LiturgicalDay): ResolvedPart[] {
|
|
switch (part.kind) {
|
|
case 'hymn': {
|
|
const body = resolveCommon(part.textRef.id);
|
|
const doxology = resolveCommon(getHymnDoxologyId(day.season, day.winner, 'hymn-doxology-per-annum'));
|
|
return [{ kind: 'hymn', text: appendDoxology(body, doxology) }];
|
|
}
|
|
case 'chapter':
|
|
case 'responsory':
|
|
case 'versicle':
|
|
case 'prayer':
|
|
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
|
|
case 'preces':
|
|
if (part.omitOnDouble && isDoubleOrHigher(resolveOfficeWinner(day))) {
|
|
return [];
|
|
}
|
|
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
|
|
case 'lesson':
|
|
return [{ kind: 'lesson', text: resolveCommon(part.textRef.id), label: part.label }];
|
|
case 'opening-versicle':
|
|
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
|
case 'creed':
|
|
// Real Divinum Officium's actual inclusion rule tangles together
|
|
// rank, commemorations, and version-specific rubrics in a way that
|
|
// isn't worth replicating exactly — this is the honest simplification:
|
|
// Sunday and not a Double-or-higher feast.
|
|
if (day.weekday !== 'sunday' || isDoubleOrHigher(resolveOfficeWinner(day))) {
|
|
return [];
|
|
}
|
|
return [{ kind: 'lesson', text: resolveCommon('athanasian-creed'), label: 'Athanasian Creed' }];
|
|
case 'closing-antiphon':
|
|
return [{ kind: 'antiphon', text: splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday])).full }];
|
|
case 'variable':
|
|
if (part.resolve === 'by-season') {
|
|
// Currently only the chapter responsory's verse uses this — see
|
|
// hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime.
|
|
return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.winner)) }];
|
|
}
|
|
{
|
|
const psalmRefs = getPsalmsFor('prime', day.weekday);
|
|
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]));
|
|
// Full text on a Double-or-higher feast, otherwise just the incipit
|
|
// — see hours/antiphon.ts. The full repeat comes later, after the
|
|
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
|
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
|
return psalmRefs.map((ref, i) => ({
|
|
kind: 'psalm' as const,
|
|
psalmNumber: ref.number,
|
|
antiphon: i === 0 ? opening : undefined,
|
|
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
|
}));
|
|
}
|
|
case 'psalm':
|
|
return [
|
|
{
|
|
kind: 'psalm',
|
|
psalmNumber: part.psalmNumber,
|
|
verses: getPsalmVerses(part.psalmNumber, part.verses).map((v) => ({
|
|
n: v.n,
|
|
text: v.text,
|
|
status: v.status,
|
|
})),
|
|
},
|
|
];
|
|
case 'canticle':
|
|
return [{ kind: 'canticle', canticleId: part.canticleId, text: resolveCommon(part.textRef.id) }];
|
|
case 'by-day-kind': {
|
|
if (part.omitOnDouble && isDoubleOrHigher(resolveOfficeWinner(day))) {
|
|
return [];
|
|
}
|
|
const ref = isSundayOrFeastOffice(day) ? part.sundayOrFeastRef : part.ferialRef;
|
|
if (part.resolvedKind === 'preces') {
|
|
return [{ kind: 'preces', text: resolveCommon(ref.id), label: part.label }];
|
|
}
|
|
return [{ kind: part.resolvedKind, text: resolveCommon(ref.id) }];
|
|
}
|
|
case 'martyrology': {
|
|
const entry = getMartyrologyEntryFor(date);
|
|
return [{ kind: 'lesson', text: { text: entry.text, status: entry.status }, label: 'Martyrology' }];
|
|
}
|
|
case 'rule-reading': {
|
|
const reading = getRegulaReadingFor(date);
|
|
const label = reading.label.en ?? reading.label.la;
|
|
return [
|
|
{ kind: 'preces', text: resolveCommon('prime-regula-blessing') },
|
|
{ kind: 'lesson', text: { text: reading.text, status: reading.status }, label },
|
|
{ kind: 'preces', text: resolveCommon('lesson-close') },
|
|
];
|
|
}
|
|
// Not used by Prime — Compline-only, or Little-Hours-only.
|
|
case 'nunc-dimittis':
|
|
case 'marian-antiphon':
|
|
case 'day-collect':
|
|
case 'lauds-psalmody':
|
|
case 'lauds-office':
|
|
case 'benedictus':
|
|
case 'suffrages':
|
|
case 'lauds-preces':
|
|
case 'day-collects':
|
|
throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`);
|
|
}
|
|
}
|
|
|
|
export function resolveOrdo(date: string): ResolvedOrdo {
|
|
const day = resolveDay(date);
|
|
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day));
|
|
return { hourId: 'prime', date, parts, dayLabel: getDayLabel(day) };
|
|
}
|