Texts library is exposed via separate menu

This commit is contained in:
catarrh committed 2026-08-01 03:30:28 +03:00
1 parent 366aea76bb
commit 4e4f928630
5 files changed
+204 -370

No files matched your search

+20
View File
@@ -13,6 +13,7 @@ Derived from the original [Java Ponomar application](https://github.com/typiconm
- **Bible Reader** — Read scripture across 13 versions (Church Slavonic, Russian Synodal, KJV, Brenton, Vulgate, etc.). Per-version reading position persists across sessions. - **Bible Reader** — Read scripture across 13 versions (Church Slavonic, Russian Synodal, KJV, Brenton, Vulgate, etc.). Per-version reading position persists across sessions.
- **Multi-language UI** — English, Russian, Church Slavonic (with dedicated Slavonic fonts). - **Multi-language UI** — English, Russian, Church Slavonic (with dedicated Slavonic fonts).
- **PWA** — Installable on desktop and mobile devices. - **PWA** — Installable on desktop and mobile devices.
- **Navigation** — 3 top-level items (Calendar, Library, Settings) with Library dropdown: Bible, Service (Horologion, Sbornik, Prayer Rule, Canons & Akathists, Parimii, Paraclete, Irmologion), Feasts (Menaion, Lenten Triodion).
## Tech Stack ## Tech Stack
@@ -42,6 +43,25 @@ npm run build # production build
npm run preview # preview production build npm run preview # preview production build
``` ```
## QA / Validation
```bash
npx tsx scripts/validate-i18n.ts # i18n completeness (en/ru/cu)
npx tsx scripts/validate-tabs.ts # service tab/template consistency
npx tsx scripts/assembler-test.ts # service assembler integration tests
```
## Liturgical Text Categories (static/data/shared/services/)
- **Triodion** — 37 sections (Pre-Lent → Great Lent → Holy Week)
- **Pentecostarion** — 50 days (Pascha → All Saints)
- **Menaion (daily)** — 367 daily entries (one per Julian calendar day)
- **Menaion (feasts)** — 34 fixed feasts with dedicated services
- **Canons** — Paraclete (weekday, 8 tones × 6 days = 48), Octoechos (Sunday, 8 tones), Great Canon
- **Services** — 78 data-backed services with full.json (Pascha, Paralytic, Sergius, Pokrov, etc.; Var/I-prefix + bare P-prefix varNodes now intercepted)
- **Horologion / Sbornik / Prayer Rule / Akathists / Parimii / Paraclete / Irmologion** — daily-cycle collections
- **Lives** — Saint lives (pre-existing schema issues, 945 known)
## Project Structure ## Project Structure
``` ```
+8
View File
@@ -27,6 +27,8 @@ export interface Translations {
menaion: string; menaion: string;
triodion: string; triodion: string;
settings: string; settings: string;
library: string;
festal: string;
}; };
// Триодь постная // Триодь постная
@@ -514,6 +516,8 @@ const translations: Record<LanguageCode, Translations> = {
menaion: '📕 Menaion', menaion: '📕 Menaion',
triodion: '📗 Lenten Triodion', triodion: '📗 Lenten Triodion',
settings: '⚙ Settings', settings: '⚙ Settings',
library: '📚 Library',
festal: '📗 Feasts',
}, },
triodion: { triodion: {
@@ -1034,6 +1038,8 @@ const translations: Record<LanguageCode, Translations> = {
menaion: '📕 Минея', menaion: '📕 Минея',
triodion: '📗 Триодь постная', triodion: '📗 Триодь постная',
settings: '⚙ Настройки', settings: '⚙ Настройки',
library: '📚 Библиотека',
festal: '📗 Праздники',
}, },
triodion: { triodion: {
@@ -1554,6 +1560,8 @@ const translations: Record<LanguageCode, Translations> = {
menaion: '📕 Мїне́ѧ', menaion: '📕 Мїне́ѧ',
triodion: '📗 Трїѡ́дь по́стнаѧ', triodion: '📗 Трїѡ́дь по́стнаѧ',
settings: '⚙ Настройки', settings: '⚙ Настройки',
library: '📚 Библіоте́ка',
festal: '📗 Пра́здники',
}, },
triodion: { triodion: {
+71
View File
@@ -147,3 +147,74 @@ html, body {
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
} }
} }
/* Nav dropdown styles */
nav details.nav-group {
position: relative;
}
nav details.nav-group summary {
list-style: none;
}
nav details.nav-group summary::-webkit-details-marker {
display: none;
}
nav details.nav-group > div {
position: absolute;
left: 0;
top: 100%;
margin-top: 0.25rem;
z-index: 50;
min-width: 14rem;
border-radius: 0.5rem;
background-color: var(--color-navy-light);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
nav details.nav-group details {
position: relative;
}
nav details.nav-group details summary {
list-style: none;
}
nav details.nav-group details summary::-webkit-details-marker {
display: none;
}
nav details.nav-group details > div {
margin-left: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
nav details.nav-group a.nav-link {
padding: 0.5rem;
display: block;
}
nav details.nav-group details a.nav-link {
padding: 0.5rem 0.5rem 0.5rem 1rem;
font-size: 0.875rem;
}
/* Mobile: nested dropdowns render in-flow below 640px */
@media (max-width: 640px) {
nav details.nav-group > div,
nav details.nav-group details > div {
position: static;
box-shadow: none;
background: transparent;
padding: 0;
margin-left: 0;
}
nav details.nav-group details > div {
margin-left: 0.75rem;
}
nav details.nav-group a.nav-link,
nav details.nav-group details a.nav-link {
padding: 0.5rem 0.5rem 0.5rem 1rem;
}
nav details[open] > summary {
font-weight: 600;
}
}
+49 -12
View File
@@ -32,6 +32,18 @@ export class App {
this.handleRoute(); this.handleRoute();
window.addEventListener('hashchange', () => this.handleRoute()); window.addEventListener('hashchange', () => this.handleRoute());
// Close dropdown menus on click/touch outside
const closeOpenDropdowns = (e: Event) => {
const target = e.target as Node;
document.querySelectorAll('details.nav-group[open]').forEach(d => {
if (!d.contains(target)) {
d.removeAttribute('open');
}
});
};
document.addEventListener('click', closeOpenDropdowns);
document.addEventListener('touchstart', closeOpenDropdowns, { passive: true });
// Register service worker for PWA install // Register service worker for PWA install
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {}); navigator.serviceWorker.register('/sw.js').catch(() => {});
@@ -76,16 +88,31 @@ export class App {
<h1 class="text-sm font-bold tracking-wide">${t.appName}</h1> <h1 class="text-sm font-bold tracking-wide">${t.appName}</h1>
<nav class="flex flex-wrap gap-4 text-sm"> <nav class="flex flex-wrap gap-4 text-sm">
<a href="#calendar" class="nav-link hover:text-gold transition-colors" data-view="calendar">${t.nav.calendar}</a> <a href="#calendar" class="nav-link hover:text-gold transition-colors" data-view="calendar">${t.nav.calendar}</a>
<a href="#bible" class="nav-link hover:text-gold transition-colors" data-view="bible">${t.nav.bible}</a> <details class="nav-group relative" data-nav="library">
<a href="#prayer" class="nav-link hover:text-gold transition-colors" data-view="prayer">${t.nav.prayer}</a> <summary class="cursor-pointer list-none hover:text-gold transition-colors flex items-center gap-1">${t.nav.library} <span class="text-xs">▾</span></summary>
<a href="#akathists" class="nav-link hover:text-gold transition-colors" data-view="akathists">${t.nav.akathists}</a> <div class="absolute left-0 top-full mt-1 z-50 min-w-56 rounded-lg bg-navy-light shadow-xl p-2 flex flex-col gap-1">
<a href="#parimii" class="nav-link hover:text-gold transition-colors" data-view="parimii">${t.nav.parimii}</a> <a href="#bible" class="nav-link hover:text-gold transition-colors px-2 py-1" data-view="bible">${t.nav.bible}</a>
<a href="#horologion" class="nav-link hover:text-gold transition-colors" data-view="horologion">${t.nav.horologion}</a> <details class="nav-group">
<a href="#sbornik" class="nav-link hover:text-gold transition-colors" data-view="sbornik">${t.nav.sbornik}</a> <summary class="cursor-pointer list-none hover:text-gold transition-colors px-2 py-1 flex items-center gap-1">${t.nav.service} <span class="text-xs">▸</span></summary>
<a href="#paraclete" class="nav-link hover:text-gold transition-colors" data-view="paraclete">${t.nav.paraclete}</a> <div class="ml-2 flex flex-col gap-1">
<a href="#irmologion" class="nav-link hover:text-gold transition-colors" data-view="irmologion">${t.nav.irmologion}</a> <a href="#horologion" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="horologion">${t.nav.horologion}</a>
<a href="#menaion" class="nav-link hover:text-gold transition-colors" data-view="menaion">${t.nav.menaion}</a> <a href="#sbornik" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="sbornik">${t.nav.sbornik}</a>
<a href="#triodion" class="nav-link hover:text-gold transition-colors" data-view="triodion">${t.nav.triodion}</a> <a href="#prayer" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="prayer">${t.nav.prayer}</a>
<a href="#akathists" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="akathists">${t.nav.akathists}</a>
<a href="#parimii" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="parimii">${t.nav.parimii}</a>
<a href="#paraclete" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="paraclete">${t.nav.paraclete}</a>
<a href="#irmologion" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="irmologion">${t.nav.irmologion}</a>
</div>
</details>
<details class="nav-group">
<summary class="cursor-pointer list-none hover:text-gold transition-colors px-2 py-1 flex items-center gap-1">${t.nav.festal} <span class="text-xs">▸</span></summary>
<div class="ml-2 flex flex-col gap-1">
<a href="#menaion" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="menaion">${t.nav.menaion}</a>
<a href="#triodion" class="nav-link hover:text-gold transition-colors px-2 py-1 text-sm" data-view="triodion">${t.nav.triodion}</a>
</div>
</details>
</div>
</details>
<a href="#settings" class="nav-link hover:text-gold transition-colors" data-view="settings">${t.nav.settings}</a> <a href="#settings" class="nav-link hover:text-gold transition-colors" data-view="settings">${t.nav.settings}</a>
</nav> </nav>
</header> </header>
@@ -101,8 +128,18 @@ export class App {
// Update active nav // Update active nav
document.querySelectorAll('.nav-link').forEach(el => { document.querySelectorAll('.nav-link').forEach(el => {
const v = el.getAttribute('data-view'); const v = el.getAttribute('data-view');
el.classList.toggle('text-gold', v === this.currentView); const isActive = v === this.currentView;
el.classList.toggle('font-bold', v === this.currentView); el.classList.toggle('text-gold', isActive);
el.classList.toggle('font-bold', isActive);
if (isActive) {
let parent = el.parentElement;
while (parent) {
if (parent.tagName === 'DETAILS') {
parent.setAttribute('open', '');
}
parent = parent.parentElement;
}
}
}); });
// Re-read settings fresh on each navigation // Re-read settings fresh on each navigation
+56 -358
View File
@@ -10,6 +10,10 @@ import { assembleService, type ServiceContext } from '../core/service-assembler'
import { evalBool } from '../core/evaluator'; import { evalBool } from '../core/evaluator';
import type { CanonData, GreatCanonPart, EvalContext, ServiceNode } from '../core/types'; import type { CanonData, GreatCanonPart, EvalContext, ServiceNode } from '../core/types';
import { getTranslations, type LanguageCode } from '../core/i18n'; import { getTranslations, type LanguageCode } from '../core/i18n';
import {
TRIODION_NDAY, BRIGHT_WEEK, PENTECOSTARION, FIRST_WEEK, MENAION_FEAST,
HOLY_WEEK, holyWeekVarName, type ServiceTabDef,
} from '../core/service-tabs';
import { loadSettings, fontClass } from './settings-view'; import { loadSettings, fontClass } from './settings-view';
interface ServiceDef { interface ServiceDef {
@@ -146,31 +150,16 @@ export class ServiceView {
// Triodion movable days (by nday): Pre-Lent Sundays + Lent Sundays // Triodion movable days (by nday): Pre-Lent Sundays + Lent Sundays
// (the ones not already covered: week 1, Marias Standing, Palm Sunday, Holy Week) // (the ones not already covered: week 1, Marias Standing, Palm Sunday, Holy Week)
const triodionDay: Record<number, { idx: number; name: string }> = { const triodionIdx = TRIODION_NDAY[dayInfo.nday];
[-70]: { idx: 1, name: this.t.triodion.sections[0] }, if (triodionIdx) {
[-63]: { idx: 2, name: this.t.triodion.sections[1] },
[-57]: { idx: 3, name: this.t.triodion.sections[2] },
[-56]: { idx: 4, name: this.t.triodion.sections[3] },
[-50]: { idx: 6, name: this.t.triodion.sections[5] },
[-49]: { idx: 7, name: this.t.triodion.sections[6] },
[-42]: { idx: 14, name: this.t.triodion.sections[13] },
[-35]: { idx: 17, name: this.t.triodion.sections[16] },
[-28]: { idx: 20, name: this.t.triodion.sections[19] },
[-21]: { idx: 23, name: this.t.triodion.sections[22] },
[-14]: { idx: 27, name: this.t.triodion.sections[26] },
[-9]: { idx: 26, name: this.t.triodion.sections[25] },
[-8]: { idx: 29, name: this.t.triodion.sections[28] },
};
const triodionHit = triodionDay[dayInfo.nday];
if (triodionHit) {
special.push({ special.push({
id: `triodion-${triodionHit.idx}`, id: `triodion-${triodionIdx}`,
name: triodionHit.name, name: this.t.triodion.sections[triodionIdx - 1],
serviceType: 'TRIODION', serviceType: 'TRIODION',
template: 'Triodion', template: 'Triodion',
description: this.t.triodion.title, description: this.t.triodion.title,
titleNode: title('Triodion1', triodionHit.name), titleNode: title('Triodion1', this.t.triodion.sections[triodionIdx - 1]),
triodionIndex: triodionHit.idx, triodionIndex: triodionIdx,
}); });
} }
@@ -252,53 +241,17 @@ export class ServiceView {
// Bright Week services (Pascha through Saturday) // Bright Week services (Pascha through Saturday)
if (dayInfo.isBrightWeek) { if (dayInfo.isBrightWeek) {
const brightDay: Record<number, { id: string; name: string; type: string; desc: string }> = { const def = BRIGHT_WEEK[dayInfo.nday];
[0]: { id: 'pascha', name: this.t.services.serviceNames.pascha, type: 'PASCHA', desc: this.t.services.serviceDescriptions.pascha }, if (def) {
[1]: { id: 'brightmonday', name: this.t.services.serviceNames.brightmonday, type: 'BRIGHTMONDAY', desc: this.t.services.serviceDescriptions.brightmonday }, special.push(this.defToService(def));
[2]: { id: 'brighttuesday', name: this.t.services.serviceNames.brighttuesday, type: 'BRIGHTTUESDAY', desc: this.t.services.serviceDescriptions.brighttuesday },
[3]: { id: 'brightwednesday', name: this.t.services.serviceNames.brightwednesday, type: 'BRIGHTWEDNESDAY', desc: this.t.services.serviceDescriptions.brightwednesday },
[4]: { id: 'brightthursday', name: this.t.services.serviceNames.brightthursday, type: 'BRIGHTTHURSDAY', desc: this.t.services.serviceDescriptions.brightthursday },
[5]: { id: 'brightfriday', name: this.t.services.serviceNames.brightfriday, type: 'BRIGHTFRIDAY', desc: this.t.services.serviceDescriptions.brightfriday },
[6]: { id: 'brightsaturday', name: this.t.services.serviceNames.brightsaturday, type: 'BRIGHTSATURDAY', desc: this.t.services.serviceDescriptions.brightsaturday },
};
const bd = brightDay[dayInfo.nday];
if (bd) {
const templateName = bd.id.charAt(0).toUpperCase() + bd.id.slice(1);
special.push({
id: bd.id, name: bd.name, serviceType: bd.type,
template: templateName,
description: bd.desc,
titleNode: title(`${templateName}1`, bd.name),
});
} }
} }
// Pentecostarion services (Antipascha through All Saints) // Pentecostarion services (Antipascha through All Saints)
if (dayInfo.pentecostarionFile !== null && dayInfo.nday > 6) { if (dayInfo.pentecostarionFile !== null && dayInfo.nday > 6) {
const pentDay: Record<number, { id: string; name: string; type: string; desc: string }> = { const pd = PENTECOSTARION[dayInfo.nday];
[7]: { id: 'antipascha', name: this.t.services.serviceNames.antipascha, type: 'ANTIPASCHA', desc: this.t.services.serviceDescriptions.antipascha },
[14]: { id: 'myrrhbearers', name: this.t.services.serviceNames.myrrhbearers, type: 'MYRRHBEARERS', desc: this.t.services.serviceDescriptions.myrrhbearers },
[21]: { id: 'paralytic', name: this.t.services.serviceNames.paralytic, type: 'PARALYTIC', desc: this.t.services.serviceDescriptions.paralytic },
[25]: { id: 'prepolovenie', name: this.t.services.serviceNames.prepolovenie, type: 'PREPOLOVENIE', desc: this.t.services.serviceDescriptions.prepolovenie },
[28]: { id: 'samaritan', name: this.t.services.serviceNames.samaritan, type: 'SAMARITAN', desc: this.t.services.serviceDescriptions.samaritan },
[35]: { id: 'blindman', name: this.t.services.serviceNames.blindman, type: 'BLINDMAN', desc: this.t.services.serviceDescriptions.blindman },
[38]: { id: 'apodosis', name: this.t.services.serviceNames.apodosis, type: 'APODOSIS', desc: this.t.services.serviceDescriptions.apodosis },
[39]: { id: 'ascension', name: this.t.services.serviceNames.ascension, type: 'ASCENSION', desc: this.t.services.serviceDescriptions.ascension },
[42]: { id: 'holyfathers', name: this.t.services.serviceNames.holyfathers, type: 'HOLYFATHERS', desc: this.t.services.serviceDescriptions.holyfathers },
[48]: { id: 'pentecostsaturday', name: this.t.services.serviceNames.pentecostsaturday, type: 'PENTECOSTSATURDAY', desc: this.t.services.serviceDescriptions.pentecostsaturday },
[49]: { id: 'pentecost', name: this.t.services.serviceNames.pentecost, type: 'PENTECOST', desc: this.t.services.serviceDescriptions.pentecost },
[50]: { id: 'holyspirit', name: this.t.services.serviceNames.holyspirit, type: 'HOLYSPIRIT', desc: this.t.services.serviceDescriptions.holyspirit },
[56]: { id: 'allsaints', name: this.t.services.serviceNames.allsaints, type: 'ALLSAINTS', desc: this.t.services.serviceDescriptions.allsaints },
};
const pd = pentDay[dayInfo.nday];
if (pd) { if (pd) {
const templateName = pd.id.charAt(0).toUpperCase() + pd.id.slice(1); special.push(this.defToService(pd));
special.push({
id: pd.id, name: pd.name, serviceType: pd.type,
template: templateName,
description: pd.desc,
titleNode: title(`${templateName}1`, pd.name),
});
} }
// All Saints of Russia (appendix, same Sunday as All Saints) // All Saints of Russia (appendix, same Sunday as All Saints)
if (dayInfo.nday === 56) { if (dayInfo.nday === 56) {
@@ -313,93 +266,29 @@ export class ServiceView {
// First Week of Lent: complete daily cycle (Matins, Hours, Typica, Vespers, Compline) // First Week of Lent: complete daily cycle (Matins, Hours, Typica, Vespers, Compline)
if (dayInfo.isLent && dayInfo.nday >= -48 && dayInfo.nday <= -43) { if (dayInfo.isLent && dayInfo.nday >= -48 && dayInfo.nday <= -43) {
const fwDay: Record<number, { id: string; name: string; type: string; desc: string }> = { const fw = FIRST_WEEK[dayInfo.nday];
[-48]: { id: 'firstweekmonday', name: this.t.services.serviceNames.firstweekmonday, type: 'FIRSTWEEKMONDAY', desc: this.t.services.serviceDescriptions.firstweekmonday },
[-47]: { id: 'firstweektuesday', name: this.t.services.serviceNames.firstweektuesday, type: 'FIRSTWEEKTUESDAY', desc: this.t.services.serviceDescriptions.firstweektuesday },
[-46]: { id: 'firstweekwednesday', name: this.t.services.serviceNames.firstweekwednesday, type: 'FIRSTWEEKWEDNESDAY', desc: this.t.services.serviceDescriptions.firstweekwednesday },
[-45]: { id: 'firstweekthursday', name: this.t.services.serviceNames.firstweekthursday, type: 'FIRSTWEEKTHURSDAY', desc: this.t.services.serviceDescriptions.firstweekthursday },
[-44]: { id: 'firstweekfriday', name: this.t.services.serviceNames.firstweekfriday, type: 'FIRSTWEEKFRIDAY', desc: this.t.services.serviceDescriptions.firstweekfriday },
[-43]: { id: 'firstweeksaturday', name: this.t.services.serviceNames.firstweeksaturday, type: 'FIRSTWEEKSATURDAY', desc: this.t.services.serviceDescriptions.firstweeksaturday },
};
const fw = fwDay[dayInfo.nday];
if (fw) { if (fw) {
const templateName = 'FirstWeek' + fw.id.charAt(0).toUpperCase() + fw.id.slice(1); special.push(this.defToService(fw));
special.push({
id: fw.id, name: fw.name, serviceType: fw.type,
template: templateName,
description: fw.desc,
titleNode: title(`${templateName}1`, fw.name),
});
} }
} }
// Festal Menaion: Great feasts on fixed Julian dates (Typikon Ch. 47) // Festal Menaion: Great feasts on fixed Julian dates (Typikon Ch. 47)
const menaionFeast: Record<string, { id: string; name: string; type: string; desc: string }> = { const feast = MENAION_FEAST[`${dayInfo.month}-${dayInfo.day}`];
'9-8': { id: 'nativitytheotokos', name: this.t.services.serviceNames.nativitytheotokos, type: 'NATIVITYTHEOTOKOS', desc: this.t.services.serviceDescriptions.nativitytheotokos },
'9-14': { id: 'exaltation', name: this.t.services.serviceNames.exaltation, type: 'EXALTATION', desc: this.t.services.serviceDescriptions.exaltation },
'11-21': { id: 'vvedenie', name: this.t.services.serviceNames.vvedenie, type: 'VVEDENIE', desc: this.t.services.serviceDescriptions.vvedenie },
'12-24': { id: 'nativityhours', name: this.t.services.serviceNames.nativityhours, type: 'NATIVITYHOURS', desc: this.t.services.serviceDescriptions.nativityhours },
'12-25': { id: 'nativity', name: this.t.services.serviceNames.nativity, type: 'NATIVITY', desc: this.t.services.serviceDescriptions.nativity },
'1-5': { id: 'theophanyhours', name: this.t.services.serviceNames.theophanyhours, type: 'THEOPHANYHOURS', desc: this.t.services.serviceDescriptions.theophanyhours },
'1-6': { id: 'theophany', name: this.t.services.serviceNames.theophany, type: 'THEOPHANY', desc: this.t.services.serviceDescriptions.theophany },
'2-2': { id: 'sretenie', name: this.t.services.serviceNames.sretenie, type: 'SRETENIE', desc: this.t.services.serviceDescriptions.sretenie },
'3-25': { id: 'annunciation', name: this.t.services.serviceNames.annunciation, type: 'ANNUNCIATION', desc: this.t.services.serviceDescriptions.annunciation },
'6-24': { id: 'forerunnerbirth', name: this.t.services.serviceNames.forerunnerbirth, type: 'FORERUNNERBIRTH', desc: this.t.services.serviceDescriptions.forerunnerbirth },
'6-29': { id: 'peterpaul', name: this.t.services.serviceNames.peterpaul, type: 'PETERPAUL', desc: this.t.services.serviceDescriptions.peterpaul },
'8-6': { id: 'transfiguration', name: this.t.services.serviceNames.transfiguration, type: 'TRANSFIGURATION', desc: this.t.services.serviceDescriptions.transfiguration },
'8-15': { id: 'dormition', name: this.t.services.serviceNames.dormition, type: 'DORMITION', desc: this.t.services.serviceDescriptions.dormition },
'8-29': { id: 'forerunnerbeheading', name: this.t.services.serviceNames.forerunnerbeheading, type: 'FORERUNNERBEHEADING', desc: this.t.services.serviceDescriptions.forerunnerbeheading },
// Middle feasts (fixed dates)
'9-25': { id: 'sergius', name: this.t.services.serviceNames.sergius, type: 'SERGIUS', desc: this.t.services.serviceDescriptions.sergius },
'9-26': { id: 'johntheologiansep', name: this.t.services.serviceNames.johntheologiansep, type: 'JOHNTHEOLOGIANSEP', desc: this.t.services.serviceDescriptions.johntheologiansep },
'10-1': { id: 'pokrov', name: this.t.services.serviceNames.pokrov, type: 'POKROV', desc: this.t.services.serviceDescriptions.pokrov },
'10-10': { id: 'ambrose', name: this.t.services.serviceNames.ambrose, type: 'AMBROSE', desc: this.t.services.serviceDescriptions.ambrose },
'10-11': { id: 'seventhcouncilefathers', name: this.t.services.serviceNames.seventhcouncilefathers, type: 'SEVENTHCOUNCILFATHERS', desc: this.t.services.serviceDescriptions.seventhcouncilefathers },
'10-22': { id: 'kazan', name: this.t.services.serviceNames.kazan, type: 'KAZAN', desc: this.t.services.serviceDescriptions.kazan },
'10-26': { id: 'demetrius', name: this.t.services.serviceNames.demetrius, type: 'DEMETRIUS', desc: this.t.services.serviceDescriptions.demetrius },
'11-8': { id: 'michaelsynaxis', name: this.t.services.serviceNames.michaelsynaxis, type: 'MICHAELSYNAXIS', desc: this.t.services.serviceDescriptions.michaelsynaxis },
'12-6': { id: 'nicholas', name: this.t.services.serviceNames.nicholas, type: 'NICHOLAS', desc: this.t.services.serviceDescriptions.nicholas },
'1-1': { id: 'circumcision', name: this.t.services.serviceNames.circumcision, type: 'CIRCUMCISION', desc: this.t.services.serviceDescriptions.circumcision },
'2-24': { id: 'findinghead1st', name: this.t.services.serviceNames.findinghead1st, type: 'FINDINGHEAD1ST', desc: this.t.services.serviceDescriptions.findinghead1st },
'3-9': { id: 'fortymartyrs', name: this.t.services.serviceNames.fortymartyrs, type: 'FORTYMARTYRS', desc: this.t.services.serviceDescriptions.fortymartyrs },
'5-8': { id: 'johntheologianmay', name: this.t.services.serviceNames.johntheologianmay, type: 'JOHNTHEOLOGIANMAY', desc: this.t.services.serviceDescriptions.johntheologianmay },
'5-9': { id: 'nicholastranslation', name: this.t.services.serviceNames.nicholastranslation, type: 'NICHOLASTRANSLATION', desc: this.t.services.serviceDescriptions.nicholastranslation },
'5-25': { id: 'findinghead3rd', name: this.t.services.serviceNames.findinghead3rd, type: 'FINDINGHEAD3RD', desc: this.t.services.serviceDescriptions.findinghead3rd },
'7-15': { id: 'vladimir', name: this.t.services.serviceNames.vladimir, type: 'VLADIMIR', desc: this.t.services.serviceDescriptions.vladimir },
'7-16': { id: 'sixcouncilfathers', name: this.t.services.serviceNames.sixcouncilfathers, type: 'SIXCOUNCILFATHERS', desc: this.t.services.serviceDescriptions.sixcouncilfathers },
'7-20': { id: 'elijah', name: this.t.services.serviceNames.elijah, type: 'ELIJAH', desc: this.t.services.serviceDescriptions.elijah },
'7-27': { id: 'panteleimon', name: this.t.services.serviceNames.panteleimon, type: 'PANTELEIMON', desc: this.t.services.serviceDescriptions.panteleimon },
'8-1': { id: 'processioncross', name: this.t.services.serviceNames.processioncross, type: 'PROCESSIONCROSS', desc: this.t.services.serviceDescriptions.processioncross },
};
const feast = menaionFeast[`${dayInfo.month}-${dayInfo.day}`];
if (feast) { if (feast) {
const templateName = feast.id.charAt(0).toUpperCase() + feast.id.slice(1); special.push(this.defToService(feast));
special.push({
id: feast.id, name: feast.name, serviceType: feast.type,
template: templateName,
description: feast.desc,
titleNode: title(`${templateName}1`, feast.name),
});
} }
// Movable Sundays of the Nativity season (dow-based) // Movable Sundays of the Nativity season (dow-based)
if (dayInfo.dow === 0 && dayInfo.month === 12) { if (dayInfo.dow === 0 && dayInfo.month === 12) {
const movable: { id: string; name: string; type: string; desc: string }[] = []; let def: ServiceTabDef | null = null;
if (dayInfo.day >= 11 && dayInfo.day <= 17) { if (dayInfo.day >= 11 && dayInfo.day <= 17) {
movable.push({ id: 'forefatherssunday', name: this.t.services.serviceNames.forefatherssunday, type: 'FOREFATHERSSUNDAY', desc: this.t.services.serviceDescriptions.forefatherssunday }); def = HOLY_WEEK['FOREFATHERSSUNDAY'];
} else if (dayInfo.day >= 18 && dayInfo.day <= 24) { } else if (dayInfo.day >= 18 && dayInfo.day <= 24) {
movable.push({ id: 'holyfathersnativity', name: this.t.services.serviceNames.holyfathersnativity, type: 'HOLYFATHERSNATIVITY', desc: this.t.services.serviceDescriptions.holyfathersnativity }); def = HOLY_WEEK['HOLYFATHERSNATIVITY'];
} else if (dayInfo.day >= 26 && dayInfo.day <= 31) { } else if (dayInfo.day >= 26 && dayInfo.day <= 31) {
movable.push({ id: 'sundayafternativity', name: this.t.services.serviceNames.sundayafternativity, type: 'SUNDAYAFTERNATIVITY', desc: this.t.services.serviceDescriptions.sundayafternativity }); def = HOLY_WEEK['SUNDAYAFTERNATIVITY'];
} }
const mv = movable[0]; if (def) {
if (mv) { special.push(this.defToService(def));
const templateName = mv.id.charAt(0).toUpperCase() + mv.id.slice(1);
special.push({
id: mv.id, name: mv.name, serviceType: mv.type,
template: templateName,
description: mv.desc,
titleNode: title(`${templateName}1`, mv.name),
});
} }
} }
@@ -427,85 +316,37 @@ export class ServiceView {
if (dayInfo.isLent) { if (dayInfo.isLent) {
// Palm Sunday: Vespers + Matins // Palm Sunday: Vespers + Matins
if (dayInfo.nday === -7) { if (dayInfo.nday === -7) {
special.push({ special.push(this.defToService(HOLY_WEEK['PALMSUNDAY']));
id: 'palmsunday', name: this.t.services.serviceNames.palmsunday, serviceType: 'PALMSUNDAY',
template: 'PalmSunday',
description: this.t.services.serviceDescriptions.palmsunday,
titleNode: title('PalmSunday1', this.t.services.serviceNames.palmsunday),
});
} }
// Great Monday through Thursday: daily cycle (Matins, Hours, Typica, Vespers) // Great Monday through Thursday: daily cycle (Matins, Hours, Typica, Vespers)
const greatDay: Record<number, { id: string; name: string; type: string; desc: string }> = { const greatDay: Record<number, string> = {
[-6]: { id: 'greatmonday', name: this.t.services.serviceNames.greatmonday, type: 'GREATMONDAY', desc: this.t.services.serviceDescriptions.greatmonday }, [-6]: 'GREATMONDAY', [-5]: 'GREATTUESDAY', [-4]: 'GREATWEDNESDAY', [-3]: 'GREATTHURSDAY',
[-5]: { id: 'greattuesday', name: this.t.services.serviceNames.greattuesday, type: 'GREATTUESDAY', desc: this.t.services.serviceDescriptions.greattuesday },
[-4]: { id: 'greatwednesday', name: this.t.services.serviceNames.greatwednesday, type: 'GREATWEDNESDAY', desc: this.t.services.serviceDescriptions.greatwednesday },
[-3]: { id: 'greatthursday', name: this.t.services.serviceNames.greatthursday, type: 'GREATTHURSDAY', desc: this.t.services.serviceDescriptions.greatthursday },
}; };
const gd = greatDay[dayInfo.nday]; const gd = greatDay[dayInfo.nday];
if (gd) { if (gd) {
special.push({ special.push(this.defToService(HOLY_WEEK[gd]));
id: gd.id, name: gd.name, serviceType: gd.type,
template: gd.id.charAt(0).toUpperCase() + gd.id.slice(1),
description: gd.desc,
titleNode: title(`${gd.id.charAt(0).toUpperCase() + gd.id.slice(1)}1`, gd.name),
});
} }
// Matins of the 12 Passion Gospels: Great Thursday evening (Great Friday Matins) // Matins of the 12 Passion Gospels: Great Thursday evening (Great Friday Matins)
if (dayInfo.nday === -3) { if (dayInfo.nday === -3) {
special.push({ special.push(this.defToService(HOLY_WEEK['PASSIONGOSPELS']));
id: 'passiongospels', name: this.t.services.serviceNames.passiongospels, serviceType: 'PASSIONGOSPELS',
template: 'PassionGospels',
description: this.t.services.serviceDescriptions.passiongospels,
titleNode: title('PassionGospels1', this.t.services.serviceNames.passiongospels),
});
} }
// Royal Hours of Great Friday // Royal Hours of Great Friday
if (dayInfo.nday === -2) { if (dayInfo.nday === -2) {
special.push({ special.push(this.defToService(HOLY_WEEK['ROYALHOURSFRIDAY']));
id: 'royalhoursfriday', name: this.t.services.serviceNames.royalhoursfriday, serviceType: 'ROYALHOURSFRIDAY',
template: 'RoyalHoursFriday',
description: this.t.services.serviceDescriptions.royalhoursfriday,
titleNode: title('RoyalHoursFriday1', this.t.services.serviceNames.royalhoursfriday),
});
} }
// Lamentations Matins: Great Saturday Matins (served Friday night) // Lamentations Matins: Great Saturday Matins (served Friday night)
if (dayInfo.nday === -1) { if (dayInfo.nday === -1) {
special.push({ special.push(this.defToService(HOLY_WEEK['LAMENTATIONS']));
id: 'lamentations', name: this.t.services.serviceNames.lamentations, serviceType: 'LAMENTATIONS',
template: 'Lamentations',
description: this.t.services.serviceDescriptions.lamentations,
titleNode: title('Lamentations1', this.t.services.serviceNames.lamentations),
});
} }
// Vespers of the Burial + cell Compline: Great Friday evening // Vespers of the Burial + cell Compline: Great Friday evening
if (dayInfo.nday === -2) { if (dayInfo.nday === -2) {
special.push({ special.push(this.defToService(HOLY_WEEK['BURIALVESPERS']));
id: 'burialvespers', name: this.t.services.serviceNames.burialvespers, serviceType: 'BURIALVESPERS',
template: 'BurialVespers',
description: this.t.services.serviceDescriptions.burialvespers,
titleNode: title('BurialVespers1', this.t.services.serviceNames.burialvespers),
});
} }
// Holy Saturday: Hours, Vespers+Liturgy of St. Basil, Midnight Office // Holy Saturday: Hours, Vespers+Liturgy of St. Basil, Midnight Office
if (dayInfo.nday === -1) { if (dayInfo.nday === -1) {
special.push({ special.push(this.defToService(HOLY_WEEK['SATURDAYHOURS']));
id: 'saturdayhours', name: this.t.services.serviceNames.saturdayhours, serviceType: 'SATURDAYHOURS', special.push(this.defToService(HOLY_WEEK['SATURDAYLITURGY']));
template: 'SaturdayHours', special.push(this.defToService(HOLY_WEEK['SATURDAYMIDNIGHT']));
description: this.t.services.serviceDescriptions.saturdayhours,
titleNode: title('SaturdayHours1', this.t.services.serviceNames.saturdayhours),
});
special.push({
id: 'saturdayliturgy', name: this.t.services.serviceNames.saturdayliturgy, serviceType: 'SATURDAYLITURGY',
template: 'SaturdayVespersLiturgy',
description: this.t.services.serviceDescriptions.saturdayliturgy,
titleNode: title('SaturdayVespersLiturgy1', this.t.services.serviceNames.saturdayliturgy),
});
special.push({
id: 'saturdaymidnight', name: this.t.services.serviceNames.saturdaymidnight, serviceType: 'SATURDAYMIDNIGHT',
template: 'SaturdayMidnight',
description: this.t.services.serviceDescriptions.saturdaymidnight,
titleNode: title('SaturdayMidnight1', this.t.services.serviceNames.saturdaymidnight),
});
} }
} }
@@ -515,6 +356,19 @@ export class ServiceView {
return [...special, ...daily]; return [...special, ...daily];
} }
private defToService(def: ServiceTabDef): ServiceDef {
const names = this.t.services.serviceNames as Record<string, string>;
const descs = this.t.services.serviceDescriptions as Record<string, string>;
return {
id: def.id,
name: names[def.id],
serviceType: def.serviceType,
template: def.template,
description: descs[def.id],
titleNode: title(`${def.template}1`, names[def.id]),
};
}
private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null> { private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null> {
const computed = computeDay(this.currentDate); const computed = computeDay(this.currentDate);
const { dayInfo } = computed; const { dayInfo } = computed;
@@ -1001,167 +855,11 @@ export class ServiceView {
} }
} }
// Load Holy Week service data // Load data-backed service content (Holy Week, Bright Week, feasts, first week)
const holyWeekVarMap: Record<string, string> = { const hwDef = HOLY_WEEK[service.serviceType];
PALMSUNDAY: 'PPalmSunday', if (hwDef && hwDef.dir) {
GREATMONDAY: 'PGreatMonday', const nodes = await this.loadHolyWeekData(hwDef.dir);
GREATTUESDAY: 'PGreatTuesday', if (nodes) varNodes.set(holyWeekVarName(hwDef), nodes);
GREATWEDNESDAY: 'PGreatWednesday',
GREATTHURSDAY: 'PGreatThursday',
PASSIONGOSPELS: 'PPassionGospels',
ROYALHOURSFRIDAY: 'PRoyalHoursFriday',
LAMENTATIONS: 'PLamentations',
BURIALVESPERS: 'PBurialVespers',
SATURDAYHOURS: 'PSaturdayHours',
SATURDAYLITURGY: 'PSaturdayVespersLiturgy',
SATURDAYMIDNIGHT: 'PSaturdayMidnight',
PASCHA: 'PPascha',
BRIGHTMONDAY: 'PBrightMonday',
BRIGHTTUESDAY: 'PBrightTuesday',
BRIGHTWEDNESDAY: 'PBrightWednesday',
BRIGHTTHURSDAY: 'PBrightThursday',
BRIGHTFRIDAY: 'PBrightFriday',
BRIGHTSATURDAY: 'PBrightSaturday',
ANTIPASCHA: 'PAntipascha',
MYRRHBEARERS: 'PMyrrhbearers',
PARALYTIC: 'PParalytic',
PREPOLOVENIE: 'PPrepolovenie',
SAMARITAN: 'PSamaritan',
BLINDMAN: 'PBlindMan',
APODOSIS: 'PApodosis',
ASCENSION: 'PAscension',
HOLYFATHERS: 'PHolyFathers',
PENTECOSTSATURDAY: 'PPentecostSaturday',
PENTECOST: 'PPentecost',
HOLYSPIRIT: 'PHolySpirit',
ALLSAINTS: 'PAllSaints',
RUSSIANSAINTS: 'PRussianSaints',
FIRSTWEEKMONDAY: 'PFirstWeekMonday',
FIRSTWEEKTUESDAY: 'PFirstWeekTuesday',
FIRSTWEEKWEDNESDAY: 'PFirstWeekWednesday',
FIRSTWEEKTHURSDAY: 'PFirstWeekThursday',
FIRSTWEEKFRIDAY: 'PFirstWeekFriday',
FIRSTWEEKSATURDAY: 'PFirstWeekSaturday',
NATIVITYTHEOTOKOS: 'PNativityTheotokos',
EXALTATION: 'PExaltation',
VVEDENIE: 'PVvedenie',
NATIVITYHOURS: 'PNativityHours',
NATIVITY: 'PNativity',
THEOPHANYHOURS: 'PTheophanyHours',
THEOPHANY: 'PTheophany',
SRETENIE: 'PSretenie',
ANNUNCIATION: 'PAnnunciation',
FORERUNNERBIRTH: 'PForerunnerBirth',
PETERPAUL: 'PPeterPaul',
TRANSFIGURATION: 'PTransfiguration',
DORMITION: 'PDormition',
FORERUNNERBEHEADING: 'PForerunnerBeheading',
SERGIUS: 'PSergius',
JOHNTHEOLOGIANSEP: 'PJohnTheologianSep',
POKROV: 'PPokrov',
AMBROSE: 'PAmbrose',
SEVENTHCOUNCILFATHERS: 'PSeventhCouncilFathers',
KAZAN: 'PKazan',
DEMETRIUS: 'PDemetrius',
MICHAELSYNAXIS: 'PMichaelSynaxis',
NICHOLAS: 'PNicholas',
CIRCUMCISION: 'PCircumcision',
FINDINGHEAD1ST: 'PFindingHead1st',
FORTYMARTYRS: 'PFortyMartyrs',
JOHNTHEOLOGIANMAY: 'PJohnTheologianMay',
NICHOLASTRANSLATION: 'PNicholasTranslation',
FINDINGHEAD3RD: 'PFindingHead3rd',
VLADIMIR: 'PVladimir',
SIXCOUNCILFATHERS: 'PSixCouncilsFathers',
ELIJAH: 'PElijah',
PANTELEIMON: 'PPanteleimon',
PROCESSIONCROSS: 'PProcessionCross',
FOREFATHERSSUNDAY: 'PForefathersSunday',
HOLYFATHERSNATIVITY: 'PHolyFathersNativity',
SUNDAYAFTERNATIVITY: 'PSundayAfterNativity',
};
const holyWeekDir: Record<string, string> = {
PALMSUNDAY: 'palm-sunday',
GREATMONDAY: 'great-monday',
GREATTUESDAY: 'great-tuesday',
GREATWEDNESDAY: 'great-wednesday',
GREATTHURSDAY: 'great-thursday',
PASSIONGOSPELS: 'passion-gospels',
ROYALHOURSFRIDAY: 'royal-hours-friday',
LAMENTATIONS: 'lamentations',
BURIALVESPERS: 'burial-vespers',
SATURDAYHOURS: 'saturday-hours',
SATURDAYLITURGY: 'saturday-vespers-liturgy',
SATURDAYMIDNIGHT: 'saturday-midnight',
PASCHA: 'pascha',
BRIGHTMONDAY: 'bright-monday',
BRIGHTTUESDAY: 'bright-tuesday',
BRIGHTWEDNESDAY: 'bright-wednesday',
BRIGHTTHURSDAY: 'bright-thursday',
BRIGHTFRIDAY: 'bright-friday',
BRIGHTSATURDAY: 'bright-saturday',
ANTIPASCHA: 'antipascha',
MYRRHBEARERS: 'myrrhbearers',
PARALYTIC: 'paralytic',
PREPOLOVENIE: 'prepolovenie',
SAMARITAN: 'samaritan',
BLINDMAN: 'blindman',
APODOSIS: 'apodosis',
ASCENSION: 'ascension',
HOLYFATHERS: 'holyfathers',
PENTECOSTSATURDAY: 'pentecostsaturday',
PENTECOST: 'pentecost',
HOLYSPIRIT: 'holyspirit',
ALLSAINTS: 'allsaints',
RUSSIANSAINTS: 'russiansaints',
FIRSTWEEKMONDAY: 'first-week-monday',
FIRSTWEEKTUESDAY: 'first-week-tuesday',
FIRSTWEEKWEDNESDAY: 'first-week-wednesday',
FIRSTWEEKTHURSDAY: 'first-week-thursday',
FIRSTWEEKFRIDAY: 'first-week-friday',
FIRSTWEEKSATURDAY: 'first-week-saturday',
NATIVITYTHEOTOKOS: 'nativity-theotokos',
EXALTATION: 'exaltation',
VVEDENIE: 'vvedenie',
NATIVITYHOURS: 'nativity-hours',
NATIVITY: 'nativity',
THEOPHANYHOURS: 'theophany-hours',
THEOPHANY: 'theophany',
SRETENIE: 'sretenie',
ANNUNCIATION: 'annunciation',
FORERUNNERBIRTH: 'forerunner-birth',
PETERPAUL: 'peter-paul',
TRANSFIGURATION: 'transfiguration',
DORMITION: 'dormition',
FORERUNNERBEHEADING: 'forerunner-beheading',
SERGIUS: 'sergius',
JOHNTHEOLOGIANSEP: 'johntheologian-sep',
POKROV: 'pokrov',
AMBROSE: 'ambrose',
SEVENTHCOUNCILFATHERS: 'seventh-council-fathers',
KAZAN: 'kazan',
DEMETRIUS: 'demetrius',
MICHAELSYNAXIS: 'michael-synaxis',
NICHOLAS: 'nicholas',
CIRCUMCISION: 'circumcision',
FINDINGHEAD1ST: 'finding-head-1st',
FORTYMARTYRS: 'forty-martyrs',
JOHNTHEOLOGIANMAY: 'johntheologian-may',
NICHOLASTRANSLATION: 'nicholas-translation',
FINDINGHEAD3RD: 'finding-head-3rd',
VLADIMIR: 'vladimir',
SIXCOUNCILFATHERS: 'six-councils-fathers',
ELIJAH: 'elijah',
PANTELEIMON: 'panteleimon',
PROCESSIONCROSS: 'procession-cross',
FOREFATHERSSUNDAY: 'forefathers-sunday',
HOLYFATHERSNATIVITY: 'holy-fathers-nativity',
SUNDAYAFTERNATIVITY: 'sunday-after-nativity',
};
const hwVar = holyWeekVarMap[service.serviceType];
if (hwVar) {
const nodes = await this.loadHolyWeekData(holyWeekDir[service.serviceType]);
if (nodes) varNodes.set(hwVar, nodes);
} }
// Load Epistle and Gospel readings from commemoration // Load Epistle and Gospel readings from commemoration
@@ -1244,8 +942,8 @@ export class ServiceView {
fetchText: this.createFetchText(), fetchText: this.createFetchText(),
fetchPrayerNodes: this.createFetchPrayerNodes(), fetchPrayerNodes: this.createFetchPrayerNodes(),
fetchServiceNodes: async (path: string): Promise<ServiceNode[] | null> => { fetchServiceNodes: async (path: string): Promise<ServiceNode[] | null> => {
const varMatch = path.match(/Services\/Var\/(\w+)\.xml$/); const varMatch = path.match(/Services\/(?:Var\/)?(\w+)\.xml$/);
if (varMatch) { if (varMatch && varNodes.has(varMatch[1])) {
return varNodes.get(varMatch[1]) || null; return varNodes.get(varMatch[1]) || null;
} }
return this.fetchServiceNodes(path); return this.fetchServiceNodes(path);