Add reusable Matins building blocks: types, content stores, seed data
New ResolvedPart kinds (nocturn-psalmody, te-deum) and lesson fields (nocturn, source, isGospel, responsory) in hours/types.ts. Three new content-store modules: propers/bible-plan.ts (the user's own continuous scripture-reading plan, replacing the historical Nocturn-1 lectionary), propers/nocturn-readings.ts (general feast/temporal-day Nocturn 2-3 readings, extending propers/octave-readings.ts's combine-into-one-reading pattern via a newly-exported resolvePassages), and propers/matins-responsories.ts (a per-book responsory pool, matched loosely rather than by exact citation). Data: Sunday's fixed 12-psalm/3-canticle psalmody (live-verified against the reference engine), the invitatory antiphon, ferial hymn and capitulum, the Te Deum, a seeded Isaiah responsory, two live-transcribed scripture chapters, and the two bible-plan rows needed for this pass' proof content.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// The user's own continuous scripture-reading plan for Matins Nocturn 1 —
|
||||
// deliberately not the historical per-day lectionary (see hours/matins.ts's
|
||||
// header and TODO.md's Matins section): a personal year-round reading plan,
|
||||
// keyed by the same (temporalId, weekday) pair every other content store in
|
||||
// this project already uses to identify a day, sourced from a TSV the user
|
||||
// maintains outside this repo. Replaces the historical Nocturn-1 lesson
|
||||
// count entirely — a *variable* number of readings per day, no RB summer
|
||||
// contraction (see calendar/temporal-id.ts's resolveTemporalId for the
|
||||
// id scheme this keys off).
|
||||
//
|
||||
// Only a small, growable subset is authored so far — this is the mechanism
|
||||
// build, not the full-calendar content pass (~390 rows total in the
|
||||
// source TSV); see TODO.md for what's deferred. Every entry not yet
|
||||
// authored here simply resolves to no readings (honest absence, not a
|
||||
// placeholder) — matins.ts falls back to whatever nocturn-2/3 content
|
||||
// exists for the day, or renders nothing for Nocturn 1 on a day with
|
||||
// neither.
|
||||
import type { LanguageCode, TranslationStatus } from '../psalter/types';
|
||||
import { resolvePassages, type ScriptureCitation } from './octave-readings';
|
||||
import { getResponsoryForBook } from './matins-responsories';
|
||||
|
||||
const GOSPEL_BOOKS = new Set(['matt', 'mark', 'luke', 'john']);
|
||||
|
||||
export interface BiblePlanReading {
|
||||
text: Partial<Record<LanguageCode, string>>;
|
||||
status: Partial<Record<LanguageCode, TranslationStatus>>;
|
||||
citation: Partial<Record<LanguageCode, string>>;
|
||||
/** True when every passage in this reading is from one of the four
|
||||
* Gospels — the user's own plan deliberately never assigns one on a
|
||||
* Sunday (confirmed 2026-08, not a gap) — see hours/matins.ts for how
|
||||
* this combines with the day's own proper Gospel+homily, when one
|
||||
* exists, rather than replacing it. */
|
||||
isGospel: boolean;
|
||||
/** Matched loosely by the reading's own (first) book against
|
||||
* data/hours/matins-responsories-by-book.yml — undefined when nothing's
|
||||
* seeded for that book yet, an honest absence, not a placeholder. */
|
||||
responsory?: Partial<Record<LanguageCode, string>>;
|
||||
}
|
||||
|
||||
interface BiblePlanReadingRecord {
|
||||
passages: ScriptureCitation[];
|
||||
/** Set explicitly when the cited passages aren't in src/data/scripture
|
||||
* yet (the common case until the full Vulgate/Douay-Rheims import
|
||||
* lands) — mirrors octave-readings.ts's own convention rather than
|
||||
* inferring "missing" from empty resolved text, since a genuinely short
|
||||
* authored passage could otherwise look indistinguishable from an
|
||||
* unauthored one. */
|
||||
status?: Partial<Record<LanguageCode, TranslationStatus>>;
|
||||
}
|
||||
|
||||
interface BiblePlanDayRecord {
|
||||
temporalId: string;
|
||||
weekday: string;
|
||||
readings: BiblePlanReadingRecord[];
|
||||
}
|
||||
|
||||
function citationLabel(citation: ScriptureCitation): string {
|
||||
const book = citation.book.charAt(0).toUpperCase() + citation.book.slice(1);
|
||||
return citation.verses ? `${book} ${citation.chapter}:${citation.verses}` : `${book} ${citation.chapter}`;
|
||||
}
|
||||
|
||||
function isGospelReading(passages: ScriptureCitation[]): boolean {
|
||||
return passages.every((p) => GOSPEL_BOOKS.has(p.book));
|
||||
}
|
||||
|
||||
function resolveReading(record: BiblePlanReadingRecord, bookIndex: number): BiblePlanReading {
|
||||
const text = resolvePassages(record.passages);
|
||||
const status = record.status ?? {
|
||||
la: text.la ? 'verified' : 'missing',
|
||||
en: text.en ? 'verified' : 'missing',
|
||||
};
|
||||
const label = record.passages.map(citationLabel).join('; ');
|
||||
const firstBook = record.passages[0]?.book;
|
||||
const responsory = firstBook ? getResponsoryForBook(firstBook, bookIndex)?.text : undefined;
|
||||
return {
|
||||
text,
|
||||
status,
|
||||
citation: { la: label, en: label },
|
||||
isGospel: isGospelReading(record.passages),
|
||||
responsory,
|
||||
};
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<{ default: BiblePlanDayRecord }>('../data/hours/bible-plan/*.yml', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const readingsByKey = new Map<string, BiblePlanReading[]>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
const { temporalId, weekday, readings } = mod.default;
|
||||
// Cycles independently per book (not per reading) so two same-day
|
||||
// readings from the same book don't collide on the pool's first entry —
|
||||
// see matins-responsories.ts's own cycling doc comment.
|
||||
const seenPerBook = new Map<string, number>();
|
||||
const resolved = readings.map((record) => {
|
||||
const book = record.passages[0]?.book ?? '';
|
||||
const index = seenPerBook.get(book) ?? 0;
|
||||
seenPerBook.set(book, index + 1);
|
||||
return resolveReading(record, index);
|
||||
});
|
||||
readingsByKey.set(`${temporalId}-${weekday}`, resolved);
|
||||
}
|
||||
|
||||
/** Empty array, not undefined, when nothing's authored for this day yet —
|
||||
* every caller already treats "no readings" as a valid, renderable state
|
||||
* (see hours/matins.ts), so there's no separate "missing entirely" signal
|
||||
* to preserve here the way getOctaveReading needs `undefined` for. */
|
||||
export function getBiblePlanReadings(temporalId: string, weekday: string): BiblePlanReading[] {
|
||||
return readingsByKey.get(`${temporalId}-${weekday}`) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// The per-book Matins responsory pool — see
|
||||
// data/hours/matins-responsories-by-book.yml's own header for why matching
|
||||
// is book-level, not citation-level, and why this is a seeded subset, not
|
||||
// a full pool.
|
||||
import type { LanguageCode, TranslationStatus } from '../psalter/types';
|
||||
|
||||
interface BilingualText {
|
||||
la?: string;
|
||||
en?: string;
|
||||
}
|
||||
|
||||
interface ResponsoryRecord {
|
||||
r: BilingualText;
|
||||
v: BilingualText;
|
||||
}
|
||||
|
||||
/** Shaped like hours/types.ts's ResolvedText, but this module doesn't
|
||||
* import from hours/ (propers/ is lower in the dependency graph) — callers
|
||||
* assemble the real ResolvedText themselves. The responsory's versicle is
|
||||
* folded into `text` as a second line (R./V. read together as one block in
|
||||
* this app's rendering, same convention as the octave-readings responsory
|
||||
* field). */
|
||||
export interface MatinsResponsory {
|
||||
text: Partial<Record<LanguageCode, string>>;
|
||||
status: Partial<Record<LanguageCode, TranslationStatus>>;
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<{ default: Record<string, ResponsoryRecord[]> }>(
|
||||
'../data/hours/matins-responsories-by-book.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const poolByBook = new Map<string, ResponsoryRecord[]>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
for (const [book, entries] of Object.entries(mod.default)) {
|
||||
poolByBook.set(book, entries);
|
||||
}
|
||||
}
|
||||
|
||||
function joinResponsory(record: ResponsoryRecord): MatinsResponsory {
|
||||
const la = record.r.la && record.v.la ? `${record.r.la}\n${record.v.la}` : record.r.la;
|
||||
const en = record.r.en && record.v.en ? `${record.r.en}\n${record.v.en}` : record.r.en;
|
||||
return {
|
||||
text: { la, en },
|
||||
status: { la: la ? 'verified' : 'missing', en: en ? 'verified' : 'missing' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Undefined when no responsory is seeded for this book yet — an honest
|
||||
* absence (see hours/types.ts's 'lesson' ResolvedPart doc comment), not a
|
||||
* placeholder. `index` cycles through the pool (e.g. the Nth reading from
|
||||
* this book this nocturn) via modulo, so a short pool still gives every
|
||||
* reading *some* responsory rather than only the first one. */
|
||||
export function getResponsoryForBook(book: string, index: number): MatinsResponsory | undefined {
|
||||
const pool = poolByBook.get(book);
|
||||
if (!pool || pool.length === 0) return undefined;
|
||||
return joinResponsory(pool[index % pool.length]!);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Matins Nocturn 2/3 readings for regular (non-octave-day) Sunday and
|
||||
// Duplex+ feast days — extends src/propers/octave-readings.ts's own
|
||||
// "combine several historical lessons into one reading, per real source
|
||||
// change" pattern to ordinary days, not just the days *within* an octave
|
||||
// that file already covers. Kept as a separate store (not folded into
|
||||
// octave-readings.ts) because the id scheme is flat (a saint id or a
|
||||
// temporal id, not `${octaveId}-octave-day-${n}`) and because a day can
|
||||
// have several of these active at once — see hours/matins.ts, which
|
||||
// loops every commemorated saint (calendar/types.ts's
|
||||
// LiturgicalDay.commemorations) and every calendar/octaves.ts
|
||||
// activeOctavesFor(date) entry, not just the office winner, per the
|
||||
// user's own "be generous, not winner-takes-all" instruction (2026-08).
|
||||
import type { LanguageCode, TranslationStatus } from '../psalter/types';
|
||||
|
||||
export interface NocturnReading {
|
||||
nocturn: number;
|
||||
isGospel: boolean;
|
||||
/** Attribution, e.g. "St. Gregory the Great, Moralia in Job, Bk. 9" —
|
||||
* absent for a bare Gospel pericope (the citation itself is enough). */
|
||||
source?: string;
|
||||
citation?: Partial<Record<LanguageCode, string>>;
|
||||
text: Partial<Record<LanguageCode, string>>;
|
||||
responsory?: Partial<Record<LanguageCode, string>>;
|
||||
status: Partial<Record<LanguageCode, TranslationStatus>>;
|
||||
}
|
||||
|
||||
interface NocturnReadingsFile {
|
||||
id: string;
|
||||
readings: NocturnReading[];
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<{ default: NocturnReadingsFile }>('../data/propers/nocturn-readings/*.yml', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const readingsById = new Map<string, NocturnReading[]>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
readingsById.set(mod.default.id, mod.default.readings);
|
||||
}
|
||||
|
||||
/** Empty array, not undefined — a saint/temporal id with nothing authored
|
||||
* here yet is the common case during this build's incremental content
|
||||
* pass (see TODO.md), not an error; callers just get nothing to render
|
||||
* for that id's own Nocturn 2/3 contribution. `id` is a saint id or a
|
||||
* temporal id (calendar/temporal-id.ts's resolveTemporalId), matched
|
||||
* exactly as `resolveOfficeWinner`/`day.commemorations` entries name it. */
|
||||
export function getNocturnReadings(id: string): NocturnReading[] {
|
||||
return readingsById.get(id) ?? [];
|
||||
}
|
||||
@@ -58,7 +58,11 @@ interface OctaveReadingRecord {
|
||||
status: Partial<Record<LanguageCode, TranslationStatus>>;
|
||||
}
|
||||
|
||||
function resolvePassages(passages: ScriptureCitation[]): Partial<Record<LanguageCode, string>> {
|
||||
/** Exported for reuse by src/propers/bible-plan.ts and
|
||||
* src/propers/nocturn-readings.ts, which need the same "pull several
|
||||
* scripture passages and join them as paragraphs" behavior for Matins
|
||||
* readings outside the octave-day-keyed store this file owns. */
|
||||
export function resolvePassages(passages: ScriptureCitation[]): Partial<Record<LanguageCode, string>> {
|
||||
const laParagraphs: string[] = [];
|
||||
const enParagraphs: string[] = [];
|
||||
for (const citation of passages) {
|
||||
|
||||
Reference in New Issue
Block a user