diff --git a/src/data/hours/hymn-doxology-by-feast.yml b/src/data/hours/hymn-doxology-by-feast.yml index 7c52c38..1b1aaea 100644 --- a/src/data/hours/hymn-doxology-by-feast.yml +++ b/src/data/hours/hymn-doxology-by-feast.yml @@ -1,5 +1,11 @@ # Per-feast override of the hymn's doxology stanza — real Divinum Officium -# has a `winner{Doxology}` mechanism for this (some feasts specify their own). -# Empty until milestone 4's sanctoral propers store exists, same as -# prime-chapter-responsory-by-feast.yml. -byFeastId: {} +# has a `winner{Doxology}` mechanism for this (some feasts specify their own), +# plus a `Rule Doxology=` mechanism a whole Common can set (e.g. the Common +# of the BVM's own `Doxology=Nat` — Commune/C10-C12Q.txt, live-checked +# 2026-09-03), which is why marian-saturday is keyed here by its temporal id +# even though it's not a "feast" in the sanctoral-propers-store sense; see +# hours/hymn-doxology.ts's getHymnDoxologyId for the kind-agnostic lookup. +# Otherwise empty until milestone 4's sanctoral propers store exists, same +# as prime-chapter-responsory-by-feast.yml. +byFeastId: + marian-saturday: hymn-doxology-nat diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 0054542..2d688d6 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -30,9 +30,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { return [{ kind: 'hymn', text: resolveCommon('compline-hymn-lenten') }]; } const body = resolveCommon(part.textRef.id); - const doxology = resolveCommon( - getHymnDoxologyId(day.season, day.winner, 'compline-hymn-doxology-per-annum'), - ); + const doxology = resolveCommon(getHymnDoxologyId(day, 'compline-hymn-doxology-per-annum')); return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; } case 'chapter': diff --git a/src/hours/hymn-doxology.ts b/src/hours/hymn-doxology.ts index d685117..cc32afc 100644 --- a/src/hours/hymn-doxology.ts +++ b/src/hours/hymn-doxology.ts @@ -1,21 +1,55 @@ -import type { Season, DayWinner } from '../calendar/types'; -import { resolveSeasonalPropersId } from './seasonal-propers'; +import type { LiturgicalDay } from '../calendar/types'; +import { resolveOfficeWinner } from './resolve-common'; import bySeasonData from '../data/hours/hymn-doxology-by-season.yml'; import byFeastData from '../data/hours/hymn-doxology-by-feast.yml'; const bySeason = bySeasonData as { bySeason: Record }; const byFeast = byFeastData as { byFeastId: Record }; +const NAT_DOXOLOGY_ID = 'hymn-doxology-nat'; + +/** + * Divinum Officium's own doxology() sub (specials/hymni.pl) hardcodes the + * Assumption's octave (Aug 16-22) to the Nativity doxology, regardless of + * season or which feast is winning that day — live-checked in the source, + * and *not* excluded for the Monastic/Tridentine 1617 track (unlike its + * Dec 9-15 companion rule for the Immaculate Conception's run-up, which the + * source explicitly excludes 1570/1617/1963/altovadensis from, so that one + * is deliberately NOT ported here). Checked directly by date, same pattern + * as marian-antiphon.ts's isCandlemasToHolyWednesday — this window doesn't + * line up with any `Season` value. + */ +function isAssumptionOctaveWindow(isoDate: string): boolean { + const [, monthStr, dayStr] = isoDate.split('-'); + const month = Number(monthStr); + const day = Number(dayStr); + return month === 8 && day > 15 && day < 23; +} + /** * Resolves the common-propers id for a hymn's seasonal final doxology * stanza. The seasonal overrides are shared across every hymn (Divinum * Officium's own Doxologies.txt table is hymn-agnostic), but the per-annum * default is each hymn's own natural ending, so callers supply it. + * + * Precedence, matching hymni.pl's doxology() sub: (1) an explicit per-feast + * override — real Divinum Officium's `winner{Doxology}`/`Rule Doxology=` + * mechanism, e.g. the Common of the BVM's own `Doxology=Nat` rule, which is + * why Marian Saturdays (a `kind: 'temporal'` winner, not `'sanctoral'` — + * checked regardless of kind, unlike chapter-responsory's/opening- + * versicle's own byFeastId tables, since a temporal id and a sanctoral id + * never collide) get the Nativity doxology even outside Christmastide; (2) + * the Assumption-octave date window above; (3) the season; (4) the hymn's + * own per-annum default. */ -export function getHymnDoxologyId(season: Season, winner: DayWinner, perAnnumId: string): string { - return resolveSeasonalPropersId(season, winner, { - perAnnum: perAnnumId, - bySeason: bySeason.bySeason, - byFeastId: byFeast.byFeastId, - }); +export function getHymnDoxologyId(day: LiturgicalDay, perAnnumId: string): string { + const winner = resolveOfficeWinner(day); + const feastOverride = winner.kind === 'sanctoral' || winner.kind === 'temporal' ? byFeast.byFeastId[winner.id] : undefined; + if (feastOverride) { + return feastOverride; + } + if (isAssumptionOctaveWindow(day.date)) { + return NAT_DOXOLOGY_ID; + } + return bySeason.bySeason[day.season] ?? perAnnumId; } diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 0e4dece..b462b60 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -30,7 +30,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved switch (part.kind) { case 'hymn': { const body = resolveCommon(part.textRef.id); - const doxology = resolveCommon(getHymnDoxologyId(day.season, day.winner, 'hymn-doxology-per-annum')); + const doxology = resolveCommon(getHymnDoxologyId(day, 'hymn-doxology-per-annum')); return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; } case 'chapter':