From b230f3f1e83f23f91bacee53c0cfaa70096db9d9 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 19 Aug 2026 06:03:51 -0400 Subject: [PATCH] Add closing Gloria Patri after psalms, omitted during the Sacred Triduum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Psalms across every hour rendered with no closing Gloria Patri at all. Add it centrally (hours/index.ts) rather than per-hour, using a new calendar/temporal.ts isInTriduum helper so it's correctly dropped from Maundy Thursday through Holy Saturday and nowhere else — live-verified against both of this app's actual source tracks (Divino Afflatu 1954 and Tridentine 1906), not just the modern Rubrics 1960 default. Also fixes Lauds' existing but Triduum-blind canticle Gloria Patri append. --- src/calendar/temporal.ts | 12 ++++++++++++ src/hours/index.ts | 24 ++++++++++++++++++++++-- src/hours/lauds.ts | 16 ++++++++++------ src/hours/resolve-common.ts | 20 ++++++++++++++++++++ src/hours/types.ts | 6 +++++- src/ui/hour-view.ts | 1 + 6 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/calendar/temporal.ts b/src/calendar/temporal.ts index 38a028f..a4460f5 100644 --- a/src/calendar/temporal.ts +++ b/src/calendar/temporal.ts @@ -126,6 +126,18 @@ export function easterOffsetOf(isoDate: string): number { return daysBetween(toIsoDate(easterSunday(year)), isoDate); } +/** The Sacred Triduum: Maundy/Holy Thursday through Holy Saturday + * (inclusive). Doesn't line up with `season` (which resolves this window + * to 'passiontide', same as the two weeks before it) — see + * hours/marian-antiphon.ts's isCandlemasToHolyWednesday for the same + * direct-date-check pattern used for another window `season` can't + * represent. Gloria Patri is omitted after psalms only in this narrower + * window, not throughout Passiontide/Holy Week generally. */ +export function isInTriduum(isoDate: string): boolean { + const offset = easterOffsetOf(isoDate); + return offset >= -3 && offset <= -1; +} + /** * A day's precedence category under the temporal cycle alone — see * calendar/types.ts's TemporalCategory doc comment and diff --git a/src/hours/index.ts b/src/hours/index.ts index 3c09e90..030f979 100644 --- a/src/hours/index.ts +++ b/src/hours/index.ts @@ -1,4 +1,4 @@ -import type { HourId, ResolvedOrdo } from './types'; +import type { HourId, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import { HOUR_IDS } from './types'; import * as prime from './prime'; import * as compline from './compline'; @@ -8,6 +8,8 @@ import * as none from './none'; import * as lauds from './lauds'; import * as vespers from './vespers'; import * as matins from './matins'; +import { resolveDay } from '../calendar'; +import { resolveGloriaPatri } from './resolve-common'; type Resolver = (date: string) => ResolvedOrdo; @@ -24,8 +26,26 @@ const registry: Record = { matins: matins.resolveOrdo, }; +/** Attaches the closing Gloria Patri to every plain psalm part (recursing + * into 'nocturn-psalmody' groups), so no per-hour builder has to remember + * to do it — and so the Sacred Triduum omission (resolveGloriaPatri) is + * enforced in exactly one place rather than once per hour file. */ +function withGloriaPatri(parts: ResolvedPart[], gloriaPatri: ResolvedText | undefined): ResolvedPart[] { + return parts.map((part) => { + if (part.kind === 'psalm') { + return { ...part, gloriaPatri }; + } + if (part.kind === 'nocturn-psalmody') { + return { ...part, psalms: withGloriaPatri(part.psalms, gloriaPatri) }; + } + return part; + }); +} + export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo { - return registry[hourId](date); + const ordo = registry[hourId](date); + const gloriaPatri = resolveGloriaPatri(resolveDay(date)); + return { ...ordo, parts: withGloriaPatri(ordo.parts, gloriaPatri) }; } export { HOUR_IDS }; diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index c5bc041..87d447e 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -1,6 +1,7 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { LiturgicalDay, Weekday } from '../calendar/types'; import { resolveDay, isSundayOrFeast, activeOctavesFor } from '../calendar'; +import { isInTriduum } from '../calendar/temporal'; import { getDayLabel } from '../calendar/day-label'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getPsalmVerses } from '../psalter'; @@ -104,8 +105,10 @@ const GLORIA_PATRI = { * how they were transcribed, not because callers need per-verse access. * Appends the fixed Gloria Patri line every canticle (or canticle part) * ends with in real practice, same wording as benedictus.yml's own - * trailing line. */ -function canticleText(canticleId: string, slice?: [number, number]): ResolvedText { + * trailing line — omitted during the Sacred Triduum, live-verified against + * both Divino Afflatu 1954 and Tridentine 1906 (Benedictus at Lauds of + * Holy Thursday omits it same as any psalm). */ +function canticleText(canticleId: string, slice: [number, number] | undefined, omitGloriaPatri: boolean): ResolvedText { const canticle = getCanticle(canticleId); const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses; const text: Partial> = {}; @@ -115,7 +118,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex if (lines.length === 0) { continue; } - text[lang] = `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`; + text[lang] = omitGloriaPatri ? lines.join('\n') : `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`; let worst: 'verified' | 'draft' | 'missing' = 'verified'; for (const v of verses) { const s = v.status[lang] ?? 'missing'; @@ -150,6 +153,7 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { const canticle = getCanticle(wd.canticle.id); const canticleOpening = opening(wd.canticle.antiphon); const canticleClosing = splitNamedAntiphon(verifiedText(wd.canticle.antiphon)).full; + const omitGloriaPatri = isInTriduum(day.date); if (wd.canticle.split) { // Said in two pieces, own Gloria Patri each, one shared antiphon // framing both (opening before the first, full repeated only after @@ -158,19 +162,19 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { kind: 'canticle', canticleId: canticle.id, antiphon: canticleOpening, - text: canticleText(canticle.id, [0, wd.canticle.split]), + text: canticleText(canticle.id, [0, wd.canticle.split], omitGloriaPatri), }); parts.push({ kind: 'canticle', canticleId: canticle.id, - text: canticleText(canticle.id, [wd.canticle.split, canticle.verses.length]), + text: canticleText(canticle.id, [wd.canticle.split, canticle.verses.length], omitGloriaPatri), }); } else { parts.push({ kind: 'canticle', canticleId: canticle.id, antiphon: canticleOpening, - text: canticleText(canticle.id), + text: canticleText(canticle.id, undefined, omitGloriaPatri), }); } parts.push({ kind: 'antiphon', text: canticleClosing }); diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index da162d4..98bd13e 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -5,6 +5,7 @@ import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; +import { isInTriduum } from '../calendar/temporal'; import { splitAntiphon } from './antiphon'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; @@ -677,3 +678,22 @@ export function splitNamedAntiphon(antiphon: ResolvedText): { } return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; } + +/** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri — + * appended after every psalm (hours/index.ts) except during the Sacred + * Triduum. Not "verified" via verifiedText() because it's boilerplate + * used everywhere, not a sourced proper text. */ +const GLORIA_PATRI: BilingualText = { + la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.', + en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.', +}; + +/** The closing Gloria Patri for a psalm, or `undefined` during the Sacred + * Triduum (calendar/temporal.ts's isInTriduum) when it's omitted + * entirely — not just seasonally varied wording, an actual omission. */ +export function resolveGloriaPatri(day: LiturgicalDay): ResolvedText | undefined { + if (isInTriduum(day.date)) { + return undefined; + } + return verifiedText(GLORIA_PATRI); +} diff --git a/src/hours/types.ts b/src/hours/types.ts index 33bd8bc..f7d93ca 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -199,7 +199,11 @@ export interface HourDefinition { export type ResolvedPart = | { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText } | { kind: 'preces'; text: ResolvedText; label?: string } - | { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[] } + // `gloriaPatri` is filled in centrally (hours/index.ts, after every hour + // builder returns) rather than per-hour, so it's absent only during the + // Sacred Triduum (calendar/temporal.ts's isInTriduum) — never omit it + // when building a psalm part yourself. + | { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[]; gloriaPatri?: ResolvedText } | { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText } // A standalone antiphon, said on its own rather than framing a psalm — // see 'closing-antiphon' above. diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index 308fb6a..39aa3ec 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -94,6 +94,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
    ${part.verses.map((v) => `
  1. ${renderColumns(v, languages)}
  2. `).join('')}
+ ${part.gloriaPatri ? `
${renderColumns(part.gloriaPatri, languages)}
` : ''} `; }