diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 2c9ae94..d0d7963 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -7,7 +7,7 @@ import { getHymnDoxologyId } from './hymn-doxology'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { isDoubleOrHigher } from './antiphon'; -import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common'; +import { resolveCommon, appendDoxology, splitNamedAntiphon, openingAntiphon, resolveResponsory } from './resolve-common'; import complineDefinitionData from '../data/hours/compline.yml'; const complineDefinition = complineDefinitionData as HourDefinition; @@ -63,8 +63,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { }, ]; case 'nunc-dimittis': { - const { incipit, full } = splitNamedAntiphon(resolveCommon('nunc-dimittis-antiphon')); - const opening = isDoubleOrHigher(day.winner) ? full : incipit; + const antiphonText = resolveCommon('nunc-dimittis-antiphon'); + const opening = openingAntiphon(antiphonText, day.winner); + const { full } = splitNamedAntiphon(antiphonText); return [ { kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening }, { kind: 'antiphon', text: full }, diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 4401cba..1bfd301 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -14,6 +14,7 @@ import { getDayCollects, getBenedictusAntiphon, splitNamedAntiphon, + openingAntiphon, resolveOfficeWinner, verifiedText, seasonalOfficeSuffix, @@ -134,10 +135,8 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex * plain weekday default below gets substituted. */ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; - const opening = (antiphon: BilingualText) => { - const { incipit, full } = splitNamedAntiphon(verifiedText(antiphon)); - return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; - }; + const winner = resolveOfficeWinner(day); + const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner); const parts: ResolvedPart[] = [ { kind: 'psalm', @@ -237,8 +236,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'lauds-office': return resolveOffice(day); case 'benedictus': { - const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day)); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const antiphonText = getBenedictusAntiphon(day); + const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day)); + const { full } = splitNamedAntiphon(antiphonText); return [ { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, { kind: 'antiphon', text: full }, diff --git a/src/hours/matins.ts b/src/hours/matins.ts index a7c68f8..4c3f114 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -78,6 +78,7 @@ import { seasonalOfficeSuffix, verifiedText, splitNamedAntiphon, + openingAntiphon, } from './resolve-common'; import { getBiblePlanReadings } from '../propers/bible-plan'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; @@ -187,8 +188,9 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] { function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] { const parts: ResolvedPart[] = []; for (const g of group.groups ?? []) { - const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon)); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const antiphonText = verifiedText(g.antiphon); + const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day)); + const { full } = splitNamedAntiphon(antiphonText); g.psalms.forEach((n, i) => { parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined)); }); @@ -201,8 +203,9 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP /** Nocturn 3's 3 fixed OT canticles under one shared antiphon — see * data/hours/matins-sunday-antiphons.yml's own header. */ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] { - const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {})); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const antiphonText = verifiedText(group.antiphon ?? {}); + const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day)); + const { full } = splitNamedAntiphon(antiphonText); const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => { const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses)); const text: BilingualText = { @@ -285,11 +288,12 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] { * as one combined V./R. block via `versicleText`. */ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { const nocturn = ferialAntiphons[day.weekday as Exclude]; - const isDouble = isDoubleOrHigher(resolveOfficeWinner(day)); + const winner = resolveOfficeWinner(day); const parts: ResolvedPart[] = []; for (const group of nocturn.groups) { - const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); - const opening = isDouble ? full : incipit; + const antiphonText = verifiedText(group.antiphon); + const opening = openingAntiphon(antiphonText, winner); + const { full } = splitNamedAntiphon(antiphonText); group.psalms.forEach((ref, i) => { const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })); const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); diff --git a/src/hours/none.ts b/src/hours/none.ts index c6a32aa..2307f8f 100644 --- a/src/hours/none.ts +++ b/src/hours/none.ts @@ -5,11 +5,11 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; +import { applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, - splitNamedAntiphon, + openingAntiphon, resolveOfficeWinner, resolveMinorHourAntiphon, resolveMinorHourChapter, @@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // psalms, no closing repeat (unlike Prime): confirmed directly // against the engine, Capitulum follows the third psalm immediately. const psalmRefs = getPsalmsFor('none', day.weekday); - const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday])); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const opening = openingAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]), 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); diff --git a/src/hours/prime.ts b/src/hours/prime.ts index a7cc4ca..ef7e946 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -14,6 +14,7 @@ import { resolveCommon, appendDoxology, splitNamedAntiphon, + openingAntiphon, resolveOfficeWinner, resolveMinorHourAntiphon, isSundayOrFeastOffice, @@ -65,11 +66,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved } { const psalmRefs = getPsalmsFor('prime', day.weekday); - const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[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 = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + 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); diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index e939bf9..f42fb3d 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -6,7 +6,7 @@ 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 { splitAntiphon, isDoubleOrHigher } from './antiphon'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; type BilingualText = Partial>; @@ -692,6 +692,19 @@ export function splitNamedAntiphon(antiphon: ResolvedText): { return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; } +/** The antiphon actually shown *before* a psalm/canticle: full text on a + * Double-rank winner or higher, incipit only below that (see + * `isDoubleOrHigher`'s own doc comment for the underlying rule) — the one + * idiom every hour's psalmody independently repeated (`splitNamedAntiphon` + * + `isDoubleOrHigher(...) ? full : incipit`) before being centralized + * here. The closing repeat after the psalm/canticle is always `full`, + * unconditionally, at each call site — this helper only decides the + * opening. */ +export function openingAntiphon(antiphon: ResolvedText, winner: DayWinner): ResolvedText { + const { incipit, full } = splitNamedAntiphon(antiphon); + return isDoubleOrHigher(winner) ? full : incipit; +} + /** 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 diff --git a/src/hours/sext.ts b/src/hours/sext.ts index b012954..24eba44 100644 --- a/src/hours/sext.ts +++ b/src/hours/sext.ts @@ -5,11 +5,11 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; +import { applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, - splitNamedAntiphon, + openingAntiphon, resolveOfficeWinner, resolveMinorHourAntiphon, resolveMinorHourChapter, @@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // psalms, no closing repeat (unlike Prime): confirmed directly // against the engine, Capitulum follows the third psalm immediately. const psalmRefs = getPsalmsFor('sext', day.weekday); - const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday])); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const opening = openingAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]), 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); diff --git a/src/hours/terce.ts b/src/hours/terce.ts index 97eb054..121a57e 100644 --- a/src/hours/terce.ts +++ b/src/hours/terce.ts @@ -5,11 +5,11 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; +import { applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, - splitNamedAntiphon, + openingAntiphon, resolveOfficeWinner, resolveMinorHourAntiphon, resolveMinorHourChapter, @@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // psalms, no closing repeat (unlike Prime): confirmed directly // against the engine, Capitulum follows the third psalm immediately. const psalmRefs = getPsalmsFor('terce', day.weekday); - const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday])); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const opening = openingAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]), 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); diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index 6c1da84..f07c926 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -4,12 +4,13 @@ import { resolveEveningDay } from '../calendar/vespers'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; +import { applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, getMagnificatAntiphon, splitNamedAntiphon, + openingAntiphon, resolveOfficeWinner, verifiedText, seasonalOfficeSuffix, @@ -47,8 +48,9 @@ function normalizePsalmRef(ref: PsalmRefLike): { number: number; verses?: string * in full once, after the last. Mirrors lauds.ts's psalmParts, extended * for verse-range entries (see PsalmRefLike). */ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] { - const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const antiphonText = verifiedText(group.antiphon); + const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day)); + const { full } = splitNamedAntiphon(antiphonText); const refs = group.psalms.map(normalizePsalmRef); const parts: ResolvedPart[] = refs.map((ref, i) => { const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })); @@ -161,8 +163,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'vespers-office': return resolveOffice(day); case 'magnificat': { - const { incipit, full } = splitNamedAntiphon(getMagnificatAntiphon(day)); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const antiphonText = getMagnificatAntiphon(day); + const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day)); + const { full } = splitNamedAntiphon(antiphonText); return [ { kind: 'canticle', canticleId: 'magnificat', text: resolveCommon('magnificat'), antiphon: opening }, { kind: 'antiphon', text: full },