initial commit
This commit is contained in:
commit
58b379d6e3
47 files changed
+8664
No files matched your search
@@ -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>`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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(', ')}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user