added Vespers, Matins, Great Liturgy services

This commit is contained in:
catarrh committed 2026-07-31 09:05:49 +03:00
1 parent 8e6c63c003
commit ff5b8dc649
4 files changed
+297 -8

No files matched your search

+10
View File
@@ -95,6 +95,14 @@ export function computeDay(date: JDate): ComputedDay {
// Priest status (1 = priest required, 0 = reader) // Priest status (1 = priest required, 0 = reader)
const PS = (dRank >= 3 || dow === 0) ? 1 : 0; 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 = { const dayInfo: DayInfo = {
dow, dow,
doy, doy,
@@ -120,6 +128,7 @@ export function computeDay(date: JDate): ComputedDay {
isNativityFast, isNativityFast,
triodionFile, triodionFile,
pentecostarionFile, pentecostarionFile,
eothinon,
}; };
// Build EvalContext from DayInfo (all values as numbers for the expression evaluator) // 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, Easter: pascha.jdn,
Pentecost: pentecost.jdn, Pentecost: pentecost.jdn,
LentStart: lentStart.jdn, LentStart: lentStart.jdn,
eothinon,
LS: 1, // Default language LS: 1, // Default language
GS: 1, // Default gospel selector GS: 1, // Default gospel selector
}; };
+25 -1
View File
@@ -85,6 +85,9 @@ export interface Translations {
liturgy: string; liturgy: string;
royalhours: string; royalhours: string;
compline: string; compline: string;
greatcompline: string;
mariasstanding: string;
matins: string;
}; };
serviceDescriptions: { serviceDescriptions: {
primes: string; primes: string;
@@ -95,6 +98,9 @@ export interface Translations {
liturgy: string; liturgy: string;
royalhours: string; royalhours: string;
compline: string; compline: string;
greatcompline: string;
mariasstanding: string;
matins: string;
}; };
}; };
@@ -275,9 +281,12 @@ const translations: Record<LanguageCode, Translations> = {
liturgy: 'Divine Liturgy', liturgy: 'Divine Liturgy',
royalhours: 'Royal Hours', royalhours: 'Royal Hours',
compline: 'Compline', compline: 'Compline',
greatcompline: 'Great Compline',
mariasstanding: 'Standing of St. Mary of Egypt',
matins: 'Matins',
}, },
serviceDescriptions: { serviceDescriptions: {
primes: 'Prime (First Hour)', primes: 'First Hour',
third: 'Third Hour', third: 'Third Hour',
sixth: 'Sixth Hour', sixth: 'Sixth Hour',
ninth: 'Ninth Hour', ninth: 'Ninth Hour',
@@ -285,6 +294,9 @@ const translations: Record<LanguageCode, Translations> = {
liturgy: 'Principal service (Divine Liturgy)', liturgy: 'Principal service (Divine Liturgy)',
royalhours: 'Royal Hours', royalhours: 'Royal Hours',
compline: 'Night office (Compline)', 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<LanguageCode, Translations> = {
liturgy: 'Божественная Литургия', liturgy: 'Божественная Литургия',
royalhours: 'Царские часы', royalhours: 'Царские часы',
compline: 'Повечерие', compline: 'Повечерие',
greatcompline: 'Великое Повечерие',
mariasstanding: 'Стояние Марии Египетской',
matins: 'Утреня',
}, },
serviceDescriptions: { serviceDescriptions: {
primes: 'Час первый', primes: 'Час первый',
@@ -471,6 +486,9 @@ const translations: Record<LanguageCode, Translations> = {
liturgy: 'Главное богослужение (Божественная Литургия)', liturgy: 'Главное богослужение (Божественная Литургия)',
royalhours: 'Царские часы', royalhours: 'Царские часы',
compline: 'Ночное богослужение (Повечерие)', compline: 'Ночное богослужение (Повечерие)',
greatcompline: 'Великое Повечерие с каноном Андрея Критского',
mariasstanding: 'Утреня с Великим каноном и житием Марии Египетской (Мариино стояние)',
matins: 'Утреннее богослужение (Утреня)',
}, },
}, },
@@ -647,6 +665,9 @@ const translations: Record<LanguageCode, Translations> = {
liturgy: 'Божественная Литургия', liturgy: 'Божественная Литургия',
royalhours: 'Царскія часъ', royalhours: 'Царскія часъ',
compline: 'Повечерiе', compline: 'Повечерiе',
greatcompline: 'Вели́кое Повече́рiе',
mariasstanding: 'Стоѧ́нїе ма́ріи є҆гѵ́петскїѧ',
matins: 'Оу҆́тренѧ',
}, },
serviceDescriptions: { serviceDescriptions: {
primes: 'Часъ первый', primes: 'Часъ первый',
@@ -657,6 +678,9 @@ const translations: Record<LanguageCode, Translations> = {
liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)', liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)',
royalhours: 'Царскія часъ', royalhours: 'Царскія часъ',
compline: 'Ночное богослуженiе (Повечерiе)', compline: 'Ночное богослуженiе (Повечерiе)',
greatcompline: 'Вели́кое Повече́рiе съ кано́номъ а҆ндре́а кри́тскагѡ',
mariasstanding: 'Оу҆́тренѧ съ вели́кимъ кано́номъ и҆ житїе́мъ ма́ріи є҆гѵ́петскїѧ (Марі́ино стоѧ́нїе)',
matins: 'Оу҆́треннее богослуженiе (Оу҆́тренѧ)',
}, },
}, },
+38
View File
@@ -37,6 +37,44 @@ export interface DayInfo {
// Data file references // Data file references
triodionFile: number | null; // Index in triodion (1-70) triodionFile: number | null; // Index in triodion (1-70)
pentecostarionFile: number | null; // Index in pentecostarion (1-315) 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. */ /** Expression evaluator variable store. */
+224 -7
View File
@@ -8,7 +8,7 @@ import { JDate } from '../core/jdate';
import { computeDay } from '../core/day-computer'; import { computeDay } from '../core/day-computer';
import { assembleService, type ServiceContext } from '../core/service-assembler'; import { assembleService, type ServiceContext } from '../core/service-assembler';
import { evalBool } from '../core/evaluator'; 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 { getTranslations, type LanguageCode } from '../core/i18n';
import { loadSettings, fontClass } from './settings-view'; import { loadSettings, fontClass } from './settings-view';
@@ -161,6 +161,13 @@ export class ServiceView {
titleNode: title('Vespers1', this.t.services.serviceNames.vespers, 'VesperSource'), 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({ services.push({
id: 'liturgy', name: this.t.services.serviceNames.liturgy, serviceType: 'LITURGY', id: 'liturgy', name: this.t.services.serviceNames.liturgy, serviceType: 'LITURGY',
template: 'DivineLiturgy', 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; 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 computed = computeDay(this.currentDate);
const { dayInfo } = computed; const { dayInfo } = computed;
const tone = dayInfo.Tone; const tone = dayInfo.Tone;
@@ -189,7 +216,7 @@ export class ServiceView {
const dd = String(dayInfo.day).padStart(2, '0'); const dd = String(dayInfo.day).padStart(2, '0');
const dateKey = `${mm}-${dd}`; 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) { if (tone > 0 && tone <= 8) {
const toneNum = tone === 8 ? 0 : tone; const toneNum = tone === 8 ? 0 : tone;
@@ -202,14 +229,15 @@ export class ServiceView {
if (!resp.ok) return null; if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json(); const nodes: ServiceNode[] = await resp.json();
for (const node of nodes) { for (const node of nodes) {
if (node.type === serviceType && node.cmd) { if (node.type === serviceType) {
if (evalBool(node.cmd as string, this.evalContext)) { if (!node.cmd || evalBool(node.cmd as string, this.evalContext)) {
return { return {
troparion1: node.troparion1 as string | undefined, troparion1: node.troparion1 as string | undefined,
kontakion1: node.kontakion1 as string | undefined, kontakion1: node.kontakion1 as string | undefined,
theotokion1: node.theotokion1 as string | undefined, theotokion1: node.theotokion1 as string | undefined,
prokimenon1: node.prokimenon1 as string | undefined, prokimenon1: node.prokimenon1 as string | undefined,
alleluia1: node.alleluia1 as string | undefined, alleluia1: node.alleluia1 as string | undefined,
hypakoe1: node.hypakoe1 as string | undefined,
}; };
} }
} }
@@ -313,10 +341,130 @@ export class ServiceView {
return result; return result;
} }
private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string } | null, serviceType: string): Map<string, ServiceNode[]> { private async loadCanonData(tone: number): Promise<ServiceNode[] | null> {
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<ServiceNode[] | null> {
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<ServiceNode[] | null> {
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<string, ServiceNode[]> {
const varNodes = new Map<string, ServiceNode[]>(); const varNodes = new Map<string, ServiceNode[]>();
const prefix: Record<string, string> = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9', VESPERS: 'V', LITURGY: 'L' }; const prefix: Record<string, string> = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9', VESPERS: 'V', LITURGY: 'L', MATINS: 'U', GREATCOMPLINE: 'GC', MARIASSTANDING: 'MS' };
const p = prefix[serviceType] ?? ''; const p = prefix[serviceType] ?? '';
if (toneData?.troparion1) { 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; return varNodes;
} }
@@ -398,6 +590,31 @@ export class ServiceView {
const toneData = await this.loadToneData(service.serviceType); const toneData = await this.loadToneData(service.serviceType);
const varNodes = this.generateVarNodes(toneData, 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 // Load Epistle and Gospel readings from commemoration
const loadReadings = async () => { const loadReadings = async () => {
const ids: string[] = []; const ids: string[] = [];