Compare commits
2 Commits
9fb9cb5e24
...
7858692fa7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7858692fa7 | |||
| 72b30fad80 |
@@ -1,5 +1,6 @@
|
|||||||
import type { DayWinner } from '../calendar/types';
|
import type { DayWinner } from '../calendar/types';
|
||||||
import { isAtLeast } from '../calendar/commemorations';
|
import { isAtLeast } from '../calendar/commemorations';
|
||||||
|
import type { ResolvedVerse, ResolvedText } from './types';
|
||||||
|
|
||||||
export interface SplitAntiphon {
|
export interface SplitAntiphon {
|
||||||
incipit: string;
|
incipit: string;
|
||||||
@@ -40,3 +41,128 @@ export function splitAntiphon(text: string): SplitAntiphon {
|
|||||||
export function isDoubleOrHigher(winner: DayWinner): boolean {
|
export function isDoubleOrHigher(winner: DayWinner): boolean {
|
||||||
return winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex');
|
return winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a word for flexa-boundary comparison only (never used for
|
||||||
|
* display text): strips chant/sentence punctuation and folds accented Latin
|
||||||
|
* vowels and digraphs to their plain form, mirroring Divinum Officium's own
|
||||||
|
* `depunct` (see `getantcross` in the reference engine's horas.pl).
|
||||||
|
*/
|
||||||
|
function depunctFold(word: string): string {
|
||||||
|
return word
|
||||||
|
.replace(/[.,:;?!"'*()]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[áà]/g, 'a')
|
||||||
|
.replace(/[éè]/g, 'e')
|
||||||
|
.replace(/[íì]/g, 'i')
|
||||||
|
.replace(/[óòöõ]/g, 'o')
|
||||||
|
.replace(/[úùüû]/g, 'u')
|
||||||
|
.replace(/[æǽ]/g, 'ae')
|
||||||
|
.replace(/œ/g, 'oe')
|
||||||
|
.replace(/j/g, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds where, within a psalm's first verse, the words already said as the
|
||||||
|
* opening antiphon (in whichever form — incipit or full — is actually being
|
||||||
|
* displayed) leave off and the rest of the verse resumes. This is the flexa
|
||||||
|
* mark's boundary: ported from Divinum Officium's `getantcross`/`depunct`
|
||||||
|
* (horas.pl), which computes this at render time rather than storing it as
|
||||||
|
* content, since it depends on which antiphon form the day's rank selects.
|
||||||
|
*
|
||||||
|
* Returns the character offset into `verseText` to insert the mark at, the
|
||||||
|
* literal `'full'` when the antiphon's words consume the entire verse (the
|
||||||
|
* mark then belongs at the start of the *next* verse instead — dangling it
|
||||||
|
* at the end of this one would be meaningless), or `null` when there's no
|
||||||
|
* word-for-word match (including when the antiphon is longer than the
|
||||||
|
* verse, per the reference engine's own rule) and so no mark should appear.
|
||||||
|
*/
|
||||||
|
export function computeFlexaBoundary(verseText: string, antiphonText: string): number | 'full' | null {
|
||||||
|
const verseWords = [...verseText.matchAll(/\S+/g)];
|
||||||
|
const antWords = antiphonText.split(/\s+/).filter(Boolean);
|
||||||
|
let pind = 0;
|
||||||
|
let aind = 0;
|
||||||
|
|
||||||
|
while (aind < antWords.length && pind < verseWords.length) {
|
||||||
|
const verseWord = verseWords[pind]![0];
|
||||||
|
pind++;
|
||||||
|
const item1 = depunctFold(verseWord);
|
||||||
|
if (!item1) continue;
|
||||||
|
const antWord = antWords[aind]!;
|
||||||
|
aind++;
|
||||||
|
const item2 = depunctFold(antWord);
|
||||||
|
if (!item2) {
|
||||||
|
pind--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item1 !== item2) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aind < antWords.length) return null; // antiphon is longer than the verse
|
||||||
|
|
||||||
|
while (pind < verseWords.length && !depunctFold(verseWords[pind]![0])) pind++;
|
||||||
|
if (pind >= verseWords.length) return 'full';
|
||||||
|
|
||||||
|
return verseWords[pind]!.index!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlexaMarked {
|
||||||
|
verses: ResolvedVerse[];
|
||||||
|
antiphon: ResolvedText | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the flexa boundary (see `computeFlexaBoundary`) to a psalm's
|
||||||
|
* verses, per language, based on the antiphon actually being displayed for
|
||||||
|
* it — and, so the same boundary reads consistently at both ends of it, also
|
||||||
|
* appends the mark to the end of the displayed antiphon text itself (the
|
||||||
|
* reference engine only ever marks the psalm side — `antetpsalm` in its
|
||||||
|
* `specials/psalmi.pl` pushes the antiphon's own display line before ever
|
||||||
|
* computing the psalm-side boundary — but showing the mark at the close of
|
||||||
|
* the antiphon too, not just where it resumes in the psalm, is this
|
||||||
|
* project's own deliberate addition, by direct instruction).
|
||||||
|
*
|
||||||
|
* A no-op whenever there's no antiphon (only the first psalm in a set
|
||||||
|
* carries one — see each hour's `resolvePart`) or no verses to mark.
|
||||||
|
*/
|
||||||
|
export function applyFlexaMark(verses: ResolvedVerse[], antiphon: ResolvedText | undefined): FlexaMarked {
|
||||||
|
const first = verses[0];
|
||||||
|
if (!antiphon || !first) return { verses, antiphon };
|
||||||
|
const second = verses[1];
|
||||||
|
|
||||||
|
let firstText = first.text;
|
||||||
|
let secondText = second?.text;
|
||||||
|
let antiphonText = antiphon.text;
|
||||||
|
|
||||||
|
for (const lang of Object.keys(antiphon.text)) {
|
||||||
|
const rawAntText = antiphon.text[lang];
|
||||||
|
if (!rawAntText) continue;
|
||||||
|
// splitNamedAntiphon bakes an "Ant. " display label directly into the
|
||||||
|
// text (same convention as "V."/"R." on versicles) — strip it before
|
||||||
|
// comparing, since it's never part of the psalm's own opening words.
|
||||||
|
const antText = rawAntText.replace(/^Ant\.\s+/, '');
|
||||||
|
const verseText = first.text[lang];
|
||||||
|
if (!antText || !verseText) continue;
|
||||||
|
const boundary = computeFlexaBoundary(verseText, antText);
|
||||||
|
if (boundary === null) continue;
|
||||||
|
|
||||||
|
antiphonText = { ...antiphonText, [lang]: `${rawAntText} ‡` };
|
||||||
|
|
||||||
|
if (boundary === 'full') {
|
||||||
|
const nextText = second?.text[lang];
|
||||||
|
if (nextText) secondText = { ...secondText, [lang]: `‡ ${nextText}` };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
firstText = { ...firstText, [lang]: `${verseText.slice(0, boundary)}‡ ${verseText.slice(boundary)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstText === first.text && secondText === second?.text && antiphonText === antiphon.text) {
|
||||||
|
return { verses, antiphon };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [...verses];
|
||||||
|
updated[0] = { ...first, text: firstText };
|
||||||
|
if (second && secondText) updated[1] = { ...second, text: secondText };
|
||||||
|
const updatedAntiphon = antiphonText === antiphon.text ? antiphon : { ...antiphon, text: antiphonText };
|
||||||
|
return { verses: updated, antiphon: updatedAntiphon };
|
||||||
|
}
|
||||||
|
|||||||
+9
-5
@@ -8,7 +8,7 @@ import { getCanticle } from './lauds-canticles';
|
|||||||
import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides';
|
import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
|
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollects,
|
getDayCollects,
|
||||||
@@ -81,12 +81,16 @@ function capitulumId(weekday: Weekday): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] {
|
function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] {
|
||||||
const parts: ResolvedPart[] = numbers.map((number, i) => ({
|
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,
|
kind: 'psalm' as const,
|
||||||
psalmNumber: number,
|
psalmNumber: number,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon: psalmAntiphon,
|
||||||
verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full });
|
parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full });
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -69,7 +69,7 @@ import { getPsalmVerses } from '../psalter';
|
|||||||
import { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
|
import { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
|
||||||
import { getScriptureVerses } from '../scripture';
|
import { getScriptureVerses } from '../scripture';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollect,
|
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[] {
|
function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] {
|
||||||
return refs.map((ref) => ({
|
return refs.map((ref) => ({
|
||||||
kind: 'psalm' as const,
|
kind: 'psalm' as const,
|
||||||
@@ -167,7 +176,7 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
|
|||||||
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
|
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
return [
|
return [
|
||||||
{ ...plainPsalm(94), antiphon: opening },
|
psalmPartWithAntiphon(94, opening),
|
||||||
{ kind: 'antiphon', text: full },
|
{ kind: 'antiphon', text: full },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -181,7 +190,7 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP
|
|||||||
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon));
|
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon));
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
g.psalms.forEach((n, i) => {
|
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 });
|
parts.push({ kind: 'antiphon', text: full });
|
||||||
}
|
}
|
||||||
@@ -282,11 +291,13 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
|
|||||||
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
|
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
|
||||||
const opening = isDouble ? full : incipit;
|
const opening = isDouble ? full : incipit;
|
||||||
group.psalms.forEach((ref, i) => {
|
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({
|
parts.push({
|
||||||
kind: 'psalm',
|
kind: 'psalm',
|
||||||
psalmNumber: ref.number,
|
psalmNumber: ref.number,
|
||||||
verses: getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
parts.push({ kind: 'antiphon', text: full });
|
parts.push({ kind: 'antiphon', text: full });
|
||||||
|
|||||||
+9
-5
@@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label';
|
|||||||
import { getPsalmsFor } from '../psalter/distribution';
|
import { getPsalmsFor } from '../psalter/distribution';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollect,
|
getDayCollect,
|
||||||
@@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
|||||||
const psalmRefs = getPsalmsFor('none', day.weekday);
|
const psalmRefs = getPsalmsFor('none', day.weekday);
|
||||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]));
|
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]));
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
return psalmRefs.map((ref, i) => ({
|
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,
|
kind: 'psalm' as const,
|
||||||
psalmNumber: ref.number,
|
psalmNumber: ref.number,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon,
|
||||||
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Not used by None.
|
// Not used by None.
|
||||||
case 'prayer':
|
case 'prayer':
|
||||||
|
|||||||
+9
-5
@@ -9,7 +9,7 @@ import { getRegulaReadingFor } from '../regula';
|
|||||||
import { getChapterResponsoryId } from './chapter-responsory';
|
import { getChapterResponsoryId } from './chapter-responsory';
|
||||||
import { getHymnDoxologyId } from './hymn-doxology';
|
import { getHymnDoxologyId } from './hymn-doxology';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
appendDoxology,
|
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
|
// — see hours/antiphon.ts. The full repeat comes later, after the
|
||||||
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
|
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
return psalmRefs.map((ref, i) => ({
|
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,
|
kind: 'psalm' as const,
|
||||||
psalmNumber: ref.number,
|
psalmNumber: ref.number,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon,
|
||||||
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
case 'psalm':
|
case 'psalm':
|
||||||
return [
|
return [
|
||||||
|
|||||||
+9
-5
@@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label';
|
|||||||
import { getPsalmsFor } from '../psalter/distribution';
|
import { getPsalmsFor } from '../psalter/distribution';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollect,
|
getDayCollect,
|
||||||
@@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
|||||||
const psalmRefs = getPsalmsFor('sext', day.weekday);
|
const psalmRefs = getPsalmsFor('sext', day.weekday);
|
||||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]));
|
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]));
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
return psalmRefs.map((ref, i) => ({
|
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,
|
kind: 'psalm' as const,
|
||||||
psalmNumber: ref.number,
|
psalmNumber: ref.number,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon,
|
||||||
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Not used by Sext.
|
// Not used by Sext.
|
||||||
case 'prayer':
|
case 'prayer':
|
||||||
|
|||||||
+9
-5
@@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label';
|
|||||||
import { getPsalmsFor } from '../psalter/distribution';
|
import { getPsalmsFor } from '../psalter/distribution';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollect,
|
getDayCollect,
|
||||||
@@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
|||||||
const psalmRefs = getPsalmsFor('terce', day.weekday);
|
const psalmRefs = getPsalmsFor('terce', day.weekday);
|
||||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]));
|
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]));
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
return psalmRefs.map((ref, i) => ({
|
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,
|
kind: 'psalm' as const,
|
||||||
psalmNumber: ref.number,
|
psalmNumber: ref.number,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon,
|
||||||
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Not used by Terce.
|
// Not used by Terce.
|
||||||
case 'prayer':
|
case 'prayer':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { resolveEveningDay } from '../calendar/vespers';
|
|||||||
import { getDayLabel } from '../calendar/day-label';
|
import { getDayLabel } from '../calendar/day-label';
|
||||||
import { getPsalmVerses } from '../psalter';
|
import { getPsalmVerses } from '../psalter';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher } from './antiphon';
|
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollects,
|
getDayCollects,
|
||||||
@@ -50,12 +50,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
|
|||||||
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
|
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||||
const refs = group.psalms.map(normalizePsalmRef);
|
const refs = group.psalms.map(normalizePsalmRef);
|
||||||
const parts: ResolvedPart[] = refs.map((ref, i) => ({
|
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,
|
kind: 'psalm' as const,
|
||||||
psalmNumber: ref.number,
|
psalmNumber: ref.number,
|
||||||
antiphon: i === 0 ? opening : undefined,
|
antiphon,
|
||||||
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
verses,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
parts.push({ kind: 'antiphon', text: full });
|
parts.push({ kind: 'antiphon', text: full });
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyFlexaMark, computeFlexaBoundary } from '../../src/hours/antiphon';
|
||||||
|
import type { ResolvedText, ResolvedVerse } from '../../src/hours/types';
|
||||||
|
|
||||||
|
describe('computeFlexaBoundary', () => {
|
||||||
|
it('finds the boundary after an exact word-for-word prefix match', () => {
|
||||||
|
const boundary = computeFlexaBoundary(
|
||||||
|
'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.',
|
||||||
|
'The king rejoices',
|
||||||
|
);
|
||||||
|
expect(boundary).not.toBeNull();
|
||||||
|
expect(boundary).not.toBe('full');
|
||||||
|
const text = 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.';
|
||||||
|
expect(text.slice(boundary as number)).toBe('in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches accented Latin words against their unaccented/depunctuated form', () => {
|
||||||
|
const boundary = computeFlexaBoundary('Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'Dómine');
|
||||||
|
expect(boundary).not.toBeNull();
|
||||||
|
expect(boundary).not.toBe('full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the antiphon is longer than the verse', () => {
|
||||||
|
const boundary = computeFlexaBoundary('Short verse text here.', 'Short verse text here and then some more words');
|
||||||
|
expect(boundary).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the words diverge', () => {
|
||||||
|
const boundary = computeFlexaBoundary('Dixit Dominus Domino meo.', 'Laudate pueri Dominum');
|
||||||
|
expect(boundary).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'full' when the antiphon consumes the entire verse", () => {
|
||||||
|
const boundary = computeFlexaBoundary('Dixit Dominus Domino meo.', 'Dixit Dominus Domino meo.');
|
||||||
|
expect(boundary).toBe('full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips over trailing punctuation-only tokens before the boundary', () => {
|
||||||
|
// A stray standalone punctuation token between the matched words and
|
||||||
|
// the next real word shouldn't become the insertion point itself.
|
||||||
|
const text = 'Alpha beta : gamma delta.';
|
||||||
|
const boundary = computeFlexaBoundary(text, 'Alpha beta');
|
||||||
|
expect(boundary).not.toBeNull();
|
||||||
|
expect(boundary).not.toBe('full');
|
||||||
|
expect(text.slice(boundary as number)).toBe('gamma delta.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyFlexaMark', () => {
|
||||||
|
const verse = (n: number, la: string, en: string): ResolvedVerse => ({
|
||||||
|
n,
|
||||||
|
text: { la, en },
|
||||||
|
status: { la: 'verified', en: 'verified' },
|
||||||
|
});
|
||||||
|
const antiphon = (la: string, en: string): ResolvedText => ({
|
||||||
|
text: { la, en },
|
||||||
|
status: { la: 'verified', en: 'verified' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts the flexa mark into the first verse at the matched boundary, per language', () => {
|
||||||
|
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.')];
|
||||||
|
const result = applyFlexaMark(verses, antiphon('Ant. Dómine', 'Ant. The king rejoices'));
|
||||||
|
expect(result.verses[0]!.text.la).toBe('Dómine, ‡ in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.');
|
||||||
|
expect(result.verses[0]!.text.en).toBe('The king rejoices ‡ in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also appends the mark to the end of the displayed antiphon text, in both languages', () => {
|
||||||
|
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.')];
|
||||||
|
const result = applyFlexaMark(verses, antiphon('Ant. Dómine', 'Ant. The king rejoices'));
|
||||||
|
expect(result.antiphon?.text.la).toBe('Ant. Dómine ‡');
|
||||||
|
expect(result.antiphon?.text.en).toBe('Ant. The king rejoices ‡');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no antiphon is given', () => {
|
||||||
|
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex.', 'The king rejoices in thy strength.')];
|
||||||
|
const result = applyFlexaMark(verses, undefined);
|
||||||
|
expect(result.verses).toBe(verses);
|
||||||
|
expect(result.antiphon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the antiphon does not share opening words with the verse', () => {
|
||||||
|
const verses = [verse(1, 'Beátus vir qui non ábiit in consílio impiórum.', 'Blessed is the man who hath not walked in the counsel of the ungodly.')];
|
||||||
|
const theAntiphon = antiphon('Ant. Fidélia', 'Ant. Faithful');
|
||||||
|
const result = applyFlexaMark(verses, theAntiphon);
|
||||||
|
expect(result.verses).toEqual(verses);
|
||||||
|
expect(result.antiphon).toBe(theAntiphon);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the mark to the start of the second verse when the antiphon matches the entire first verse', () => {
|
||||||
|
const verses = [
|
||||||
|
verse(2, 'Dixit Dóminus Dómino meo:', 'The Lord said to my Lord:'),
|
||||||
|
verse(3, 'Sede a dextris meis.', 'Sit thou at my right hand.'),
|
||||||
|
];
|
||||||
|
const result = applyFlexaMark(verses, antiphon('Ant. Dixit Dóminus Dómino meo:', 'Ant. The Lord said to my Lord:'));
|
||||||
|
expect(result.verses[0]!.text.la).toBe('Dixit Dóminus Dómino meo:');
|
||||||
|
expect(result.verses[1]!.text.la).toBe('‡ Sede a dextris meis.');
|
||||||
|
expect(result.verses[1]!.text.en).toBe('‡ Sit thou at my right hand.');
|
||||||
|
expect(result.antiphon?.text.la).toBe('Ant. Dixit Dóminus Dómino meo: ‡');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('‡');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user