hours: split Saturday's Deuteronomy canticle back into its own two parts
Deploy / deploy (push) Successful in 48s

Per direct instruction: RB 13 says this canticle "in duas glorias" --
two pieces, each closing with its own Gloria Patri, framed by one
shared antiphon (opening before the first, full only after the
second) -- not joined into one continuous block the way this was
first built. canticleText() now takes an optional verse slice so
Saturday can ask for two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 13:46:20 -04:00
parent 73f9c1aebe
commit 4548042b65
3 changed files with 70 additions and 23 deletions
+8
View File
@@ -96,6 +96,14 @@ saturday:
antiphon: { la: "In veritáte tua * exáudi me, Dómine.", en: "In thy truth, O Lord, * listen unto me." } antiphon: { la: "In veritáte tua * exáudi me, Dómine.", en: "In thy truth, O Lord, * listen unto me." }
canticle: canticle:
id: canticum-moysis-deuteronomii id: canticum-moysis-deuteronomii
# Split across two D.O. Lauds slots (own doc comment on the split, own
# Gloria Patri each -- confirmed live) rather than joined into one
# continuous canticle the way every other weekday's is: per direct
# instruction, this is the older monastic practice, matching how the
# Rule of St. Benedict itself divides it (RB 13's "Canticum de
# Deuteronomio... in duas glorias"). `split: 32` is the verse count of
# the first part (Deut 32:1-32); the rest (32:33-65) is the second.
split: 32
antiphon: { la: "Date magnitúdinem * Deo nostro.", en: "Ascribe greatness * to our God." } antiphon: { la: "Date magnitúdinem * Deo nostro.", en: "Ascribe greatness * to our God." }
laudate: laudate:
antiphon: { la: "In cýmbalis * benesonántibus laudáte Deum.", en: "Praise God * upon the resounding cymbals." } antiphon: { la: "In cýmbalis * benesonántibus laudáte Deum.", en: "Praise God * upon the resounding cymbals." }
+41 -14
View File
@@ -20,7 +20,11 @@ interface LaudsGroup {
} }
interface LaudsWeekdayAntiphons { interface LaudsWeekdayAntiphons {
groups: LaudsGroup[]; groups: LaudsGroup[];
canticle: { id: string; antiphon: BilingualText }; // `split`: verse count of the canticle's first part, when it's said in
// two pieces (own Gloria Patri each) rather than continuously — see
// lauds-antiphons.yml's saturday.canticle comment. Absent every other
// weekday.
canticle: { id: string; antiphon: BilingualText; split?: number };
laudate: { antiphon: BilingualText }; laudate: { antiphon: BilingualText };
} }
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, LaudsWeekdayAntiphons>; const laudsAntiphons = laudsAntiphonsData as Record<Weekday, LaudsWeekdayAntiphons>;
@@ -51,28 +55,32 @@ function psalmParts(numbers: number[], antiphon: BilingualText, opening: Resolve
const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const; const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const;
/** Joins a canticle's verse array into one flowing text block, the same const GLORIA_PATRI = {
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.',
};
/** Joins a canticle's verse array (or a `[start, end)` slice of it — see
* Saturday's split canticle below) into one flowing text block, the same
* shape nunc-dimittis/benedictus already use — Lauds' weekday canticles * shape nunc-dimittis/benedictus already use — Lauds' weekday canticles
* are stored verse-by-verse (see lauds-canticles.ts) purely because that's * are stored verse-by-verse (see lauds-canticles.ts) purely because that's
* how they were transcribed, not because callers need per-verse access. * how they were transcribed, not because callers need per-verse access.
* Appends the fixed Gloria Patri line every canticle ends with in real * Appends the fixed Gloria Patri line every canticle (or canticle part)
* practice, same wording as benedictus.yml's own trailing line. */ * ends with in real practice, same wording as benedictus.yml's own
function canticleText(canticleId: string): ResolvedText { * trailing line. */
function canticleText(canticleId: string, slice?: [number, number]): ResolvedText {
const canticle = getCanticle(canticleId); const canticle = getCanticle(canticleId);
const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses;
const text: Partial<Record<string, string>> = {}; const text: Partial<Record<string, string>> = {};
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {}; const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
const GLORIA = {
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.',
};
for (const lang of ['la', 'en'] as const) { for (const lang of ['la', 'en'] as const) {
const lines = canticle.verses.map((v) => v.text[lang]).filter((t): t is string => Boolean(t)); const lines = verses.map((v) => v.text[lang]).filter((t): t is string => Boolean(t));
if (lines.length === 0) { if (lines.length === 0) {
continue; continue;
} }
text[lang] = `${lines.join('\n')}\n${GLORIA[lang]}`; text[lang] = `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`;
let worst: 'verified' | 'draft' | 'missing' = 'verified'; let worst: 'verified' | 'draft' | 'missing' = 'verified';
for (const v of canticle.verses) { for (const v of verses) {
const s = v.status[lang] ?? 'missing'; const s = v.status[lang] ?? 'missing';
if (STATUS_RANK[s] > STATUS_RANK[worst]) { if (STATUS_RANK[s] > STATUS_RANK[worst]) {
worst = s; worst = s;
@@ -102,13 +110,32 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
parts.push(...psalmParts(group.psalms, group.antiphon, opening(group.antiphon))); parts.push(...psalmParts(group.psalms, group.antiphon, opening(group.antiphon)));
} }
const canticle = getCanticle(wd.canticle.id); const canticle = getCanticle(wd.canticle.id);
const canticleOpening = opening(wd.canticle.antiphon);
const canticleClosing = splitNamedAntiphon(wd.canticle.antiphon).full;
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
// the second) — see lauds-antiphons.yml's saturday.canticle comment.
parts.push({ parts.push({
kind: 'canticle', kind: 'canticle',
canticleId: canticle.id, canticleId: canticle.id,
antiphon: opening(wd.canticle.antiphon), antiphon: canticleOpening,
text: canticleText(canticle.id, [0, wd.canticle.split]),
});
parts.push({
kind: 'canticle',
canticleId: canticle.id,
text: canticleText(canticle.id, [wd.canticle.split, canticle.verses.length]),
});
} else {
parts.push({
kind: 'canticle',
canticleId: canticle.id,
antiphon: canticleOpening,
text: canticleText(canticle.id), text: canticleText(canticle.id),
}); });
parts.push({ kind: 'antiphon', text: splitNamedAntiphon(wd.canticle.antiphon).full }); }
parts.push({ kind: 'antiphon', text: canticleClosing });
parts.push(...psalmParts([148, 149, 150], wd.laudate.antiphon, opening(wd.laudate.antiphon))); parts.push(...psalmParts([148, 149, 150], wd.laudate.antiphon, opening(wd.laudate.antiphon)));
return parts; return parts;
} }
+16 -4
View File
@@ -57,7 +57,7 @@ describe('resolveOrdo("lauds", ...)', () => {
expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum'); expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum');
}); });
it("resolves Saturday's own psalmody: only one weekday psalm (142), and the Canticle of Moses split across two D.O. slots joins into one continuous canticle", () => { it("resolves Saturday's own psalmody: only one weekday psalm (142), and the Canticle of Moses said in two pieces (RB 13's own division), one shared antiphon framing both", () => {
// Any Saturday works here: lauds-psalmody doesn't yet model the // Any Saturday works here: lauds-psalmody doesn't yet model the
// Commune/feast override real practice would apply on a Marian- // Commune/feast override real practice would apply on a Marian-
// Sabbato Saturday (see hours/types.ts's 'lauds-psalmody' doc comment) // Sabbato Saturday (see hours/types.ts's 'lauds-psalmody' doc comment)
@@ -69,9 +69,21 @@ describe('resolveOrdo("lauds", ...)', () => {
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); .map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
expect(psalmNumbers).toEqual([66, 50, 142, 148, 149, 150]); expect(psalmNumbers).toEqual([66, 50, 142, 148, 149, 150]);
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus'); const canticles = ordo.parts.filter((p) => p.kind === 'canticle' && p.canticleId === 'canticum-moysis-deuteronomii');
expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-moysis-deuteronomii'); expect(canticles.length).toBe(2);
expect(canticle?.kind === 'canticle' ? canticle.text.text.la : undefined).toContain('Audíte, cæli'); const [part1, part2] = canticles;
expect(part1?.kind === 'canticle' ? part1.antiphon?.text.la : undefined).toBe('Ant. Date magnitúdinem');
expect(part2?.kind === 'canticle' ? part2.antiphon : 'should be undefined -- shares part 1s opening antiphon').toBeUndefined();
expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Audíte, cæli');
expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Ignis succénsus est');
// Each part gets its own Gloria Patri.
expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Glória Patri');
expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Glória Patri');
const repeatIndex = ordo.parts.indexOf(part2!) + 1;
const repeat = ordo.parts[repeatIndex];
expect(repeat?.kind).toBe('antiphon');
expect(repeat?.kind === 'antiphon' ? repeat.text.text.la : undefined).toBe('Ant. Date magnitúdinem * Deo nostro.');
}); });
it('gives the Laudate psalms (148-150) one shared antiphon, opening before 148 and repeated in full after 150', () => { it('gives the Laudate psalms (148-150) one shared antiphon, opening before 148 and repeated in full after 150', () => {