Author Benedictus antiphon + P/T/S/N Commons for 66 propers:null saints
Deploy / deploy (push) Successful in 56s

Continues the 94-saint batch this collects pass unblocked. Raw
Commune/*.txt fields don't reliably match what the engine actually
renders per hour -- same lesson as the collects pass, confirmed again:
no CommuneM/*.txt file defines explicit per-hour antiphon fields for any
category this batch needed, and the already-authored
common-of-a-confessor-bishop content (from the original 29-saint pass)
doesn't match Commune/C4.txt's own raw [Ant 1] either, since it was
live-query-sourced rather than read from the file directly. Fixed by
querying web/cgi-bin/horas/officium.pl directly (local Perl, not a
network fetch) for one representative saint per Common category, with
every representative's title verified against an accent-insensitive
name-stem match before its content is trusted -- caught a stale-date bug
this way: St. Emerentiana's stored clean-year comment actually rendered
St. Hilary of Poitiers's office that year.

Cross-querying multiple saints per nominal SaintRecord.common category
found the real liturgical groupings are coarser (Martyr-Bishop and
Martyr; Abbot, Confessor-not-Bishop, and Confessor; Virgin-Martyr,
Virgin, and Holy Woman all render identical minor-hour text) but not
uniformly so (Pope-Martyrs render distinct allelúja-toned text from
plain Several-Martyrs; the Dedication of the Basilicas of Ss. Peter and
Paul draws from the Dedication Common, not its nominal
common-of-an-apostle at all). Net: 7 new shared Common categories plus
the pre-existing common-of-a-confessor-bishop (which also needed a new
Benedictus-antiphon file of its own) cover 66 of the 94 saints.

New mechanism: SaintRecord.benedictusCommon, with getBenedictusAntiphon
falling back to it the same way getDayCollect already falls back to
collectCommon -- no name-substitution needed here, since every Common
category in current use renders name-free Benedictus-antiphon text.

Also fixes a real bug this surfaced: getMinorHourOverrideId gated all
Prime/Terce/Sext/None lookup behind the same duplex-majus+ threshold
Lauds' own psalmody override uses, on the assumption that only a strong
feast carries its own minor-hour content -- live-querying proved that
wrong (a winning Semiduplex saint still renders its own antiphons, not
the ferial default). Lauds' psalmody override itself stays majus+-gated
correctly, since it's a much bigger per-saint commitment this batch
doesn't add a Common fallback for.

Ss. Ursula and Companions deliberately left without a minor-hour Common:
her collect template has no other calendar member to verify a shared
text against, and she can never be queried directly (a genuinely
unwinnable stub) -- left honestly unauthored rather than guessed.

The other 26 of the 94 (own proper collect, from the collects batch)
still need their own individually-authored Benedictus antiphon, same
shape as the original 109-saint pass -- not done in this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 08:58:13 -04:00
parent 6bb4c6a80f
commit ffaa797940
128 changed files with 1224 additions and 21 deletions
+35 -18
View File
@@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types'
import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, isAtLeast } from '../calendar';
import { resolveActiveOctave } from '../calendar';
import { splitAntiphon } from './antiphon';
function toResolvedText(proper: ProperText): ResolvedText {
@@ -93,30 +93,39 @@ export function isSundayOrFeastOffice(day: LiturgicalDay): boolean {
}
/**
* Whether today's office winner (resolveOfficeWinner) is a strong enough
* feast to carry its own proper minor-hour content (antiphon + chapter)
* — duplex-majus+, the same threshold Lauds' own psalmody override uses
* (hours/lauds.ts's getPsalmodyOverrideFor), kept in sync deliberately:
* as each remaining duplex-majus+ saint gets a Lauds override authored,
* the same id is what Prime/Terce/Sext/None would look for too, so it's
* one shared backlog (see TODO.md), not per-hour lists that can drift.
* The id to look up for today's office winner's (resolveOfficeWinner)
* minor-hour content (antiphon + chapter) — `${hourId}-antiphon-${id}`/
* `${hourId}-capitulum-${id}` first (a per-saint proper file, only ever
* authored for duplex-majus+ saints in practice), then that saint's
* `minorHoursCommon` fallback (see resolveMinorHourAntiphon/Chapter
* below), tried regardless of rank: live-querying the reference engine
* directly (2026-08, the 94 `propers: null` saints' P/T/S/N pass)
* confirmed a winning Simplex/Semiduplex saint's own minor hours *do*
* carry proper/Common antiphons, not the plain ferial default — a real
* liturgical fact, not an app gap. Any sanctoral winner is therefore
* eligible here; it's `resolveMinorHourAntiphon`/`resolveMinorHourChapter`
* that decide, per hour, whether anything's actually been authored for
* that id (falling through to the weekday default when not). Until
* 2026-08 this was gated at duplex-majus+ (the same threshold Lauds'
* own *psalmody* override uses, hours/lauds.ts's getPsalmodyOverrideFor)
* — that threshold remains correct for the *psalmody* override
* specifically (a much bigger authored-content commitment, still
* majus+-only), but was wrongly reused here as if it were a general
* "is this saint's office strong enough" rule.
* Also honors ALWAYS_OVERRIDE_TEMPORAL_IDS the same way
* getPsalmodyOverrideFor does — a named temporal feast (`christ-the-
* king`, `christmas-octave-sunday`) is eligible regardless of rank, since
* it has no FeastClass to compare against duplex-majus in the first
* place. Missing until 2026-08: this function only checked the sanctoral
* branch, so a temporal id in that set got a Lauds psalmody override but
* silently no Prime/Terce/Sext/None one, however much was authored for
* it — a real gap, not by design.
* Returns the id to look up (`${hourId}-antiphon-${id}` etc.), or
* `undefined` when nothing eligible is happening today.
* it has no FeastClass to compare against in the first place.
* Returns the id to look up, or `undefined` when nothing sanctoral or
* named-temporal is happening today (a plain temporal day just stays on
* its own weekday default, same as always).
*/
export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined {
const winner = resolveOfficeWinner(day);
if (winner.kind === 'temporal' && ALWAYS_OVERRIDE_TEMPORAL_IDS.has(winner.id)) {
return winner.id;
}
if (winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus')) {
if (winner.kind === 'sanctoral') {
return winner.id;
}
return undefined;
@@ -329,7 +338,9 @@ export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
* pull, sourced from each saint's own raw [Ant 1] — see e.g.
* st-lawrence-antiphon.yml) for a sanctoral winner, `${id}-benedictus-
* antiphon` (not authored yet for any temporal id — resolves "missing",
* same pending convention as everywhere else) for a temporal one. Uses
* same pending convention as everywhere else) for a temporal one, then
* `saint.benedictusCommon` (`benedictus-antiphon-${benedictusCommon}.yml`,
* see SaintRecord's own doc comment) when neither exists. Uses
* resolveOfficeWinner, not the raw `day.winner` — on an octave day this
* is the octave's own feast's antiphon (live-verified: St. Lawrence's
* "In cratícula", not the commemorated St. Clare's, and not the plain
@@ -343,7 +354,13 @@ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText {
}
const saint = getSaintRecord(winner.id);
if (saint?.propers) {
return resolveCommon(`${saint.propers}-antiphon`);
const proper = resolveCommon(`${saint.propers}-antiphon`);
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
return proper;
}
}
if (saint?.benedictusCommon) {
return resolveCommon(`benedictus-antiphon-${saint.benedictusCommon}`);
}
return { text: {}, status: { la: 'missing', en: 'missing' } };
}