initial commit

This commit is contained in:
catarrh committed 2026-07-23 02:41:15 +03:00
commit 58b379d6e3
47 files changed
+8664

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
/**
* Bible Reader — parses .text files and manages Bible navigation.
* Port of key Bible.java functionality.
*
* Bible.java is part of the Ponomar program.
* Copyright 2007, 2008 Aleksandr Andreev.
* GPL v3 — see LICENSE.
*/
// Bible types used for version metadata
export interface BiblePassage {
bookId: string;
chapter: number;
verses: { num: number; text: string }[];
}
export interface ParsedReading {
chapters: number[];
verses: number[];
}
/**
* Parse a passage string like "2:11-3:2, 5, 13-14" into structured data.
*/
export function parsePassage(passage: string): ParsedReading {
const chapters: number[] = [];
const verses: number[] = [];
if (!passage.includes(':')) {
// Just a chapter, e.g. "1"
const ch = parseInt(passage, 10);
chapters.push(ch);
verses.push(1);
return { chapters, verses };
}
// Composite passage: "2:11-3:2, 5, 13-14"
const parts = passage.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed.includes('-')) {
// Single verse or chapter:verse
if (trimmed.includes(':')) {
const [ch, v] = trimmed.split(':');
chapters.push(parseInt(ch, 10));
verses.push(parseInt(v, 10));
} else {
const v = parseInt(trimmed, 10);
chapters.push(0); // current chapter
verses.push(v);
}
} else {
// Range: "2:11-3:2" or "13-14"
const [start, end] = trimmed.split('-');
if (start.includes(':')) {
const [ch, v] = start.split(':');
chapters.push(parseInt(ch, 10));
verses.push(parseInt(v, 10));
} else {
chapters.push(0);
verses.push(parseInt(start, 10));
}
if (end.includes(':')) {
const [ch, v] = end.split(':');
chapters.push(parseInt(ch, 10));
verses.push(parseInt(v, 10));
} else {
chapters.push(0);
verses.push(parseInt(end, 10));
}
}
}
return { chapters, verses };
}
/**
* Parse a .text file's content into structured verses.
* Format: "#N" = chapter marker, "N|" = verse marker followed by text.
*/
export function parseTextFile(content: string): Map<number, Map<number, string>> {
const chapters = new Map<number, Map<number, string>>();
let currentChapter = 0;
let currentVerses = new Map<number, string>();
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('#')) {
// Chapter marker
if (currentChapter > 0) {
chapters.set(currentChapter, currentVerses);
}
currentChapter = parseInt(trimmed.substring(1), 10);
currentVerses = new Map();
} else if (trimmed.includes('|')) {
// Verse marker
const pipeIdx = trimmed.indexOf('|');
const verseNum = parseInt(trimmed.substring(0, pipeIdx), 10);
const verseText = trimmed.substring(pipeIdx + 1).trim();
currentVerses.set(verseNum, verseText);
}
}
// Don't forget the last chapter
if (currentChapter > 0) {
chapters.set(currentChapter, currentVerses);
}
return chapters;
}
/**
* Extract a passage from parsed chapter data.
*/
export function extractPassage(
chapters: Map<number, Map<number, string>>,
reading: ParsedReading
): { chapter: number; verse: number; text: string }[] {
const result: { chapter: number; verse: number; text: string }[] = [];
for (let i = 0; i < reading.chapters.length; i += 2) {
const startCh = reading.chapters[i];
const startV = reading.verses[i];
const endCh = reading.chapters[i + 1] ?? startCh;
const endV = reading.verses[i + 1] ?? -1;
for (const [chNum, verses] of chapters) {
if (chNum < startCh) continue;
if (chNum > endCh) break;
for (const [vNum, text] of verses) {
if (chNum === startCh && vNum < startV) continue;
if (chNum === endCh && endV > 0 && vNum > endV) continue;
result.push({ chapter: chNum, verse: vNum, text });
}
}
}
return result;
}
/**
* Format a passage for display.
*/
export function formatPassageHTML(
passages: { chapter: number; verse: number; text: string }[],
options?: { showVerseNumbers?: boolean; verseNewLine?: boolean }
): string {
const showVerseNum = options?.showVerseNumbers ?? true;
const verseNewLine = options?.verseNewLine ?? false;
let html = '';
let lastChapter = 0;
for (const p of passages) {
if (p.chapter !== lastChapter) {
if (lastChapter > 0 && !verseNewLine) html += '</p>';
if (!verseNewLine) html += '<p>';
lastChapter = p.chapter;
}
let text = p.text;
text = text.replace(/\*\*(.*?)\*\*/g, '<span class="text-red italic text-sm">$1</span>');
text = text.replace(/\*\*/g, '');
if (verseNewLine && p.verse > 0) {
const num = showVerseNum ? `<sup class="text-red text-xs">${p.verse}</sup> ` : '';
html += `<span class="block mt-1">${num}${text.charAt(0).toUpperCase() + text.slice(1)}</span>`;
} else {
if (showVerseNum && p.verse > 0) {
html += `<sup class="text-red text-xs">${p.verse}</sup> `;
}
html += text + ' ';
}
}
if (!verseNewLine && lastChapter > 0) html += '</p>';
return html;
}
/**
* Create a hyperlink for a Bible reading reference.
*/
export function createReadingLink(
bookId: string,
passage: string,
abbrevFormat: string,
abbreviations: Record<string, string>
): string {
const abbrev = abbreviations[bookId] ?? bookId;
const display = abbrevFormat
.replace('^NAME', abbrev)
.replace('^CNN', passage);
return `<a href="bible://${bookId}/${passage}" class="text-blue-underline">${display}</a>`;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Day Computer — computes all liturgical variables for a given Julian date.
* Replaces the core of Main.write() in the Java version.
*/
import { JDate } from './jdate';
import { Paschalion } from './paschalion';
import type { EvalContext, DayInfo } from './types';
export interface ComputedDay {
jdate: JDate;
gregorian: Date;
evalContext: EvalContext;
dayInfo: DayInfo;
}
/**
* Compute all liturgical variables for a given Julian date.
* Returns both the EvalContext (for expression evaluator) and structured DayInfo.
*/
export function computeDay(date: JDate): ComputedDay {
const year = date.getYear();
const dow = date.getDayOfWeek();
const doy = date.getDoy();
const month = date.getMonth();
const day = date.getDay();
const gregorian = date.toGregorian();
// Paschal distances
const pascha = Paschalion.getPascha(year);
const nday = date.difference(pascha);
const ndayP = date.difference(Paschalion.getPascha(year - 1));
const ndayF = date.difference(Paschalion.getPascha(year + 1));
const pentecost = Paschalion.getPentecost(year);
const lentStart = Paschalion.getLentStart(year);
// Determine file indices for triodion/pentecostarion
let triodionFile: number | null = null;
let pentecostarionFile: number | null = null;
if (nday >= -70 && nday < 0) {
// Great Lent period — triodion
triodionFile = Math.abs(nday);
} else if (nday < -70) {
// Before Lent — pentecostarion from last year
const lastPascha = Paschalion.getPascha(year - 1);
pentecostarionFile = date.difference(lastPascha) + 1;
} else {
// After Pascha — pentecostarion
pentecostarionFile = nday + 1;
}
// Determine liturgical state
const isLent = nday >= -48 && nday < 0;
const isBrightWeek = nday >= 0 && nday <= 6;
const isApostlesFast = nday >= 57 && nday <= Paschalion.getApostlesFastLength(year);
const isDormitionFast = month === 8 && day >= 1 && day <= 14;
const isNativityFast = (month === 11 && day >= 15) || (month === 12 && day <= 24);
// Tone computation — 8-week cycle after Pentecost
// Matches Java pentecostarion XML: tone = (⌊(nday - 7) / 7⌋ % 8) + 1
// Exceptions: Ascension (nday 39-40) and Pentecost week (nday 48-55) have no tone
let tone = -1;
if (nday >= 7) {
if ((nday >= 39 && nday <= 40) || (nday >= 48 && nday <= 55)) {
tone = -1; // Ascension or Pentecost week — no octoechos tone
} else {
tone = (Math.floor((nday - 7) / 7) % 8) + 1;
}
}
// Rank computation
let dRank = 0;
// Major feasts get higher rank
if (month === 9 && day === 14) dRank = 6; // Exaltation
else if (month === 8 && day === 29) dRank = 6; // Beheading
else if (nday === 0) dRank = 8; // Pascha
else if (nday === 49) dRank = 8; // Pentecost
else if (nday === 39) dRank = 8; // Ascension
else if (nday === -7) dRank = 8; // Palm Sunday
else if (month === 12 && day === 25) dRank = 6; // Nativity
else if (month === 1 && day === 6) dRank = 6; // Theophany
else if (month === 8 && day === 15) dRank = 6; // Dormition
else if (month === 3 && day === 25) dRank = 6; // Annunciation
else if (nday >= -48 && nday < 0) dRank = 3; // Lent
else if (isBrightWeek || nday >= 49 && nday <= 56) dRank = 5; // Bright/Pentecost week
// Paschal flag
const PFlag = nday >= 0 ? 1 : 0;
const PFlag1 = 1; // Default service inclusion
const PFlag2 = isBrightWeek || (nday >= -7 && nday < 0) ? 2 : (isLent && dow !== 0 ? 1 : 0);
// Priest status (1 = priest required, 0 = reader)
const PS = (dRank >= 3 || dow === 0) ? 1 : 0;
const dayInfo: DayInfo = {
dow,
doy,
month,
day,
year,
nday,
ndayP,
ndayF,
Easter: pascha.jdn,
Pentecost: pentecost.jdn,
LentStart: lentStart.jdn,
Tone: tone,
dRank,
PFlag,
PFlag1,
PFlag2,
PS,
isLent,
isBrightWeek,
isApostlesFast,
isDormitionFast,
isNativityFast,
triodionFile,
pentecostarionFile,
};
// Build EvalContext from DayInfo (all values as numbers for the expression evaluator)
const evalContext: EvalContext = {
dow,
doy,
nday,
ndayP,
ndayF,
Year: year,
dRank,
PFlag,
PFlag1,
PFlag2,
PS,
Tone: tone,
Easter: pascha.jdn,
Pentecost: pentecost.jdn,
LentStart: lentStart.jdn,
LS: 1, // Default language
GS: 1, // Default gospel selector
};
return { jdate: date, gregorian, evalContext, dayInfo };
}
+294
View File
@@ -0,0 +1,294 @@
/**
* Expression evaluator — recursive descent parser.
* Faithful port of Ponomar/StringOp.java to TypeScript.
* Supports: ||, &&, ==, !=, <, >, <=, >=, +, -, *, /, %, unary !, unary -, true/false, variables.
*
* StringOp.java is part of the Ponomar program.
* Copyright 2007 Aleksandr Andreev and Yuri Shardt.
* See LICENSE for redistribution terms.
*/
import type { EvalContext } from './types';
function bool2double(expr: boolean): number {
return expr ? 1.0 : 0.0;
}
/**
* Evaluate a boolean expression using dayInfo variable context.
* Returns true if the expression evaluates to a non-zero number.
*/
export function evalBool(expression: string, ctx: EvalContext): boolean {
return evalNum(expression, ctx) !== 0;
}
/**
* Evaluate a numeric expression.
* Fully obeys correct order of operations (recursive descent).
*/
export function evalNum(expression: string, ctx: EvalContext): number {
const parser = new ExprParser(expression.trim(), ctx);
return parser.parseOr();
}
class ExprParser {
private pos = 0;
private readonly src: string;
private readonly ctx: EvalContext;
constructor(expression: string, ctx: EvalContext) {
// Strip all spaces for tokenization (matches Java behavior)
this.src = expression.replace(/\s/g, '');
this.ctx = ctx;
}
private peek(): string {
return this.pos < this.src.length ? this.src[this.pos] : '';
}
private advance(): string {
const ch = this.src[this.pos];
this.pos++;
return ch;
}
private peekTwo(): string {
return this.src.substring(this.pos, this.pos + 2);
}
/**
* Read a token: number, variable name, or keyword (true/false).
*/
private readToken(): string {
// Number
if (/[0-9.]/.test(this.peek())) {
let num = '';
while (this.pos < this.src.length && /[0-9.eE+\-]/.test(this.peek())) {
num += this.advance();
}
return num;
}
// Variable or keyword
if (/[a-zA-Z_]/.test(this.peek())) {
let name = '';
while (this.pos < this.src.length && /[a-zA-Z_0-9]/.test(this.peek())) {
name += this.advance();
}
return name;
}
return '';
}
/**
* OR operator: lowest precedence.
* expr || expr
*/
parseOr(): number {
let left = this.parseAnd();
while (this.pos < this.src.length && this.peekTwo() === '||') {
this.pos += 2;
const right = this.parseAnd();
left = bool2double(evalBool(String(left !== 0), this.ctx) || evalBool(String(right !== 0), this.ctx));
}
return left;
}
/**
* AND operator.
*/
parseAnd(): number {
let left = this.parseEquality();
while (this.pos < this.src.length && this.peekTwo() === '&&') {
this.pos += 2;
const right = this.parseEquality();
left = bool2double(evalBool(String(left !== 0), this.ctx) && evalBool(String(right !== 0), this.ctx));
}
return left;
}
/**
* Equality: == and !=
*/
parseEquality(): number {
let left = this.parseComparison();
while (this.pos < this.src.length) {
if (this.peekTwo() === '==') {
this.pos += 2;
const right = this.parseComparison();
left = bool2double(left === right);
} else if (this.peekTwo() === '!=') {
this.pos += 2;
const right = this.parseComparison();
left = bool2double(left !== right);
} else {
break;
}
}
return left;
}
/**
* Comparison: <, >, <=, >=
*/
parseComparison(): number {
let left = this.parseAddSub();
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '<' || ch === '>') {
const next = this.pos + 1 < this.src.length ? this.src[this.pos + 1] : '';
if (next === '=') {
// <= or >=
if (ch === '<') {
this.pos += 2;
const right = this.parseAddSub();
left = bool2double(left <= right);
} else {
this.pos += 2;
const right = this.parseAddSub();
left = bool2double(left >= right);
}
} else {
// < or >
if (ch === '<') {
this.pos += 1;
const right = this.parseAddSub();
left = bool2double(left < right);
} else {
this.pos += 1;
const right = this.parseAddSub();
left = bool2double(left > right);
}
}
} else {
break;
}
}
return left;
}
/**
* Addition and subtraction.
* Must distinguish binary + from unary +/-.
*/
parseAddSub(): number {
let left = this.parseMulDiv();
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '+') {
this.pos += 1;
const right = this.parseMulDiv();
left = left + right;
} else if (ch === '-') {
// Check if this is a binary minus (preceded by a value) or unary
// If preceded by operator or start, it's unary — handled by parseMulDiv → parseUnary
// Here we need to check if this is a binary minus
// A minus is binary if preceded by: number, ), variable, true, false
if (this.pos > 0) {
const prevCh = this.src[this.pos - 1];
if (/[0-9)TrueFals]/.test(prevCh) || prevCh === 'e' || prevCh === 'E') {
this.pos += 1;
const right = this.parseMulDiv();
left = left - right;
} else {
break;
}
} else {
break;
}
} else {
break;
}
}
return left;
}
/**
* Multiplication, division, modulus.
*/
parseMulDiv(): number {
let left = this.parseUnary();
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '*') {
this.pos += 1;
const right = this.parseUnary();
left = left * right;
} else if (ch === '/') {
this.pos += 1;
const right = this.parseUnary();
left = left / right;
} else if (ch === '%') {
this.pos += 1;
const right = this.parseUnary();
left = left % right;
} else {
break;
}
}
return left;
}
/**
* Unary operators: !, -
*/
parseUnary(): number {
// Handle repeated unary operators (e.g., "!!", "---")
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '!') {
this.pos += 1;
const val = this.parseUnary();
return bool2double(val === 0);
} else if (ch === '-') {
this.pos += 1;
const val = this.parseUnary();
return -val;
} else if (ch === '+') {
this.pos += 1;
continue;
} else {
break;
}
}
return this.parsePrimary();
}
/**
* Primary: number, variable, parenthesized expression, true/false.
*/
parsePrimary(): number {
// Parenthesized expression
if (this.peek() === '(') {
this.pos++; // skip (
const val = this.parseOr();
if (this.peek() === ')') {
this.pos++; // skip )
}
return val;
}
const token = this.readToken();
if (token === 'true' || token === 'True') {
return 1.0;
}
if (token === 'false' || token === 'False') {
return 0.0;
}
// Try as number
const num = Number(token);
if (!isNaN(num)) {
return num;
}
// Variable lookup
if (token in this.ctx) {
return Number(this.ctx[token]);
}
throw new Error(`Unknown variable: ${token}`);
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Fasting rules engine.
* Evaluates Fasting.xml rules against dayInfo to determine fasting level for a given day.
*
* Fasting.java is part of the Ponomar program.
* Copyright 2009 Yuri Shardt.
* GPL v3 — see LICENSE.
*/
import { evalBool } from './evaluator';
import type { EvalContext } from './types';
/** 7-bit fasting case string. Each bit: 1=permitted, 0=forbidden. */
export type FastingCase = string;
export interface FastingPeriod {
cmd: string;
rules: FastingRule[];
}
export interface FastingRule {
case: FastingCase;
cmd: string;
}
export interface FastingResult {
case: FastingCase;
level: number; // 0-6
description: string;
allowed: string[];
forbidden: string[];
}
const FOOD_NAMES_LOCALIZED: Record<string, string[]> = {
en: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'],
ru: ['мясо', 'молочные продукты', 'рыба', 'рыбная икра', 'растительное масло', 'вино', 'сухоядение'],
cu: ['мѧ́со', 'мла҆чные продꙋ́кты', 'ры́ба', 'ры́бнаѧ и҆́кра', 'є҆ле́й', 'ві́но̀', 'сухоѧденїе'],
fr: ['le viande', 'les produits lactières', 'le poisson', 'le caviar', "l'huile", 'le vin', 'des nourritures secs'],
el: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'],
zh: ['肉类和动物制品', '乳制品', '鱼', '鱼子', '油', '酒', '干食'],
'zh-Hant': ['肉類和動物製品', '乳製品', '魚', '魚子', '油', '酒', '幹食'],
};
/**
* Parse fasting XML data. The XML is pre-converted to JSON at build time.
*/
export function parseFastingData(data: FastingPeriod[]): FastingPeriod[] {
return data;
}
/**
* Evaluate fasting rules for a given day.
* Returns the fasting case (7-bit string) based on matching rules.
*/
export function evaluateFasting(
periods: FastingPeriod[],
ctx: EvalContext,
lang: string = 'en'
): FastingResult {
let currentCase: FastingCase = '1111111'; // Default: no fast
// Process periods in order; later periods can override earlier ones
for (const period of periods) {
// Check if this period applies to today
if (period.cmd && !evalBool(period.cmd, ctx)) {
continue;
}
// Evaluate rules within this period
for (const rule of period.rules) {
if (rule.cmd && !evalBool(rule.cmd, ctx)) {
continue;
}
// This rule matches — apply it
currentCase = rule.case;
}
}
return formatFastingResult(currentCase, lang);
}
/**
* Format a fasting case into a human-readable result.
*/
function formatFastingResult(fastCase: FastingCase, lang: string = 'en'): FastingResult {
const names = FOOD_NAMES_LOCALIZED[lang] || FOOD_NAMES_LOCALIZED.en;
const allowed: string[] = [];
const forbidden: string[] = [];
// Case format: meat,cheese,fish,caviar,oil,wine,dryFood
// Index 0 = meat, 6 = dry food (leftmost = highest food, rightmost = lowest)
for (let i = 0; i < 7; i++) {
const permitted = fastCase[i] === '1';
if (permitted) {
allowed.push(names[i]);
} else {
forbidden.push(names[i]);
}
}
// Determine fasting level
let level = 0;
switch (fastCase) {
case '1111111': level = 0; break; // No fast
case '0111111': level = 1; break; // Meat excluded
case '0011111': level = 2; break; // Fish permitted
case '0001111': level = 3; break; // Caviar permitted
case '0000111': level = 4; break; // Wine and oil
case '0000011': level = 5; break; // Without oil
case '0000001': level = 6; break; // Dry food only (xerophagy)
case '0000000': level = 7; break; // Complete fast
default: level = 0;
}
// Generate description
const description = generateDescription(fastCase, allowed, forbidden, lang);
return {
case: fastCase,
level,
description,
allowed,
forbidden,
};
}
function generateDescription(
fastCase: FastingCase,
allowed: string[],
forbidden: string[],
lang: string
): string {
if (fastCase === '1111111') {
return lang === 'ru' ? 'Поста нет' : lang === 'cu' ? 'Поста нѣтъ' : 'No fast';
}
if (fastCase === '0000000') {
return lang === 'ru' ? 'Полное воздержание от пищи' : lang === 'cu' ? 'Полное воздержаніе отъ пищи' : 'Complete fast (no food)';
}
const labels: Record<string, { permitted: string; forbidden: string; is: string; are: string }> = {
en: { permitted: 'Permitted', forbidden: 'Forbidden', is: 'is', are: 'are' },
ru: { permitted: 'Разрешено', forbidden: 'Запрещено', is: '', are: '' },
cu: { permitted: 'Позволено', forbidden: 'Запрещено', is: '', are: '' },
};
const L = labels[lang] || labels.en;
if (allowed.length === 0) {
return `${L.forbidden}: ${forbidden.join(', ')}`;
}
if (forbidden.length === 0) {
return `${L.permitted}: ${allowed.join(', ')}`;
}
const verb = allowed.length === 1 ? L.is : L.are;
const verbF = forbidden.length === 1 ? L.is : L.are;
return `${L.permitted} ${verb} ${allowed.join(', ')}. ${L.forbidden} ${verbF} ${forbidden.join(', ')}`;
}
+751
View File
@@ -0,0 +1,751 @@
/**
* Internationalization (i18n) module for Ponomar PWA.
* Provides translations for UI strings in English, Russian, and Church Slavonic.
*/
export type LanguageCode = 'en' | 'ru' | 'cu';
export interface Translations {
// General
appName: string;
loading: string;
error: string;
notAvailable: string;
// Navigation
nav: {
calendar: string;
service: string;
bible: string;
settings: string;
};
// Calendar
calendar: {
months: string[];
monthsGenitive: string[];
dayNames: string[];
dayNamesFull: string[];
rankLabels: Record<number, string>;
rankIcons: Record<number, string>;
pascha: string;
pentecost: string;
cleanMonday: string;
greatLent: string;
brightWeek: string;
weekAfterPentecost: string;
weekOrdSuffix: string;
apostlesFast: string;
dormitionFast: string;
nativityFast: string;
commemorations: string;
fastingRule: string;
readings: string;
noCommemorations: string;
noReadings: string;
readingTypes: Record<string, string>;
defaultBibleVersion: string;
dayReading: string[];
readSep: string;
colon: string;
dayOfYear: string;
ofYear: string;
tone: string;
gregorianDate: string;
churchDate: string;
annoMundi: string;
yearOfWorld: string;
fromYear: string;
fromAdam: string;
};
// Services
services: {
title: string;
selectService: string;
serviceProducedNoOutput: string;
errorLoading: string;
nodesAssembled: string;
greatLent: string;
brightWeek: string;
apostlesFast: string;
dormitionFast: string;
nativityFast: string;
tone: string;
roleLabel: string;
rolePriest: string;
roleReader: string;
roleAuto: string;
serviceNames: {
primes: string;
third: string;
sixth: string;
ninth: string;
vespers: string;
liturgy: string;
royalhours: string;
compline: string;
};
serviceDescriptions: {
primes: string;
third: string;
sixth: string;
ninth: string;
vespers: string;
liturgy: string;
royalhours: string;
compline: string;
};
};
// Bible
bible: {
selectBook: string;
selectChapter: string;
read: string;
loading: string;
noTextFound: string;
notAvailableOffline: string;
ensureDataConverted: string;
};
// Settings
settings: {
title: string;
language: string;
fontSettings: string;
usePonomarFont: string;
usePonomarFontDesc: string;
fontPreview: string;
fontPreviewNoteActive: string;
fontPreviewNoteInactive: string;
fontSize: string;
systemFont: string;
bibleSettings: string;
defaultTranslation: string;
showVerseNumbers: string;
verseNewLine: string;
bibleComments: {
fullBible: string;
ntOnly: string;
otOnly: string;
otProphets: string;
};
calendarType: string;
julian: string;
gregorian: string;
about: string;
aboutText: string;
aboutLicense: string;
installPwa: string;
};
// Common
common: {
priest: string;
reader: string;
deacon: string;
choir: string;
all: string;
};
}
const translations: Record<LanguageCode, Translations> = {
en: {
appName: '☦ Ponomar',
loading: 'Loading...',
error: 'Error',
notAvailable: 'Not available',
nav: {
calendar: '📅 Calendar',
service: '📖 Service',
bible: '☦ Bible',
settings: '⚙ Settings',
},
calendar: {
months: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
monthsGenitive: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
dayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
dayNamesFull: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
rankLabels: {
1: 'Troparion',
2: 'Antiphon',
3: 'Hexahymn',
4: 'Doxology',
5: 'Polyeleos',
6: 'Great Saint',
7: 'Great Feast of the Theotokos',
8: 'Great Feast of the Lord',
},
rankIcons: {
2: '🕃',
3: '🕃',
4: '🕂',
5: '🕁',
6: '🕀',
},
pascha: 'Pascha: ',
pentecost: 'Pentecost: ',
cleanMonday: 'Clean Monday: ',
greatLent: ' · Great Lent',
brightWeek: ' · Bright Week',
weekAfterPentecost: 'week after Pentecost',
weekOrdSuffix: 'th',
apostlesFast: " · Apostles' Fast",
dormitionFast: ' · Dormition Fast',
nativityFast: ' · Nativity Fast',
commemorations: 'Commemorations',
fastingRule: 'Fasting Rule',
readings: 'Readings',
noCommemorations: 'No commemorations for this day',
noReadings: 'No readings assigned',
readingTypes: {
apostol: 'Apostol',
gospel: 'Gospel',
matins: 'Matins',
vespers: 'Vespers',
primes: '1st Hour',
terce: '3rd Hour',
sext: '6th Hour',
none: '9th Hour',
liturgy: 'Liturgy',
'0': 'Vespers',
'1': 'Reading',
'2': 'Reading',
'3': 'Reading',
'4': '4th Reading',
'5': '5th Reading',
'6': '6th Reading',
'7': '7th Reading',
'8': '8th Reading',
'9': '9th Reading',
'10': '10th Reading',
'11': '11th Reading',
'12': '12th Reading',
'13': '13th Reading',
'14': '14th Reading',
'15': '15th Reading',
A: 'Reading',
},
defaultBibleVersion: 'en/bible/kjv',
dayReading: ['for Sunday', 'for Monday', 'for Tuesday', 'for Wednesday', 'for Thursday', 'for Friday', 'for Saturday'],
readSep: '; ',
colon: ': ',
dayOfYear: ' · Day ',
ofYear: ' of year',
tone: 'Tone ',
gregorianDate: 'Civil (Gregorian) Date',
churchDate: 'Church (Julian) Date',
annoMundi: 'Year of the World',
yearOfWorld: 'year of the world',
fromYear: 'from Christ',
fromAdam: 'from Adam',
},
services: {
title: 'Services',
selectService: 'Select a service to view its content.',
serviceProducedNoOutput: 'Service produced no output.',
errorLoading: 'Error loading service.',
nodesAssembled: 'nodes assembled.',
greatLent: ' · Great Lent',
brightWeek: ' · Bright Week',
apostlesFast: " · Apostles' Fast",
dormitionFast: ' · Dormition Fast',
nativityFast: ' · Nativity Fast',
tone: ' · Tone ',
roleLabel: 'Role:',
rolePriest: 'Priest',
roleReader: 'Reader',
roleAuto: 'Auto',
serviceNames: {
primes: 'First Hour',
third: 'Third Hour',
sixth: 'Sixth Hour',
ninth: 'Ninth Hour',
vespers: 'Vespers',
liturgy: 'Divine Liturgy',
royalhours: 'Royal Hours',
compline: 'Compline',
},
serviceDescriptions: {
primes: 'Prime (First Hour)',
third: 'Third Hour',
sixth: 'Sixth Hour',
ninth: 'Ninth Hour',
vespers: 'Evening service (Vespers)',
liturgy: 'Principal service (Divine Liturgy)',
royalhours: 'Royal Hours',
compline: 'Night office (Compline)',
},
},
bible: {
selectBook: 'Select a book',
selectChapter: 'Select a chapter',
read: 'Read',
loading: 'Loading...',
noTextFound: 'No text found for this passage.',
notAvailableOffline: 'Text not available for offline viewing.',
ensureDataConverted: 'Bible text files are lazy-loaded on demand. Please ensure the data has been converted and is available.',
},
settings: {
title: 'Settings',
language: 'Interface Language',
fontSettings: 'Church Slavonic Font',
usePonomarFont: 'Use Ponomar font for Church Slavonic texts',
usePonomarFontDesc: 'Use Ponomar font for Church Slavonic texts',
fontPreview: 'Preview:',
fontPreviewNoteActive: 'Ponomar font active — liturgical rank icons will render correctly',
fontPreviewNoteInactive: 'System font active — rank icons will use Ponomar font regardless',
fontSize: 'Liturgical Text Size: ',
systemFont: 'System Default',
bibleSettings: 'Bible',
defaultTranslation: 'Default Translation',
showVerseNumbers: 'Show verse numbers',
verseNewLine: 'Each verse on a new line',
bibleComments: {
fullBible: 'Full Bible',
ntOnly: 'NT only',
otOnly: 'OT only',
otProphets: 'OT Prophets',
},
calendarType: 'Default Calendar',
julian: 'Julian',
gregorian: 'Gregorian',
about: 'About Ponomar',
aboutText: 'Ponomar is an Orthodox Church calendar application providing liturgical information, service texts, and Bible readings in multiple languages.',
aboutLicense: 'Based on the Ponomar Java desktop application by Aleksandr Andreev and Yuri Shardt. Licensed under GPL v3.',
installPwa: 'Install Ponomar',
},
common: {
priest: 'Priest',
reader: 'Reader',
deacon: 'Deacon',
choir: 'Choir',
all: 'All',
},
},
ru: {
appName: '☦ Понома́рь',
loading: 'Загрузка...',
error: 'Ошибка',
notAvailable: 'Недоступно',
nav: {
calendar: '📅 Календарь',
service: '📖 Устав',
bible: '☦ Библия',
settings: '⚙ Настройки',
},
calendar: {
months: [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
],
monthsGenitive: [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
],
dayNames: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
dayNamesFull: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'],
rankLabels: {
1: 'Тропарь',
2: 'Антифон',
3: 'Шестипеснец',
4: 'Славословие',
5: 'Полиелей',
6: 'Великий святой',
7: 'Великий праздник Богородичный',
8: 'Великий праздник Господень',
},
rankIcons: {
2: '🕃',
3: '🕃',
4: '🕂',
5: '🕁',
6: '🕀',
},
pascha: 'Пасха: ',
pentecost: 'Пятидесятница: ',
cleanMonday: 'Чистый понедельник: ',
greatLent: ' · Великий пост',
brightWeek: ' · Светлая седмица',
weekAfterPentecost: 'седмица по Пятидесятнице',
weekOrdSuffix: 'я',
apostlesFast: ' · Петров пост',
dormitionFast: ' · Успенский пост',
nativityFast: ' · Рождественский пост',
commemorations: 'Память святых',
fastingRule: 'Правило поста',
readings: 'Чтения',
noCommemorations: 'Нет памятей на этот день',
noReadings: 'Чтения не назначены',
readingTypes: {
apostol: 'Апостол',
gospel: 'Евангелие',
matins: 'Утреня',
vespers: 'Вечерня',
primes: '1-й час',
terce: '3-й час',
sext: '6-й час',
none: '9-й час',
liturgy: 'Литургия',
'0': 'Вечерня',
'1': 'Вечерня',
'2': 'Вечерня',
'3': 'Вечерня',
'4': '4-е чтение',
'5': '5-е чтение',
'6': '6-е чтение',
'7': '7-е чтение',
'8': '8-е чтение',
'9': '9-е чтение',
'10': '10-е чтение',
'11': '11-е чтение',
'12': '12-е чтение',
'13': '13-е чтение',
'14': '14-е чтение',
'15': '15-е чтение',
A: 'Чтение',
},
defaultBibleVersion: 'ru/bible/synod',
dayReading: ['для воскресенья', 'для понедельника', 'для вторника', 'для среды', 'для четверга', 'для пятницы', 'для субботы'],
readSep: '; ',
colon: ': ',
dayOfYear: ' · День ',
ofYear: ' года',
tone: 'Глас ',
gregorianDate: 'По гражданскому стилю',
churchDate: 'По церковному стилю',
annoMundi: 'Лета от сотворения мира',
yearOfWorld: 'год от сотворения мира',
fromYear: 'от РХ',
fromAdam: 'от Адама',
},
services: {
title: 'Богослужения',
selectService: 'Выберите богослужение для просмотра.',
serviceProducedNoOutput: 'Богослужение не дало текста.',
errorLoading: 'Ошибка загрузки богослужения.',
nodesAssembled: 'узлов собрано.',
greatLent: ' · Великий пост',
brightWeek: ' · Светлая седмица',
apostlesFast: ' · Петров пост',
dormitionFast: ' · Успенский пост',
nativityFast: ' · Рождественский пост',
tone: ' · Глас ',
roleLabel: 'Чин:',
rolePriest: 'Священник',
roleReader: 'Чтец',
roleAuto: 'Авто',
serviceNames: {
primes: 'Первый час',
third: 'Третий час',
sixth: 'Шестой час',
ninth: 'Девятый час',
vespers: 'Вечерня',
liturgy: 'Божественная Литургия',
royalhours: 'Царские часы',
compline: 'Повечерие',
},
serviceDescriptions: {
primes: 'Час первый',
third: 'Час третий',
sixth: 'Час шестой',
ninth: 'Час девятый',
vespers: 'Вечернее богослужение (Вечерня)',
liturgy: 'Главное богослужение (Божественная Литургия)',
royalhours: 'Царские часы',
compline: 'Ночное богослужение (Повечерие)',
},
},
bible: {
selectBook: 'Выберите книгу',
selectChapter: 'Выберите главу',
read: 'Читать',
loading: 'Загрузка...',
noTextFound: 'Текст не найден для этого отрывка.',
notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.',
ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.',
},
settings: {
title: 'Настройки',
language: 'Язык интерфейса',
fontSettings: 'Шрифт церковнославянского',
usePonomarFont: 'Использовать шрифт Понома́рь для церковнославянских текстов',
usePonomarFontDesc: 'Использовать шрифт Понома́рь для церковнославянских текстов',
fontPreview: 'Предпросмотр:',
fontPreviewNoteActive: 'Шрифт Понома́рь активен — иконы чинов будут отображаться правильно',
fontPreviewNoteInactive: 'Системный шрифт активен — иконы чинов будут использовать Понома́рь в любом случае',
fontSize: 'Размер литургического текста: ',
systemFont: 'Системный шрифт',
bibleSettings: 'Библия',
defaultTranslation: 'Перевод по умолчанию',
showVerseNumbers: 'Показывать номера стихов',
verseNewLine: 'Каждый стих с новой строки',
bibleComments: {
fullBible: 'Полная Библия',
ntOnly: 'Новый Завет',
otOnly: 'Ветхий Завет',
otProphets: 'Пророки',
},
calendarType: 'Календарь по умолчанию',
julian: 'Юлианский',
gregorian: 'Григорианский',
about: 'О Понома́ре',
aboutText: 'Понома́рь — приложение православного календаря, предоставляющее литургическую информацию, тексты служб и библейские чтения на нескольких языках.',
aboutLicense: 'Основано на Java-приложении Понома́рь от Александра Андреева и Юрия Шардт. Лицензия GPL v3.',
installPwa: 'Установить Пономарь',
},
common: {
priest: 'Свящ.',
reader: 'Чтец',
deacon: 'Диакон',
choir: 'Хор',
all: 'Все',
},
},
cu: {
appName: '☦ Понома́рь',
loading: 'Загрузка...',
error: 'Ошибка',
notAvailable: 'Недоступно',
nav: {
calendar: '📅 Календарь',
service: '📖 Уставъ',
bible: '☦ Библия',
settings: '⚙ Настройки',
},
calendar: {
months: [
'Январь', 'Февраль', 'Мартъ', 'Апрель', 'Май', 'Июнь',
'Июль', 'Августъ', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
],
monthsGenitive: [
'января', 'февраля', 'марта', 'апрѣля', 'мая', 'іюня',
'іюля', 'августа', 'септемврія', 'октоврія', 'ноемврія', 'декаврія'
],
dayNames: ['Нед', 'Пон', 'Вто', 'Сре', 'Чет', 'Пят', 'Суб'],
dayNamesFull: ['Неделя', 'Понедельникъ', 'Вторникъ', 'Среда', 'Четвергъ', 'Пятница', 'Суббота'],
rankLabels: {
1: 'Тропарь',
2: 'Антифонъ',
3: 'Шестопѣснецъ',
4: 'Славословіе',
5: 'Полиелей',
6: 'Великій святой',
7: 'Великій праздникъ Богородичный',
8: 'Великій праздникъ Господень',
},
rankIcons: {
2: '🕃',
3: '🕃',
4: '🕂',
5: '🕁',
6: '🕀',
},
pascha: 'Пасха: ',
pentecost: 'Пятидесятница: ',
cleanMonday: 'Чистый понедельникъ: ',
greatLent: ' · Великій постъ',
brightWeek: ' · Светлая седмица',
weekAfterPentecost: 'седмица по Пѧтидесѧтницѣ',
weekOrdSuffix: 'ѧ',
apostlesFast: ' · Петровъ постъ',
dormitionFast: ' · Успенскій постъ',
nativityFast: ' · Рождественскій постъ',
commemorations: 'Память святыхъ',
fastingRule: 'Правило поста',
readings: 'Чтения',
noCommemorations: 'Нетъ памятей на сей день',
noReadings: 'Чтения не назначаются',
readingTypes: {
apostol: 'А҆пⷭ҇лъ',
gospel: 'Є҆ѵⷢ҇лїе',
matins: 'Оу҆́тренѧ',
vespers: 'Вече́рнѧ',
primes: 'Ча́съ пе́рвый',
terce: 'Ча́съ тре́тїй',
sext: 'Ча́съ шесты́й',
none: 'Ча́съ девѧ́тый',
liturgy: 'Лїтꙋргі́ѧ',
'0': 'Вече́рнѧ',
'1': 'Вече́рнѧ',
'2': 'Вече́рнѧ',
'3': 'Вече́рнѧ',
'4': '4-е чте́ніе',
'5': '5-е чте́ніе',
'6': '6-е чте́ніе',
'7': '7-е чте́ніе',
'8': '8-е чте́ніе',
'9': '9-е чте́ніе',
'10': '10-е чте́ніе',
'11': '11-е чте́ніе',
'12': '12-е чте́ніе',
'13': '13-е чте́ніе',
'14': '14-е чте́ніе',
'15': '15-е чте́ніе',
A: 'Чте́ніе',
},
defaultBibleVersion: 'cu/bible/elis',
dayReading: ['длѧ̀ недѣ́ли', 'длѧ̀ понедѣ́льника', 'длѧ̀ вто́рника', 'длѧ̀ сре́ды', 'длѧ̀ четвертка̀', 'длѧ̀ пѧткѝ', 'длѧ̀ сꙋббѡ́ты'],
readSep: ': ',
colon: ': ',
dayOfYear: ' · День ',
ofYear: ' года',
tone: 'Гласъ ',
gregorianDate: 'По гражданскому стилю',
churchDate: 'По церковному стилю',
annoMundi: 'Лѣта отъ сотворенiя мiра',
yearOfWorld: 'лѣта отъ сотворенiя мiра',
fromYear: 'от РХ',
fromAdam: 'от Адама',
},
services: {
title: 'Богослуженiя',
selectService: 'Изберете богослуженiе для просмотра.',
serviceProducedNoOutput: 'Богослуженiе не дало текста.',
errorLoading: 'Ошибка загрузки богослуженiя.',
nodesAssembled: 'узлов собрано.',
greatLent: ' · Великій постъ',
brightWeek: ' · Светлая седмица',
apostlesFast: ' · Петровъ постъ',
dormitionFast: ' · Успенскій постъ',
nativityFast: ' · Рождественскій постъ',
tone: ' · Гласъ ',
roleLabel: 'Чинъ:',
rolePriest: 'Священникъ',
roleReader: 'Чтецъ',
roleAuto: 'Авто',
serviceNames: {
primes: 'Первый часъ',
third: 'Третий часъ',
sixth: 'Шестой часъ',
ninth: 'Девятый часъ',
vespers: 'Вечерня',
liturgy: 'Божественная Литургия',
royalhours: 'Царскія часъ',
compline: 'Повечерiе',
},
serviceDescriptions: {
primes: 'Часъ первый',
third: 'Часъ третий',
sixth: 'Часъ шестой',
ninth: 'Часъ девятый',
vespers: 'Вечернее богослуженiе (Вечерня)',
liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)',
royalhours: 'Царскія часъ',
compline: 'Ночное богослуженiе (Повечерiе)',
},
},
bible: {
selectBook: 'Изберете книгу',
selectChapter: 'Изберете главу',
read: 'Читати',
loading: 'Загрузка...',
noTextFound: 'Текстъ не найденъ для сего отрывка.',
notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.',
ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.',
},
settings: {
title: 'Настройки',
language: 'Языкъ интерфейса',
fontSettings: 'Шрифтъ церковнославянскаго',
usePonomarFont: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ',
usePonomarFontDesc: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ',
fontPreview: 'Предпросмотръ:',
fontPreviewNoteActive: 'Шрифтъ Понома́рь активенъ — иконы чинѣвъ будутъ отображатися правильно',
fontPreviewNoteInactive: 'Системный шрифтъ активенъ — иконы чинѣвъ будутъ использовать Понома́рь въ любомъ случаи',
fontSize: 'Размеръ литургическаго текста: ',
systemFont: 'Системный шрифтъ',
bibleSettings: 'Библия',
defaultTranslation: 'Переводъ по умолчанiю',
showVerseNumbers: 'Показывать номера стиховъ',
verseNewLine: 'Каждый стихъ съ новыя строки',
bibleComments: {
fullBible: 'Полная Библія',
ntOnly: 'Новый Завѣтъ',
otOnly: 'Ветхій Завѣтъ',
otProphets: 'Пророцы',
},
calendarType: 'Календарь по умолчанию',
julian: 'Юліанскій',
gregorian: 'Григоріанскій',
about: 'О Понома́ри',
aboutText: 'Понома́рь — приложенiе православнаго календаря, предоставляющее литургическiю информацию, тексты службъ и библейскія чтения на нескольких языкахъ.',
aboutLicense: 'Основано на Java-приложенiи Понома́рь отъ Александра Андреева и Юрия Шардт. Лицензия GPL v3.',
installPwa: 'Оустановити Понома́рь',
},
common: {
priest: 'Свящ.',
reader: 'Чтецъ',
deacon: 'Диаконъ',
choir: 'Хоръ',
all: 'Все',
},
},
};
/**
* Get translations for a specific language.
* Falls back to English if language not found.
*/
export function getTranslations(lang: LanguageCode): Translations {
return translations[lang] || translations.en;
}
/**
* Get a specific translation key with fallback.
*/
export function t(lang: LanguageCode, key: string, ...args: string[]): string {
const tr = getTranslations(lang);
const keys = key.split('.');
let value: unknown = tr;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = (value as Record<string, unknown>)[k];
} else {
// Fallback to English
value = translations.en;
for (const k2 of keys) {
if (value && typeof value === 'object' && k2 in value) {
value = (value as Record<string, unknown>)[k2];
} else {
return key; // Return key if not found
}
}
break;
}
}
if (typeof value === 'string') {
return args.length > 0 ? value.replace(/{(\d+)}/g, (_, i) => args[i] ?? '') : value;
}
return key;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* JDate — Julian calendar date engine.
* Faithful port of Ponomar/JDate.java to TypeScript.
* All dates are Julian calendar. Internally stored as Julian Day Number (JDN).
*
* JDate.java is part of the Ponomar program.
* Copyright 2006, 2007 Aleksandr Andreev.
* GPL v3 — see LICENSE.
*/
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const DAYS_IN_MONTH_LEAP = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/**
* 1-based modulo. If result is 0, returns divisor.
* Matches Java JDate.mod() exactly.
*/
function mod1(divisor: number, modulo: number): number {
let temp = divisor % modulo;
if (temp === 0) temp = modulo;
return temp;
}
export class JDate {
readonly jdn: number;
/** Create from Julian month/day/year. */
constructor(monthOrJdn: number, day?: number, year?: number) {
if (day === undefined && year === undefined) {
// Constructor from JDN
this.jdn = monthOrJdn;
return;
}
const month = monthOrJdn;
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
if (day! < 0) throw new Error(`Invalid day: ${day}`);
const leap = year! % 4 === 0;
const maxDays = leap ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1];
if (day! > maxDays) throw new Error(`Invalid day ${day} for month ${month} (year ${year})`);
// Fliegel-Van Flandern formula (Julian calendar)
const a = Math.floor((14 - month) / 12);
const y = year! + 4800 - a;
const m = month + 12 * a - 3;
this.jdn = day! + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083;
}
/** Create from JavaScript Date (Gregorian). Converts to Julian calendar. */
static fromGregorian(date: Date): JDate {
return JDate.fromGregorianParts(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
/** Create from Gregorian year/month/day. */
static fromGregorianParts(gYear: number, gMonth: number, gDay: number): JDate {
const a = Math.floor((14 - gMonth) / 12);
const y = gYear + 4800 - a;
const m = gMonth + 12 * a - 3;
const jd = gDay + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045;
return new JDate(jd);
}
/** Create from today's date. */
static today(): JDate {
return JDate.fromGregorian(new Date());
}
/** Get year (Julian calendar). */
getYear(): number {
const jbar = this.jdn + 32083;
const n1 = Math.floor(jbar / 1461) * 4;
const n2 = Math.floor((jbar % 1461) / 365);
const da = jbar % 1461;
const adj = (da % 365) > 306 ? 1 : 0;
return Math.floor(n1 + n2) - 4800 + adj;
}
/** Get month (1=Jan ... 12=Dec, Julian calendar). */
getMonth(): number {
const jbar = this.jdn + 32083;
const da = jbar % 1461;
let m = mod1(da, 365);
let j = 2;
while (m > DAYS_IN_MONTH_LEAP[j]) {
m -= DAYS_IN_MONTH_LEAP[j];
j++;
if (j === 12) j = 0;
}
return j + 1;
}
/** Get day of month (Julian calendar). */
getDay(): number {
const jbar = this.jdn + 32083;
const da = mod1(jbar, 1461);
if (da === 1461) return 29; // Feb 29
let m = mod1(da, 365);
let k = 2;
while (m > DAYS_IN_MONTH[k]) {
m -= DAYS_IN_MONTH[k];
k++;
if (k === 12) k = 0;
}
return m;
}
/** Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday. */
getDayOfWeek(): number {
let temp = (this.jdn % 7) + 1;
if (temp === 7) temp = 0;
return temp;
}
/** Day of year: 0=Jan 1, ..., 364/365=Dec 31. */
getDoy(): number {
const jbar = this.jdn + 32083;
const da = jbar % 1461;
return da === 1461 ? 366 : mod1(da + 59, 365) - 1;
}
/** Convert to JavaScript Date (Gregorian calendar). */
toGregorian(): Date {
let j1: number;
if (this.jdn >= 2299160.5) {
const tmp = Math.floor(((this.jdn - 1867216.0) - 0.25) / 36524.25);
j1 = this.jdn + 1 + tmp - Math.floor(0.25 * tmp);
} else {
j1 = this.jdn;
}
const j2 = j1 + 1524.0;
const j3 = Math.floor(6680.0 + ((j2 - 2439870.0) - 122.1) / 365.25);
const j4 = Math.floor(j3 * 365.25);
const j5 = Math.floor((j2 - j4) / 30.6001);
let d = Math.floor(j2 - j4 - Math.floor(j5 * 30.6001));
let m = Math.floor(j5 - 1.0);
if (m > 12) m -= 12;
let y = Math.floor(j3 - 4715.0);
if (m > 2) --y;
if (y <= 0) --y;
return new Date(y, m - 1, d);
}
/** Days to add to get to another date. Positive if this > other. */
difference(other: JDate): number {
return this.jdn - other.jdn;
}
/** Add n days, returning a new JDate. */
addDays(n: number): JDate {
return new JDate(this.jdn + n);
}
/** Subtract n days, returning a new JDate. */
subtractDays(n: number): JDate {
return new JDate(this.jdn - n);
}
/** Days in month for this date's month/year. */
static maxDaysInMonth(month: number, year: number): number {
return (year % 4 === 0) ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1];
}
equals(other: JDate): boolean {
return this.jdn === other.jdn;
}
compareTo(other: JDate): number {
return this.jdn - other.jdn;
}
toString(): string {
const y = this.getYear();
const m = this.getMonth();
const d = this.getDay();
return `${m < 10 ? '0' + m : m}/${d < 10 ? '0' + d : d}/${y}`;
}
/** Clone — returns a new JDate with the same JDN. */
clone(): JDate {
return new JDate(this.jdn);
}
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Paschalion — Orthodox Church paschal and feast day computations.
* Faithful port of Ponomar/Paschalion.java to TypeScript.
*
* Paschalion.java is part of the Ponomar program.
* Copyright 2006, 2007 Aleksandr Andreev.
* GPL v3 — see LICENSE.
*/
import { JDate } from './jdate';
const LUNAR_MONTH = 29.52916667;
/** Age of the moon on March 1 for each year in the 19-year Metonic cycle (hours since midnight). */
const FOUNDATION = [
14.042016807, 25.462184874, 6.084033613, 17.966386555, 28.336134454,
9.210084034, 20.504201681, 1.420168067, 12.294117647, 23.168067227,
4.546218487, 15.042016807, 26.294117647, 7.630252101, 18.546218487,
29.420168067, 11.756302521, 26.210084034, 3.042016807,
];
/** 1-based modulo. If result is 0, returns divisor. */
function mod1(divisor: number, modulo: number): number {
let temp = divisor % modulo;
if (temp === 0) temp = modulo;
return temp;
}
/** Modulo for decimal numbers. */
function modF(divisor: number, modulo: number): number {
return divisor - Math.floor(divisor / modulo) * modulo;
}
export const Paschalion = {
/**
* Get the date of Julian Pascha (Easter) for a given year.
* Uses Gaussian Easter formulae.
*/
getPascha(year: number): JDate {
if (year < 33) throw new Error('Invalid year');
const a = year % 4;
const b = year % 7;
const c = year % 19;
const d = (19 * c + 15) % 30;
const e = (2 * a + 4 * b - d + 34) % 7;
const month = Math.floor((d + e + 114) / 31); // 3=March, 4=April
const day = ((d + e + 114) % 31) + 1;
return new JDate(month, day, year);
},
/** Pentecost: Pascha + 49 days. */
getPentecost(year: number): JDate {
return this.getPascha(year).addDays(49);
},
/** Clean Monday (Great Lent starts): Pascha - 48 days. */
getLentStart(year: number): JDate {
return this.getPascha(year).subtractDays(48);
},
/** Apostles' Fast starts: Pascha + 57 days (Monday after All Saints). */
getApostlesFastStart(year: number): JDate {
return this.getPascha(year).addDays(57);
},
/** Length of Apostles' Fast in days. Ends June 28 (Julian). */
getApostlesFastLength(year: number): number {
const start = this.getApostlesFastStart(year);
const end = new JDate(6, 28, year);
return end.difference(start);
},
/** Key of Boundaries (Ключ границ) — letter of the Paschal boundary. 1-30. */
getKeyOfBoundaries(year: number): number {
const date = this.getPascha(year);
const m = date.getMonth();
let g = date.getDay();
if (m === 3) {
g -= 21;
} else {
g += 10;
}
return g;
},
/** Indiction cycle. */
getIndiction(year: number): number {
if (year < 33) throw new Error('Invalid year');
return mod1(year - 312, 15);
},
/** Solar cycle. */
getSolarCycle(year: number): number {
if (year < 33) throw new Error('Invalid year');
return mod1(year + 5508, 28);
},
/** Lunar cycle (1-19). */
getLunarCycle(year: number): number {
if (year < 33) throw new Error('Invalid year');
let temp = (year + 1) % 19 - 3;
if (temp <= 0) temp += 19;
return temp;
},
/** Lunar phase for a date: 0=new moon, 0.5=full moon. */
getLunarPhase(date: JDate): number {
const year = date.getYear();
const cycle = this.getLunarCycle(year);
let diff = date.difference(new JDate(3, 1, year));
if (diff < 0) {
diff = date.difference(new JDate(3, 1, year - 1));
}
let remainder = modF(diff, LUNAR_MONTH);
remainder += FOUNDATION[cycle - 1];
while (remainder >= LUNAR_MONTH) {
remainder -= LUNAR_MONTH;
}
return remainder / LUNAR_MONTH;
},
/**
* Annual fasting array: 0=no fast, 1=fast day, 2=cheesefare.
* One entry per day of the year (index 0=Jan 1).
*/
getFasts(year: number): number[] {
if (year < 33) throw new Error('Invalid year');
const pascha = this.getPascha(year);
const numDays = (year % 4 === 0) ? 366 : 365;
// Mandatory fast days (JDN → reason string)
const mustFast = new Set<number>();
mustFast.add(new JDate(1, 5, year).jdn);
mustFast.add(new JDate(8, 29, year).jdn);
mustFast.add(new JDate(9, 14, year).jdn);
// Mandatory fast-free days
const cantFast = new Set<number>();
cantFast.add(new JDate(1, 6, year).jdn);
// Period boundaries
const SVIATKI_START = new JDate(12, 25, year);
const SVIATKI_END = new JDate(1, 4, year);
const PUB_PHAR_START = pascha.subtractDays(70);
const PUB_PHAR_END = pascha.subtractDays(63);
const CHEESE_START = pascha.subtractDays(55);
const CHEESE_END = pascha.subtractDays(49);
const LENT_START = pascha.subtractDays(48);
const LENT_END = pascha.subtractDays(1);
const BRIGHT_START = pascha;
const BRIGHT_END = pascha.addDays(6);
const PENT_START = pascha.addDays(49);
const PENT_END = pascha.addDays(56);
const APOSTLES_START = pascha.addDays(57);
const APOSTLES_END = new JDate(6, 28, year);
const DORM_START = new JDate(8, 1, year);
const DORM_END = new JDate(8, 14, year);
const ADVENT_START = new JDate(11, 15, year);
const ADVENT_END = new JDate(12, 24, year);
const retval = new Array<number>(numDays);
let dummy = new JDate(1, 1, year);
for (let i = 0; i < numDays; i++) {
let fast = 0;
const jdn = dummy.jdn;
if (mustFast.has(jdn)) {
fast = 1;
} else if (cantFast.has(jdn)) {
fast = 0;
} else if (dummy.compareTo(SVIATKI_START) >= 0 || dummy.compareTo(SVIATKI_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(PUB_PHAR_START) >= 0 && dummy.compareTo(PUB_PHAR_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(CHEESE_START) >= 0 && dummy.compareTo(CHEESE_END) <= 0) {
fast = 2;
} else if (dummy.compareTo(LENT_START) >= 0 && dummy.compareTo(LENT_END) <= 0) {
fast = 1;
} else if (dummy.compareTo(BRIGHT_START) >= 0 && dummy.compareTo(BRIGHT_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(PENT_START) >= 0 && dummy.compareTo(PENT_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(APOSTLES_START) >= 0 && dummy.compareTo(APOSTLES_END) <= 0) {
fast = 1;
} else if (dummy.compareTo(DORM_START) >= 0 && dummy.compareTo(DORM_END) <= 0) {
fast = 1;
} else if (dummy.compareTo(ADVENT_START) >= 0 && dummy.compareTo(ADVENT_END) <= 0) {
fast = 1;
} else {
const dow = dummy.getDayOfWeek();
if (dow === 3 || dow === 5) fast = 1;
}
retval[i] = fast;
dummy = dummy.addDays(1);
}
return retval;
},
/**
* Major feasts for the year.
* Returns Map< JDN, feastName >.
*/
getFeasts(year: number): Map<number, string> {
if (year < 33) throw new Error('Invalid year');
const feastNames = [
'New Year', 'Theophany', 'Nativity of the Forerunner',
'Peter and Paul', 'Transfiguration', 'Dormition',
'Beheading of the Forerunner', 'Nativity of the Theotokos',
'Exaltation of the Cross', 'Protection of the Theotokos',
'Entrance of the Theotokos', 'Nativity of Christ',
'Pascha and Annunciation', 'Pascha', 'Annunciation',
'Pentecost', 'Ascension', 'Palm Sunday', 'Meeting of the Lord',
];
const feasts = new Map<number, string>();
// Fixed feasts
feasts.set(new JDate(1, 1, year).jdn, feastNames[0]);
feasts.set(new JDate(1, 6, year).jdn, feastNames[1]);
feasts.set(new JDate(6, 24, year).jdn, feastNames[2]);
feasts.set(new JDate(6, 29, year).jdn, feastNames[3]);
feasts.set(new JDate(8, 6, year).jdn, feastNames[4]);
feasts.set(new JDate(8, 15, year).jdn, feastNames[5]);
feasts.set(new JDate(8, 29, year).jdn, feastNames[6]);
feasts.set(new JDate(9, 8, year).jdn, feastNames[7]);
feasts.set(new JDate(9, 14, year).jdn, feastNames[8]);
feasts.set(new JDate(10, 1, year).jdn, feastNames[9]);
feasts.set(new JDate(11, 21, year).jdn, feastNames[10]);
feasts.set(new JDate(12, 25, year).jdn, feastNames[11]);
// Moveable feasts
const pascha = this.getPascha(year);
if (pascha.equals(new JDate(3, 25, year))) {
feasts.set(pascha.jdn, feastNames[12]);
} else {
feasts.set(pascha.jdn, feastNames[13]);
feasts.set(new JDate(3, 25, year).jdn, feastNames[14]);
}
feasts.set(pascha.addDays(49).jdn, feastNames[15]);
feasts.set(pascha.addDays(39).jdn, feastNames[16]);
feasts.set(pascha.subtractDays(7).jdn, feastNames[17]);
// Meeting of the Lord — Feb 2, but transferred to Forgiveness Sunday if it falls on Clean Monday
let meeting = new JDate(2, 2, year);
if (pascha.difference(meeting) === 48) {
meeting = meeting.subtractDays(1);
}
feasts.set(meeting.jdn, feastNames[18]);
return feasts;
},
};
+597
View File
@@ -0,0 +1,597 @@
/**
* Service Assembler — recursive template processor for liturgical services.
* Processes service XML templates (pre-converted to JSON) and renders HTML.
*
* Service.java is part of the Ponomar program.
* Copyright 2008, 2009, 2011 Yuri Shardt.
* See LICENSE for redistribution terms.
*/
import { evalBool } from './evaluator';
import type { EvalContext, ServiceNode } from './types';
export interface CommonTranslations {
priest: string;
reader: string;
deacon: string;
choir: string;
}
export interface ServiceContext {
evalCtx: EvalContext;
lang: string;
fetchText: (path: string) => Promise<string | null>;
fetchServiceNodes: (path: string) => Promise<ServiceNode[] | null>;
fetchBibleText: (book: string, passage: string, showVerseNumbers?: boolean, verseNewLine?: boolean) => Promise<string>;
fetchLives: (id: string) => Promise<Record<string, Record<string, Record<string, unknown>>> | null>;
fetchCommandText: (name: string) => Promise<string>;
resolveTimes: (times: number) => Promise<string>;
t?: { common: CommonTranslations };
}
function localizeWho(who: string, ctx: ServiceContext): string {
const t = ctx.t?.common;
if (!t) return who;
const map: Record<string, string | undefined> = {
P: t.priest,
Priest: t.priest,
R: t.reader,
Reader: t.reader,
SR: t.reader,
D: t.deacon,
Deacon: t.deacon,
C: t.choir,
Choir: t.choir,
};
return map[who] ?? who;
}
// Localized reading intro names (getreading case)
// Values from Java bible.xml Intro field
const READING_NAMES: Record<string, Record<string, string>> = {
en: {
Isa: 'A reading from the Prophecy of Isaiah',
Mica: 'A reading from the Prophecy of Micah',
Zech: 'A reading from the Prophecy of Zechariah',
Jerem: 'A reading from the Prophecy of Jeremiah',
Baruch: 'A reading from the Prophecy of Jeremiah',
Mal: 'A reading from the Prophecy of Malachi',
Zeph: 'A reading from the Prophecy of Zephaniah',
Mt: 'A reading from the Holy Gospel according to Matthew',
Mk: 'A reading from the Holy Gospel according to Mark',
Lk: 'A reading from the Holy Gospel according to Luke',
Jn: 'A reading from the Holy Gospel according to John',
Acts: 'A reading from the Acts of the Apostles',
Rom: 'A reading from the Epistle of Paul to the Romans',
Gal: 'A reading from the Epistle of Paul to the Galatians',
Tit: 'A reading from the Epistle to Titus',
Heb: 'A reading from the Epistle to the Hebrews',
},
cu: {
Isa: 'Прⷪ҇ро́чества и҆са́їина чте́нїе',
Mica: 'Прⷪ҇ро́чества мїхе́ина чте́нїе',
Zech: 'Прⷪ҇ро́чества заха́рїина чте́нїе',
Jerem: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе',
Baruch: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе',
Mal: 'Прⷪ҇ро́чества малахі́ина чте́нїе',
Zeph: 'Прⷪ҇ро́чества софо́нїева чте́нїе',
Mt: 'Ѿ матѳе́а ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Mk: 'Ѿ ма́рка ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Lk: 'Ѿ лꙋкѝ ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Jn: 'Ѿ і҆ѡа́нна ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Acts: 'Дѣѧ̑нїй ст҃ы́хъ а҆пⷭ҇лъ чте́нїе',
Rom: 'Къ ри́млѧнѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
Gal: 'Къ гала́тѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
Tit: 'Къ ті́тꙋ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
Heb: 'Ко є҆вре́ємъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
},
fr: {
Isa: 'Une lection d\'Ésaïe',
Mica: 'Une lection de la prophétie de Michée',
Zech: 'Une lection de la prophétie de Zacharie',
Jerem: 'Une lection de la prophétie de Jérémie',
Baruch: 'Une lection de la prophétie de Jérémie',
Mal: 'Une lection de la prophétie de Malachie',
Zeph: 'Une lection de la prophétie de Sophonie',
Mt: 'Une lection du Saint Évangile selon Saint Matthieu',
Mk: 'Une lection du Saint Évangile selon Saint Marc',
Lk: 'Une lection du Saint Évangile selon Saint Luc',
Jn: 'Une lection du Saint Évangile selon Saint Jean',
Acts: 'Une lection des Actes des Apôtres',
Rom: 'Une lection de l\'Épître de Paul aux Romains',
Gal: 'Une lection de l\'Épître de Paul aux Galates',
Tit: 'Une lection de l\'Épître de Paul au Tite',
Heb: 'Une lection de l\'Épître de Paul aux Hébreux',
},
el: {
Isa: 'Προφητείας Ἡσαΐου τὸ Ἀνάγνωσμα',
Mica: 'Προφητείας Μιχαίου τὸ Ἀνάγνωσμα',
Zech: 'Προφητείας Ζαχαρίου τὸ Ἀνάγνωσμα',
Jerem: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα',
Baruch: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα',
Mal: 'Προφητείας Μαλαχίου τὸ ἀνάγνωσμα',
Zeph: 'Προφητείας Σοφονίου τὸ Ἀνάγνωσμα',
Mt: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ματθαῖον',
Mk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Μᾶρκον',
Lk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Λουκᾶν',
Jn: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ἰωάννην',
Acts: 'Πράξεων τῶν Ἀποστολων',
Rom: 'Πρὸς Ῥωμαίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
Gal: 'Πρὸς Γαλάτας ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
Tit: 'Πρὸς Τίτον ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
Heb: 'Πρὸς Ἑβραίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
},
};
// Localized book names for passage headers
const HEADER_NAMES: Record<string, Record<string, string>> = {
en: {
Psalm: 'Psalm', Isa: 'Isaiah', Jr: 'Jeremiah', Jerem: 'Jeremiah',
Ez: 'Ezekiel', Dn: 'Daniel', Mic: 'Micah', Mica: 'Micah',
Na: 'Nahum', Hab: 'Habakkuk', Zeph: 'Zephaniah', Hag: 'Haggai',
Zech: 'Zechariah', Mal: 'Malachi', Mt: 'Matthew', Mk: 'Mark',
Lk: 'Luke', Jn: 'John', Rom: 'Romans', Heb: 'Hebrews',
Gal: 'Galatians', Acts: 'Acts', Baruch: 'Baruch',
},
cu: {
Psalm: 'Ѱалти́рь', Isa: 'И҆са́їи', Jr: 'І҆еремі́и', Jerem: 'І҆еремі́и',
Ez: 'І҆езекі́ль', Dn: 'Дані́илъ', Mic: 'Мїхе́й', Mica: 'Мїхе́й',
Na: 'Наꙋ́мъ', Hab: 'Аввакꙋ́мъ', Zeph: 'Софо́нїа', Hag: 'Аггѣ́й',
Zech: 'Заха́рїа', Mal: 'Малахі́а', Mt: 'Ѿ Матѳе́а', Mk: 'Ѿ Ма́рка',
Lk: 'Ѿ лꙋкѝ', Jn: 'Ѿ і҆ѡа́нна', Rom: 'Къ ри́млѧнѡмъ', Heb: 'Ко є҆вре́ємъ',
Gal: 'Къ гала́тѡмъ', Acts: 'Дѣѧ̑нїѧ ст҃ы́хъ а҆пⷭ҇лъ', Baruch: 'Варꙋ́хъ',
},
fr: {
Psalm: 'Psaumes', Isa: 'Isaïe / Ésaïe', Jr: 'Jérémie', Jerem: 'Jérémie',
Ez: 'Ézéchiel', Dn: 'Daniel', Mic: 'Michée', Mica: 'Michée',
Na: 'Nahum', Hab: 'Habacuc', Zeph: 'Sophonie', Hag: 'Aggée',
Zech: 'Zacharie', Mal: 'Malachie', Mt: 'Matthieu', Mk: 'Marc',
Lk: 'Luc', Jn: 'Jean', Rom: 'Romains', Heb: 'Hébreux',
Gal: 'Galates', Acts: 'Actes des Apôtres', Baruch: 'Baruch',
},
el: {
Psalm: 'Ψαλμοί', Isa: 'Ἡσαΐας', Jr: 'Ἱερεμίας', Jerem: 'Ἱερεμίας',
Ez: 'Ἰεζεκιήλ', Dn: 'Δανιήλ', Mic: 'Μιχαίας', Mica: 'Μιχαίας',
Na: 'Ναούμ', Hab: 'Ἀμβακούμ', Zeph: 'Σοφονίας', Hag: 'Ἀγγαῖος',
Zech: 'Ζαχαρίας', Mal: 'Μαλαχίας', Mt: 'Εὐαγγέλιον κατὰ Ματθαῖον', Mk: 'Εὐαγγέλιον κατὰ Μᾶρκον',
Lk: 'Εὐαγγέλιον κατὰ Λουκᾶν', Jn: 'Εὐαγγέλιον κατὰ Ἰωάννην',
Rom: 'Πρὸς Ῥωμαίους', Heb: 'Πρὸς Ἑβραίους',
Gal: 'Πρὸς Γαλάτας', Acts: 'Πράξεων τῶν Ἀποστολων', Baruch: 'Βαρούχ',
},
};
function getReadingName(bookId: string, lang: string): string {
const lookupLang = lang === 'ru' ? 'cu' : lang;
const map = READING_NAMES[lookupLang] ?? READING_NAMES.en;
return map[bookId] ?? bookId;
}
function getHeaderName(bookId: string, lang: string): string {
const lookupLang = lang === 'ru' ? 'cu' : lang;
const map = HEADER_NAMES[lookupLang] ?? HEADER_NAMES.en;
return map[bookId] ?? bookId;
}
interface ImplementOptions {
header?: string;
who: string;
command?: string;
commandB?: string;
commandText?: string;
commandBText?: string;
timesText?: string;
redFirst?: boolean;
newLine?: boolean;
headerLevel?: number;
}
function str(v: unknown): string | undefined {
return typeof v === 'string' ? v : undefined;
}
function bool(v: unknown): boolean | undefined {
if (typeof v === 'boolean') return v;
if (v === '1' || v === 'true') return true;
if (v === '0' || v === 'false') return false;
return undefined;
}
function num(v: unknown): number | undefined {
if (typeof v === 'number') return v;
if (typeof v === 'string' && /^\d+$/.test(v)) return parseInt(v, 10);
return undefined;
}
/**
* Assemble a service from pre-parsed JSON template nodes.
* Returns HTML string.
*/
export async function assembleService(
nodes: ServiceNode[],
ctx: ServiceContext,
depth: number = 0
): Promise<{ html: string; whoLast: string }> {
if (depth > 10) return { html: '<!-- Max recursion depth reached -->', whoLast: '\x00' };
let html = '';
let whoLast = '\x00';
// Buffer for grouping same-who lines into a single flex container
let groupWho: string | null = null;
let groupTexts: string[] = [];
function flushGroup() {
if (!groupWho || groupTexts.length === 0) return;
const linesHtml = groupTexts.map((t, i) => {
const redPart = extractFirstLetter(t);
const line = redPart
? `<b><span class="text-red">${redPart}</span></b>${t.substring(redPart.length)}`
: t;
return (i > 0 ? '<br>' : '') + line;
}).join('');
html += `<div class="flex items-baseline gap-1"><b class="text-red shrink-0">${groupWho}:</b><span>${linesHtml}</span></div>\n`;
whoLast = groupWho;
groupWho = null;
groupTexts = [];
}
for (const raw of nodes) {
const n = raw as Record<string, unknown>;
const type = n.type as string;
// Evaluate Cmd attribute if present
const cmd = str(n.cmd);
if (cmd) {
if (!evalBool(cmd, ctx.evalCtx)) continue;
}
switch (type) {
case 'TITLE': {
flushGroup();
const value = str(n.value);
const source = str(n.source);
const comment = str(n.comment);
if (value) {
const titleText = await ctx.fetchText(`Services/Text/${value}.xml`);
if (titleText) html += `<h1 class="mt-4">${titleText}</h1>\n`;
}
if (source) {
const sourceText = await ctx.fetchText(`Services/Text/${source}.xml`);
if (sourceText) html += `<p class="text-red italic text-sm">${sourceText}</p>`;
}
if (comment) {
const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`);
if (commentText) html += `<p class="italic text-sm">${commentText}</p>`;
}
break;
}
case 'SUBTITLE': {
flushGroup();
const value = str(n.value);
const comment = str(n.comment);
if (value) {
const subText = await ctx.fetchText(`Services/Text/${value}.xml`);
if (subText) html += `<h2 class="mt-4">${subText}</h2>\n`;
}
if (comment) {
const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`);
if (commentText) html += `<p class="italic text-sm">${commentText}</p>`;
}
break;
}
case 'GET': {
flushGroup();
const file = str(n.file);
if (!file) break;
const subNodes = await ctx.fetchServiceNodes(`Services/${file}.xml`);
if (subNodes) {
const subResult = await assembleService(subNodes, ctx, depth + 1);
if (n.null && !subResult.html.trim()) {
// Null check: skip if empty
} else {
html += subResult.html;
whoLast = subResult.whoLast;
}
}
break;
}
case 'CREATE': {
const what = str(n.what) ?? '';
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
let text = '';
let header = '';
if (what) {
text = await ctx.fetchText(`Services/CommonPrayers/${what}.xml`) ?? '';
// Fallback: if fetchText returns empty, try to use the 'what' field as a key
if (!text.trim()) {
text = what; // fallback to the key itself
}
header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? '';
}
const commandName = str(n.command);
const commandBName = str(n.commandB);
const timesVal = num(n.times);
const [commandText, commandBText, timesText] = await Promise.all([
commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
]);
const opts = {
who,
commandText: commandText || undefined,
commandBText: commandBText || undefined,
timesText: timesText || undefined,
redFirst: bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(text, opts);
const headerHtml = opts.headerLevel && header
? `<h2 class="text-center text-red font-bold mt-4">${header}</h2>`
: '';
if (who) {
if (who !== groupWho || headerHtml) flushGroup();
if (headerHtml) html += headerHtml + '\n';
groupWho = who;
groupTexts.push(fmt);
} else {
flushGroup();
const br = opts.newLine ? '<br>' : '';
html += `${headerHtml}${br}<p>${fmt}</p>`;
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'TEXT': {
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
// Support both 'what' (programmatic nodes) and 'value' (converted JSON)
const textContent = str(n.what) ?? str(n.value) ?? '';
const opts = {
who,
redFirst: bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(textContent, opts);
if (who) {
if (who !== groupWho) flushGroup();
groupWho = who;
groupTexts.push(fmt);
} else {
flushGroup();
html += `<p>${fmt}</p>`;
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'GETID': {
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
let text = '';
let header = '';
const id = str(n.id);
const what = str(n.what);
const nodeType = str(n.type) ?? 'M';
if (id && what) {
const lifeId = nodeType === 'T' ? `98${id}` : id;
const lives = await ctx.fetchLives(lifeId);
if (lives) {
const lastSlash = what.lastIndexOf('/');
const nodePath = what.substring(0, lastSlash);
const typeKey = what.substring(lastSlash + 1);
const group = lives[nodePath];
if (group) {
const entry = group[typeKey];
if (entry) {
text = String(entry.text ?? '');
header = String(entry.Header ?? '');
}
}
}
}
const commandName = str(n.command);
const commandBName = str(n.commandB);
const timesVal = num(n.times);
const [commandText, commandBText, timesText] = await Promise.all([
commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
]);
const opts = {
who,
commandText: commandText || undefined,
commandBText: commandBText || undefined,
timesText: timesText || undefined,
redFirst: bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(text, opts);
const headerHtml = opts.headerLevel && header
? `<h2 class="text-center text-red font-bold mt-4">${header}</h2>`
: '';
if (who) {
if (who !== groupWho || headerHtml) flushGroup();
groupWho = who;
if (headerHtml) html += headerHtml + '\n';
groupTexts.push(fmt);
} else {
flushGroup();
const br = opts.newLine ? '<br>' : '';
html += `${headerHtml}${br}<p>${fmt}</p>`;
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'BIBLE': {
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
const verseNewLine = !who;
// getreading: display a reading intro header (e.g. "Prophet Isaiah")
const getreading = str(n.getreading);
if (getreading && !str(n.verses)) {
flushGroup();
const bookName = getReadingName(getreading, ctx.lang);
const readingHeader = `<p class="italic">${bookName}</p>`;
html += readingHeader;
if (whoRaw !== undefined) whoLast = who;
break;
}
const verses = str(n.verses) ?? '';
const lastUnderscore = verses.lastIndexOf('_');
const book = lastUnderscore > 0 ? verses.substring(0, lastUnderscore) : verses;
const passage = lastUnderscore > 0 ? verses.substring(lastUnderscore + 1) : '';
// Generate header from book+passage when header=true and no twostars
let header = '';
if (n.header && !str(n.twostars)) {
const bookName = getHeaderName(book, ctx.lang);
header = passage ? `${bookName} ${passage}` : bookName;
}
let text = await ctx.fetchBibleText(book, passage, false, verseNewLine);
// Handle twostars: extract **-delimited instructions as header
const twostars = str(n.twostars);
if (twostars === '1' || twostars === '2') {
const starIdx = text.indexOf('**');
if (starIdx !== -1) {
const lastStar = text.lastIndexOf('**');
if (starIdx !== lastStar) {
const instruction = text.substring(starIdx + 2, lastStar);
if (twostars === '1') {
header = instruction;
} else if (twostars === '2') {
// Extract quoted text between ** markers
const quoteStart = instruction.indexOf('"');
if (quoteStart !== -1) {
const quoteEnd = instruction.indexOf('"', quoteStart + 1);
if (quoteEnd !== -1) {
header = instruction.substring(quoteStart + 1, quoteEnd).replace(/\.\.\./g, '');
}
}
}
// Strip ** markers from text
text = text.substring(0, starIdx) + text.substring(lastStar + 2);
}
}
}
// Strip all remaining ** markers
text = text.replace(/\*\*/g, '');
const commandName = str(n.command);
const commandBName = str(n.commandB);
const timesVal = num(n.times);
const [commandText, commandBText, timesText] = await Promise.all([
commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
]);
const opts = {
who,
commandText: commandText || undefined,
commandBText: commandBText || undefined,
timesText: timesText || undefined,
redFirst: verseNewLine ? false : bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(text, opts);
const headerHtml = opts.headerLevel && header
? `<h2 class="text-center text-red font-bold mt-4">${header}</h2>`
: '';
if (who) {
if (who !== groupWho || headerHtml) flushGroup();
groupWho = who;
if (headerHtml) html += headerHtml + '\n';
groupTexts.push(fmt);
} else {
flushGroup();
if (verseNewLine) {
const verses = fmt.split('<br>').filter(Boolean);
const linesHtml = verses.map(v => {
const redPart = extractFirstLetter(v);
return redPart ? `<b><span class="text-red">${redPart}</span></b>${v.substring(redPart.length)}` : v;
}).join('<br>');
if (headerHtml) html += headerHtml + '\n';
html += `<p>${linesHtml}</p>`;
} else {
const br = opts.newLine ? '<br>' : '';
html += `${headerHtml}${br}<p>${fmt}</p>`;
}
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'TIMES': {
flushGroup();
break;
}
default: {
flushGroup();
// Skip wrapper nodes that aren't real service elements
if (type === 'SERVICES' || type === 'TONE') break;
const children = n.children;
if (Array.isArray(children) && children.length > 0) {
const subHtml = await assembleService(children, ctx, depth + 1);
html += subHtml;
}
break;
}
}
}
flushGroup();
return { html, whoLast };
}
/**
* Format service text content: redFirst, command rubrics, commandB, times.
* Returns formatted HTML string (no wrapping in <p>).
*/
function formatServiceContent(text: string, opts: ImplementOptions): string {
let result = text;
if (opts.redFirst && result.length > 0) {
const redPart = extractFirstLetter(result);
if (redPart) {
result = `<b><span class="text-red">${redPart}</span></b>${result.substring(redPart.length)}`;
}
}
if (opts.commandText) {
if (opts.timesText) {
result += ` <span class="rubric">(${opts.timesText} ${opts.commandText})</span>`;
} else {
result += ` <span class="rubric">(${opts.commandText})</span>`;
}
} else if (opts.timesText) {
result += ` <span class="rubric">(${opts.timesText})</span>`;
}
if (opts.commandBText) {
result = `<span class="rubric">${opts.commandBText}</span> ${result}`;
}
return result;
}
function extractFirstLetter(text: string): string {
const combiningMarks = /[\u0300-\u036f\u037a\u0384-\u0385\u0483-\u0489\u1fbd-\u1ffe\u2de0-\u2dff\ua66f-\ua67d]/;
let result = text[0] || '';
let i = 1;
while (i < text.length && combiningMarks.test(text[i])) {
result += text[i];
i++;
}
return result;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Service Rules — determines which service template to use for a given day.
* Port of Ponomar/ServiceInfo.java.
*/
import { evalBool } from './evaluator';
import type { EvalContext } from './types';
export interface ServiceRuleEntry {
type: string;
troparion?: string;
pickT?: number;
kontakion?: string;
pickK?: number;
lentenk?: number;
[key: string]: string | number | undefined;
}
export interface ServiceRulesData {
periods: {
cmd: string;
rules: {
type: string;
attrs: Record<string, string>;
}[];
}[];
}
/**
* Evaluate service rules for a given day.
* Returns the matching service configuration.
*/
export function evaluateServiceRules(
data: ServiceRulesData,
serviceType: string, // e.g., "VESPERS", "LITURGY", "MATINS"
ctx: EvalContext
): ServiceRuleEntry | null {
let result: ServiceRuleEntry | null = null;
for (const period of data.periods) {
if (period.cmd && !evalBool(period.cmd, ctx)) {
continue;
}
for (const rule of period.rules) {
if (rule.type === serviceType) {
result = { type: rule.type, ...rule.attrs };
}
}
}
return result;
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Shared type definitions for the Ponomar liturgical engine.
*/
/** All liturgical variables computed for a given day. */
export interface DayInfo {
// Date components
dow: number; // Day of week (0=Sun, 1=Mon, ..., 6=Sat)
doy: number; // Day of year (0=Jan 1)
month: number; // Julian calendar month (1-12)
day: number; // Day of month
year: number; // Julian calendar year
// Paschal cycle
nday: number; // Days from this year's Pascha (- before, 0 = Pascha, + after)
ndayP: number; // Days from last year's Pascha
ndayF: number; // Days to next year's Pascha (negative before, 0 at next Pascha)
Easter: number; // JDN of this year's Pascha
Pentecost: number; // JDN of this year's Pentecost
LentStart: number; // JDN of Clean Monday
// Liturgical state
Tone: number; // Current Octoechos tone (1-8, -1 = none)
dRank: number; // Liturgical rank (0-8, higher = more important)
PFlag: number; // Paschal flag
PFlag1: number; // Service inclusion flag
PFlag2: number; // Service type (0=Normal, 1=Lenten, 2=Holy Week/Bright Week)
PS: number; // Priest status (0=Reader, 1=Priest)
// Seasonal flags
isLent: boolean;
isBrightWeek: boolean;
isApostlesFast: boolean;
isDormitionFast: boolean;
isNativityFast: boolean;
// Data file references
triodionFile: number | null; // Index in triodion (1-70)
pentecostarionFile: number | null; // Index in pentecostarion (1-315)
}
/** Expression evaluator variable store. */
export type EvalContext = Record<string, number>;
/** A saint/feast commemoration entry from monthly day XML. */
export interface Commemoration {
sIds: string[]; // Saint IDs (comma-separated in source)
cId: string; // Commemoration ID (points to lives/{cId}.xml)
src?: string; // Source: G=Greek, R=Russian, U=Unsourced
tone?: string; // Tone expression (evaluated at runtime)
cmd?: string; // Conditional expression
}
/** A day's worth of commemoration data. */
export interface DayData {
commemorations: Commemoration[];
}
/** Saint life data (from lives/{cId}.xml). */
export interface SaintData {
name?: {
nominative: string;
short?: string;
genitive?: string;
};
grammar?: Record<string, string>;
rank?: number;
life?: {
text: string;
id?: string;
copyright?: string;
};
services?: Record<string, ServiceData>;
troparion?: { tone: number; text: string }[];
kontakion?: { tone: number; text: string }[];
scriptures?: { type: string; reading: string }[];
icon?: string;
}
/** Service data within a saint's life file. */
export interface ServiceData {
[path: string]: Record<string, { text: string; [key: string]: string }>;
}
/** Fasting rule for a day. */
export interface FastingInfo {
level: FastingLevel;
description: string;
meatFree: boolean;
fishPermitted: boolean;
caviarPermitted: boolean;
oilPermitted: boolean;
winePermitted: boolean;
strictFast: boolean;
noFood: boolean;
}
export enum FastingLevel {
NoFast = 0,
WineAndOil = 1,
Xerophagy = 2,
StrictWithOil = 3,
Strict = 4,
CompleteFast = 5,
}
/** Service template node — dynamic JSON from converted XML. */
export interface ServiceNode {
type: string;
[key: string]: unknown;
}
/** Service rules for a given liturgical period. */
export interface ServiceRule {
cmd: string;
services: Record<string, ServiceTypeConfig>;
}
export interface ServiceTypeConfig {
type: string;
troparion?: string;
pickT?: number;
kontakion?: string;
pickK?: number;
lentenk?: number;
}
/** Bible translation metadata. */
export interface BibleVersion {
id: string;
name: string;
language: string;
books: BibleBook[];
info: BibleInfo;
}
export interface BibleBook {
id: string;
name: string;
chapters: number;
short: string;
intro?: string;
}
export interface BibleInfo {
verseNumFormat: string;
verseNoNumFormat: string;
verseLink: string;
abbrevFormat: string;
chapterN: string;
headerFormat: string;
chapterNI: string;
verseNo: string[];
chapterNo: string[];
cvSep: string;
duration: string;
selectionSeparator: string;
bcSep: string;
fontFace: string;
fontSize: number;
parts: string;
orient: string;
}
/** Language info. */
export type LanguageCode = 'en' | 'ru' | 'cu';
export interface LanguageInfo {
code: LanguageCode;
name: string;
nameLocal: string;
dir: 'ltr' | 'rtl';
fontFace: string;
liturgicalFont: string;
liturgicalSize: number;
}
/** Fasting rule from Fasting.xml. */
export interface FastingRule {
case: string; // 7-bit binary string
cmd: string; // boolean expression
}
/** Fasting period from Fasting.xml. */
export interface FastingPeriod {
cmd: string;
rules: FastingRule[];
}