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." }
canticle:
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." }
laudate:
antiphon: { la: "In cýmbalis * benesonántibus laudáte Deum.", en: "Praise God * upon the resounding cymbals." }
+44 -17
View File
@@ -20,7 +20,11 @@ interface LaudsGroup {
}
interface LaudsWeekdayAntiphons {
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 };
}
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;
/** Joins a canticle's verse array into one flowing text block, the same
* shape nunc-dimittis/benedictus already use — Lauds' weekday canticles
* are stored verse-by-verse (see lauds-canticles.ts) purely because that's
* how they were transcribed, not because callers need per-verse access.
* Appends the fixed Gloria Patri line every canticle ends with in real
* practice, same wording as benedictus.yml's own trailing line. */
function canticleText(canticleId: string): ResolvedText {
const canticle = getCanticle(canticleId);
const text: Partial<Record<string, string>> = {};
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
const GLORIA = {
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
* are stored verse-by-verse (see lauds-canticles.ts) purely because that's
* 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 {
const canticle = getCanticle(canticleId);
const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses;
const text: Partial<Record<string, string>> = {};
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
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) {
continue;
}
text[lang] = `${lines.join('\n')}\n${GLORIA[lang]}`;
text[lang] = `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`;
let worst: 'verified' | 'draft' | 'missing' = 'verified';
for (const v of canticle.verses) {
for (const v of verses) {
const s = v.status[lang] ?? 'missing';
if (STATUS_RANK[s] > STATUS_RANK[worst]) {
worst = s;
@@ -102,13 +110,32 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
parts.push(...psalmParts(group.psalms, group.antiphon, opening(group.antiphon)));
}
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({
kind: 'canticle',
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),
});
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)));
return parts;
}
+16 -4
View File
@@ -57,7 +57,7 @@ describe('resolveOrdo("lauds", ...)', () => {
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
// Commune/feast override real practice would apply on a Marian-
// 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));
expect(psalmNumbers).toEqual([66, 50, 142, 148, 149, 150]);
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus');
expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-moysis-deuteronomii');
expect(canticle?.kind === 'canticle' ? canticle.text.text.la : undefined).toContain('Audíte, cæli');
const canticles = ordo.parts.filter((p) => p.kind === 'canticle' && p.canticleId === 'canticum-moysis-deuteronomii');
expect(canticles.length).toBe(2);
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', () => {