Prime: real content (psalms, Martyrology, Regula) and ordo corrections
Deploy / deploy (push) Failing after 21s
Deploy / deploy (push) Failing after 21s
Pulls in verified content from Divinum Officium rather than placeholders: 17 psalms (2, 6, 7-19, 118, 129), 365 days of the Martyrology, and the full 121-reading Regula cycle, plus the Athanasian Creed. Ordo corrections driven by review against the real engine output: - Capitulum and Preces now pick a Sunday/feast vs. ferial form (calendar/isSundayOrFeast); ferial Preces said every ferial day by choice. - Chapter responsory, hymn doxology, and the opening versicle's Alleluia/Laus tibi all vary by season via a shared resolver (hours/seasonal-propers.ts). - Real Roman Kalends/Nones/Ides Latin dating (calendar/roman-date.ts, verified against 363/365 real Martyrology headings) plus the historical "bis sextus" Feb 29 handling, and the Martyrology's Luna (moon-day) heading (a ported Golden-Number calculation). - Fixed responsory structure (was missing its initial full repeat), weekday psalm antiphons (opening as incipit-or-full by rank, full repeat after the psalms/Creed as its own part, "*" chant mark kept), scripture citations on the capitulum/lectio brevis, and V./R. markers switched from Unicode symbols to plain text for reliable font rendering. - Dropped Pretiosa and the dead-commemoration psalm (129) for time; trimmed section headings down to the ones that are actually named things (Preces, Chapter Office, etc. no longer relabel connective text). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import type { OccurringFeast } from '../calendar/types';
|
||||
|
||||
export interface SplitAntiphon {
|
||||
incipit: string;
|
||||
full: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antiphon text is stored as one string with an embedded "*" marking where
|
||||
* the incipit (the "half antiphon") ends — the same convention Divinum
|
||||
* Officium's own data uses, so no separate half/full fields are needed.
|
||||
*
|
||||
* The "*" is kept in `full` (by explicit choice — it's a meaningful chant
|
||||
* mark, not just an incipit-boundary artifact, even though the reference
|
||||
* engine's own rendering happens to strip it there).
|
||||
*/
|
||||
export function splitAntiphon(text: string): SplitAntiphon {
|
||||
const starIndex = text.indexOf('*');
|
||||
if (starIndex === -1) {
|
||||
return { incipit: text, full: text };
|
||||
}
|
||||
const before = text.slice(0, starIndex).trim();
|
||||
const after = text.slice(starIndex + 1).trim();
|
||||
return { incipit: before.replace(/,$/, '.'), full: `${before} * ${after}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Real practice: the antiphon is said in full both before and after the
|
||||
* psalms on a Double-rank feast or higher; below that, only the incipit is
|
||||
* said before the psalms (the full text is always said after, regardless
|
||||
* of rank).
|
||||
*
|
||||
* FeastRank is an open string with no defined hierarchy yet (see
|
||||
* calendar/types.ts), and `occurring` is always [] until milestone 4 wires
|
||||
* up real feast data — so this can only ever return false today. That's
|
||||
* the *correct* answer for every day currently reachable (a plain ferial
|
||||
* day is below Double), not a stub papering over missing logic; it starts
|
||||
* doing real work the moment FeastRank has an ordering to compare against.
|
||||
*/
|
||||
export function isDoubleOrHigher(_occurring: OccurringFeast[]): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Season, OccurringFeast } from '../calendar/types';
|
||||
import { resolveSeasonalPropersId } from './seasonal-propers';
|
||||
import bySeasonData from '../data/hours/prime-chapter-responsory-by-season.yml';
|
||||
import byFeastData from '../data/hours/prime-chapter-responsory-by-feast.yml';
|
||||
|
||||
const bySeason = bySeasonData as { perAnnum: string; bySeason: Record<string, string> };
|
||||
const byFeast = byFeastData as { byFeastId: Record<string, string> };
|
||||
|
||||
/** Resolves the common-propers id for the chapter responsory's variable verse. */
|
||||
export function getChapterResponsoryId(season: Season, occurring: OccurringFeast[]): string {
|
||||
return resolveSeasonalPropersId(season, occurring, { ...bySeason, byFeastId: byFeast.byFeastId });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Season, OccurringFeast } from '../calendar/types';
|
||||
import { resolveSeasonalPropersId } from './seasonal-propers';
|
||||
import bySeasonData from '../data/hours/hymn-doxology-by-season.yml';
|
||||
import byFeastData from '../data/hours/hymn-doxology-by-feast.yml';
|
||||
|
||||
const bySeason = bySeasonData as { perAnnum: string; bySeason: Record<string, string> };
|
||||
const byFeast = byFeastData as { byFeastId: Record<string, string> };
|
||||
|
||||
/** Resolves the common-propers id for a hymn's seasonal final doxology stanza. */
|
||||
export function getHymnDoxologyId(season: Season, occurring: OccurringFeast[]): string {
|
||||
return resolveSeasonalPropersId(season, occurring, { ...bySeason, byFeastId: byFeast.byFeastId });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Season, OccurringFeast } from '../calendar/types';
|
||||
import { resolveSeasonalPropersId } from './seasonal-propers';
|
||||
import bySeasonData from '../data/hours/prime-opening-by-season.yml';
|
||||
|
||||
const bySeason = bySeasonData as { perAnnum: string; bySeason: Record<string, string> };
|
||||
|
||||
/** Resolves the common-propers id for the opening versicle's ending (Allelúja vs. Laus tibi). */
|
||||
export function getOpeningVersicleId(season: Season, occurring: OccurringFeast[]): string {
|
||||
return resolveSeasonalPropersId(season, occurring, bySeason);
|
||||
}
|
||||
+127
-27
@@ -1,44 +1,128 @@
|
||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
||||
import { resolveDay } from '../calendar';
|
||||
import { getPsalmNumbersFor } from '../psalter/distribution';
|
||||
import { getPsalm } from '../psalter';
|
||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||
import { resolveDay, isSundayOrFeast } from '../calendar';
|
||||
import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getCommonProper } from '../propers';
|
||||
import { getMartyrologyEntryFor } from '../martyrology';
|
||||
import { getRegulaReadingFor } from '../regula';
|
||||
import { getChapterResponsoryId } from './chapter-responsory';
|
||||
import { getHymnDoxologyId } from './hymn-doxology';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { splitAntiphon, isDoubleOrHigher } from './antiphon';
|
||||
import primeDefinitionData from '../data/hours/prime.yml';
|
||||
import antiphonsData from '../data/hours/prime-antiphons.yml';
|
||||
|
||||
const primeDefinition = primeDefinitionData as HourDefinition;
|
||||
const antiphons = antiphonsData as Record<Weekday, Partial<Record<string, string>>>;
|
||||
|
||||
// No propers data store yet (see plan: "content stores on the horizon") — every
|
||||
// fixed textRef resolves as pending until that lands. Keeping this as an
|
||||
// explicit function (rather than inlining) is exactly the seam that gets
|
||||
// swapped out for a real lookup later.
|
||||
function resolveTextRef(): ResolvedText {
|
||||
return { text: {}, status: { la: 'missing', en: 'missing' } };
|
||||
function resolveCommon(id: string): ResolvedText {
|
||||
const proper = getCommonProper(id);
|
||||
return { text: proper.text, status: proper.status, citation: proper.citation };
|
||||
}
|
||||
|
||||
function resolvePart(part: HourPart, psalmNumbers: number[]): ResolvedPart[] {
|
||||
function verifiedText(text: Partial<Record<string, string>>): ResolvedText {
|
||||
const status: Partial<Record<string, 'verified'>> = {};
|
||||
for (const lang of Object.keys(text)) {
|
||||
status[lang] = 'verified';
|
||||
}
|
||||
return { text, status };
|
||||
}
|
||||
|
||||
const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const;
|
||||
|
||||
/** Joins a hymn's body with its (seasonally-variable) final doxology
|
||||
* stanza, per language — status is the worse of the two per language. */
|
||||
function appendDoxology(body: ResolvedText, doxology: ResolvedText): ResolvedText {
|
||||
const text: Partial<Record<string, string>> = { ...body.text };
|
||||
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = { ...body.status };
|
||||
for (const lang of Object.keys(doxology.text)) {
|
||||
const doxText = doxology.text[lang];
|
||||
if (doxText) {
|
||||
text[lang] = text[lang] ? `${text[lang]}\n\n${doxText}` : doxText;
|
||||
}
|
||||
const bodyStatus = status[lang] ?? 'missing';
|
||||
const doxStatus = doxology.status[lang] ?? 'missing';
|
||||
status[lang] = STATUS_RANK[doxStatus] > STATUS_RANK[bodyStatus] ? doxStatus : bodyStatus;
|
||||
}
|
||||
return { text, status };
|
||||
}
|
||||
|
||||
/** Splits a weekday's stored antiphon (one string per language, each with
|
||||
* an embedded "*") into its incipit and full forms, per language — each
|
||||
* prefixed "Ant. " inline, the same way "V."/"R." are baked directly into
|
||||
* versicle text rather than rendered as a separate UI marker. */
|
||||
function splitWeekdayAntiphon(weekday: Weekday): { incipit: ResolvedText; full: ResolvedText } {
|
||||
const incipitText: Partial<Record<string, string>> = {};
|
||||
const fullText: Partial<Record<string, string>> = {};
|
||||
for (const [lang, text] of Object.entries(antiphons[weekday])) {
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const split = splitAntiphon(text);
|
||||
incipitText[lang] = `Ant. ${split.incipit}`;
|
||||
fullText[lang] = `Ant. ${split.full}`;
|
||||
}
|
||||
return { incipit: verifiedText(incipitText), full: verifiedText(fullText) };
|
||||
}
|
||||
|
||||
function resolvePart(part: HourPart, date: string, day: LiturgicalDay): ResolvedPart[] {
|
||||
switch (part.kind) {
|
||||
case 'hymn':
|
||||
case 'hymn': {
|
||||
const body = resolveCommon(part.textRef.id);
|
||||
const doxology = resolveCommon(getHymnDoxologyId(day.season, day.occurring));
|
||||
return [{ kind: 'hymn', text: appendDoxology(body, doxology) }];
|
||||
}
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
case 'versicle':
|
||||
case 'prayer':
|
||||
return [{ kind: part.kind, text: resolveTextRef() }];
|
||||
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
|
||||
case 'preces':
|
||||
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
|
||||
case 'lesson':
|
||||
return [{ kind: 'lesson', text: resolveCommon(part.textRef.id), label: part.label }];
|
||||
case 'opening-versicle':
|
||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.occurring)) }];
|
||||
case 'creed':
|
||||
// Real Divinum Officium's actual inclusion rule tangles together
|
||||
// rank, commemorations, and version-specific rubrics in a way that
|
||||
// isn't worth replicating exactly — this is the honest simplification:
|
||||
// Sunday and not a Double-or-higher feast. Octaves aren't modeled at
|
||||
// all yet (milestone 4), so they can't suppress it either, same as
|
||||
// isDoubleOrHigher's own limitation.
|
||||
if (day.weekday !== 'sunday' || isDoubleOrHigher(day.occurring)) {
|
||||
return [];
|
||||
}
|
||||
return [{ kind: 'lesson', text: resolveCommon('athanasian-creed'), label: 'Athanasian Creed' }];
|
||||
case 'closing-antiphon':
|
||||
return [{ kind: 'antiphon', text: splitWeekdayAntiphon(day.weekday).full }];
|
||||
case 'variable':
|
||||
// Only 'by-weekday' is meaningful for Prime; by-season/by-feast-rank
|
||||
// don't apply until milestone 4.
|
||||
return psalmNumbers.map((n) => {
|
||||
const psalm = getPsalm(n);
|
||||
return {
|
||||
if (part.resolve === 'by-season') {
|
||||
// Currently only the chapter responsory's verse uses this — see
|
||||
// hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime.
|
||||
return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.occurring)) }];
|
||||
}
|
||||
{
|
||||
const psalmRefs = getPsalmsFor('prime', day.weekday);
|
||||
const { incipit, full } = splitWeekdayAntiphon(day.weekday);
|
||||
// Full text on a Double-or-higher feast, otherwise just the incipit
|
||||
// — see hours/antiphon.ts. The full repeat comes later, after the
|
||||
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
|
||||
const opening = isDoubleOrHigher(day.occurring) ? full : incipit;
|
||||
return psalmRefs.map((ref, i) => ({
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: n,
|
||||
verses: psalm.verses.map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
||||
};
|
||||
});
|
||||
psalmNumber: ref.number,
|
||||
antiphon: i === 0 ? opening : undefined,
|
||||
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
||||
}));
|
||||
}
|
||||
case 'psalm':
|
||||
return [
|
||||
{
|
||||
kind: 'psalm',
|
||||
psalmNumber: part.psalmNumber,
|
||||
verses: getPsalm(part.psalmNumber).verses.map((v) => ({
|
||||
verses: getPsalmVerses(part.psalmNumber, part.verses).map((v) => ({
|
||||
n: v.n,
|
||||
text: v.text,
|
||||
status: v.status,
|
||||
@@ -47,15 +131,31 @@ function resolvePart(part: HourPart, psalmNumbers: number[]): ResolvedPart[] {
|
||||
];
|
||||
case 'canticle':
|
||||
return [{ kind: 'canticle', canticleId: part.canticleId }];
|
||||
case 'lesson':
|
||||
// Prime has no lessons — Matins is where lesson slotting matters.
|
||||
return [];
|
||||
case 'by-day-kind': {
|
||||
const ref = isSundayOrFeast(day) ? part.sundayOrFeastRef : part.ferialRef;
|
||||
if (part.resolvedKind === 'preces') {
|
||||
return [{ kind: 'preces', text: resolveCommon(ref.id), label: part.label }];
|
||||
}
|
||||
return [{ kind: part.resolvedKind, text: resolveCommon(ref.id) }];
|
||||
}
|
||||
case 'martyrology': {
|
||||
const entry = getMartyrologyEntryFor(date);
|
||||
return [{ kind: 'lesson', text: { text: entry.text, status: entry.status }, label: 'Martyrology' }];
|
||||
}
|
||||
case 'rule-reading': {
|
||||
const reading = getRegulaReadingFor(date);
|
||||
const label = reading.label.en ?? reading.label.la;
|
||||
return [
|
||||
{ kind: 'preces', text: resolveCommon('prime-regula-blessing') },
|
||||
{ kind: 'lesson', text: { text: reading.text, status: reading.status }, label },
|
||||
{ kind: 'preces', text: resolveCommon('prime-lesson-close') },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
const day = resolveDay(date);
|
||||
const psalmNumbers = getPsalmNumbersFor('prime', day.weekday);
|
||||
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, psalmNumbers));
|
||||
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, date, day));
|
||||
return { hourId: 'prime', date, parts };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Season, OccurringFeast } from '../calendar/types';
|
||||
|
||||
export interface SeasonalPropersTable {
|
||||
perAnnum: string;
|
||||
bySeason: Record<string, string>;
|
||||
byFeastId?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by everything that picks a common-propers id by season with a
|
||||
* per-feast override (the chapter responsory's verse, the hymn doxology,
|
||||
* and presumably more once milestone 4 lands) — feast overrides win over
|
||||
* season, which wins over the "per annum" default. `occurring` is always
|
||||
* [] until milestone 4, so today this always returns a season match or
|
||||
* perAnnum.
|
||||
*/
|
||||
export function resolveSeasonalPropersId(
|
||||
season: Season,
|
||||
occurring: OccurringFeast[],
|
||||
table: SeasonalPropersTable,
|
||||
): string {
|
||||
for (const feast of occurring) {
|
||||
const override = table.byFeastId?.[feast.id];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
}
|
||||
return table.bySeason[season] ?? table.perAnnum;
|
||||
}
|
||||
+50
-2
@@ -27,14 +27,51 @@ export interface PropersRef {
|
||||
|
||||
export type HourPart =
|
||||
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; textRef: PropersRef }
|
||||
| { kind: 'psalm'; psalmNumber: number; antiphonRef?: PropersRef }
|
||||
// A fixed multi-line cluster of versicles/responses/prayers (Preces, the
|
||||
// monastic "De Officio Capituli" mini-office, the dead-commemoration
|
||||
// responsory/collect) — these don't decompose usefully into individual
|
||||
// hymn/chapter/versicle parts, so they're stored and rendered as one block.
|
||||
// `label`, when given, is shown as a heading (e.g. "Preces", "Pretiosa") —
|
||||
// omitted for parts that are just connective tissue around a reading
|
||||
// (a blessing, a closing dialogue) that don't read as their own section.
|
||||
| { kind: 'preces'; textRef: PropersRef; label?: string }
|
||||
| { kind: 'psalm'; psalmNumber: number; verses?: string; antiphonRef?: PropersRef }
|
||||
| { kind: 'canticle'; canticleId: string; antiphonRef?: PropersRef }
|
||||
| {
|
||||
kind: 'lesson';
|
||||
textRef: PropersRef;
|
||||
label?: string;
|
||||
nocturn?: number;
|
||||
lessonSource?: 'historical' | 'continuous-plan';
|
||||
}
|
||||
// Resolved by date, not by textRef — see src/martyrology. Monastic Prime
|
||||
// reads *tomorrow's* entry (the announcement of the next day's saints).
|
||||
| { kind: 'martyrology' }
|
||||
// Resolved by date via the day->canonical-reading table (RB is read
|
||||
// through 3x/year) — see src/regula.
|
||||
| { kind: 'rule-reading' }
|
||||
// Chooses between two fixed texts by calendar/isSundayOrFeast — e.g.
|
||||
// Prime's capitulum (1 Tim on Sunday/feasts, Zach on ferias). Renders as
|
||||
// a plain 'chapter' or 'preces' ResolvedPart per resolvedKind.
|
||||
| {
|
||||
kind: 'by-day-kind';
|
||||
resolvedKind: 'chapter' | 'preces';
|
||||
label?: string;
|
||||
sundayOrFeastRef: PropersRef;
|
||||
ferialRef: PropersRef;
|
||||
}
|
||||
// The Athanasian Creed — included on Sundays that aren't a Double-or-higher
|
||||
// feast (see calendar/isDoubleOrHigher and hours/prime.ts's 'creed' case
|
||||
// for how far that's actually implemented today).
|
||||
| { kind: 'creed' }
|
||||
// Resolved by season — Allelúja normally, Laus tibi Septuagesima through
|
||||
// Holy Saturday. See hours/opening-versicle.ts.
|
||||
| { kind: 'opening-versicle' }
|
||||
// The full antiphon, repeated after the whole psalm group (and the
|
||||
// Athanasian Creed, on days it's said) — real practice says it once more
|
||||
// in full here, distinct from the incipit-or-full opening attached to the
|
||||
// first psalm. See hours/antiphon.ts.
|
||||
| { kind: 'closing-antiphon' }
|
||||
// Unused by Prime/Compline/the little hours — exercised starting milestone 4.
|
||||
| { kind: 'variable'; resolve: 'by-weekday' | 'by-season' | 'by-feast-rank' };
|
||||
|
||||
@@ -46,8 +83,16 @@ export interface HourDefinition {
|
||||
/** A part with its text actually filled in, ready for the UI to render. */
|
||||
export type ResolvedPart =
|
||||
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText }
|
||||
| { kind: 'preces'; text: ResolvedText; label?: string }
|
||||
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[] }
|
||||
| { kind: 'canticle'; canticleId: string; antiphon?: ResolvedText };
|
||||
| { kind: 'canticle'; canticleId: string; antiphon?: ResolvedText }
|
||||
// A standalone antiphon, said on its own rather than framing a psalm —
|
||||
// see 'closing-antiphon' above.
|
||||
| { kind: 'antiphon'; text: ResolvedText }
|
||||
// Martyrology and Regula both render as a block of prose text with a
|
||||
// label (a fixed section name for Martyrology, a date-derived chapter
|
||||
// title for Regula) rather than a fixed textRef.
|
||||
| { kind: 'lesson'; text: ResolvedText; label?: string };
|
||||
|
||||
export interface ResolvedVerse {
|
||||
n: number;
|
||||
@@ -58,6 +103,9 @@ export interface ResolvedVerse {
|
||||
export interface ResolvedText {
|
||||
text: Partial<Record<string, string>>;
|
||||
status: Partial<Record<string, 'verified' | 'draft' | 'missing'>>;
|
||||
/** e.g. "1 Tim 1:17" — set when the text is a scripture quotation. Always
|
||||
* given in full (book, chapter, verse) rather than just a book name. */
|
||||
citation?: Partial<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user