diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 7e2ac0b..4401cba 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -8,7 +8,7 @@ import { getCanticle } from './lauds-canticles'; import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, @@ -81,12 +81,16 @@ function capitulumId(weekday: Weekday): string { } function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] { - const parts: ResolvedPart[] = numbers.map((number, i) => ({ - kind: 'psalm' as const, - psalmNumber: number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + const parts: ResolvedPart[] = numbers.map((number, i) => { + const rawVerses = getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })); + const { verses, antiphon: psalmAntiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); + return { + kind: 'psalm' as const, + psalmNumber: number, + antiphon: psalmAntiphon, + verses, + }; + }); parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full }); return parts; } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index fdebcbe..02f5000 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -69,7 +69,7 @@ import { getPsalmVerses } from '../psalter'; import { getPsalmsFor, type PsalmRef } from '../psalter/distribution'; import { getScriptureVerses } from '../scripture'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -150,6 +150,15 @@ function plainPsalm(number: number): PsalmPart { }; } +/** `plainPsalm` plus the given antiphon, with the flexa mark (see + * hours/antiphon.ts's `applyFlexaMark`) applied to the first verse against + * that antiphon — a no-op when `antiphon` is undefined. */ +function psalmPartWithAntiphon(number: number, rawAntiphon: ResolvedText | undefined): PsalmPart { + const base = plainPsalm(number); + const { verses, antiphon } = applyFlexaMark(base.verses, rawAntiphon); + return { ...base, antiphon, verses }; +} + function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] { return refs.map((ref) => ({ kind: 'psalm' as const, @@ -167,7 +176,7 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] { const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon')); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return [ - { ...plainPsalm(94), antiphon: opening }, + psalmPartWithAntiphon(94, opening), { kind: 'antiphon', text: full }, ]; } @@ -181,7 +190,7 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon)); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; g.psalms.forEach((n, i) => { - parts.push({ ...plainPsalm(n), antiphon: i === 0 ? opening : undefined }); + parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined)); }); parts.push({ kind: 'antiphon', text: full }); } @@ -282,11 +291,13 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); const opening = isDouble ? full : incipit; 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); parts.push({ kind: 'psalm', psalmNumber: ref.number, - verses: getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })), - antiphon: i === 0 ? opening : undefined, + verses, + antiphon, }); }); parts.push({ kind: 'antiphon', text: full }); diff --git a/src/hours/none.ts b/src/hours/none.ts index 3aa1022..c6a32aa 100644 --- a/src/hours/none.ts +++ b/src/hours/none.ts @@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { const psalmRefs = getPsalmsFor('none', day.weekday); const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday])); 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 })), - })); + 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, + }; + }); } // Not used by None. case 'prayer': diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 7ce44e1..a7cc4ca 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -9,7 +9,7 @@ import { getRegulaReadingFor } from '../regula'; import { getChapterResponsoryId } from './chapter-responsory'; import { getHymnDoxologyId } from './hymn-doxology'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, appendDoxology, @@ -70,12 +70,16 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved // — 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 })), - })); + 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 [ diff --git a/src/hours/sext.ts b/src/hours/sext.ts index 6fdedb7..b012954 100644 --- a/src/hours/sext.ts +++ b/src/hours/sext.ts @@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { const psalmRefs = getPsalmsFor('sext', day.weekday); const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday])); 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 })), - })); + 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, + }; + }); } // Not used by Sext. case 'prayer': diff --git a/src/hours/terce.ts b/src/hours/terce.ts index 48b2e21..97eb054 100644 --- a/src/hours/terce.ts +++ b/src/hours/terce.ts @@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { const psalmRefs = getPsalmsFor('terce', day.weekday); const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday])); 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 })), - })); + 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, + }; + }); } // Not used by Terce. case 'prayer': diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index 3091033..6c1da84 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -4,7 +4,7 @@ import { resolveEveningDay } from '../calendar/vespers'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, @@ -50,12 +50,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] { const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const refs = group.psalms.map(normalizePsalmRef); - const parts: ResolvedPart[] = refs.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 })), - })); + 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 })); + const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); + return { + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon, + verses, + }; + }); parts.push({ kind: 'antiphon', text: full }); return parts; } diff --git a/tests/hours/flexa.test.ts b/tests/hours/flexa.test.ts new file mode 100644 index 0000000..91fce37 --- /dev/null +++ b/tests/hours/flexa.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { resolveOrdo } from '../../src/hours'; + +describe('resolveOrdo("matins", ...) flexa mark on Ps 20', () => { + it("marks where today's opening antiphon leaves off, in both the antiphon itself and Ps 20's first verse (Sunday Matins, Nocturn 1, 2026-08-23)", () => { + const ordo = resolveOrdo('matins', '2026-08-23'); + const ps20 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 20); + expect(ps20).toBeDefined(); + if (ps20?.kind !== 'psalm') throw new Error('expected a psalm part'); + const firstVerse = ps20.verses[0]; + expect(firstVerse?.text.la).toContain('‡'); + expect(firstVerse?.text.en).toContain('‡'); + expect(ps20.antiphon?.text.la).toContain('‡'); + expect(ps20.antiphon?.text.en).toContain('‡'); + }); +});