From ff5b8dc649cbab20b2d1ecaec704fb2c96e07596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 31 Jul 2026 09:05:49 +0300 Subject: [PATCH] added Vespers, Matins, Great Liturgy services --- src/core/day-computer.ts | 10 ++ src/core/i18n.ts | 26 ++++- src/core/types.ts | 38 +++++++ src/ui/service-view.ts | 231 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 297 insertions(+), 8 deletions(-) diff --git a/src/core/day-computer.ts b/src/core/day-computer.ts index 69a27ef..8f2f5aa 100644 --- a/src/core/day-computer.ts +++ b/src/core/day-computer.ts @@ -95,6 +95,14 @@ export function computeDay(date: JDate): ComputedDay { // Priest status (1 = priest required, 0 = reader) const PS = (dRank >= 3 || dow === 0) ? 1 : 0; + // Eothinon (Sunday Matins Gospel cycle, 1-11) + // Based on weeks after Pascha: (⌊nday / 7⌋ % 11) + 1 for Sundays + // Non-Sundays get 0 + let eothinon = 0; + if (dow === 0 && nday >= 0) { + eothinon = (Math.floor(nday / 7) % 11) + 1; + } + const dayInfo: DayInfo = { dow, doy, @@ -120,6 +128,7 @@ export function computeDay(date: JDate): ComputedDay { isNativityFast, triodionFile, pentecostarionFile, + eothinon, }; // Build EvalContext from DayInfo (all values as numbers for the expression evaluator) @@ -139,6 +148,7 @@ export function computeDay(date: JDate): ComputedDay { Easter: pascha.jdn, Pentecost: pentecost.jdn, LentStart: lentStart.jdn, + eothinon, LS: 1, // Default language GS: 1, // Default gospel selector }; diff --git a/src/core/i18n.ts b/src/core/i18n.ts index b88bffe..fb8b0ca 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -85,6 +85,9 @@ export interface Translations { liturgy: string; royalhours: string; compline: string; + greatcompline: string; + mariasstanding: string; + matins: string; }; serviceDescriptions: { primes: string; @@ -95,6 +98,9 @@ export interface Translations { liturgy: string; royalhours: string; compline: string; + greatcompline: string; + mariasstanding: string; + matins: string; }; }; @@ -275,9 +281,12 @@ const translations: Record = { liturgy: 'Divine Liturgy', royalhours: 'Royal Hours', compline: 'Compline', + greatcompline: 'Great Compline', + mariasstanding: 'Standing of St. Mary of Egypt', + matins: 'Matins', }, serviceDescriptions: { - primes: 'Prime (First Hour)', + primes: 'First Hour', third: 'Third Hour', sixth: 'Sixth Hour', ninth: 'Ninth Hour', @@ -285,6 +294,9 @@ const translations: Record = { liturgy: 'Principal service (Divine Liturgy)', royalhours: 'Royal Hours', compline: 'Night office (Compline)', + greatcompline: 'Great Compline with the Canon of St. Andrew', + mariasstanding: 'Matins with the Great Canon and the Life of St. Mary of Egypt (Marias Standing)', + matins: 'Morning service (Matins)', }, }, @@ -461,6 +473,9 @@ const translations: Record = { liturgy: 'Божественная Литургия', royalhours: 'Царские часы', compline: 'Повечерие', + greatcompline: 'Великое Повечерие', + mariasstanding: 'Стояние Марии Египетской', + matins: 'Утреня', }, serviceDescriptions: { primes: 'Час первый', @@ -471,6 +486,9 @@ const translations: Record = { liturgy: 'Главное богослужение (Божественная Литургия)', royalhours: 'Царские часы', compline: 'Ночное богослужение (Повечерие)', + greatcompline: 'Великое Повечерие с каноном Андрея Критского', + mariasstanding: 'Утреня с Великим каноном и житием Марии Египетской (Мариино стояние)', + matins: 'Утреннее богослужение (Утреня)', }, }, @@ -647,6 +665,9 @@ const translations: Record = { liturgy: 'Божественная Литургия', royalhours: 'Царскія часъ', compline: 'Повечерiе', + greatcompline: 'Вели́кое Повече́рiе', + mariasstanding: 'Стоѧ́нїе ма́ріи є҆гѵ́петскїѧ', + matins: 'Оу҆́тренѧ', }, serviceDescriptions: { primes: 'Часъ первый', @@ -657,6 +678,9 @@ const translations: Record = { liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)', royalhours: 'Царскія часъ', compline: 'Ночное богослуженiе (Повечерiе)', + greatcompline: 'Вели́кое Повече́рiе съ кано́номъ а҆ндре́а кри́тскагѡ', + mariasstanding: 'Оу҆́тренѧ съ вели́кимъ кано́номъ и҆ житїе́мъ ма́ріи є҆гѵ́петскїѧ (Марі́ино стоѧ́нїе)', + matins: 'Оу҆́треннее богослуженiе (Оу҆́тренѧ)', }, }, diff --git a/src/core/types.ts b/src/core/types.ts index afc550e..b37d25e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -37,6 +37,44 @@ export interface DayInfo { // Data file references triodionFile: number | null; // Index in triodion (1-70) pentecostarionFile: number | null; // Index in pentecostarion (1-315) + eothinon: number; // Sunday Matins Eothinon (1-11, 0 = none) +} + +/** A single sub-canon within an ode. */ +export interface CanonSubCanon { + irmos: string; + troparia: string[]; + theotokion: string; +} + +/** A single ode within a Matins canon. */ +export interface CanonOde { + ode: number; // Ode number (1,3,4,5,6,7,8,9) + canons: CanonSubCanon[]; // Sub-canons per ode +} + +/** Matins canon data for a given tone. */ +export interface CanonData { + tone: number; + odes: CanonOde[]; +} + +/** A single ode within the Great Canon of St. Andrew. */ +export interface GreatCanonOde { + ode: number; // 1-9 (includes Ode 2) + irmos: string; + troparia: string[]; // Penitential troparia + trinityTroparion: string; + theotokion: string; + kontakion?: string; // After Ode 6: "Душе моя..." + saintTroparia?: string[]; // Troparia to St. Mary of Egypt / St. Andrew +} + +/** One part of the Great Canon (divided across the 1st week). */ +export interface GreatCanonPart { + part: number; // 1-4 for first week + title: string; + odes: GreatCanonOde[]; } /** Expression evaluator variable store. */ diff --git a/src/ui/service-view.ts b/src/ui/service-view.ts index 273024e..c4b4cfa 100644 --- a/src/ui/service-view.ts +++ b/src/ui/service-view.ts @@ -8,7 +8,7 @@ import { JDate } from '../core/jdate'; import { computeDay } from '../core/day-computer'; import { assembleService, type ServiceContext } from '../core/service-assembler'; import { evalBool } from '../core/evaluator'; -import type { EvalContext, ServiceNode } from '../core/types'; +import type { CanonData, GreatCanonPart, EvalContext, ServiceNode } from '../core/types'; import { getTranslations, type LanguageCode } from '../core/i18n'; import { loadSettings, fontClass } from './settings-view'; @@ -161,6 +161,13 @@ export class ServiceView { titleNode: title('Vespers1', this.t.services.serviceNames.vespers, 'VesperSource'), }); + services.push({ + id: 'matins', name: this.t.services.serviceNames.matins, serviceType: 'MATINS', + template: 'Matins', + description: this.t.services.serviceDescriptions.matins, + titleNode: title('Matins1', this.t.services.serviceNames.matins, 'MatinsSource'), + }); + services.push({ id: 'liturgy', name: this.t.services.serviceNames.liturgy, serviceType: 'LITURGY', template: 'DivineLiturgy', @@ -177,10 +184,30 @@ export class ServiceView { }); } + // Great Compline: Lenten weekdays (Mon-Thu) + if (dayInfo.isLent && dayInfo.dow >= 1 && dayInfo.dow <= 4) { + services.push({ + id: 'greatcompline', name: this.t.services.serviceNames.greatcompline, serviceType: 'GREATCOMPLINE', + template: 'GreatCompline', + description: this.t.services.serviceDescriptions.greatcompline, + titleNode: title('GreatCompline1', this.t.services.serviceNames.greatcompline), + }); + } + + // Standing of St. Mary of Egypt: Thursday of the 5th week of Great Lent + if (dayInfo.isLent && dayInfo.nday === -17) { + services.push({ + id: 'mariasstanding', name: this.t.services.serviceNames.mariasstanding, serviceType: 'MARIASSTANDING', + template: 'MariasStanding', + description: this.t.services.serviceDescriptions.mariasstanding, + titleNode: title('MariasStanding1', this.t.services.serviceNames.mariasstanding), + }); + } + return services; } - private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: 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 { dayInfo } = computed; const tone = dayInfo.Tone; @@ -189,7 +216,7 @@ export class ServiceView { const dd = String(dayInfo.day).padStart(2, '0'); const dateKey = `${mm}-${dd}`; - let result: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string } | null = null; + let result: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null = null; if (tone > 0 && tone <= 8) { const toneNum = tone === 8 ? 0 : tone; @@ -202,14 +229,15 @@ export class ServiceView { if (!resp.ok) return null; const nodes: ServiceNode[] = await resp.json(); for (const node of nodes) { - if (node.type === serviceType && node.cmd) { - if (evalBool(node.cmd as string, this.evalContext)) { + if (node.type === serviceType) { + if (!node.cmd || evalBool(node.cmd as string, this.evalContext)) { return { troparion1: node.troparion1 as string | undefined, kontakion1: node.kontakion1 as string | undefined, theotokion1: node.theotokion1 as string | undefined, prokimenon1: node.prokimenon1 as string | undefined, alleluia1: node.alleluia1 as string | undefined, + hypakoe1: node.hypakoe1 as string | undefined, }; } } @@ -313,10 +341,130 @@ export class ServiceView { return result; } - private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string } | null, serviceType: string): Map { + private async loadCanonData(tone: number): Promise { + if (tone < 1 || tone > 8) return null; + const toneNum = tone === 8 ? 0 : tone; + const url = `/data/shared/services/canons/tone${toneNum}/sunday.json`; + try { + const resp = await fetch(url); + if (!resp.ok) return null; + const canonData: CanonData = await resp.json(); + const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ']; + + const nodes: ServiceNode[] = []; + for (const ode of canonData.odes) { + const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode); + nodes.push({ type: 'HEADER', value: `Пѣснь ${slavNum}҃.` }); + + for (const canon of ode.canons) { + if (canon.irmos) { + nodes.push({ type: 'HEADER', value: `Ірмосъ, гла́съ ${canonData.tone}:` }); + nodes.push({ type: 'TEXT', value: canon.irmos }); + } + for (const trop of canon.troparia) { + nodes.push({ type: 'TEXT', value: trop }); + } + if (canon.theotokion) { + nodes.push({ type: 'HEADER', value: 'Бг҃ородиченъ:' }); + nodes.push({ type: 'TEXT', value: canon.theotokion }); + } + } + } + return nodes; + } catch { + return null; + } + } + + private async loadMariasStandingData(): Promise { + const url = `/data/shared/services/marias-standing/full.json`; + try { + const resp = await fetch(url); + if (!resp.ok) return null; + const nodes: ServiceNode[] = await resp.json(); + return nodes; + } catch { + return null; + } + } + + private async loadGreatCanonData(): Promise { + const computed = computeDay(this.currentDate); + const { dayInfo } = computed; + const nday = dayInfo.nday; + // Determine which part of the Great Canon: + // 1st week: nday -48 (Mon) to -45 (Thu) → parts 1-4 + // Other Lenten weekdays: default to part 1 + let part: number; + if (nday >= -48 && nday <= -45) { + part = nday + 49; // -48→1, -47→2, -46→3, -45→4 + } else { + part = 1; + } + + const url = `/data/shared/services/canons/great-canon/part${part}.json`; + try { + const resp = await fetch(url); + if (!resp.ok) return null; + const canonData: GreatCanonPart = await resp.json(); + const ODE_SLAV = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃', 'ѳ҃']; + const nodes: ServiceNode[] = []; + + nodes.push({ type: 'HEADER', value: 'Вели́кїй канѡ́нъ, гла́съ ѕ҃.' }); + nodes.push({ type: 'HEADER', value: 'Творе́нїе ст҃а́гѡ ѻ҆тца̀ на́шегѡ а҆ндре́а кри́тскагѡ.' }); + + for (const ode of canonData.odes) { + const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode); + nodes.push({ type: 'HEADER', value: `Пѣ́снь ${slavNum}.` }); + + // Irmos + if (ode.irmos) { + nodes.push({ type: 'HEADER', value: 'І҆рмо́съ:' }); + nodes.push({ type: 'TEXT', value: ode.irmos }); + } + + // Penitential troparia with refrain + const refrain = 'Поми́лꙋй мѧ̀, бж҃е, поми́лꙋй мѧ̀.'; + for (const trop of ode.troparia) { + nodes.push({ type: 'TEXT', value: `${trop}\nПрипѣ́въ: ${refrain}` }); + } + + // Trinity troparion + if (ode.trinityTroparion) { + nodes.push({ type: 'HEADER', value: 'Сла́ва, трⷪ҇ченъ:' }); + nodes.push({ type: 'TEXT', value: ode.trinityTroparion }); + } + + // Theotokion + if (ode.theotokion) { + nodes.push({ type: 'HEADER', value: 'И҆ ны́нѣ, бг҃оро́диченъ:' }); + nodes.push({ type: 'TEXT', value: ode.theotokion }); + } + + // Saint troparia (St. Mary of Egypt / St. Andrew) + if (ode.saintTroparia && ode.saintTroparia.length > 0) { + for (const saintTrop of ode.saintTroparia) { + nodes.push({ type: 'TEXT', value: saintTrop }); + } + } + + // Kontakion (after Ode 6) + if (ode.kontakion) { + nodes.push({ type: 'HEADER', value: 'Конда́къ, гла́съ ѕ҃:' }); + nodes.push({ type: 'TEXT', value: ode.kontakion }); + } + } + + return nodes; + } catch { + return null; + } + } + + private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null, serviceType: string): Map { const varNodes = new Map(); - const prefix: Record = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9', VESPERS: 'V', LITURGY: 'L' }; + const prefix: Record = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9', VESPERS: 'V', LITURGY: 'L', MATINS: 'U', GREATCOMPLINE: 'GC', MARIASSTANDING: 'MS' }; const p = prefix[serviceType] ?? ''; if (toneData?.troparion1) { @@ -379,6 +527,50 @@ export class ServiceView { }]); } + if (toneData?.hypakoe1) { + const key = `PHypak${p}1`; + varNodes.set(key, [{ + type: 'CREATE', + what: toneData.hypakoe1, + who: '', + header: true, + redfirst: true, + newline: true, + }]); + } + + // Exapostilarion uses Eothinon (11-week cycle), not tone + if (serviceType === 'MATINS') { + const computed = computeDay(this.currentDate); + const eoth = computed.dayInfo.eothinon; + if (eoth >= 1 && eoth <= 11) { + const key = `PExapost${p}1`; + varNodes.set(key, [{ + type: 'CREATE', + what: `Exapostilarion${eoth}`, + who: '', + header: true, + redfirst: true, + newline: true, + }]); + } + } + + // Troparion after Doxology: odd tones use TroparAfterDoxologyOdd, even use TroparAfterDoxologyEven + if (serviceType === 'MATINS') { + const computed = computeDay(this.currentDate); + const tone = computed.dayInfo.Tone; + const doxFile = (tone % 2 === 1) ? 'TroparAfterDoxologyOdd' : 'TroparAfterDoxologyEven'; + varNodes.set('PTropDoxU1', [{ + type: 'CREATE', + what: doxFile, + who: 'C', + header: true, + redfirst: true, + newline: true, + }]); + } + return varNodes; } @@ -398,6 +590,31 @@ export class ServiceView { const toneData = await this.loadToneData(service.serviceType); const varNodes = this.generateVarNodes(toneData, service.serviceType); + // Load Matins canon data + if (service.serviceType === 'MATINS') { + const computed = computeDay(this.currentDate); + const canonNodes = await this.loadCanonData(computed.dayInfo.Tone); + if (canonNodes) { + varNodes.set('PCanonU', canonNodes); + } + } + + // Load Great Canon data for Great Compline + if (service.serviceType === 'GREATCOMPLINE') { + const canonNodes = await this.loadGreatCanonData(); + if (canonNodes) { + varNodes.set('PGreatCanon', canonNodes); + } + } + + // Load Standing of St. Mary of Egypt data + if (service.serviceType === 'MARIASSTANDING') { + const standingNodes = await this.loadMariasStandingData(); + if (standingNodes) { + varNodes.set('PMariasStanding', standingNodes); + } + } + // Load Epistle and Gospel readings from commemoration const loadReadings = async () => { const ids: string[] = [];