a462464f7c
Adds the full 8-hour Office of the Dead for All Souls' Day: Matins (3 nocturns, Job/Augustine/1 Cor lessons and their own 9 responsories, no Te Deum), Lauds, Vespers (Second Vespers only — Nov 1 evening stays First Vespers of All Saints), and reduced special psalmody at Prime/Terce/Sext/None/Compline. The "Requiem gloria" rubric substitutes "Réquiem ætérnam/Et lux perpétua" for the ordinary Gloria Patri all day, wired centrally into resolve-common.ts's resolveGloriaPatri alongside its existing Triduum omission. Content is split into a reusable src/hours/office-of-the-dead.ts engine (Commune C9's own generic psalmody/lessons/collects, usable for any future occasion this office is said) and a thin src/hours/all-souls.ts supplying only what's genuinely Nov 2-specific (the date trigger, the Augustine + 1 Cor lesson substitution, the Martyrology introduction, and two day-specific collects). No new calendar trigger, route, or per-day override table is added — Nov 2 remains the only caller today. Live-verified hour-by-hour against the reference engine's own rendering, which caught two things the raw static source alone would have gotten wrong: Lauds' "Ps 222" is the Canticle of Ezechias, not a numbered psalm, and Compline's would-be 4th special psalm never actually renders under any selectable rubric version, so it's dropped rather than invented. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LGsATZvfnpQ81HQuoF5JuL
152 lines
6.5 KiB
TypeScript
152 lines
6.5 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, applyFlexaMark } from './antiphon';
|
|
import {
|
|
resolveCommon,
|
|
appendDoxology,
|
|
splitNamedAntiphon,
|
|
openingAntiphon,
|
|
resolveOfficeWinner,
|
|
resolveMinorHourAntiphon,
|
|
isSundayOrFeastOffice,
|
|
resolveResponsory,
|
|
} from './resolve-common';
|
|
import { isAllSouls, buildPrimeParts, DAY_LABEL } from './all-souls';
|
|
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, '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: resolveResponsory(resolveCommon(getChapterResponsoryId(day.season, day.winner)), day) }];
|
|
}
|
|
{
|
|
const psalmRefs = getPsalmsFor('prime', day.weekday);
|
|
const antiphonText = 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 = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
|
return psalmRefs.map((ref, i) => {
|
|
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
|
|
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
|
|
return {
|
|
kind: 'psalm' as const,
|
|
psalmNumber: ref.number,
|
|
antiphon,
|
|
verses,
|
|
};
|
|
});
|
|
}
|
|
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': {
|
|
const winner = resolveOfficeWinner(day);
|
|
if (part.omitOnDouble && isDoubleOrHigher(winner)) {
|
|
return [];
|
|
}
|
|
const isVigil = winner.kind === 'sanctoral' && winner.rank === 'vigil';
|
|
const useFeastForm = isSundayOrFeastOffice(day) && !(part.vigilIsFerial && isVigil);
|
|
const ref = useFeastForm ? 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':
|
|
case 'vespers-psalmody':
|
|
case 'vespers-office':
|
|
case 'magnificat':
|
|
case 'vespers-preces':
|
|
throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`);
|
|
}
|
|
}
|
|
|
|
export function resolveOrdo(date: string): ResolvedOrdo {
|
|
if (isAllSouls(date)) {
|
|
return { hourId: 'prime', date, parts: buildPrimeParts(date), dayLabel: DAY_LABEL };
|
|
}
|
|
const day = resolveDay(date);
|
|
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day));
|
|
return { hourId: 'prime', date, parts, dayLabel: getDayLabel(day) };
|
|
}
|