Add Matins psalmody overrides for a Duplex+ weekday feast

matins.ts's three-nocturn gate correctly governed nocturn count
(Sunday, or Duplex+) but wrongly reused the literal Sunday psalmody as
content for every three-nocturn day, including a weekday feast. Live-
verified (Monastic Tridentinum 1617) that a Duplex+ weekday feast has
its own genuinely different psalmody -- confirmed per-Common-category,
not per-saint, by cross-checking St. Lawrence against St. Ignatius of
Antioch (a different Martyr sub-category, nearly identical psalm
numbers, different antiphons).

New mechanism (matins-psalmody-overrides.ts, mirroring the Lauds one
but keyed per-category) with St. Lawrence authored as the live-verified
proof; falls back to the plain ferial weekday table, redistributed into
3 nocturns, for every other Duplex+ id until its own category's content
is authored. Also generalizes the canticle shape to support a citation
spanning multiple scripture chapters under one heading (Lawrence's own
Nocturn 3), and adds the new Sirach/Jeremiah chapters his canticles cite.
This commit is contained in:
2026-08-18 06:11:52 -04:00
parent 1fc4bd7160
commit 3e58f2258d
9 changed files with 399 additions and 7 deletions
+61
View File
@@ -0,0 +1,61 @@
/** Matins' own analogue of hours/lauds-psalmody-overrides.ts, but keyed
* by Common category rather than per-saint: live-verified (see
* data/hours/matins-psalmody-overrides/st-lawrence.yml's own header)
* that the psalm *numbers* for Nocturns 1-2 are shared across saints of
* the same Common category (a plain-Duplex Martyr and a plain-Duplex
* Martyr-Bishop share all but one of the same twelve psalm numbers) —
* only the antiphon text varies, proper to the individual saint when
* authored, generic Common-of-* text otherwise. Deliberately the
* opposite indexing choice from Lauds' own per-feast override (see that
* file's own doc comment) — Lauds' 5-antiphon pool is shared across
* every Duplex-majus+ feast regardless of category, so per-feast is the
* natural key there; Matins' pool varies *by* category, so per-category
* is the natural key here.
*
* `id` is looked up against `hours/resolve-common.ts`'s
* `resolveOfficeWinner(day).id` when it's sanctoral, exactly like every
* other per-feast/per-category content lookup in this app — but the
* `id` values populated so far are individual saint ids (St. Lawrence
* proper), not Common-category ids, since only the live-verified proof
* has been authored; extending this to a real Common-category key
* (`common-of-a-martyr`, etc., matching `SaintRecord.common`) with
* per-saint antiphon overrides layered on top is the deferred bulk-
* content work (see TODO.md) — not built yet, so a saint without their
* own entry here falls back to the plain ferial weekday table
* (matins.ts's own fallback), not to some other saint's proper
* antiphons. */
export interface MatinsPsalmodyGroup {
psalms: number[];
antiphon: Partial<Record<string, string>>;
}
export interface ScriptureCanticleRef {
book: string;
chapter: number;
verses?: string;
}
export interface MatinsPsalmodyNocturn {
groups?: MatinsPsalmodyGroup[];
canticles?: { refs: ScriptureCanticleRef[] }[];
antiphon?: Partial<Record<string, string>>;
versicle: { v: Partial<Record<string, string>>; r: Partial<Record<string, string>> };
}
export interface MatinsPsalmodyOverride {
id: string;
nocturn1: MatinsPsalmodyNocturn;
nocturn2: MatinsPsalmodyNocturn;
nocturn3: MatinsPsalmodyNocturn;
}
const modules = import.meta.glob<{ default: MatinsPsalmodyOverride }>(
'../data/hours/matins-psalmody-overrides/*.yml',
{ eager: true },
);
const overrides = new Map<string, MatinsPsalmodyOverride>();
for (const mod of Object.values(modules)) {
overrides.set(mod.default.id, mod.default);
}
export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyOverride | undefined {
return id ? overrides.get(id) : undefined;
}
+54 -4
View File
@@ -69,6 +69,7 @@ import { resolveCommon, getDayCollect, resolveOfficeWinner, verifiedText, splitN
import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings';
import { getMatinsPsalmodyOverride } from './matins-psalmody-overrides';
import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml';
type BilingualText = Partial<Record<string, string>>;
@@ -76,9 +77,20 @@ interface SundayGroup {
psalms: number[];
antiphon: BilingualText;
}
interface ScriptureRef {
book: string;
chapter: number;
verses?: string;
}
interface SundayNocturn {
groups?: SundayGroup[];
canticles?: { book: string; chapter: number; verses?: string }[];
// Usually one ref per canticle; more than one when the source cites a
// single canticle across a chapter boundary (e.g. St. Lawrence's own
// Nocturn 3, "Eccli 14:22;15:3-4;15:6" — one canticle, two Sirach
// chapters) — concatenated in order, not rendered as separate
// canticles, matching how the source itself presents it as one entry
// under one heading.
canticles?: { refs: ScriptureRef[] }[];
antiphon?: BilingualText;
versicle: { v: BilingualText; r: BilingualText };
}
@@ -144,14 +156,14 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {}));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => {
const verses = getScriptureVerses(c.book, c.chapter, c.verses);
const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
const text: BilingualText = {
la: verses.map((v) => v.text.la).filter(Boolean).join(' '),
en: verses.map((v) => v.text.en).filter(Boolean).join(' '),
};
return {
kind: 'canticle' as const,
canticleId: `${c.book}-${c.chapter}${c.verses ? `-${c.verses}` : ''}`,
canticleId: c.refs.map((ref) => `${ref.book}-${ref.chapter}${ref.verses ? `-${ref.verses}` : ''}`).join('_'),
text: verifiedText(text),
antiphon: i === 0 ? opening : undefined,
};
@@ -165,6 +177,20 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
return psalmRefParts(getPsalmsFor('matins', day.weekday));
}
/** Fallback for a Duplex+ weekday feast with no matins-psalmody-overrides
* entry authored for its winner yet (most of them, until the bulk-content
* pass — see hours/matins-psalmody-overrides.ts's own doc comment): the
* plain ferial weekday table, chunked into 3 nocturns instead of 1 (no
* antiphons, no canticles — that content doesn't exist for the ferial
* table at all, unlike a real per-category override). Direct instruction:
* never blank, but honestly not that saint's own real proper psalmody
* until it's authored. */
function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], ResolvedPart[], ResolvedPart[]] {
const psalms = ferialPsalmody(day);
const size = Math.ceil(psalms.length / 3);
return [psalms.slice(0, size), psalms.slice(size, size * 2), psalms.slice(size * 2)];
}
/** Every id whose own patristic/hagiographic/Gospel content should be
* gathered for `day` — the office winner (if sanctoral), every
* commemorated saint (a transferred-in feast already appears as `day.winner`
@@ -279,7 +305,7 @@ export function resolveOrdo(date: string): ResolvedOrdo {
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
];
if (threeNocturns) {
if (threeNocturns && day.weekday === 'sunday') {
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
parts.push(...(nocturn1Readings ?? []));
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
@@ -287,6 +313,30 @@ export function resolveOrdo(date: string): ResolvedOrdo {
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
parts.push(...(nocturn3Readings ?? []));
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else if (threeNocturns) {
// A Duplex+ weekday feast — genuinely different psalmody from a real
// Sunday's (see hours/matins-psalmody-overrides.ts's own doc
// comment), not the same content reused. Uses that saint's own
// Common-category override when authored, falling back to the plain
// ferial weekday table (redistributed into 3 nocturns) otherwise.
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
if (override) {
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
parts.push(...(nocturn1Readings ?? []));
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
parts.push(...(nocturn2Readings ?? []));
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
parts.push(...(nocturn3Readings ?? []));
} else {
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
parts.push(...n1);
parts.push(...(nocturn1Readings ?? []));
parts.push(...n2);
parts.push(...(nocturn2Readings ?? []));
parts.push(...n3);
parts.push(...(nocturn3Readings ?? []));
}
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else {
parts.push(...ferialPsalmody(day));
parts.push(...(nocturn1Readings ?? []));