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[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import './styles/main.css';
|
||||
import { App } from './ui/app';
|
||||
|
||||
const app = new App(document.getElementById('app')!);
|
||||
app.init();
|
||||
@@ -0,0 +1,149 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-parchment: #f5f0e8;
|
||||
--color-parchment-dark: #e8dfd0;
|
||||
--color-navy: #1a1a2e;
|
||||
--color-navy-light: #2a2a4e;
|
||||
--color-gold: #c5a55a;
|
||||
--color-gold-light: #d4b96e;
|
||||
--color-red: #8b0000;
|
||||
--color-red-light: #a52a2a;
|
||||
--color-purple: #4a0e4e;
|
||||
--color-green: #2d5a27;
|
||||
--color-blue: #1a3a6e;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ponomar';
|
||||
src: url('/fonts/Ponomar-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fedorovsk';
|
||||
src: url('/fonts/Fedorovsk-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Indiction';
|
||||
src: url('/fonts/Indiction-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Menaion';
|
||||
src: url('/fonts/Menaion-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Monomakh';
|
||||
src: url('/fonts/Monomakh-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Triodion';
|
||||
src: url('/fonts/Triodion-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vilnius';
|
||||
src: url('/fonts/Vilnius-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Voskresensky';
|
||||
src: url('/fonts/Voskresensky-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--color-parchment);
|
||||
color: var(--color-navy);
|
||||
font-family: 'Noto Sans', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.font-slavonic {
|
||||
font-family: 'Ponomar', 'Noto Serif', Georgia, serif;
|
||||
}
|
||||
.font-ponomar { font-family: 'Ponomar', 'Noto Serif', Georgia, serif; }
|
||||
.font-fedrovsk { font-family: 'Fedorovsk', 'Noto Serif', Georgia, serif; }
|
||||
.font-indiction { font-family: 'Indiction', 'Noto Serif', Georgia, serif; }
|
||||
.font-menaion { font-family: 'Menaion', 'Noto Serif', Georgia, serif; }
|
||||
.font-monomakh { font-family: 'Monomakh', 'Noto Serif', Georgia, serif; }
|
||||
.font-triodion { font-family: 'Triodion', 'Noto Serif', Georgia, serif; }
|
||||
.font-vilnius { font-family: 'Vilnius', 'Noto Serif', Georgia, serif; }
|
||||
.font-voskresensky { font-family: 'Voskresensky', 'Noto Serif', Georgia, serif; }
|
||||
|
||||
.font-system-slavonic {
|
||||
font-family: 'Noto Serif', Georgia, serif;
|
||||
}
|
||||
|
||||
.rank-icon {
|
||||
font-family: 'Ponomar', serif;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.liturgical-text {
|
||||
font-size: var(--liturgical-font-size, 18px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.liturgical-text > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.speaker-label {
|
||||
color: var(--color-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rubric {
|
||||
color: var(--color-red);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.drop-cap::first-letter {
|
||||
color: var(--color-red);
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
float: left;
|
||||
line-height: 1;
|
||||
margin-right: 0.1em;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-gold);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* PWA safe area insets */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
.nav-bar {
|
||||
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Main application shell — SPA router and layout.
|
||||
*/
|
||||
|
||||
import { CalendarView } from './calendar-view';
|
||||
import { BibleView } from './bible-view';
|
||||
import { SettingsView } from './settings-view';
|
||||
import { JDate } from '../core/jdate';
|
||||
import { loadSettings, type Settings } from './settings-view';
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
|
||||
type View = 'calendar' | 'bible' | 'settings';
|
||||
|
||||
export class App {
|
||||
private container: HTMLElement;
|
||||
private currentView: View = 'calendar';
|
||||
private currentDate: JDate;
|
||||
private settings: Settings;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.currentDate = JDate.today();
|
||||
this.settings = loadSettings();
|
||||
document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.render();
|
||||
this.handleRoute();
|
||||
window.addEventListener('hashchange', () => this.handleRoute());
|
||||
|
||||
// Register service worker for PWA install
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
(window as any).__deferredPrompt = e;
|
||||
});
|
||||
}
|
||||
|
||||
private handleRoute() {
|
||||
const hash = window.location.hash.slice(1) || 'calendar';
|
||||
const parts = hash.split('/');
|
||||
this.currentView = parts[0] as View || 'calendar';
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
private resolveBibleVersion(shortId: string, lang: string): string {
|
||||
// Map short version IDs like "kjv" to full paths like "en/bible/kjv"
|
||||
const langMap: Record<string, string> = {
|
||||
en: 'en/bible/kjv',
|
||||
ru: 'ru/bible/synod',
|
||||
cu: 'cu/bible/elis',
|
||||
};
|
||||
if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return `en/bible/${shortId}`;
|
||||
if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return `ru/bible/${shortId}`;
|
||||
if (shortId === 'elis') return 'cu/bible/elis';
|
||||
if (shortId === 'ls') return 'fr/bible/ls';
|
||||
if (shortId === 'spt') return 'el/bible/spt';
|
||||
if (shortId === 'vulgate') return 'la/bible/vulgate';
|
||||
if (shortId === 'cuv') return 'zh/Hans/bible/cuv';
|
||||
if (shortId === 'cuv-hant') return 'zh/Hant/bible/cuv';
|
||||
if (shortId === 'svd') return 'ar/bible/svd';
|
||||
return langMap[lang] || 'en/bible/kjv';
|
||||
}
|
||||
|
||||
private render() {
|
||||
const t = getTranslations(this.settings.language as LanguageCode);
|
||||
this.container.innerHTML = `
|
||||
<div class="app-container min-h-screen flex flex-col">
|
||||
<header class="bg-navy text-parchment p-3 flex items-center justify-between shadow-md">
|
||||
<h1 class="text-sm font-bold tracking-wide">${t.appName}</h1>
|
||||
<nav class="flex gap-4 text-sm">
|
||||
<a href="#calendar" class="nav-link hover:text-gold transition-colors" data-view="calendar">${t.nav.calendar}</a>
|
||||
<a href="#bible" class="nav-link hover:text-gold transition-colors" data-view="bible">${t.nav.bible}</a>
|
||||
<a href="#settings" class="nav-link hover:text-gold transition-colors" data-view="settings">${t.nav.settings}</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="view-container" class="flex-1 overflow-auto"></main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderView() {
|
||||
const viewContainer = document.getElementById('view-container')!;
|
||||
viewContainer.innerHTML = '';
|
||||
|
||||
// Update active nav
|
||||
document.querySelectorAll('.nav-link').forEach(el => {
|
||||
const v = el.getAttribute('data-view');
|
||||
el.classList.toggle('text-gold', v === this.currentView);
|
||||
el.classList.toggle('font-bold', v === this.currentView);
|
||||
});
|
||||
|
||||
// Re-read settings fresh on each navigation
|
||||
this.settings = loadSettings();
|
||||
const lang = this.settings.language as LanguageCode;
|
||||
const calendarType = this.settings.calendarType || 'julian';
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'calendar':
|
||||
new CalendarView(viewContainer, this.currentDate, (date) => {
|
||||
this.currentDate = date;
|
||||
}, lang, calendarType).render();
|
||||
break;
|
||||
case 'bible': {
|
||||
// Parse route params: #bible/{lang~short}/{book}/{passage}
|
||||
const hash = window.location.hash.slice(1);
|
||||
const bp = hash.split('/');
|
||||
const versionSpec = bp[1] || '';
|
||||
const bibleBook = bp[2] || '';
|
||||
const biblePassage = bp[3] || '';
|
||||
let bibleVersion: string;
|
||||
if (!versionSpec) {
|
||||
bibleVersion = this.resolveBibleVersion(this.settings.defaultBibleVersion, lang);
|
||||
} else if (versionSpec.includes('~')) {
|
||||
// Format: lang~short e.g. "en~kjv" → "en/bible/kjv"
|
||||
const [vl, vs] = versionSpec.split('~');
|
||||
bibleVersion = `${vl}/bible/${vs}`;
|
||||
} else if (versionSpec.includes('/')) {
|
||||
bibleVersion = versionSpec; // legacy full path
|
||||
} else {
|
||||
bibleVersion = this.resolveBibleVersion(versionSpec, lang);
|
||||
}
|
||||
new BibleView(viewContainer, lang, bibleVersion, bibleBook, biblePassage).render();
|
||||
break;
|
||||
}
|
||||
case 'settings':
|
||||
new SettingsView(viewContainer, () => {
|
||||
this.render();
|
||||
this.renderView();
|
||||
}).render();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
go(view: View) {
|
||||
window.location.hash = view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Bible view — reader with all translation variants.
|
||||
*/
|
||||
|
||||
import {
|
||||
parsePassage,
|
||||
parseTextFile,
|
||||
extractPassage,
|
||||
formatPassageHTML,
|
||||
} from '../core/bible-reader';
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
|
||||
const SETTINGS_KEY = 'ponomar-settings';
|
||||
const BIBLE_STATE_KEY = 'ponomar-bible-state';
|
||||
|
||||
function loadBibleState(version: string): { book: string; passage: string } | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(BIBLE_STATE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const pos = parsed.positions?.[version];
|
||||
if (pos?.book && pos?.passage) return pos;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveBibleState(version: string, book: string, passage: string) {
|
||||
try {
|
||||
const stored = localStorage.getItem(BIBLE_STATE_KEY);
|
||||
const state: { positions: Record<string, { book: string; passage: string }> } = stored ? JSON.parse(stored) : { positions: {} };
|
||||
state.positions[version] = { book, passage };
|
||||
localStorage.setItem(BIBLE_STATE_KEY, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadSettings(): { cuFont: string; showVerseNumbers: boolean; verseNewLine: boolean } {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
const defaults = { cuFont: 'Ponomar', showVerseNumbers: true, verseNewLine: false };
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.usePonomarFont !== undefined) {
|
||||
parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : '';
|
||||
delete parsed.usePonomarFont;
|
||||
}
|
||||
return { ...defaults, ...parsed };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { cuFont: 'Ponomar', showVerseNumbers: true, verseNewLine: false };
|
||||
}
|
||||
|
||||
const FONT_CSS_MAP: Record<string, string> = {
|
||||
Ponomar: 'font-ponomar',
|
||||
Fedorovsk: 'font-fedrovsk',
|
||||
Indiction: 'font-indiction',
|
||||
Menaion: 'font-menaion',
|
||||
Monomakh: 'font-monomakh',
|
||||
Triodion: 'font-triodion',
|
||||
Vilnius: 'font-vilnius',
|
||||
Voskresensky: 'font-voskresensky',
|
||||
};
|
||||
|
||||
function fontClass(fontName: string): string {
|
||||
return FONT_CSS_MAP[fontName] || (fontName ? '' : 'font-system-slavonic');
|
||||
}
|
||||
|
||||
interface BibleVersion {
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
books: { id: string; name: string; short: string; chapters: number }[];
|
||||
}
|
||||
|
||||
export class BibleView {
|
||||
private container: HTMLElement;
|
||||
private currentVersion: string;
|
||||
private currentBook: string;
|
||||
private currentPassage: string;
|
||||
private versions: BibleVersion[] = [];
|
||||
private loadedText: Map<string, string> = new Map();
|
||||
private t: ReturnType<typeof getTranslations>;
|
||||
|
||||
constructor(container: HTMLElement, language: LanguageCode = 'en', version = '', book = '', passage = '') {
|
||||
this.container = container;
|
||||
this.t = getTranslations(language);
|
||||
this.currentVersion = version || 'en/bible/kjv';
|
||||
|
||||
if (!book && !passage) {
|
||||
const saved = loadBibleState(this.currentVersion);
|
||||
if (saved) { book = saved.book; passage = saved.passage; }
|
||||
}
|
||||
|
||||
this.currentBook = book || 'Gen';
|
||||
this.currentPassage = passage || '1';
|
||||
this.loadVersions();
|
||||
}
|
||||
|
||||
private async loadVersions() {
|
||||
try {
|
||||
const resp = await fetch('/data/bible/versions.json');
|
||||
this.versions = await resp.json();
|
||||
} catch {
|
||||
this.versions = [
|
||||
{ id: 'en/bible/kjv', name: 'English (KJV)', language: 'en', books: [] },
|
||||
{ id: 'ru/bible/synod', name: 'Русский (Синодальный)', language: 'ru', books: [] },
|
||||
{ id: 'cu/bible/elis', name: 'Церковнославянская', language: 'cu', books: [] },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async render() {
|
||||
await this.loadVersions();
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="bg-navy text-parchment p-3 flex items-center gap-4 flex-wrap">
|
||||
<select id="version-select" class="bg-navy-light text-parchment border border-gold/30 rounded px-2 py-1 text-sm">
|
||||
${this.versions.map(v =>
|
||||
`<option value="${v.id}" ${v.id === this.currentVersion ? 'selected' : ''}>${v.name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<select id="book-select" class="bg-navy-light text-parchment border border-gold/30 rounded px-2 py-1 text-sm">
|
||||
${this.getBooksForVersion().map(b =>
|
||||
`<option value="${b.id}" ${b.id === this.currentBook ? 'selected' : ''}>${b.name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<div class="flex items-center gap-1">
|
||||
<button id="prev-chapter" class="bg-navy-light text-parchment border border-gold/30 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">◀</button>
|
||||
<select id="chapter-select" class="bg-navy-light text-parchment border border-gold/30 rounded px-2 py-1 text-sm">
|
||||
</select>
|
||||
<button id="next-chapter" class="bg-navy-light text-parchment border border-gold/30 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bible-text" class="flex-1 overflow-auto p-2 max-w-3xl mx-auto liturgical-text">
|
||||
<p class="text-navy-light italic">${this.t.bible.selectBook}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.updateChapterSelect();
|
||||
this.loadReading();
|
||||
|
||||
document.getElementById('version-select')?.addEventListener('change', (e) => {
|
||||
this.currentVersion = (e.target as HTMLSelectElement).value;
|
||||
const books = this.getBooksForVersion();
|
||||
const sameBook = books.find(b => b.id === this.currentBook);
|
||||
if (sameBook) {
|
||||
const ch = parseInt(this.currentPassage, 10);
|
||||
if (ch > sameBook.chapters) this.currentPassage = '1';
|
||||
} else {
|
||||
this.currentBook = books[0]?.id || 'Gen';
|
||||
this.currentPassage = '1';
|
||||
}
|
||||
this.updateBookSelect();
|
||||
this.loadReading();
|
||||
});
|
||||
|
||||
document.getElementById('book-select')?.addEventListener('change', (e) => {
|
||||
this.currentBook = (e.target as HTMLSelectElement).value;
|
||||
this.currentPassage = '1';
|
||||
this.updateChapterSelect();
|
||||
this.loadReading();
|
||||
});
|
||||
|
||||
document.getElementById('chapter-select')?.addEventListener('change', (e) => {
|
||||
this.currentPassage = (e.target as HTMLSelectElement).value;
|
||||
this.loadReading();
|
||||
});
|
||||
|
||||
document.getElementById('prev-chapter')?.addEventListener('click', () => {
|
||||
const ch = parseInt(this.currentPassage, 10);
|
||||
if (ch > 1) {
|
||||
this.currentPassage = String(ch - 1);
|
||||
this.updateChapterSelect();
|
||||
this.loadReading();
|
||||
} else {
|
||||
const books = this.getBooksForVersion();
|
||||
const idx = books.findIndex(b => b.id === this.currentBook);
|
||||
if (idx > 0) {
|
||||
const prevBook = books[idx - 1];
|
||||
this.currentBook = prevBook.id;
|
||||
this.currentPassage = String(prevBook.chapters);
|
||||
this.updateBookSelect();
|
||||
this.loadReading();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('next-chapter')?.addEventListener('click', () => {
|
||||
const ch = parseInt(this.currentPassage, 10);
|
||||
const books = this.getBooksForVersion();
|
||||
const book = books.find(b => b.id === this.currentBook);
|
||||
const maxCh = book?.chapters ?? 50;
|
||||
if (ch < maxCh) {
|
||||
this.currentPassage = String(ch + 1);
|
||||
this.updateChapterSelect();
|
||||
this.loadReading();
|
||||
} else {
|
||||
const idx = books.findIndex(b => b.id === this.currentBook);
|
||||
if (idx < books.length - 1) {
|
||||
const nextBook = books[idx + 1];
|
||||
this.currentBook = nextBook.id;
|
||||
this.currentPassage = '1';
|
||||
this.updateBookSelect();
|
||||
this.loadReading();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getBooksForVersion(): { id: string; name: string; chapters: number }[] {
|
||||
const version = this.versions.find(v => v.id === this.currentVersion);
|
||||
return version?.books ?? [];
|
||||
}
|
||||
|
||||
private updateBookSelect() {
|
||||
const select = document.getElementById('book-select') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
const books = this.getBooksForVersion();
|
||||
select.innerHTML = books.map(b =>
|
||||
`<option value="${b.id}" ${b.id === this.currentBook ? 'selected' : ''}>${b.name}</option>`
|
||||
).join('');
|
||||
this.updateChapterSelect();
|
||||
}
|
||||
|
||||
private updateChapterSelect() {
|
||||
const select = document.getElementById('chapter-select') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
const books = this.getBooksForVersion();
|
||||
const book = books.find(b => b.id === this.currentBook);
|
||||
const chapters = book?.chapters ?? 50;
|
||||
select.innerHTML = Array.from({ length: chapters }, (_, i) =>
|
||||
`<option value="${i + 1}" ${i + 1 === parseInt(this.currentPassage, 10) ? 'selected' : ''}>${i + 1}</option>`
|
||||
).join('');
|
||||
// Sync passage to selected chapter
|
||||
this.currentPassage = select.value;
|
||||
}
|
||||
|
||||
private async loadReading() {
|
||||
const textEl = document.getElementById('bible-text');
|
||||
if (!textEl) return;
|
||||
|
||||
textEl.innerHTML = `<p class="text-navy-light italic">${this.t.loading}</p>`;
|
||||
|
||||
try {
|
||||
const cacheKey = `${this.currentVersion}/${this.currentBook}`;
|
||||
let text = this.loadedText.get(cacheKey);
|
||||
|
||||
if (!text) {
|
||||
// Version IDs are full paths like "en/bible/kjv"
|
||||
const resp = await fetch(`/data/${this.currentVersion}/${this.currentBook}.text`);
|
||||
if (!resp.ok) throw new Error('Not found');
|
||||
text = await resp.text();
|
||||
this.loadedText.set(cacheKey, text);
|
||||
}
|
||||
|
||||
const chapters = parseTextFile(text!);
|
||||
const reading = parsePassage(this.currentPassage);
|
||||
const passages = extractPassage(chapters, reading);
|
||||
const settings = loadSettings();
|
||||
const html = formatPassageHTML(passages, { showVerseNumbers: settings.showVerseNumbers, verseNewLine: settings.verseNewLine });
|
||||
|
||||
// Apply Ponomar font for Church Slavonic texts
|
||||
const version = this.versions.find(v => v.id === this.currentVersion);
|
||||
const isChurchSlavonic = version?.language === 'cu';
|
||||
const fnClass = isChurchSlavonic ? fontClass(settings.cuFont) : '';
|
||||
|
||||
textEl.className = `flex-1 overflow-auto p-2 max-w-3xl mx-auto liturgical-text ${fnClass}`;
|
||||
textEl.innerHTML = html || `<p class="text-navy-light">${this.t.bible.noTextFound}</p>`;
|
||||
} catch {
|
||||
textEl.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-navy-light mb-4">${this.t.bible.notAvailableOffline}</p>
|
||||
<p class="text-sm text-navy-light">
|
||||
${this.t.bible.ensureDataConverted}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
saveBibleState(this.currentVersion, this.currentBook, this.currentPassage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
/**
|
||||
* Calendar view — month grid + day detail panel with actual data loading.
|
||||
*/
|
||||
|
||||
import { JDate } from '../core/jdate';
|
||||
import { Paschalion } from '../core/paschalion';
|
||||
import { computeDay } from '../core/day-computer';
|
||||
import { evaluateFasting } from '../core/fasting';
|
||||
import type { FastingPeriod } from '../core/fasting';
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
import { fontClass } from './settings-view';
|
||||
import { ServiceView } from './service-view';
|
||||
|
||||
let cachedFastingPeriods: FastingPeriod[] | null = null;
|
||||
const lifeCache = new Map<string, LifeData | null>();
|
||||
const menaionBundles = new Map<string, Record<string, unknown>>();
|
||||
let sharedScriptureIndex: Record<string, unknown[]> | null = null;
|
||||
let bookAbbrevMap: Record<string, string> | null = null;
|
||||
let bookCVSep = ':';
|
||||
let bookAbbrevLang = '';
|
||||
|
||||
function resolveDefaultBibleVersion(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem('ponomar-settings');
|
||||
if (stored) {
|
||||
const s = JSON.parse(stored);
|
||||
return s.defaultBibleVersion || 'kjv';
|
||||
}
|
||||
} catch {}
|
||||
return 'kjv';
|
||||
}
|
||||
|
||||
function shortToLang(shortId: string): string {
|
||||
if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return 'en';
|
||||
if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return 'ru';
|
||||
if (shortId === 'elis') return 'cu';
|
||||
if (shortId === 'ls') return 'fr';
|
||||
if (shortId === 'spt') return 'el';
|
||||
if (shortId === 'vulgate') return 'la';
|
||||
if (shortId === 'cuv' || shortId === 'cuv-hant') return 'zh';
|
||||
if (shortId === 'svd') return 'ar';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
async function loadBookAbbrevs(lang: string): Promise<void> {
|
||||
if (bookAbbrevLang === lang && bookAbbrevMap) return;
|
||||
bookAbbrevLang = lang;
|
||||
bookAbbrevMap = null;
|
||||
try {
|
||||
const resp = await fetch('/data/bible/versions.json');
|
||||
if (!resp.ok) return;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return;
|
||||
const versions = await resp.json() as any[];
|
||||
const ver = versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0];
|
||||
if (ver) {
|
||||
const map: Record<string, string> = {};
|
||||
if (ver.books) {
|
||||
for (const book of ver.books) {
|
||||
map[book.id] = book.short || book.id;
|
||||
}
|
||||
}
|
||||
bookAbbrevMap = map;
|
||||
if (ver.formatting?.CVSep) {
|
||||
bookCVSep = ver.formatting.CVSep;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function formatReadingRef(reading: string, _lang: string): string {
|
||||
const idx = reading.indexOf('_');
|
||||
if (idx === -1) return reading.replace(/_/g, ' ');
|
||||
const bookId = reading.substring(0, idx);
|
||||
const passage = reading.substring(idx + 1);
|
||||
const lookupId = bookId.replace(/ /g, '_');
|
||||
const abbrev = bookAbbrevMap?.[lookupId] || bookId;
|
||||
const formatted = passage.replace(/:/g, bookCVSep === ',' ? ',' : bookCVSep === ':' ? ':' : ':');
|
||||
const display = `${abbrev} ${formatted}`;
|
||||
const chMatch = passage.match(/^(\d+)/);
|
||||
const chapter = chMatch ? chMatch[1] : '1';
|
||||
const defaultShort = resolveDefaultBibleVersion();
|
||||
const langPrefix = shortToLang(defaultShort);
|
||||
return `<a href="#bible/${langPrefix}~${defaultShort}/${bookId.replace(/ /g, '_')}/${chapter}" class="text-blue-600 underline hover:text-blue-800">${display}</a>`;
|
||||
}
|
||||
|
||||
async function loadSharedScriptureIndex(): Promise<Record<string, unknown[]> | null> {
|
||||
if (sharedScriptureIndex) return sharedScriptureIndex;
|
||||
try {
|
||||
const resp = await fetch('/data/shared/lives-index.json');
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
sharedScriptureIndex = await resp.json();
|
||||
return sharedScriptureIndex;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMenaionBundle(lang: string): Promise<Record<string, unknown> | null> {
|
||||
const key = lang === 'cu' ? 'cu' : lang;
|
||||
if (menaionBundles.has(key)) return menaionBundles.get(key)!;
|
||||
try {
|
||||
const resp = await fetch(`/data/${key}/menaion-bundle.json`);
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
const bundle = await resp.json() as Record<string, unknown>;
|
||||
menaionBundles.set(key, bundle);
|
||||
return bundle;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFastingPeriods(): Promise<FastingPeriod[]> {
|
||||
if (cachedFastingPeriods) return cachedFastingPeriods;
|
||||
try {
|
||||
const resp = await fetch('/data/shared/fasting.json');
|
||||
if (!resp.ok) return [];
|
||||
cachedFastingPeriods = await resp.json();
|
||||
return cachedFastingPeriods!;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface LifeData {
|
||||
name?: {
|
||||
nominative?: string;
|
||||
short?: string;
|
||||
'@_Nominative'?: string;
|
||||
'@_Short'?: string;
|
||||
};
|
||||
life?: {
|
||||
text: string;
|
||||
};
|
||||
scripture?: {
|
||||
type?: string;
|
||||
Type?: string;
|
||||
reading?: string;
|
||||
Reading?: string;
|
||||
pericope?: string;
|
||||
Pericope?: string;
|
||||
cmd?: string;
|
||||
}[];
|
||||
services?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CommemorationData {
|
||||
NAME?: { Long?: string; ShortN?: string; ShortF?: string } | { text: string };
|
||||
LIFE?: { text: string };
|
||||
CHURCH?: { Rank?: string };
|
||||
SCRIPTURE?: { Type?: string; Reading?: string; Pericope?: string; Cmd?: string } | { Type?: string; Reading?: string; Pericope?: string; Cmd?: string }[];
|
||||
SERVICE?: Record<string, unknown>;
|
||||
TROPARION?: { Tone?: string; text?: string } | { Tone?: string; text?: string }[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ReadingItem {
|
||||
ref: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
interface ReadingsGroup {
|
||||
service: string;
|
||||
items: ReadingItem[];
|
||||
}
|
||||
|
||||
/** Order of services for display (Vespers → Hours → Matins → Liturgy sub-types) */
|
||||
const SERVICE_ORDER = ['Вечерня', '1-й час', '3-й час', '6-й час', '9-й час', 'Утреня', 'Апостол', 'Евангелие'];
|
||||
|
||||
/** Map Type/service name to display service label */
|
||||
function readingsTypeToService(type: string, t: ReturnType<typeof getTranslations>): string {
|
||||
switch (type) {
|
||||
case 'apostol': return t.calendar.readingTypes.apostol;
|
||||
case 'gospel': return t.calendar.readingTypes.gospel;
|
||||
case 'matins': return t.calendar.readingTypes.matins;
|
||||
case 'vespers': return t.calendar.readingTypes.vespers;
|
||||
case 'primes': return t.calendar.readingTypes.primes;
|
||||
case 'terce': return t.calendar.readingTypes.terce;
|
||||
case 'sext': return t.calendar.readingTypes.sext;
|
||||
case 'none': return t.calendar.readingTypes.none;
|
||||
case 'liturgy': return t.calendar.readingTypes.liturgy;
|
||||
case 'A': return t.calendar.readings;
|
||||
default:
|
||||
if (/^[0-9]+$/.test(type)) {
|
||||
const n = parseInt(type, 10);
|
||||
if (n <= 3 || n === 0) return t.calendar.readingTypes.vespers;
|
||||
return t.calendar.readingTypes.matins;
|
||||
}
|
||||
return t.calendar.readingTypes[type] || t.calendar.readings;
|
||||
}
|
||||
}
|
||||
|
||||
function svcNameToService(svcName: string, scType: string, t: ReturnType<typeof getTranslations>): string {
|
||||
const upper = svcName.toUpperCase();
|
||||
if (upper === 'LITURGY') {
|
||||
return readingsTypeToService(scType, t);
|
||||
}
|
||||
return readingsTypeToService(upper.toLowerCase(), t);
|
||||
}
|
||||
|
||||
function findOrCreateGroup(groups: ReadingsGroup[], service: string): ReadingsGroup {
|
||||
let group = groups.find(g => g.service === service);
|
||||
if (!group) {
|
||||
group = { service, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Map of menaion CId to shared commemoration ID (for feast days with special commemorations) */
|
||||
const CID_TO_SHARED_COMM: Record<string, string> = {
|
||||
'010101': '134', // Circumcision
|
||||
'010601': '35', // Theophany
|
||||
'011901': '373', // Theophany (Julian)
|
||||
'020201': '373', // Meeting of the Lord
|
||||
'032501': '543', // Annunciation
|
||||
'080601': '543', // Transfiguration
|
||||
'081501': '373', // Dormition
|
||||
'091401': 'F0', // Elevation of Cross
|
||||
'092101': 'F6', // Nativity of Theotokos
|
||||
'010701': '373', // Synaxis of John the Baptist
|
||||
'070701': '373', // Nativity of John the Baptist
|
||||
'082901': '373', // Beheading of John the Baptist
|
||||
};
|
||||
|
||||
async function loadLife(lang: LanguageCode, cid: string): Promise<LifeData | null> {
|
||||
const cacheKey = `${lang}/${cid}`;
|
||||
if (lifeCache.has(cacheKey)) return lifeCache.get(cacheKey)!;
|
||||
try {
|
||||
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(cid) ? cid.substring(0, 2) : 'misc';
|
||||
async function tryFetch(prefix: string): Promise<Response | null> {
|
||||
const resp = await fetch(`/data/${prefix}/lives/${bundleName}.json`);
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
return resp;
|
||||
}
|
||||
let resp = await tryFetch(lang);
|
||||
if (!resp && lang !== 'cu') {
|
||||
resp = await tryFetch('cu');
|
||||
}
|
||||
if (!resp) {
|
||||
lifeCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
const bundle = await resp.json() as Record<string, unknown>;
|
||||
const life = (bundle[cid] || null) as LifeData | null;
|
||||
if (!life) {
|
||||
lifeCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Merge rank from cu life if language-specific life lacks it
|
||||
if (!life.rank && lang !== 'cu') {
|
||||
try {
|
||||
const cuResp = await fetch(`/data/cu/lives/${bundleName}.json`);
|
||||
if (cuResp.ok) {
|
||||
const ct = cuResp.headers.get('content-type');
|
||||
if (!ct || !ct.includes('text/html')) {
|
||||
const cuBundle = await cuResp.json() as Record<string, unknown>;
|
||||
const cuLife = cuBundle[cid] as LifeData | undefined;
|
||||
if (cuLife?.rank) life.rank = cuLife.rank;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Merge scripture from shared lives index (one HTTP request for all)
|
||||
const index = await loadSharedScriptureIndex();
|
||||
if (index && index[cid]) {
|
||||
const raw = index[cid];
|
||||
const sharedArr = (Array.isArray(raw) ? raw : [raw]) as LifeData['scripture'];
|
||||
if (sharedArr && sharedArr.length > 0) {
|
||||
if (life.scripture) {
|
||||
const existing = Array.isArray(life.scripture) ? life.scripture : [life.scripture];
|
||||
life.scripture = [...existing, ...sharedArr] as LifeData['scripture'];
|
||||
} else {
|
||||
life.scripture = sharedArr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifeCache.set(cacheKey, life);
|
||||
return life;
|
||||
} catch {
|
||||
lifeCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommemoration(sharedId: string): Promise<CommemorationData | null> {
|
||||
try {
|
||||
const resp = await fetch(`/data/shared/commemorations/${sharedId}.json`);
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPeriodName(life: LifeData | undefined): string {
|
||||
if (!life?.name) return '';
|
||||
return life.name['@_Nominative'] || life.name.nominative || life.name['@_Short'] || life.name.short || '';
|
||||
}
|
||||
|
||||
function getShortFeastName(life: LifeData | undefined, shared: CommemorationData | null | undefined): string {
|
||||
if (life?.name?.['@_Short']) return life.name['@_Short'];
|
||||
if (life?.name?.short) return life.name.short;
|
||||
return getCommsName(shared);
|
||||
}
|
||||
|
||||
function getCommsName(shared: CommemorationData | null | undefined): string {
|
||||
if (!shared?.NAME) return '';
|
||||
if (typeof shared.NAME === 'object' && 'Long' in shared.NAME) {
|
||||
return shared.NAME.Long || shared.NAME.ShortN || '';
|
||||
}
|
||||
if (typeof shared.NAME === 'object' && 'text' in shared.NAME) {
|
||||
return (shared.NAME as { text: string }).text || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function loadMenaion(lang: LanguageCode, month: number, day: number): Promise<unknown[]> {
|
||||
const mm = String(month).padStart(2, '0');
|
||||
const dd = String(day).padStart(2, '0');
|
||||
const dateKey = `${mm}-${dd}`;
|
||||
try {
|
||||
// Try lang-specific bundle first
|
||||
let bundle = await loadMenaionBundle(lang);
|
||||
if (!bundle && lang !== 'cu') {
|
||||
bundle = await loadMenaionBundle('cu');
|
||||
}
|
||||
if (bundle && bundle[dateKey]) {
|
||||
const arr = bundle[dateKey];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTriodionFile(index: number): Promise<unknown[]> {
|
||||
try {
|
||||
const resp = await fetch(`/data/shared/triodion/${String(index).padStart(2, '0')}.json`);
|
||||
if (!resp.ok) return [];
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPentecostarionFile(index: number): Promise<unknown[]> {
|
||||
try {
|
||||
const resp = await fetch(`/data/shared/pentecostarion/${String(index).padStart(2, '0')}.json`);
|
||||
if (!resp.ok) return [];
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class CalendarView {
|
||||
private container: HTMLElement;
|
||||
private currentDate: JDate;
|
||||
private onDateChange: (date: JDate) => void;
|
||||
private skipCids: Set<string> = new Set();
|
||||
private language: LanguageCode;
|
||||
private t: ReturnType<typeof getTranslations>;
|
||||
private calendarType: 'julian' | 'gregorian';
|
||||
|
||||
constructor(container: HTMLElement, date: JDate, onDateChange: (d: JDate) => void, language: LanguageCode = 'en', calendarType: 'julian' | 'gregorian' = 'julian') {
|
||||
this.container = container;
|
||||
this.currentDate = date;
|
||||
this.onDateChange = onDateChange;
|
||||
this.language = language;
|
||||
this.t = getTranslations(language);
|
||||
this.calendarType = calendarType;
|
||||
}
|
||||
|
||||
private getFontClass(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem('ponomar-settings');
|
||||
if (stored) {
|
||||
const settings = JSON.parse(stored);
|
||||
let cuFont = settings.cuFont || '';
|
||||
// Migrate old usePonomarFont
|
||||
if (settings.usePonomarFont !== undefined) {
|
||||
cuFont = settings.usePonomarFont ? 'Ponomar' : '';
|
||||
}
|
||||
const useFont = cuFont !== '' || settings.usePonomarFont !== false;
|
||||
return (this.language === 'cu') && useFont ? fontClass(cuFont) : '';
|
||||
}
|
||||
} catch {}
|
||||
return this.language === 'cu' ? 'font-ponomar' : '';
|
||||
}
|
||||
|
||||
/** Lightweight dRank computation for calendar grid highlighting */
|
||||
private getDayRank(date: JDate, julianYear: number): number {
|
||||
const month = date.getMonth();
|
||||
const day = date.getDay();
|
||||
const pascha = Paschalion.getPascha(julianYear);
|
||||
const nday = date.difference(pascha);
|
||||
const isBrightWeek = nday >= 0 && nday <= 6;
|
||||
|
||||
if (month === 9 && day === 14) return 6;
|
||||
if (month === 8 && day === 29) return 6;
|
||||
if (nday === 0) return 8;
|
||||
if (nday === 49) return 8;
|
||||
if (nday === 39) return 8;
|
||||
if (nday === -7) return 8;
|
||||
if (month === 12 && day === 25) return 6;
|
||||
if (month === 1 && day === 6) return 6;
|
||||
if (month === 8 && day === 15) return 6;
|
||||
if (month === 3 && day === 25) return 6;
|
||||
if (nday >= -48 && nday < 0) return 3;
|
||||
if (isBrightWeek || (nday >= 49 && nday <= 56)) return 5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
render() {
|
||||
const useGregorian = this.calendarType === 'gregorian';
|
||||
const fnClass = this.getFontClass();
|
||||
|
||||
function daysInGregorianMonth(m: number, y: number): number {
|
||||
const dim = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
if (m === 2 && ((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0)) return 29;
|
||||
return dim[m - 1];
|
||||
}
|
||||
|
||||
const julianYear = this.currentDate.getYear();
|
||||
const julianMonth = this.currentDate.getMonth();
|
||||
const julianDay = this.currentDate.getDay();
|
||||
|
||||
// Grid display params: Julian or Gregorian based on setting
|
||||
let month: number, year: number, day: number;
|
||||
if (useGregorian) {
|
||||
const g = this.currentDate.toGregorian();
|
||||
year = g.getFullYear();
|
||||
month = g.getMonth() + 1;
|
||||
day = g.getDate();
|
||||
} else {
|
||||
year = julianYear;
|
||||
month = julianMonth;
|
||||
day = julianDay;
|
||||
}
|
||||
|
||||
let firstDow: number;
|
||||
let daysInMonth: number;
|
||||
if (useGregorian) {
|
||||
const firstJulian = JDate.fromGregorianParts(year, month, 1);
|
||||
firstDow = firstJulian.getDayOfWeek();
|
||||
daysInMonth = daysInGregorianMonth(month, year);
|
||||
} else {
|
||||
const firstOfMonth = new JDate(month, 1, year);
|
||||
firstDow = firstOfMonth.getDayOfWeek();
|
||||
daysInMonth = JDate.maxDaysInMonth(month, year);
|
||||
}
|
||||
|
||||
const fasts = Paschalion.getFasts(julianYear);
|
||||
|
||||
let gridHtml = '';
|
||||
let col = 0;
|
||||
|
||||
gridHtml += this.t.calendar.dayNames.map(d =>
|
||||
`<div class="text-center text-xs font-bold text-navy-light py-1">${d}</div>`
|
||||
).join('');
|
||||
|
||||
for (let i = 0; i < firstDow; i++) {
|
||||
gridHtml += '<div></div>';
|
||||
col++;
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
let fast = 0;
|
||||
let isSelected = false;
|
||||
let isToday = false;
|
||||
let dayRank = 0;
|
||||
if (useGregorian) {
|
||||
const jDate = JDate.fromGregorianParts(year, month, d);
|
||||
const dayDoy = jDate.getDoy();
|
||||
fast = fasts[dayDoy] ?? 0;
|
||||
isSelected = d === day;
|
||||
dayRank = this.getDayRank(jDate, julianYear);
|
||||
const todayGreg = JDate.today().toGregorian();
|
||||
isToday = todayGreg.getFullYear() === year && todayGreg.getMonth() + 1 === month && todayGreg.getDate() === d;
|
||||
} else {
|
||||
const date = new JDate(month, d, year);
|
||||
const dayDoy = date.getDoy();
|
||||
fast = fasts[dayDoy] ?? 0;
|
||||
isSelected = d === day;
|
||||
dayRank = this.getDayRank(date, julianYear);
|
||||
isToday = date.equals(JDate.today());
|
||||
}
|
||||
|
||||
let classes = 'day-cell p-1 text-center text-sm cursor-pointer rounded transition-colors ';
|
||||
if (isSelected) classes += 'bg-navy text-parchment font-bold ';
|
||||
else if (isToday) classes += 'bg-gold/20 font-bold ';
|
||||
else if (dayRank >= 6) classes += 'bg-red-100 text-red-800 font-bold ';
|
||||
else if (fast === 1) classes += 'bg-purple-50 ';
|
||||
else if (fast === 2) classes += 'bg-blue-50 ';
|
||||
|
||||
gridHtml += `<div class="${classes}" data-day="${d}">${d}</div>`;
|
||||
|
||||
col++;
|
||||
}
|
||||
|
||||
const computed = computeDay(this.currentDate);
|
||||
|
||||
|
||||
|
||||
// Get Gregorian date for display
|
||||
const gregorianDate = this.currentDate.toGregorian();
|
||||
const gregorianDayName = this.t.calendar.dayNamesFull[gregorianDate.getDay()];
|
||||
const gregorianStr = `${gregorianDayName}, ${gregorianDate.getDate()} ${this.t.calendar.monthsGenitive[gregorianDate.getMonth()]} ${gregorianDate.getFullYear()} г.`;
|
||||
// Julian date (church calendar)
|
||||
const julianDayName = this.t.calendar.dayNamesFull[this.currentDate.getDayOfWeek()];
|
||||
// Anno Mundi year (Old Slavonic year = year + 5508)
|
||||
const annoMundi = julianYear + 5508;
|
||||
const julianStr = `${julianDayName}, ${this.currentDate.getDay()} ${this.t.calendar.monthsGenitive[this.currentDate.getMonth() - 1]}, год ${julianYear} ${this.t.calendar.fromYear}, ${annoMundi} ${this.t.calendar.fromAdam}`;
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="flex flex-col lg:flex-row h-full">
|
||||
<div class="lg:w-80 bg-parchment-dark border-r border-gold/20 p-4 flex-shrink-0">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button id="prev-month" class="p-2 hover:bg-navy/10 rounded">◀</button>
|
||||
<span class="font-bold text-navy">${this.t.calendar.months[month - 1]} ${year}</span>
|
||||
<button id="next-month" class="p-2 hover:bg-navy/10 rounded">▶</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-0.5 text-xs">
|
||||
${gridHtml}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="max-w-2xl">
|
||||
<h2 class="text-base font-bold text-navy mb-1">
|
||||
${julianStr}
|
||||
</h2>
|
||||
<div class="text-sm text-navy-light mb-2">
|
||||
${this.t.calendar.gregorianDate[0].toLowerCase() + this.t.calendar.gregorianDate.slice(1)}: ${gregorianStr}
|
||||
</div>
|
||||
|
||||
<div id="day-title" class="mb-4"></div>
|
||||
|
||||
<div id="commemorations-panel" class="mb-4 p-4 bg-white/50 border border-gold/20 rounded-lg ${fnClass}">
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
|
||||
</div>
|
||||
|
||||
<div id="fasting-panel" class="mb-4 p-4 bg-white/50 border border-gold/20 rounded-lg ${fnClass}">
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.fastingRule}</h3>
|
||||
<p class="text-sm italic text-navy-light">${this.t.loading}</p>
|
||||
</div>
|
||||
|
||||
<div id="readings-panel" class="mb-4 p-4 bg-white/50 border border-gold/20 rounded-lg ${fnClass}">
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.readings}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
|
||||
</div>
|
||||
|
||||
<div id="services-panel" class="mb-4 border-t border-gold/20 pt-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load data asynchronously
|
||||
this.loadData(computed);
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('prev-month')?.addEventListener('click', () => {
|
||||
const prevMonth = month <= 1 ? 12 : month - 1;
|
||||
const prevYear = month <= 1 ? year - 1 : year;
|
||||
if (useGregorian) {
|
||||
const prevDay = Math.min(day, daysInGregorianMonth(prevMonth, prevYear));
|
||||
this.currentDate = JDate.fromGregorianParts(prevYear, prevMonth, prevDay);
|
||||
} else {
|
||||
const prevDay = Math.min(day, JDate.maxDaysInMonth(prevMonth, prevYear));
|
||||
this.currentDate = new JDate(prevMonth, prevDay, prevYear);
|
||||
}
|
||||
this.onDateChange(this.currentDate);
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('next-month')?.addEventListener('click', () => {
|
||||
const nextMonth = month >= 12 ? 1 : month + 1;
|
||||
const nextYear = month >= 12 ? year + 1 : year;
|
||||
if (useGregorian) {
|
||||
const nextDay = Math.min(day, daysInGregorianMonth(nextMonth, nextYear));
|
||||
this.currentDate = JDate.fromGregorianParts(nextYear, nextMonth, nextDay);
|
||||
} else {
|
||||
const nextDay = Math.min(day, JDate.maxDaysInMonth(nextMonth, nextYear));
|
||||
this.currentDate = new JDate(nextMonth, nextDay, nextYear);
|
||||
}
|
||||
this.onDateChange(this.currentDate);
|
||||
this.render();
|
||||
});
|
||||
|
||||
this.container.querySelectorAll('.day-cell').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const d = parseInt(el.getAttribute('data-day')!, 10);
|
||||
if (useGregorian) {
|
||||
this.currentDate = JDate.fromGregorianParts(year, month, d);
|
||||
} else {
|
||||
this.currentDate = new JDate(month, d, year);
|
||||
}
|
||||
this.onDateChange(this.currentDate);
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async loadData(computed: { dayInfo: import('../core/types').DayInfo; evalContext: import('../core/types').EvalContext }) {
|
||||
const { dayInfo, evalContext } = computed;
|
||||
this.skipCids = new Set();
|
||||
|
||||
// Load fasting
|
||||
const fastingPeriods = await loadFastingPeriods();
|
||||
const fastingResult = evaluateFasting(fastingPeriods, evalContext, this.language);
|
||||
const fastingPanel = document.getElementById('fasting-panel');
|
||||
if (fastingPanel) {
|
||||
fastingPanel.innerHTML = `
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.fastingRule}</h3>
|
||||
<p class="text-sm">${fastingResult.description || this.t.notAvailable}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
// Load commemorations from menaion (language-specific)
|
||||
const menaion = await loadMenaion(this.language, dayInfo.month, dayInfo.day);
|
||||
|
||||
// Load triodion/pentecostarion entries (shared, language-agnostic)
|
||||
let calendarEntries: unknown[] = [];
|
||||
if (dayInfo.triodionFile !== null) {
|
||||
calendarEntries = await loadTriodionFile(dayInfo.triodionFile);
|
||||
} else if (dayInfo.pentecostarionFile !== null) {
|
||||
calendarEntries = await loadPentecostarionFile(dayInfo.pentecostarionFile);
|
||||
}
|
||||
|
||||
// Collect CIds from all entries
|
||||
const cids: string[] = [];
|
||||
const allEntries = [...calendarEntries, ...menaion];
|
||||
for (const entry of allEntries) {
|
||||
if (typeof entry === 'object' && entry !== null) {
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (e.CId) cids.push(String(e.CId));
|
||||
}
|
||||
}
|
||||
|
||||
// Load commemorations from BOTH sources:
|
||||
// 1. Lives (language-specific, for names & life text)
|
||||
// 2. Shared commemorations (for readings, rank, troparion/kontakion)
|
||||
// Use CID_TO_SHARED_COMM mapping for known feast days
|
||||
const livesData: Map<string, LifeData> = new Map();
|
||||
const sharedComms: Map<string, CommemorationData> = new Map();
|
||||
|
||||
for (const cid of cids.slice(0, 30)) {
|
||||
const [life, shared] = await Promise.all([
|
||||
loadLife(this.language, cid),
|
||||
loadCommemoration(CID_TO_SHARED_COMM[cid] || cid),
|
||||
]);
|
||||
if (life) livesData.set(cid, life);
|
||||
if (shared) sharedComms.set(cid, shared);
|
||||
}
|
||||
|
||||
// Build day title: period description + tone + great feast
|
||||
const titleEl = document.getElementById('day-title');
|
||||
if (titleEl) {
|
||||
let periodName = '';
|
||||
let periodCid = '';
|
||||
let feastCid = '';
|
||||
let feastName = '';
|
||||
let feastRank = 0;
|
||||
|
||||
// Pass 1: Find period name from primary calendar entry (SId ''/'0' + CId 9[0-38]xxxx)
|
||||
for (const entry of calendarEntries) {
|
||||
const e = entry as Record<string, unknown>;
|
||||
const cid = String(e.CId || '');
|
||||
const sid = String(e.SId ?? '');
|
||||
if (!cid || !/^9[0-38]\d{2}$/.test(cid)) continue;
|
||||
if (sid !== '' && sid !== '0') continue;
|
||||
const name = getPeriodName(livesData.get(cid)) || getCommsName(sharedComms.get(cid));
|
||||
if (!name) continue;
|
||||
periodName = name;
|
||||
periodCid = cid;
|
||||
break;
|
||||
}
|
||||
|
||||
// Pass 2: Find great feast (rank >= 6) from all entries
|
||||
for (const entry of allEntries) {
|
||||
const e = entry as Record<string, unknown>;
|
||||
const cid = String(e.CId || '');
|
||||
if (!cid || this.skipCids.has(cid)) continue;
|
||||
const life = livesData.get(cid);
|
||||
const shared = sharedComms.get(cid);
|
||||
const name = getShortFeastName(life, shared);
|
||||
if (!name) continue;
|
||||
const rank = parseInt(shared?.CHURCH?.Rank || '0', 10) || (life?.rank as number) || 0;
|
||||
if (rank >= 6 && rank > feastRank) {
|
||||
feastRank = rank;
|
||||
feastName = name;
|
||||
feastCid = cid;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip period CId from commemorations if not a feast
|
||||
if (periodCid && !feastCid) this.skipCids.add(periodCid);
|
||||
|
||||
if (!periodName) periodName = this.t.calendar.rankLabels[dayInfo.dRank] ?? '';
|
||||
|
||||
if (periodName) {
|
||||
const tone = dayInfo.Tone;
|
||||
const toneSuffix = tone > 0 ? `; ${this.t.calendar.tone}${tone}` : '';
|
||||
const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : '';
|
||||
const titleColor = feastName ? 'text-red-700' : 'text-navy';
|
||||
titleEl.innerHTML = `<span class="text-base font-semibold ${titleColor} italic">${periodName}${toneSuffix}${feastSuffix}</span>`;
|
||||
}
|
||||
|
||||
if (feastCid) this.skipCids.add(feastCid);
|
||||
}
|
||||
|
||||
// Load commemorations for each CId
|
||||
const commPanel = document.getElementById('commemorations-panel');
|
||||
if (commPanel) {
|
||||
if (cids.length === 0) {
|
||||
commPanel.innerHTML = `
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.calendar.noCommemorations}</p>
|
||||
`;
|
||||
} else {
|
||||
const commHtmlParts: string[] = [];
|
||||
for (const cid of cids.slice(0, 15)) {
|
||||
if (this.skipCids.has(cid)) continue;
|
||||
// Prefer shared commemoration for name (feast days), fallback to lives (saints)
|
||||
const shared = sharedComms.get(cid);
|
||||
const life = livesData.get(cid);
|
||||
|
||||
const lifeText = life?.life?.text || (shared?.LIFE as any)?.text || '';
|
||||
|
||||
let name = '';
|
||||
if (life?.name) {
|
||||
name = life.name['@_Nominative'] || life.name.nominative || life.name['@_Short'] || life.name.short || '';
|
||||
}
|
||||
if (!name && shared?.NAME) {
|
||||
if (typeof shared.NAME === 'object' && 'Long' in shared.NAME) {
|
||||
name = shared.NAME.Long || shared.NAME.ShortN || '';
|
||||
} else if (typeof shared.NAME === 'object' && 'text' in shared.NAME) {
|
||||
name = (shared.NAME as { text: string }).text || '';
|
||||
}
|
||||
}
|
||||
if (name) {
|
||||
// Determine rank: shared commemoration rank > day rank
|
||||
const rank = parseInt(shared?.CHURCH?.Rank || '0', 10) || (life?.rank as number) || dayInfo.dRank || 0;
|
||||
const icon = this.t.calendar.rankIcons[rank] || '';
|
||||
const iconHtml = icon ? `<span class="font-slavonic text-lg mr-1">${icon}</span>` : '';
|
||||
const lifeHtml = lifeText
|
||||
? `<div class="life-text hidden mt-2 p-3 bg-white border border-gold/20 rounded text-sm leading-relaxed max-h-60 overflow-y-auto">${lifeText}</div>`
|
||||
: '';
|
||||
commHtmlParts.push(
|
||||
`<div class="comm-item">
|
||||
<div class="text-sm font-medium ${lifeText ? 'text-blue-600 underline cursor-pointer hover:text-blue-800' : 'text-navy'}">${iconHtml}${name}</div>
|
||||
${lifeHtml}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (commHtmlParts.length > 0) {
|
||||
commPanel.innerHTML = `
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<div class="space-y-1">${commHtmlParts.join('')}</div>
|
||||
`;
|
||||
commPanel.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const item = target.closest('.comm-item');
|
||||
if (item) {
|
||||
const lifeDiv = item.querySelector('.life-text') as HTMLElement;
|
||||
if (lifeDiv) lifeDiv.classList.toggle('hidden');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
commPanel.innerHTML = `
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.calendar.noCommemorations}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect readings grouped by service
|
||||
const readingsGroups: ReadingsGroup[] = [];
|
||||
const addReading = (service: string, reading: string, purpose: string) => {
|
||||
const group = findOrCreateGroup(readingsGroups, service);
|
||||
const ref = formatReadingRef(reading, this.language);
|
||||
if (!group.items.some(i => i.ref === ref)) {
|
||||
group.items.push({ ref, purpose });
|
||||
}
|
||||
};
|
||||
const isPeriodCid = (cid: string): boolean => /^9[0-38]\d{2}$/.test(cid);
|
||||
const readingContext = (shortName: string, cid?: string): string => {
|
||||
if (!shortName) return '';
|
||||
if (cid && isPeriodCid(cid)) return '';
|
||||
return ` (${shortName})`;
|
||||
};
|
||||
loadBookAbbrevs(this.language);
|
||||
for (const [cid, shared] of sharedComms) {
|
||||
if (shared?.SCRIPTURE) {
|
||||
const shortN = typeof shared.NAME === 'object' && 'ShortN' in shared.NAME ? (shared.NAME.ShortN || '') : '';
|
||||
const tag = readingContext(shortN, cid);
|
||||
const scripts = Array.isArray(shared.SCRIPTURE) ? shared.SCRIPTURE : [shared.SCRIPTURE];
|
||||
for (const s of scripts) {
|
||||
if (s && typeof s === 'object') {
|
||||
const sr = s as Record<string, string>;
|
||||
if (sr.Reading) {
|
||||
const svc = readingsTypeToService(sr.Type || '', this.t);
|
||||
addReading(svc, sr.Reading, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also load readings from saint lives (from services structure)
|
||||
for (const [cid, life] of livesData) {
|
||||
const shortName = life?.name?.['@_Short'] || life?.name?.short || '';
|
||||
const tag = readingContext(shortName, cid);
|
||||
|
||||
if (life?.services) {
|
||||
const svcMap = life.services as Record<string, Record<string, Record<string, string>>>;
|
||||
for (const [svcName, scriptures] of Object.entries(svcMap)) {
|
||||
for (const [, sc] of Object.entries(scriptures)) {
|
||||
const reading = sc.Reading || sc.reading;
|
||||
if (!reading) continue;
|
||||
const scType = sc.Type || sc.type || '';
|
||||
const svc = svcNameToService(svcName, scType, this.t);
|
||||
addReading(svc, reading, tag);
|
||||
}
|
||||
}
|
||||
} else if (life?.scripture) {
|
||||
for (const s of life.scripture) {
|
||||
const sc = s as Record<string, string>;
|
||||
const reading = sc.Reading || sc.reading;
|
||||
if (reading) {
|
||||
const svc = readingsTypeToService(sc.Type || sc.type || '', this.t);
|
||||
addReading(svc, reading, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check calendar entries for Scripture
|
||||
for (const entry of allEntries) {
|
||||
if (typeof entry === 'object' && entry !== null) {
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (e.Scripture && typeof e.Scripture === 'object') {
|
||||
const s = e.Scripture as Record<string, string>;
|
||||
if (s.Reading) {
|
||||
const svc = readingsTypeToService(s.Type || '', this.t);
|
||||
addReading(svc, s.Reading, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readingsPanel = document.getElementById('readings-panel');
|
||||
if (readingsPanel) {
|
||||
if (readingsGroups.length === 0) {
|
||||
readingsPanel.innerHTML = `
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.readings}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.calendar.noReadings}</p>
|
||||
`;
|
||||
} else {
|
||||
const sorted = [...readingsGroups].sort((a, b) => {
|
||||
const ai = SERVICE_ORDER.indexOf(a.service);
|
||||
const bi = SERVICE_ORDER.indexOf(b.service);
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
});
|
||||
const groupsHtml = sorted.map(g => {
|
||||
// Group consecutive items with same purpose; tag only on last of each group
|
||||
let refs = '';
|
||||
for (let i = 0; i < g.items.length; i++) {
|
||||
const item = g.items[i];
|
||||
const next = g.items[i + 1];
|
||||
if (i > 0) refs += this.t.calendar.readSep;
|
||||
refs += item.ref;
|
||||
if (!next || next.purpose !== item.purpose) {
|
||||
refs += item.purpose;
|
||||
}
|
||||
}
|
||||
return `<div class="text-sm text-navy"><strong>${g.service}</strong>${this.t.calendar.colon}${refs}</div>`;
|
||||
}).join('');
|
||||
readingsPanel.innerHTML = `
|
||||
<h3 class="font-bold text-navy mb-2">${this.t.calendar.readings}</h3>
|
||||
<div class="space-y-1">${groupsHtml}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load services
|
||||
const svcPanel = document.getElementById('services-panel');
|
||||
if (svcPanel) {
|
||||
svcPanel.innerHTML = '';
|
||||
try {
|
||||
const svcView = new ServiceView(svcPanel, this.currentDate, this.language);
|
||||
await svcView.render(false);
|
||||
} catch {
|
||||
svcPanel.innerHTML = `<p class="text-sm text-navy-light italic">${this.t.loading}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
/**
|
||||
* Service view — displays assembled liturgical services.
|
||||
* Loads XML templates (converted to JSON) and assembles them with
|
||||
* dynamic Var nodes from Octoecheos tone data.
|
||||
*/
|
||||
|
||||
import { JDate } from '../core/jdate';
|
||||
import { computeDay } from '../core/day-computer';
|
||||
import { assembleService, type ServiceContext } from '../core/service-assembler';
|
||||
import { evalBool } from '../core/evaluator';
|
||||
import type { EvalContext, ServiceNode } from '../core/types';
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
import { loadSettings, fontClass } from './settings-view';
|
||||
|
||||
interface ServiceDef {
|
||||
id: string;
|
||||
name: string;
|
||||
serviceType: string;
|
||||
template: string;
|
||||
description: string;
|
||||
titleNode: ServiceNode;
|
||||
}
|
||||
|
||||
function title(value: string, header?: string, source?: string, comment?: string): ServiceNode {
|
||||
return { type: 'TITLE', value, header: header ?? value, source, comment };
|
||||
}
|
||||
|
||||
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
export class ServiceView {
|
||||
private container: HTMLElement;
|
||||
private currentDate: JDate;
|
||||
private evalContext: EvalContext;
|
||||
private language: LanguageCode;
|
||||
private t: ReturnType<typeof getTranslations>;
|
||||
private jsonCache = new Map<string, Record<string, unknown>[]>();
|
||||
private nodeCache = new Map<string, ServiceNode[]>();
|
||||
private notFound = new Set<string>();
|
||||
private bibleCache = new Map<string, string>();
|
||||
private livesCache = new Map<string, Record<string, Record<string, Record<string, unknown>>>>();
|
||||
private commandCache = new Map<string, string>();
|
||||
private timesCache = new Map<string, { cmd: string; value: string }[]>();
|
||||
private activeService: string | null = null;
|
||||
|
||||
constructor(container: HTMLElement, date: JDate, language: LanguageCode = 'en') {
|
||||
this.container = container;
|
||||
this.currentDate = date;
|
||||
this.evalContext = computeDay(date).evalContext;
|
||||
this.language = language;
|
||||
this.t = getTranslations(language);
|
||||
}
|
||||
|
||||
private getFontClass(): string {
|
||||
const settings = loadSettings();
|
||||
return (this.language === 'cu' || this.language === 'ru') && settings.cuFont !== undefined ? fontClass(settings.cuFont) : '';
|
||||
}
|
||||
|
||||
private get serviceLang(): string {
|
||||
return this.language === 'ru' ? 'cu' : this.language;
|
||||
}
|
||||
|
||||
async render(standalone = true) {
|
||||
const computed = computeDay(this.currentDate);
|
||||
const { dayInfo } = computed;
|
||||
|
||||
const availableServices = this.getAvailableServices(dayInfo);
|
||||
const fontClass = this.getFontClass();
|
||||
|
||||
const contentClass = `bg-white/50 border border-gold/20 rounded-lg p-6 ${this.activeService ? 'min-h-[300px]' : ''} ${fontClass}`;
|
||||
|
||||
const tabHtml = `
|
||||
<h3 class="font-bold text-navy mb-3">${this.t.services.title}</h3>
|
||||
<div class="flex flex-wrap gap-1 mb-6 border-b border-gold/20">
|
||||
${availableServices.map(s => `
|
||||
<button
|
||||
class="service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap
|
||||
${this.activeService === s.id
|
||||
? 'border-gold text-navy font-bold'
|
||||
: 'border-transparent text-navy-light hover:text-navy hover:border-gold/50'}"
|
||||
data-service="${s.id}"
|
||||
>${s.name}</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div id="service-content" class="${contentClass}">
|
||||
<p class="text-navy-light italic">${this.t.services.selectService}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = standalone
|
||||
? `<div class="p-6 max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-navy mb-2">${this.t.services.title}</h2>
|
||||
<p class="text-navy-light mb-4">
|
||||
${this.currentDate.toString()}
|
||||
${dayInfo.isLent ? this.t.services.greatLent : ''}
|
||||
${dayInfo.isBrightWeek ? this.t.services.brightWeek : ''}
|
||||
${dayInfo.isApostlesFast ? this.t.services.apostlesFast : ''}
|
||||
${dayInfo.isDormitionFast ? this.t.services.dormitionFast : ''}
|
||||
${dayInfo.isNativityFast ? this.t.services.nativityFast : ''}
|
||||
${dayInfo.Tone > 0 ? `${this.t.services.tone}${dayInfo.Tone}` : ''}
|
||||
</p>
|
||||
${tabHtml}
|
||||
</div>`
|
||||
: tabHtml;
|
||||
|
||||
this.container.querySelectorAll('.service-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const serviceId = btn.getAttribute('data-service')!;
|
||||
const service = availableServices.find(s => s.id === serviceId);
|
||||
if (service) {
|
||||
this.activeService = serviceId;
|
||||
this.container.querySelectorAll('.service-tab').forEach(t => {
|
||||
const id = t.getAttribute('data-service');
|
||||
if (id === serviceId) {
|
||||
t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-gold text-navy font-bold';
|
||||
} else {
|
||||
t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-transparent text-navy-light hover:text-navy';
|
||||
}
|
||||
});
|
||||
this.loadService(service);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getAvailableServices(dayInfo: import('../core/types').DayInfo): ServiceDef[] {
|
||||
const services: ServiceDef[] = [];
|
||||
|
||||
services.push({
|
||||
id: 'primes', name: this.t.services.serviceNames.primes, serviceType: 'PRIMES',
|
||||
template: 'Prime',
|
||||
description: this.t.services.serviceDescriptions.primes,
|
||||
titleNode: title('Primes1', this.t.services.serviceNames.primes, 'PrimeSource'),
|
||||
});
|
||||
|
||||
services.push({
|
||||
id: 'third', name: this.t.services.serviceNames.third, serviceType: 'TERCE',
|
||||
template: 'ThirdHour',
|
||||
description: this.t.services.serviceDescriptions.third,
|
||||
titleNode: title('Third1', this.t.services.serviceNames.third),
|
||||
});
|
||||
|
||||
services.push({
|
||||
id: 'sixth', name: this.t.services.serviceNames.sixth, serviceType: 'SEXTE',
|
||||
template: 'SixthHour',
|
||||
description: this.t.services.serviceDescriptions.sixth,
|
||||
titleNode: title('Sixth1', this.t.services.serviceNames.sixth),
|
||||
});
|
||||
|
||||
services.push({
|
||||
id: 'ninth', name: this.t.services.serviceNames.ninth, serviceType: 'NONE',
|
||||
template: 'NinthHour',
|
||||
description: this.t.services.serviceDescriptions.ninth,
|
||||
titleNode: title('Ninth1', this.t.services.serviceNames.ninth),
|
||||
});
|
||||
|
||||
if (dayInfo.dRank >= 6) {
|
||||
services.push({
|
||||
id: 'royalhours', name: this.t.services.serviceNames.royalhours, serviceType: 'ROYALHOURS',
|
||||
template: 'RoyalHours',
|
||||
description: this.t.services.serviceDescriptions.royalhours,
|
||||
titleNode: title('RoyalHours1', this.t.services.serviceNames.royalhours),
|
||||
});
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string } | null> {
|
||||
const computed = computeDay(this.currentDate);
|
||||
const { dayInfo } = computed;
|
||||
const tone = dayInfo.Tone;
|
||||
const dow = dayInfo.dow;
|
||||
const mm = String(dayInfo.month).padStart(2, '0');
|
||||
const dd = String(dayInfo.day).padStart(2, '0');
|
||||
const dateKey = `${mm}-${dd}`;
|
||||
|
||||
let result: { troparion1?: string; kontakion1?: string } | null = null;
|
||||
|
||||
if (tone > 0 && tone <= 8) {
|
||||
const toneNum = tone === 8 ? 0 : tone;
|
||||
const weekday = WEEKDAY_NAMES[dow];
|
||||
|
||||
try {
|
||||
const tryFetch = async (lang: string) => {
|
||||
const url = `/data/${lang}/services/Octoecheos/Tone ${toneNum}/${weekday}.json`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
const nodes: ServiceNode[] = await resp.json();
|
||||
for (const node of nodes) {
|
||||
if (node.type === serviceType && node.cmd) {
|
||||
if (evalBool(node.cmd as string, this.evalContext)) {
|
||||
return {
|
||||
troparion1: node.troparion1 as string | undefined,
|
||||
kontakion1: node.kontakion1 as string | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
result = await tryFetch(this.serviceLang);
|
||||
if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
|
||||
if (!result) result = await tryFetch('shared');
|
||||
} catch { /* fall through to menologion */ }
|
||||
}
|
||||
|
||||
// Menologion fallback when Octoecheos has no valid entry
|
||||
if (!result) {
|
||||
try {
|
||||
const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => {
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
const all = await resp.json();
|
||||
if (Array.isArray(all)) return all.map((e: Record<string, unknown>) => ({ cid: String(e.CId ?? '') }));
|
||||
const entries = all[dateKey] as Record<string, unknown>[] | undefined;
|
||||
if (Array.isArray(entries)) return entries.map((e: Record<string, unknown>) => ({ cid: String(e.CId ?? '') }));
|
||||
return null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const allEntries: { cid: string }[] = [];
|
||||
|
||||
const menaionEntries = await tryLoad(`/data/${this.serviceLang}/menaion-bundle.json`);
|
||||
if (menaionEntries) allEntries.push(...menaionEntries);
|
||||
|
||||
if (dayInfo.triodionFile !== null) {
|
||||
const triodion = await tryLoad(`/data/shared/triodion/${dayInfo.triodionFile}.json`);
|
||||
if (triodion) allEntries.push(...triodion);
|
||||
}
|
||||
if (dayInfo.pentecostarionFile !== null) {
|
||||
const pent = await tryLoad(`/data/shared/pentecostarion/${dayInfo.pentecostarionFile}.json`);
|
||||
if (pent) allEntries.push(...pent);
|
||||
}
|
||||
|
||||
const TONE_NUM = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃'];
|
||||
for (const entry of allEntries) {
|
||||
if (!entry.cid) continue;
|
||||
try {
|
||||
const shmResp = await fetch(`/data/shared/commemorations/${entry.cid}.json`);
|
||||
if (!shmResp.ok) continue;
|
||||
const shm = await shmResp.json();
|
||||
const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined;
|
||||
const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined;
|
||||
if (troparion?.text || kontakion?.text) {
|
||||
const key = `MEN_${entry.cid}`;
|
||||
|
||||
if (troparion?.text) {
|
||||
const tn = TONE_NUM[parseInt(troparion.Tone ?? '1', 10)] ?? troparion.Tone ?? '1';
|
||||
this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/TROPARION/${key}.json`, [
|
||||
{ type: 'HEADER', value: `Тропа́рь, гла́съ ${tn}` },
|
||||
{ type: 'TEXT', value: troparion.text },
|
||||
]);
|
||||
}
|
||||
|
||||
if (kontakion?.text) {
|
||||
const tn = TONE_NUM[parseInt(kontakion.Tone ?? '1', 10)] ?? kontakion.Tone ?? '1';
|
||||
this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/KONTAKION/${key}.json`, [
|
||||
{ type: 'HEADER', value: `Конда́къ, гла́съ ${tn}` },
|
||||
{ type: 'TEXT', value: kontakion.text },
|
||||
]);
|
||||
}
|
||||
|
||||
result = { troparion1: key, kontakion1: key };
|
||||
break;
|
||||
}
|
||||
} catch { continue; }
|
||||
}
|
||||
} catch { /* fall through to tertiary fallback */ }
|
||||
}
|
||||
|
||||
// Tertiary fallback: known feasts not yet in shared commemorations
|
||||
if (!result) {
|
||||
const MENAION_TROP: Record<string, { troparion1: string; kontakion1: string }> = {
|
||||
'03-23': { troparion1: 'PROPHET', kontakion1: 'PROPHET' },
|
||||
'03-22': { troparion1: 'PROPHET', kontakion1: 'PROPHET' },
|
||||
};
|
||||
const m = MENAION_TROP[dateKey];
|
||||
if (m) result = m;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string } | null, serviceType: string): Map<string, ServiceNode[]> {
|
||||
const varNodes = new Map<string, ServiceNode[]>();
|
||||
|
||||
const prefix: Record<string, string> = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9' };
|
||||
const p = prefix[serviceType] ?? '';
|
||||
|
||||
if (toneData?.troparion1) {
|
||||
const key1 = `PTrop${p}1`;
|
||||
varNodes.set(key1, [{
|
||||
type: 'CREATE',
|
||||
what: `TROPARION/${toneData.troparion1}`,
|
||||
who: '',
|
||||
header: true,
|
||||
redfirst: true,
|
||||
newline: true,
|
||||
}]);
|
||||
}
|
||||
|
||||
if (toneData?.kontakion1) {
|
||||
const key = p ? `PKont${p}` : 'PKont1';
|
||||
varNodes.set(key, [{
|
||||
type: 'CREATE',
|
||||
what: `KONTAKION/${toneData.kontakion1}`,
|
||||
who: '',
|
||||
header: true,
|
||||
redfirst: true,
|
||||
newline: true,
|
||||
}]);
|
||||
}
|
||||
|
||||
return varNodes;
|
||||
}
|
||||
|
||||
private async loadService(service: ServiceDef) {
|
||||
const contentEl = document.getElementById('service-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
contentEl.innerHTML = `<p class="text-navy-light italic">${this.t.loading}</p>`;
|
||||
|
||||
try {
|
||||
const templateNodes = await this.fetchServiceNodes(`Services/${service.template}.json`);
|
||||
if (!templateNodes) {
|
||||
contentEl.innerHTML = `<p class="text-navy-light">Service template not found</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const toneData = await this.loadToneData(service.serviceType);
|
||||
const varNodes = this.generateVarNodes(toneData, service.serviceType);
|
||||
|
||||
const nodes: ServiceNode[] = [...templateNodes];
|
||||
|
||||
const computed = computeDay(this.currentDate);
|
||||
const isMenologionTone = toneData?.troparion1?.startsWith('PROPHET') || toneData?.troparion1?.startsWith('MEN_');
|
||||
const dayPFlag2 = computed.dayInfo.PFlag2;
|
||||
const evalCtx: EvalContext = {
|
||||
...this.evalContext,
|
||||
PFlag1: 0,
|
||||
PFlag2: isMenologionTone && dayPFlag2 !== 0 ? 0 : dayPFlag2,
|
||||
PFlag3: 0,
|
||||
PS: 1,
|
||||
};
|
||||
|
||||
const ctx: ServiceContext = {
|
||||
evalCtx,
|
||||
lang: this.serviceLang,
|
||||
t: this.t,
|
||||
fetchBibleText: (book, passage, showVerseNumbers, verseNewLine) => this.fetchBibleText(book, passage, showVerseNumbers, verseNewLine),
|
||||
fetchLives: (id) => this.fetchLives(id),
|
||||
fetchCommandText: (name) => this.fetchCommandText(name),
|
||||
resolveTimes: (times) => this.resolveTimes(times),
|
||||
fetchText: this.createFetchText(),
|
||||
fetchServiceNodes: async (path: string): Promise<ServiceNode[] | null> => {
|
||||
const varMatch = path.match(/Services\/Var\/(\w+)\.xml$/);
|
||||
if (varMatch) {
|
||||
return varNodes.get(varMatch[1]) || null;
|
||||
}
|
||||
return this.fetchServiceNodes(path);
|
||||
},
|
||||
};
|
||||
|
||||
const { html } = await assembleService(nodes, ctx);
|
||||
|
||||
if (html.trim()) {
|
||||
const fontClass = this.getFontClass();
|
||||
contentEl.className = `prose prose-sm max-w-none liturgical-text ${fontClass}`;
|
||||
contentEl.innerHTML = html;
|
||||
} else {
|
||||
contentEl.className = 'prose prose-sm max-w-none liturgical-text';
|
||||
contentEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-navy-light mb-2">${this.t.services.serviceProducedNoOutput}</p>
|
||||
<p class="text-xs text-navy-light">${nodes.length} ${this.t.services.nodesAssembled}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-navy-light mb-2">${this.t.services.errorLoading}</p>
|
||||
<p class="text-xs text-navy-light">${err instanceof Error ? err.message : this.t.error}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private bibleVersion(lang: string): string {
|
||||
const map: Record<string, string> = {
|
||||
en: 'kjv',
|
||||
cu: 'elis',
|
||||
fr: 'ls',
|
||||
el: 'spt',
|
||||
la: 'vulgate',
|
||||
zh: 'cuv',
|
||||
ar: 'svd',
|
||||
};
|
||||
return map[lang] || 'kjv';
|
||||
}
|
||||
|
||||
private async fetchBibleText(book: string, passage: string, showVerseNumbers = true, verseNewLine = false): Promise<string> {
|
||||
const cacheKey = `${this.language}/${book}/${passage}/${showVerseNumbers ? 'vn' : 'nn'}/${verseNewLine ? 'vb' : 'nvb'}`;
|
||||
const cached = this.bibleCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const version = this.bibleVersion(this.serviceLang);
|
||||
const tryFetch = async (lang: string): Promise<string | null> => {
|
||||
const url = `/data/${lang}/bible/${version}/${book}.text`;
|
||||
if (this.notFound.has(url)) return null;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||||
return resp.text();
|
||||
};
|
||||
|
||||
let text = await tryFetch(this.serviceLang);
|
||||
if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
|
||||
if (!text) text = await tryFetch('shared');
|
||||
if (!text) return '';
|
||||
|
||||
const lines = text.split('\n');
|
||||
// Parse comma-separated passage parts, each potentially: Chapter[:VerseStart[-VerseEnd]]
|
||||
const parts = passage.split(',');
|
||||
const ranges: { chapter: number; vStart: number; vEnd: number }[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
let chapter: number;
|
||||
let verseSpec: string;
|
||||
|
||||
if (trimmed.includes(':')) {
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
chapter = parseInt(trimmed.substring(0, colonIdx), 10);
|
||||
verseSpec = trimmed.substring(colonIdx + 1);
|
||||
} else {
|
||||
// Bare number = chapter number (e.g. "50" = Psalm 50, read all verses)
|
||||
chapter = parseInt(trimmed, 10);
|
||||
verseSpec = '1-';
|
||||
}
|
||||
|
||||
let vStart: number;
|
||||
let vEnd: number;
|
||||
|
||||
if (verseSpec.includes('-')) {
|
||||
const dashIdx = verseSpec.indexOf('-');
|
||||
vStart = parseInt(verseSpec.substring(0, dashIdx), 10);
|
||||
const after = verseSpec.substring(dashIdx + 1);
|
||||
// Check if after contains chapter:verse (cross-chapter range)
|
||||
if (after.includes(':')) {
|
||||
const afterColon = after.indexOf(':');
|
||||
const nextChapter = parseInt(after.substring(0, afterColon), 10);
|
||||
vEnd = parseInt(after.substring(afterColon + 1), 10);
|
||||
ranges.push({ chapter, vStart, vEnd: -1 }); // all remaining verses in current chapter
|
||||
chapter = nextChapter;
|
||||
vStart = 1;
|
||||
vEnd = vEnd;
|
||||
} else {
|
||||
vEnd = after ? parseInt(after, 10) : -1; // empty end = all remaining verses
|
||||
}
|
||||
} else {
|
||||
vStart = parseInt(verseSpec, 10);
|
||||
vEnd = vStart;
|
||||
}
|
||||
|
||||
ranges.push({ chapter, vStart, vEnd });
|
||||
}
|
||||
|
||||
// Scan file and collect matching verses
|
||||
let result = '';
|
||||
let curChapter = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const chapterMatch = trimmed.match(/^#(\d+)/);
|
||||
if (chapterMatch) {
|
||||
curChapter = parseInt(chapterMatch[1], 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
const verseMatch = trimmed.match(/^(\d+)\|(.*)/);
|
||||
if (verseMatch) {
|
||||
const curVerse = parseInt(verseMatch[1], 10);
|
||||
const verseText = verseNewLine ? verseMatch[2].replace(/^ /, '') : verseMatch[2];
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.chapter !== curChapter) continue;
|
||||
if (curVerse < range.vStart) continue;
|
||||
if (range.vEnd !== -1 && curVerse > range.vEnd) continue;
|
||||
if (result) result += verseNewLine ? '<br>' : ' ';
|
||||
result += showVerseNumbers ? `<sup>${curVerse}</sup>${verseText}` : verseText;
|
||||
break;
|
||||
}
|
||||
} else if (trimmed.startsWith('*')) {
|
||||
if (result) result += ' ';
|
||||
result += trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
this.bibleCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchLives(id: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> {
|
||||
const cacheKey = `${this.language}/${id}`;
|
||||
const cached = this.livesCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : 'misc';
|
||||
const tryFetch = async (lang: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> => {
|
||||
const url = `/data/${lang}/lives/${bundleName}.json`;
|
||||
if (this.notFound.has(url)) return null;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||||
const bundle = await resp.json() as Record<string, Record<string, unknown>>;
|
||||
const entry = bundle[id] as { services?: Record<string, Record<string, Record<string, unknown>>> } | undefined;
|
||||
return entry?.services || null;
|
||||
};
|
||||
|
||||
let result = await tryFetch(this.serviceLang);
|
||||
if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
|
||||
if (!result) result = await tryFetch('shared');
|
||||
if (result) this.livesCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchCommandText(name: string): Promise<string> {
|
||||
const cacheKey = `${this.language}/Command/${name}`;
|
||||
const cached = this.commandCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const tryFetch = async (lang: string): Promise<string | null> => {
|
||||
const url = `/data/${lang}/services/Command/${name}.json`;
|
||||
if (this.notFound.has(url)) return null;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||||
const json = await resp.json() as Record<string, unknown>[];
|
||||
return json
|
||||
.filter(n => n.type === 'TEXT')
|
||||
.map(n => String(n.value ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
};
|
||||
|
||||
let result = await tryFetch(this.serviceLang);
|
||||
if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
|
||||
if (!result) result = await tryFetch('shared');
|
||||
if (result) this.commandCache.set(cacheKey, result);
|
||||
return result || '';
|
||||
}
|
||||
|
||||
private async resolveTimes(times: number): Promise<string> {
|
||||
const cacheKey = this.language;
|
||||
let rules = this.timesCache.get(cacheKey);
|
||||
if (!rules) {
|
||||
const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => {
|
||||
const url = `/data/${lang}/commands/Times.json`;
|
||||
if (this.notFound.has(url)) return null;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||||
const json = await resp.json() as { DATA: { TIMES: { '@_Value': string; '@_Cmd': string }[] } };
|
||||
const arr = json?.DATA?.TIMES;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] }));
|
||||
};
|
||||
let loaded = await tryFetch(this.serviceLang);
|
||||
if (!loaded && this.serviceLang !== 'cu') loaded = await tryFetch('cu');
|
||||
if (!loaded) loaded = await tryFetch('shared');
|
||||
if (loaded) {
|
||||
this.timesCache.set(cacheKey, loaded);
|
||||
rules = loaded;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rules) return `${times}×`;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.cmd) {
|
||||
if (evalBool(rule.cmd, { Times: times } as EvalContext)) {
|
||||
return rule.value.replace('^#', String(times));
|
||||
}
|
||||
} else {
|
||||
return rule.value.replace('^#', String(times));
|
||||
}
|
||||
}
|
||||
return `${times}×`;
|
||||
}
|
||||
|
||||
private createFetchText() {
|
||||
return async (path: string): Promise<string | null> => {
|
||||
const wantsHeader = path.endsWith('.header');
|
||||
const basePath = wantsHeader ? path.slice(0, -'.header'.length) : path;
|
||||
|
||||
const resolvePath = (lang: string): string | null => {
|
||||
let fetchPath = basePath;
|
||||
if (fetchPath.startsWith('Services/')) {
|
||||
fetchPath = fetchPath.replace('Services/', '').replace('.xml', '.json');
|
||||
}
|
||||
return `/data/${lang}/services/${fetchPath}`;
|
||||
};
|
||||
|
||||
const fetchJson = async (url: string): Promise<Record<string, unknown>[] | null> => {
|
||||
if (this.notFound.has(url)) return null;
|
||||
const cached = this.jsonCache.get(url);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||||
const json = JSON.parse(await resp.text());
|
||||
if (Array.isArray(json)) {
|
||||
this.jsonCache.set(url, json);
|
||||
return json;
|
||||
}
|
||||
this.notFound.add(url);
|
||||
return null;
|
||||
} catch {
|
||||
this.notFound.add(url);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filterNodes = (json: Record<string, unknown>[], header: boolean): string | null => {
|
||||
if (header) {
|
||||
return json
|
||||
.filter(n => n.type === 'HEADER')
|
||||
.map(n => String(n.value ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
}
|
||||
return json
|
||||
.filter(n => n.type === 'TEXT')
|
||||
.map(n => String(n.value ?? '').replace(/\n/g, ' '))
|
||||
.filter(Boolean)
|
||||
.join('<br>') || null;
|
||||
};
|
||||
|
||||
try {
|
||||
let url = resolvePath(this.serviceLang);
|
||||
let json = url ? await fetchJson(url) : null;
|
||||
if (!json && this.serviceLang !== 'cu') {
|
||||
url = resolvePath('cu');
|
||||
json = url ? await fetchJson(url) : null;
|
||||
}
|
||||
if (!json) {
|
||||
url = resolvePath('shared');
|
||||
json = url ? await fetchJson(url) : null;
|
||||
}
|
||||
return json ? filterNodes(json, wantsHeader) : null;
|
||||
} catch (err) {
|
||||
console.error('Service fetchText error:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchServiceNodes(path: string): Promise<ServiceNode[] | null> {
|
||||
const resolvePath = (lang: string): string | null => {
|
||||
let fetchPath = path;
|
||||
if (fetchPath.startsWith('Services/')) {
|
||||
fetchPath = fetchPath.replace('Services/', '').replace('.xml', '.json');
|
||||
}
|
||||
return `/data/${lang}/services/${fetchPath}`;
|
||||
};
|
||||
const tryFetch = async (url: string): Promise<ServiceNode[] | null> => {
|
||||
if (this.notFound.has(url)) return null;
|
||||
const cached = this.nodeCache.get(url);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||||
const nodes = await resp.json();
|
||||
this.nodeCache.set(url, nodes);
|
||||
return nodes;
|
||||
} catch { this.notFound.add(url); return null; }
|
||||
};
|
||||
let url = resolvePath(this.serviceLang);
|
||||
if (url) {
|
||||
const result = await tryFetch(url);
|
||||
if (result) return result;
|
||||
}
|
||||
if (this.serviceLang !== 'cu') {
|
||||
const fbUrl = resolvePath('cu');
|
||||
if (fbUrl) {
|
||||
const result = await tryFetch(fbUrl);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
const sharedUrl = resolvePath('shared');
|
||||
if (sharedUrl) {
|
||||
return await tryFetch(sharedUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Settings view — language, font, and display preferences.
|
||||
*/
|
||||
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', name: 'English', local: 'English' },
|
||||
{ code: 'ru', name: 'Russian', local: 'Русский' },
|
||||
{ code: 'cu', name: 'Church Slavonic', local: 'Церковнославянский' },
|
||||
];
|
||||
|
||||
const CU_FONTS = [
|
||||
{ id: '', name: 'System Default' },
|
||||
{ id: 'Ponomar', name: 'Ponomar' },
|
||||
{ id: 'Fedorovsk', name: 'Fedorovsk' },
|
||||
{ id: 'Menaion', name: 'Menaion' },
|
||||
{ id: 'Monomakh', name: 'Monomakh' },
|
||||
{ id: 'Triodion', name: 'Triodion' },
|
||||
{ id: 'Vilnius', name: 'Vilnius' },
|
||||
{ id: 'Voskresensky', name: 'Voskresensky' },
|
||||
];
|
||||
|
||||
const SETTINGS_KEY = 'ponomar-settings';
|
||||
|
||||
interface Settings {
|
||||
language: string;
|
||||
cuFont: string;
|
||||
fontSize: number;
|
||||
defaultBibleVersion: string;
|
||||
showVerseNumbers: boolean;
|
||||
verseNewLine: boolean;
|
||||
calendarType: 'julian' | 'gregorian';
|
||||
serviceRole: 'priest' | 'reader' | 'auto';
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
language: 'en',
|
||||
cuFont: 'Ponomar',
|
||||
fontSize: 16,
|
||||
defaultBibleVersion: 'kjv',
|
||||
showVerseNumbers: true,
|
||||
verseNewLine: false,
|
||||
calendarType: 'julian',
|
||||
serviceRole: 'priest',
|
||||
};
|
||||
|
||||
function detectBrowserLanguage(): LanguageCode {
|
||||
const lang = navigator.language || (navigator as any).userLanguage || '';
|
||||
if (lang.startsWith('ru')) return 'ru';
|
||||
if (lang.startsWith('cu')) return 'cu';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Migrate old usePonomarFont to cuFont
|
||||
if (parsed.usePonomarFont !== undefined) {
|
||||
parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : '';
|
||||
delete parsed.usePonomarFont;
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_SETTINGS, language: detectBrowserLanguage() };
|
||||
}
|
||||
|
||||
function saveSettings(settings: Settings) {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function fontClass(fontName: string): string {
|
||||
if (!fontName) return 'font-system-slavonic';
|
||||
const lower = fontName.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
ponomar: 'font-ponomar',
|
||||
fedorovsk: 'font-fedrovsk',
|
||||
indiction: 'font-indiction',
|
||||
menaion: 'font-menaion',
|
||||
monomakh: 'font-monomakh',
|
||||
triodion: 'font-triodion',
|
||||
vilnius: 'font-vilnius',
|
||||
voskresensky: 'font-voskresensky',
|
||||
};
|
||||
return map[lower] || 'font-slavonic';
|
||||
}
|
||||
|
||||
export { loadSettings, saveSettings, fontClass };
|
||||
export type { Settings };
|
||||
|
||||
export class SettingsView {
|
||||
private container: HTMLElement;
|
||||
private settings: Settings;
|
||||
private onLanguageChange?: () => void;
|
||||
|
||||
constructor(container: HTMLElement, onLanguageChange?: () => void) {
|
||||
this.container = container;
|
||||
this.settings = loadSettings();
|
||||
this.onLanguageChange = onLanguageChange;
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = getTranslations(this.settings.language as LanguageCode);
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-navy mb-6">${t.settings.title}</h2>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-navy mb-2">${t.settings.language}</label>
|
||||
<select id="language" class="w-full border border-gold/30 rounded p-2 bg-white text-navy">
|
||||
${LANGUAGES.map(l => `
|
||||
<option value="${l.code}" ${l.code === this.settings.language ? 'selected' : ''}>
|
||||
${l.name} (${l.local})
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Font Settings -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold text-navy mb-3">${t.settings.fontSettings}</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
<select id="cu-font" class="w-full border border-gold/30 rounded p-2 bg-white text-navy">
|
||||
${CU_FONTS.map(f => `
|
||||
<option value="${f.id}" ${f.id === this.settings.cuFont ? 'selected' : ''}>
|
||||
${f.id === '' ? t.settings.systemFont : f.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-parchment-dark rounded border border-gold/20">
|
||||
<p class="text-sm text-navy mb-2">${t.settings.fontPreview}</p>
|
||||
<div id="font-preview" class="text-lg">
|
||||
Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
${t.settings.fontSize} <span id="font-size-value">${this.settings.fontSize}px</span>
|
||||
</label>
|
||||
<input type="range" id="font-size" min="12" max="32" value="${this.settings.fontSize}"
|
||||
class="w-full accent-gold">
|
||||
</div>
|
||||
|
||||
<!-- Calendar Type -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold text-navy mb-3">${t.settings.calendarType}</h3>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="calendar-type" value="julian" ${this.settings.calendarType === 'julian' ? 'checked' : ''}
|
||||
class="accent-gold">
|
||||
<span class="text-sm text-navy">${t.settings.julian}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="calendar-type" value="gregorian" ${this.settings.calendarType === 'gregorian' ? 'checked' : ''}
|
||||
class="accent-gold">
|
||||
<span class="text-sm text-navy">${t.settings.gregorian}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bible Settings -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold text-navy mb-3">${t.settings.bibleSettings}</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-bold text-navy mb-1">${t.settings.defaultTranslation}</label>
|
||||
<select id="default-bible" class="w-full border border-gold/30 rounded p-2 bg-white text-navy">
|
||||
<optgroup label="English">
|
||||
<option value="kjv" ${this.settings.defaultBibleVersion === 'kjv' ? 'selected' : ''}>KJV (${t.settings.bibleComments.fullBible})</option>
|
||||
<option value="brenton" ${this.settings.defaultBibleVersion === 'brenton' ? 'selected' : ''}>Brenton LXX (${t.settings.bibleComments.otOnly})</option>
|
||||
<option value="nonkjv" ${this.settings.defaultBibleVersion === 'nonkjv' ? 'selected' : ''}>Modern (${t.settings.bibleComments.ntOnly})</option>
|
||||
</optgroup>
|
||||
<optgroup label="Русский">
|
||||
<option value="synod" ${this.settings.defaultBibleVersion === 'synod' ? 'selected' : ''}>Синодальный (${t.settings.bibleComments.fullBible})</option>
|
||||
<option value="kassian" ${this.settings.defaultBibleVersion === 'kassian' ? 'selected' : ''}>Еп. Кассиана (${t.settings.bibleComments.ntOnly})</option>
|
||||
<option value="yungerov" ${this.settings.defaultBibleVersion === 'yungerov' ? 'selected' : ''}>Юнгеров (${t.settings.bibleComments.otProphets})</option>
|
||||
</optgroup>
|
||||
<optgroup label="Церковнославянский">
|
||||
<option value="elis" ${this.settings.defaultBibleVersion === 'elis' ? 'selected' : ''}>Елисаветинская (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
<optgroup label="Ἑλληνικά">
|
||||
<option value="spt" ${this.settings.defaultBibleVersion === 'spt' ? 'selected' : ''}>Ἡ Ἁγία Γραφή (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
<optgroup label="Français">
|
||||
<option value="ls" ${this.settings.defaultBibleVersion === 'ls' ? 'selected' : ''}>Louis Segond (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
<optgroup label="Latina">
|
||||
<option value="vulgate" ${this.settings.defaultBibleVersion === 'vulgate' ? 'selected' : ''}>Vulgata (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
<optgroup label="简体中文">
|
||||
<option value="cuv" ${this.settings.defaultBibleVersion === 'cuv' ? 'selected' : ''}>简体圣经 (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
<optgroup label="正體中文">
|
||||
<option value="cuv-hant" ${this.settings.defaultBibleVersion === 'cuv-hant' ? 'selected' : ''}>正體聖經 (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
<optgroup label="العربية">
|
||||
<option value="svd" ${this.settings.defaultBibleVersion === 'svd' ? 'selected' : ''}>Smith - van Dyck (${t.settings.bibleComments.fullBible})</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="show-verses" ${this.settings.showVerseNumbers ? 'checked' : ''}
|
||||
class="w-4 h-4 accent-gold">
|
||||
<label for="show-verses" class="text-sm text-navy">${t.settings.showVerseNumbers}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="verse-newline" ${this.settings.verseNewLine ? 'checked' : ''}
|
||||
class="w-4 h-4 accent-gold">
|
||||
<label for="verse-newline" class="text-sm text-navy">${t.settings.verseNewLine}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install PWA -->
|
||||
<div class="mb-6">
|
||||
<button id="install-pwa" class="w-full bg-navy text-parchment rounded p-3 font-bold hover:bg-navy-light transition-colors ${!('serviceWorker' in navigator) || window.matchMedia('(display-mode: standalone)').matches ? 'hidden' : ''}">
|
||||
${t.settings.installPwa}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- About -->
|
||||
<div class="mt-8 pt-6 border-t border-gold/20">
|
||||
<h3 class="text-lg font-bold text-navy mb-2">${t.settings.about}</h3>
|
||||
<p class="text-sm text-navy-light">
|
||||
${t.settings.aboutText}
|
||||
</p>
|
||||
<p class="text-xs text-navy-light mt-2">
|
||||
<a href="https://github.com/typiconman/ponomar" target="_blank" rel="noopener" class="underline hover:text-gold">
|
||||
${t.settings.aboutLicense}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('language')?.addEventListener('change', (e) => {
|
||||
this.settings.language = (e.target as HTMLSelectElement).value;
|
||||
saveSettings(this.settings);
|
||||
this.onLanguageChange?.();
|
||||
});
|
||||
|
||||
document.getElementById('cu-font')?.addEventListener('change', (e) => {
|
||||
this.settings.cuFont = (e.target as HTMLSelectElement).value;
|
||||
saveSettings(this.settings);
|
||||
this.updateFontPreview();
|
||||
});
|
||||
|
||||
document.getElementById('font-size')?.addEventListener('input', (e) => {
|
||||
this.settings.fontSize = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
document.getElementById('font-size-value')!.textContent = `${this.settings.fontSize}px`;
|
||||
document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px');
|
||||
saveSettings(this.settings);
|
||||
});
|
||||
|
||||
document.getElementById('default-bible')?.addEventListener('change', (e) => {
|
||||
this.settings.defaultBibleVersion = (e.target as HTMLSelectElement).value;
|
||||
saveSettings(this.settings);
|
||||
});
|
||||
|
||||
document.getElementById('show-verses')?.addEventListener('change', (e) => {
|
||||
this.settings.showVerseNumbers = (e.target as HTMLInputElement).checked;
|
||||
saveSettings(this.settings);
|
||||
});
|
||||
|
||||
document.getElementById('verse-newline')?.addEventListener('change', (e) => {
|
||||
this.settings.verseNewLine = (e.target as HTMLInputElement).checked;
|
||||
saveSettings(this.settings);
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="calendar-type"]').forEach(el => {
|
||||
el.addEventListener('change', (e) => {
|
||||
this.settings.calendarType = (e.target as HTMLInputElement).value as 'julian' | 'gregorian';
|
||||
saveSettings(this.settings);
|
||||
});
|
||||
});
|
||||
document.getElementById('install-pwa')?.addEventListener('click', async () => {
|
||||
const prompt = (window as any).__deferredPrompt;
|
||||
if (prompt) {
|
||||
prompt.prompt();
|
||||
await prompt.userChoice;
|
||||
(window as any).__deferredPrompt = null;
|
||||
document.getElementById('install-pwa')?.remove();
|
||||
}
|
||||
});
|
||||
this.updateFontPreview();
|
||||
}
|
||||
|
||||
private updateFontPreview() {
|
||||
const preview = document.getElementById('font-preview');
|
||||
if (preview) {
|
||||
preview.className = `${fontClass(this.settings.cuFont)} text-lg`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in new issue
Block a user