initial commit

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

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
dist/
node_modules/
static/data/
.opencode/
*.log
.DS_Store
Thumbs.db
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="description" content="Ponomar — Orthodox Church calendar and liturgical information" />
<link rel="icon" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preload" href="/fonts/Ponomar-Regular.woff" as="font" type="font/woff" crossorigin />
<title>Ponomar</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1948
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ponomar-pwa",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "npm run convert && vite dev",
"build": "npm run convert && vite build",
"preview": "vite preview",
"convert": "tsx scripts/convert-all.ts",
"check": "svelte-kit sync 2>/dev/null; tsc --noEmit"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"fast-xml-parser": "^5.10.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.1",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Master conversion script — converts all XML data to JSON at build time.
* Run with: tsx scripts/convert-all.ts
*/
import { convertCalendar } from './convert-calendar.ts';
import { convertLives } from './convert-lives.ts';
import { convertServices } from './convert-services.ts';
import { convertBibleMeta } from './convert-bible-meta.ts';
import { convertFasting } from './convert-fasting.ts';
import { convertCommands } from './convert-commands.ts';
import { convertServiceRules } from './convert-service-rules.ts';
import { mkdirSync, existsSync, cpSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = join(__dirname, '..', '..', 'ponomar', 'Ponomar');
const OUT = join(__dirname, '..', 'static', 'data');
function ensureDir(dir: string) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
async function main() {
const start = Date.now();
console.log('Converting Ponomar XML data to JSON...\n');
ensureDir(OUT);
// Copy font
const fontSrc = join(SRC, 'Ponomar-Regular.woff');
const fontDst = join(__dirname, '..', 'static', 'fonts', 'Ponomar-Regular.woff');
ensureDir(dirname(fontDst));
if (existsSync(fontSrc)) {
cpSync(fontSrc, fontDst);
console.log('Copied Ponomar font.');
}
// Convert all data in parallel
await Promise.all([
convertCalendar(SRC, OUT),
convertLives(SRC, OUT),
convertServices(SRC, OUT),
convertBibleMeta(SRC, OUT),
convertFasting(SRC, OUT),
convertCommands(SRC, OUT),
convertServiceRules(SRC, OUT),
]);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\nAll conversions complete in ${elapsed}s.`);
console.log(`Output: ${OUT}`);
}
main().catch((err) => {
console.error('Conversion failed:', err);
process.exit(1);
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Convert bible.xml to JSON metadata for all translations.
* Bible text .text files are copied as-is.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, cpSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
});
interface BibleBookMeta {
id: string;
name: string;
short: string;
chapters: number;
intro?: string;
}
interface BibleVersionMeta {
id: string;
name: string;
language: string;
books: BibleBookMeta[];
}
export async function convertBibleMeta(SRC: string, OUT: string) {
console.log('Converting Bible metadata...');
const bibleXml = join(SRC, 'languages', 'xml', 'bible.xml');
if (!existsSync(bibleXml)) {
console.log(' bible.xml not found, skipping');
return;
}
const data = parser.parse(readFileSync(bibleXml, 'utf-8'));
const versions: BibleVersionMeta[] = [];
// Root element is <BMLFILE>, <BIBLE> elements are children
const bml = data['BMLFILE'] || data;
const bibleNodes = bml['BIBLE'];
if (!bibleNodes) {
console.log(' No BIBLE elements found');
return;
}
const bibleArr = Array.isArray(bibleNodes) ? bibleNodes : [bibleNodes];
for (const bible of bibleArr) {
// fast-xml-parser with attributeNamePrefix '@_' puts attributes as flat keys
const id = bible['@_Id'] || bible['@_ID'] || '';
const name = bible['@_Name'] || '';
// Handle special case: Russian Bibles are under cu/ru/ in XML but should be ru/
const isRussianBible = id.startsWith('cu/ru/bible/');
const effectiveId = isRussianBible ? id.replace('cu/ru/', 'ru/') : id;
const effectiveLang = isRussianBible ? 'ru' : effectiveId.split('/')[0];
const version: BibleVersionMeta = {
id: effectiveId,
name,
language: effectiveLang,
books: [],
};
// Parse INFO attributes for display formatting
if (bible['INFO']) {
const infoObj = bible['INFO'] as Record<string, unknown>;
const formatting: Record<string, unknown> = {};
for (const [k, v] of Object.entries(infoObj)) {
if (k.startsWith('@_')) {
formatting[k.slice(2)] = v;
}
}
if (Object.keys(formatting).length > 0) {
(version as Record<string, unknown>).formatting = formatting;
}
}
// Parse books
if (bible['BOOK']) {
const bookArr = Array.isArray(bible['BOOK']) ? bible['BOOK'] : [bible['BOOK']];
for (const book of bookArr) {
const bookId = book['@_Id'] || book['@_ID'] || '';
const bookName = book['@_Name'] || '';
const bookShort = book['@_Short'] || book['@_Abbr'] || '';
const bookChapters = parseInt(book['@_Chapters'] || '0', 10);
const bookIntro = book['@_Intro'] || undefined;
const bookMeta: BibleBookMeta = {
id: bookId,
name: bookName,
short: bookShort,
chapters: bookChapters,
};
if (bookIntro) bookMeta.intro = bookIntro;
version.books.push(bookMeta);
}
}
versions.push(version);
}
// Write versions metadata
const outDir = join(OUT, 'bible');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'versions.json'), JSON.stringify(versions, null, 2));
console.log(` ${versions.length} Bible versions`);
// Copy .text files for each version
for (const ver of versions) {
// Use the version's id directly (already normalized)
const idParts = ver.id.split('/');
const lang = idParts[0];
const subPath = idParts.slice(1).join('/'); // "bible/kjv"
const textSrc = join(SRC, 'languages', lang, subPath);
if (!existsSync(textSrc)) continue;
const textFiles = readdirSync(textSrc).filter(f => f.endsWith('.text'));
if (textFiles.length === 0) continue;
// Fix: Russian Bibles are under cu/ru/ in source but should go to ru/ in output
let outLang: string;
let outSubPath: string;
if (ver.id.startsWith('cu/ru/')) {
outLang = 'ru';
outSubPath = idParts.slice(2).join('/'); // Skip "cu/ru" → "bible/synod"
} else {
outLang = lang;
outSubPath = subPath;
}
const textDst = join(OUT, outLang, outSubPath);
mkdirSync(textDst, { recursive: true });
for (const f of textFiles) {
cpSync(join(textSrc, f), join(textDst, f));
}
console.log(` ${ver.id}: ${textFiles.length} .text files copied to ${outLang}/`);
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Convert calendar XML data (triodion, pentecostarion, menaion, commemorations) to JSON.
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
});
function parseFile(path: string): Record<string, unknown> | null {
if (!existsSync(path)) return null;
const xml = readFileSync(path, 'utf-8');
try {
return parser.parse(xml);
} catch {
return null;
}
}
function extractDayData(data: Record<string, unknown> | null): unknown[] {
if (!data) return [];
const result: unknown[] = [];
// Handle <DAY><SAINT .../></DAY> structure (menaion per-language files)
if (data['DAY']) {
const day = data['DAY'];
if (typeof day === 'object' && day !== null) {
const saints = day['SAINT'];
if (saints) {
const saintArr = Array.isArray(saints) ? saints : [saints];
for (const saint of saintArr) {
if (typeof saint === 'object' && saint !== null) {
// fast-xml-parser puts attributes at top level with @_ prefix
const sId = saint['@_SId'] || saint['@_SID'] || '';
const cId = saint['@_CId'] || saint['@_CID'] || '';
if (cId) {
result.push({ SId: sId, CId: cId });
}
}
}
}
}
}
// Handle <LANGUAGE>...</LANGUAGE> structure (triodion/pentecostarion shared files)
if (data['LANGUAGE']) {
const lang = data['LANGUAGE'];
if (typeof lang === 'object' && lang !== null) {
for (const [key, value] of Object.entries(lang as Record<string, unknown>)) {
if (key === '@_attributes') continue;
if (typeof value === 'object' && value !== null) {
const attrs = (value as Record<string, unknown>)['@_attributes'] as Record<string, string> | undefined;
if (attrs && Object.keys(attrs).length > 0) {
result.push(attrs);
}
}
}
}
}
return result;
}
function convertCommemorationFile(data: Record<string, unknown>): Record<string, unknown> | null {
if (!data) return null;
const comm = data['COMMEMORATION'];
if (!comm || typeof comm !== 'object') return null;
const result: Record<string, unknown> = {};
function extractAttrs(obj: Record<string, unknown>): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k.startsWith('@_')) {
attrs[k.slice(2)] = v; // strip @_ prefix
}
}
return attrs;
}
function processElement(obj: Record<string, unknown>): Record<string, unknown> {
const attrs = extractAttrs(obj);
const text = obj['#text'];
const out = { ...attrs };
if (typeof text === 'string' && text.trim()) {
out.text = text.trim();
}
return out;
}
for (const [key, value] of Object.entries(comm as Record<string, unknown>)) {
if (key.startsWith('@_')) continue; // skip root attributes
if (typeof value !== 'object' || value === null) continue;
if (Array.isArray(value)) {
result[key] = value.map(item =>
typeof item === 'object' && item !== null ? processElement(item as Record<string, unknown>) : item
);
} else if (typeof value === 'object') {
result[key] = processElement(value as Record<string, unknown>);
}
}
return Object.keys(result).length > 0 ? result : null;
}
export async function convertCalendar(SRC: string, OUT: string) {
console.log('Converting calendar data...');
// --- Triodion (shared, language-agnostic) ---
const triodionDir = join(SRC, 'languages', 'xml', 'triodion');
if (existsSync(triodionDir)) {
const outDir = join(OUT, 'shared', 'triodion');
mkdirSync(outDir, { recursive: true });
const files = readdirSync(triodionDir).filter(f => f.endsWith('.xml')).sort();
for (const file of files) {
const data = parseFile(join(triodionDir, file));
const dayData = extractDayData(data);
writeFileSync(join(outDir, file.replace('.xml', '.json')), JSON.stringify(dayData));
}
console.log(` triodion: ${files.length} files`);
}
// --- Pentecostarion (shared, language-agnostic) ---
const pentecostarionDir = join(SRC, 'languages', 'xml', 'pentecostarion');
if (existsSync(pentecostarionDir)) {
const outDir = join(OUT, 'shared', 'pentecostarion');
mkdirSync(outDir, { recursive: true });
const files = readdirSync(pentecostarionDir).filter(f => f.endsWith('.xml')).sort();
for (const file of files) {
const data = parseFile(join(pentecostarionDir, file));
const dayData = extractDayData(data);
writeFileSync(join(outDir, file.replace('.xml', '.json')), JSON.stringify(dayData));
}
console.log(` pentecostarion: ${files.length} files`);
}
// --- Commemorations (shared, indexed by CId) ---
const commBase = join(SRC, 'languages', 'xml', 'Commemorations');
if (existsSync(commBase)) {
const outDir = join(OUT, 'shared', 'commemorations');
mkdirSync(outDir, { recursive: true });
let count = 0;
const subdirs = readdirSync(commBase);
for (const sub of subdirs) {
const subDir = join(commBase, sub);
if (!existsSync(subDir)) continue;
const files = readdirSync(subDir).filter(f => f.endsWith('.xml'));
for (const file of files) {
const data = parseFile(join(subDir, file));
const commData = convertCommemorationFile(data);
if (commData) {
const cid = file.replace('.xml', '');
writeFileSync(join(outDir, `${cid}.json`), JSON.stringify(commData));
count++;
}
}
}
console.log(` commemorations: ${count} files`);
}
// --- Menaion (per-language monthly day files) ---
const MENAION_SOURCES = [
{ id: 'en', prefix: 'en' },
{ id: 'cu', prefix: 'cu' },
{ id: 'el', prefix: 'el' },
{ id: 'fr', prefix: 'fr' },
{ id: 'zh', prefix: 'zh' },
];
for (const { id, prefix } of MENAION_SOURCES) {
const xmlDir = join(SRC, 'languages', prefix, 'xml');
if (!existsSync(xmlDir)) continue;
const outDir = join(OUT, id, 'menaion');
mkdirSync(outDir, { recursive: true });
let count = 0;
const months = readdirSync(xmlDir).filter(f => /^\d{2}$/.test(f));
for (const month of months) {
const monthDir = join(xmlDir, month);
if (!existsSync(monthDir)) continue;
const days = readdirSync(monthDir).filter(f => f.endsWith('.xml'));
for (const day of days) {
const data = parseFile(join(monthDir, day));
const dayData = extractDayData(data);
if (dayData) {
const mm = month;
const dd = day.replace('.xml', '');
writeFileSync(join(outDir, `${mm}-${dd}.json`), JSON.stringify(dayData));
count++;
}
}
}
console.log(` ${id}: ${count} menaion day files`);
// Build menaion bundle (all days in one file for fast loading)
if (count > 0) {
const bundle: Record<string, unknown> = {};
const files = readdirSync(outDir).filter(f => f.endsWith('.json'));
for (const f of files) {
try {
bundle[f.replace('.json', '')] = JSON.parse(readFileSync(join(outDir, f), 'utf-8'));
} catch {}
}
writeFileSync(join(outDir, '..', 'menaion-bundle.json'), JSON.stringify(bundle));
}
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Convert command XML files to JSON.
* Handles both per-language (en/xml/Commands/, cu/xml/Commands/)
* and shared (xml/Commands/).
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
});
function convertCmdDir(srcDir: string, outDir: string): number {
if (!existsSync(srcDir)) return 0;
mkdirSync(outDir, { recursive: true });
const files = readdirSync(srcDir).filter(f => f.endsWith('.xml'));
for (const file of files) {
const data = parser.parse(readFileSync(join(srcDir, file), 'utf-8'));
const outName = file.replace('.xml', '.json');
writeFileSync(join(outDir, outName), JSON.stringify(data, null, 2));
}
return files.length;
}
export async function convertCommands(SRC: string, OUT: string) {
console.log('Converting command files...');
// Shared commands (Fasting.xml, ServiceRules.xml, DivineLiturgy.xml, ScriptureTransfers.xml)
const sharedCmdDir = join(SRC, 'languages', 'xml', 'Commands');
const sharedOut = join(OUT, 'shared', 'commands');
const sharedCount = convertCmdDir(sharedCmdDir, sharedOut);
console.log(` shared: ${sharedCount} command files`);
// Per-language commands (Times.xml, Podobni.xml, LanguagePacks.xml, etc.)
const CMD_SOURCES = [
{ id: 'en', prefix: 'en' },
{ id: 'cu', prefix: 'cu' },
{ id: 'ru', prefix: 'cu/ru' },
{ id: 'el', prefix: 'el' },
{ id: 'el/mono', prefix: 'el/mono' },
{ id: 'fr', prefix: 'fr' },
{ id: 'zh/Hans', prefix: 'zh/Hans' },
{ id: 'zh/Hant', prefix: 'zh/Hant' },
{ id: 'zh', prefix: 'zh' },
];
for (const { id, prefix } of CMD_SOURCES) {
const cmdDir = join(SRC, 'languages', prefix, 'xml', 'Commands');
const outDir = join(OUT, id, 'commands');
const count = convertCmdDir(cmdDir, outDir);
if (count > 0) console.log(` ${id}: ${count} command files`);
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Convert Fasting.xml to JSON.
* Fasting.xml is shared at languages/xml/Commands/Fasting.xml
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
});
export async function convertFasting(SRC: string, OUT: string) {
console.log('Converting Fasting.xml...');
const filePath = join(SRC, 'languages', 'xml', 'Commands', 'Fasting.xml');
if (!existsSync(filePath)) {
console.log(' Fasting.xml not found, skipping');
return;
}
const data = parser.parse(readFileSync(filePath, 'utf-8'));
const periods: { cmd: string; rules: { case: string; cmd: string }[] }[] = [];
if (data['FASTING']) {
const fasting = data['FASTING'];
const periodArr = Array.isArray(fasting['PERIOD']) ? fasting['PERIOD'] : [fasting['PERIOD']];
for (const period of periodArr) {
if (!period) continue;
const periodCmd = period['@_Cmd'] || '';
const rules: { case: string; cmd: string }[] = [];
if (period['RULE']) {
const ruleArr = Array.isArray(period['RULE']) ? period['RULE'] : [period['RULE']];
for (const rule of ruleArr) {
if (!rule) continue;
rules.push({
case: rule['@_Case'] || '1111111',
cmd: rule['@_Cmd'] || '',
});
}
}
periods.push({
cmd: periodCmd,
rules,
});
}
}
const outDir = join(OUT, 'shared');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'fasting.json'), JSON.stringify(periods, null, 2));
console.log(` ${periods.length} fasting periods`);
}
+261
View File
@@ -0,0 +1,261 @@
/**
* Convert saint lives XML to JSON.
* Lives are per-language: languages/{lang}/xml/lives/
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
});
function parseFile(path: string): Record<string, unknown> | null {
if (!existsSync(path)) return null;
const xml = readFileSync(path, 'utf-8');
try {
return parser.parse(xml);
} catch {
return null;
}
}
function extractAttrs(obj: Record<string, unknown>): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k.startsWith('@_')) {
attrs[k.slice(2)] = v;
}
}
return attrs;
}
function simplifySaintData(data: Record<string, unknown>): Record<string, unknown> | null {
if (!data) return null;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (key === '@_attributes') continue;
if (typeof value !== 'object' || value === null) continue;
const attrs = (value as Record<string, unknown>)['@_attributes'] as Record<string, string> | undefined;
const k = key.toUpperCase();
if (k === 'GRAMMAR') {
const grammar: Record<string, string> = {};
for (const [gKey, gValue] of Object.entries(value as Record<string, unknown>)) {
if (gKey === '@_attributes') continue;
if (typeof gValue === 'string') grammar[gKey] = gValue;
else if (typeof gValue === 'object' && gValue !== null) {
const inner = (gValue as Record<string, unknown>)['#text'];
if (typeof inner === 'string') grammar[gKey] = inner;
}
}
result.grammar = grammar;
} else if (k === 'NAME') {
const nameObj: Record<string, string> = {};
const nameItems = Array.isArray(value) ? value : [value];
for (const nameItem of nameItems) {
if (typeof nameItem === 'object' && nameItem !== null) {
for (const [nKey, nValue] of Object.entries(nameItem as Record<string, unknown>)) {
if (nKey === '@_attributes') continue;
if (typeof nValue === 'string') nameObj[nKey] = nValue;
}
}
}
result.name = nameObj;
} else if (k === 'LIFE') {
const text = (value as Record<string, unknown>)['#text'];
const lifeAttrs = extractAttrs(value as Record<string, unknown>);
result.life = {
id: lifeAttrs.ID as string || '',
copyright: lifeAttrs.Copyright as string || '',
text: typeof text === 'string' ? text : '',
};
} else if (k === 'RANK') {
const rankAttrs = extractAttrs(value as Record<string, unknown>);
result.rank = parseInt(rankAttrs.ID as string || '0', 10);
} else if (k === 'SERVICE') {
const services: Record<string, Record<string, Record<string, unknown>>> = {};
const scriptures: { type?: string; reading?: string; pericope?: string; cmd?: string }[] = [];
// Extract rank from SERVICE Type attribute (handles both single and array)
const svcItems = Array.isArray(value) ? value : [value];
for (const svcItem of svcItems) {
const svcType = (svcItem as Record<string, unknown>)['@_Type'];
if (svcType) {
const t = parseInt(svcType as string, 10);
if (!isNaN(t) && t > (result.rank || 0)) {
result.rank = t;
}
}
}
function extractScriptures(obj: Record<string, unknown>): void {
for (const [sk, sv] of Object.entries(obj)) {
if (sk === 'SCRIPTURE' && sv) {
const svArr = Array.isArray(sv) ? sv : [sv];
for (const item of svArr) {
if (typeof item === 'object' && item !== null) {
const attrs = extractAttrs(item as Record<string, unknown>);
if (Object.keys(attrs).length > 0) {
scriptures.push(attrs);
}
}
}
} else if (typeof sv === 'object' && sv !== null) {
extractScriptures(sv as Record<string, unknown>);
}
}
}
// Recursively extract nested service elements into {group/{element/Type}: {attrs+text}}
function extractServiceGroup(obj: Record<string, unknown>): Record<string, Record<string, unknown>> {
const group: Record<string, Record<string, unknown>> = {};
for (const [gk, gv] of Object.entries(obj)) {
if (gk === '@_attributes' || gk === '#text') continue;
if (typeof gv !== 'object' || gv === null) continue;
const gArr = Array.isArray(gv) ? gv : [gv];
for (const item of gArr) {
if (typeof item !== 'object' || item === null) continue;
const attrs = extractAttrs(item as Record<string, unknown>);
const text = (item as Record<string, unknown>)['#text'];
const type = attrs.Type as string || '0';
const key = `${gk}/${type}`;
group[key] = { text: typeof text === 'string' ? text : '', ...attrs };
}
}
return group;
}
for (const [sKey, sValue] of Object.entries(value as Record<string, unknown>)) {
if (sKey === '@_attributes') continue;
if (typeof sValue === 'object' && sValue !== null) {
const sObj = sValue as Record<string, unknown>;
const sAttrs = sObj['@_attributes'] as Record<string, string> | undefined;
const sText = sObj['#text'];
// Check if this element has nested children (e.g., ROYALHOURS contains IDIOMEL, VERSE, etc.)
const childKeys = Object.keys(sObj).filter(k => k !== '@_attributes' && k !== '#text');
if (childKeys.length > 0) {
// Has nested elements — extract recursively
services[sKey] = extractServiceGroup(sObj);
} else {
// Leaf element — store directly
services[sKey] = { text: typeof sText === 'string' ? sText : '', ...(sAttrs || {}) };
}
// Extract scriptures from this service (recursively)
extractScriptures(sValue as Record<string, unknown>);
}
}
if (Object.keys(services).length > 0) result.services = services;
if (scriptures.length > 0) result.scripture = scriptures;
} else if (k === 'ICON') {
const iconAttrs = extractAttrs(value as Record<string, unknown>);
result.icon = iconAttrs.ID as string || '';
} else if (k === 'SCRIPTURE') {
const scriptures: Record<string, unknown>[] = [];
const scriptureArr = Array.isArray(value) ? value : [value];
for (const s of scriptureArr) {
if (typeof s === 'object' && s !== null) {
const attrs = extractAttrs(s as Record<string, unknown>);
if (Object.keys(attrs).length > 0) {
scriptures.push(attrs);
}
}
}
if (scriptures.length > 0) {
result.scripture = scriptures;
}
}
}
return Object.keys(result).length > 0 ? result : null;
}
export async function convertLives(SRC: string, OUT: string) {
console.log('Converting saint lives...');
const LANG_CONFIGS = [
{ id: 'en', prefix: 'en' },
{ id: 'cu', prefix: 'cu' },
{ id: 'ru', prefix: 'cu/ru' },
{ id: 'el', prefix: 'el' },
{ id: 'el/mono', prefix: 'el/mono' },
{ id: 'fr', prefix: 'fr' },
{ id: 'zh/Hans', prefix: 'zh/Hans' },
{ id: 'zh/Hant', prefix: 'zh/Hant' },
{ id: 'shared', prefix: '' },
];
for (const { id, prefix } of LANG_CONFIGS) {
const livesDir = join(SRC, 'languages', prefix, 'xml', 'lives');
if (!existsSync(livesDir)) continue;
const outDir = join(OUT, id, 'lives');
mkdirSync(outDir, { recursive: true });
// Clean old individual files from previous conversions
for (const oldFile of readdirSync(outDir).filter(f => f.endsWith('.json'))) {
rmSync(join(outDir, oldFile), { force: true });
}
let count = 0;
const files = readdirSync(livesDir).filter(f => f.endsWith('.xml'));
const bundles: Record<string, Record<string, Record<string, unknown>>> = {};
for (const file of files) {
const data = parseFile(join(livesDir, file));
if (!data) continue;
let saintData: Record<string, unknown> | null = null;
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'object' && value !== null && key !== '@_attributes') {
saintData = simplifySaintData(value as Record<string, unknown>);
break;
}
}
if (saintData) {
const cid = file.replace('.xml', '');
const mm = cid.match(/^(0[1-9]|1[0-2])\d+/)?.[1];
const bundleName = mm ? `${mm}.json` : 'misc.json';
if (!bundles[bundleName]) bundles[bundleName] = {};
bundles[bundleName][cid] = saintData;
count++;
}
}
// Write bundles
for (const [bundleName, bundle] of Object.entries(bundles)) {
writeFileSync(join(outDir, bundleName), JSON.stringify(bundle));
}
console.log(` ${id}: ${count} saint lives in ${Object.keys(bundles).length} bundles`);
}
// Build shared lives index (CId → scripture for fast lookup)
const sharedOut = join(OUT, 'shared', 'lives');
const index: Record<string, unknown[]> = {};
if (existsSync(sharedOut)) {
const files = readdirSync(sharedOut).filter(f => f.endsWith('.json'));
for (const f of files) {
if (f === 'lives-index.json') continue;
try {
const bundle = JSON.parse(readFileSync(join(sharedOut, f), 'utf-8'));
for (const [cid, data] of Object.entries(bundle as Record<string, Record<string, unknown>>)) {
if (data.scripture) {
index[cid] = data.scripture;
}
}
} catch {}
}
}
writeFileSync(join(sharedOut, '..', 'lives-index.json'), JSON.stringify(index));
console.log(` lives-index: ${Object.keys(index).length} entries`);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Convert ServiceRules.xml to JSON.
* ServiceRules.xml is shared at languages/xml/Commands/ServiceRules.xml
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
});
export async function convertServiceRules(SRC: string, OUT: string) {
console.log('Converting ServiceRules.xml...');
const filePath = join(SRC, 'languages', 'xml', 'Commands', 'ServiceRules.xml');
if (!existsSync(filePath)) {
console.log(' ServiceRules.xml not found, skipping');
return;
}
const data = parser.parse(readFileSync(filePath, 'utf-8'));
const periods: { cmd: string; rules: Record<string, string>[] }[] = [];
const root = data['DATA'] || data['SERVICES'];
if (root) {
const periodArr = Array.isArray(root['PERIOD']) ? root['PERIOD'] : [root['PERIOD']];
for (const period of periodArr) {
if (!period) continue;
const periodCmd = root['@_Cmd'] || period['@_Cmd'] || '';
const rules: Record<string, string>[] = [];
for (const [key, value] of Object.entries(period)) {
if (key.startsWith('@_')) continue;
if (typeof value === 'object' && value !== null) {
const ruleArr = Array.isArray(value) ? value : [value];
for (const rule of ruleArr) {
if (typeof rule === 'object' && rule !== null) {
// Collect all @_-prefixed attributes (flat format)
const attrs: Record<string, string> = {};
for (const [rKey, rValue] of Object.entries(rule as Record<string, unknown>)) {
if (rKey.startsWith('@_') && typeof rValue === 'string') {
attrs[rKey.slice(2)] = rValue;
}
}
if (Object.keys(attrs).length > 0) {
rules.push({ type: key, ...attrs });
}
}
}
}
}
periods.push({
cmd: periodCmd,
rules,
});
}
}
const outDir = join(OUT, 'shared');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'service-rules.json'), JSON.stringify(periods, null, 2));
console.log(` ${periods.length} service rule periods`);
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Convert service template XML to JSON.
* Recursively converts all subdirectories under Services/.
* Unwraps COMMONPRAYER root wrappers, flattens children, lowercases attributes.
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseTagValue: false,
trimValues: true,
preserveOrder: true,
});
function parseFile(path: string): unknown[] | null {
if (!existsSync(path)) return null;
const xml = readFileSync(path, 'utf-8');
try {
return parser.parse(xml) as unknown[];
} catch {
return null;
}
}
/**
* Lowercase all @_-prefixed attribute keys and strip the prefix.
* e.g. { '@_Value': 'Wisdom.', '@_Who': 'Deacon' } → { value: 'Wisdom.', who: 'Deacon' }
*/
function lowerAttrs(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('@_')) {
result[key.slice(2).toLowerCase()] = value;
} else {
result[key] = value;
}
}
return result;
}
/**
* Convert a parsed XML node (preserveOrder format) into a flat array of service nodes.
* Input is an array of ordered entries, each: { tagName: children[], ":@": { attrs } }
*/
function convertNodes(data: unknown[]): unknown[] {
const result: unknown[] = [];
for (const entry of data as Record<string, unknown>[]) {
// Get the tag name (first key that isn't ':@')
const tagName = Object.keys(entry).find(k => k !== ':@');
if (!tagName) continue;
const attrs = (entry[':@'] ?? {}) as Record<string, unknown>;
const children = (entry[tagName] ?? []) as unknown[];
// Unwrap wrapper elements (COMMONPRAYER, SERVICES, SERVICE)
if (tagName === 'COMMONPRAYER' || tagName === 'SERVICE' || tagName === 'SERVICES') {
result.push(...convertNodes(children));
continue;
}
// Convert element with its attributes
const node = convertNodeItem(tagName, attrs, children);
if (node) result.push(node);
}
return result;
}
/**
* Convert a single XML element into a service node object.
*/
function convertNodeItem(
tagName: string,
attrs: Record<string, unknown>,
children: unknown[]
): Record<string, unknown> | null {
const lowercased = lowerAttrs(attrs);
// Convert boolean attribute values: "1"→true, "0"→false
const boolKeys = ['redfirst', 'newline', 'header', 'null', 'withmen'];
for (const k of boolKeys) {
if (lowercased[k] === '1') lowercased[k] = true;
else if (lowercased[k] === '0') lowercased[k] = false;
}
// Convert numeric attribute values: "3"→3
const numKeys = ['times'];
for (const k of numKeys) {
if (typeof lowercased[k] === 'string' && /^\d+$/.test(lowercased[k])) {
lowercased[k] = parseInt(lowercased[k], 10);
}
}
const node: Record<string, unknown> = { type: tagName };
// Copy all attributes as lowercase keys
for (const [key, value] of Object.entries(lowercased)) {
if (key === 'type') continue;
node[key] = value;
}
// Process child elements recursively
if (children.length > 0) {
// Check for #text content
const textNode = children.find(c => (c as Record<string, unknown>)['#text']);
if (textNode) {
node.text = (textNode as Record<string, unknown>)['#text'] as string;
} else {
const childNodes = convertNodes(children);
if (childNodes.length > 0) {
node._children = childNodes;
}
}
}
return node;
}
function convertDirRecursive(srcDir: string, outDir: string): number {
let count = 0;
mkdirSync(outDir, { recursive: true });
const entries = readdirSync(srcDir);
for (const entry of entries) {
if (entry.startsWith('.')) continue;
const srcPath = join(srcDir, entry);
if (!existsSync(srcPath)) continue;
if (entry.endsWith('.xml')) {
const data = parseFile(srcPath);
if (!data) continue;
// Convert and flatten
const nodes = flattenNodes(convertNodes(data));
const outName = entry.replace('.xml', '.json');
writeFileSync(join(outDir, outName), JSON.stringify(nodes));
count++;
} else {
// Subdirectory (e.g. "Tone 1", "KONTAKION", "TROPARION")
const subOut = join(outDir, entry);
count += convertDirRecursive(srcPath, subOut);
}
}
return count;
}
/**
* Flatten nodes that have _children into a single flat array.
*/
function flattenNodes(nodes: unknown[]): unknown[] {
const result: unknown[] = [];
for (const node of nodes) {
if (typeof node !== 'object' || node === null) {
result.push(node);
continue;
}
const n = node as Record<string, unknown>;
if (n._children && Array.isArray(n._children)) {
// This node has children — push the node itself (without _children) then flatten children
const { _children, ...rest } = n;
result.push(rest);
result.push(...flattenNodes(_children));
} else {
result.push(n);
}
}
return result;
}
export async function convertServices(SRC: string, OUT: string) {
console.log('Converting service templates...');
const SERVICE_SOURCES = [
{ id: 'en', prefix: 'en' },
{ id: 'cu', prefix: 'cu' },
{ id: 'ru', prefix: 'cu/ru' },
{ id: 'el', prefix: 'el' },
{ id: 'fr', prefix: 'fr' },
{ id: 'zh/Hans', prefix: 'zh/Hans' },
{ id: 'zh/Hant', prefix: 'zh/Hant' },
{ id: 'zh', prefix: 'zh' },
{ id: 'shared', prefix: '' },
];
for (const { id, prefix } of SERVICE_SOURCES) {
const servicesDir = join(SRC, 'languages', prefix, 'xml', 'Services');
if (!existsSync(servicesDir)) continue;
const outDir = join(OUT, id, 'services');
const count = convertDirRecursive(servicesDir, outDir);
console.log(` ${id}: ${count} service files`);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import zlib from 'zlib';
const __dirname = dirname(fileURLToPath(import.meta.url));
const iconsDir = join(__dirname, '..', 'static', 'icons');
mkdirSync(iconsDir, { recursive: true });
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i];
for (let j = 0; j < 8; j++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createChunk(type, data) {
const typeBytes = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([typeBytes, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function generatePNG(size) {
const bg = [0x1a, 0x1a, 0x2e];
const gold = [0xc5, 0xa5, 0x5a];
const pixels = [];
for (let y = 0; y < size; y++) {
pixels.push(0);
for (let x = 0; x < size; x++) {
if (isCross(x, y, size)) {
pixels.push(...gold);
} else {
pixels.push(...bg);
}
}
}
const raw = Buffer.from(pixels);
const compressed = zlib.deflateSync(raw);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 2;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([
signature,
createChunk('IHDR', ihdr),
createChunk('IDAT', compressed),
createChunk('IEND', Buffer.alloc(0)),
]);
}
function isCross(x, y, size) {
const cx = Math.floor(size / 2);
const cy = Math.floor(size / 2);
const arm = Math.max(2, Math.floor(size * 0.06));
const halfLen = Math.floor(size * 0.32);
const topLen = Math.floor(size * 0.38);
const inVert = Math.abs(x - cx) <= arm && y >= cy - topLen && y <= cy + halfLen;
const inHoriz = Math.abs(y - cy) <= arm && x >= cx - halfLen && x <= cx + halfLen;
return inVert || inHoriz;
}
writeFileSync(join(iconsDir, 'icon-192.png'), generatePNG(192));
writeFileSync(join(iconsDir, 'icon-512.png'), generatePNG(512));
console.log('Icons generated.');
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Generate PWA icons from OrthodoxCross.svg using rsvg-convert + Pillow."""
import os
import subprocess
import tempfile
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.join(SCRIPT_DIR, '..')
SVG_PATH = os.path.join(PROJECT_DIR, 'static', 'OrthodoxCross.svg')
ICONS_DIR = os.path.join(PROJECT_DIR, 'static', 'icons')
os.makedirs(ICONS_DIR, exist_ok=True)
GOLD = (0xC5, 0xA5, 0x5A)
NAVY = (0x1A, 0x1A, 0x2E)
RENDER_SIZE = 2048
def generate_icon(size):
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
tmp_path = tmp.name
try:
subprocess.run([
'rsvg-convert',
'-w', str(RENDER_SIZE),
'-h', str(RENDER_SIZE),
SVG_PATH,
'-o', tmp_path,
], check=True)
img = Image.open(tmp_path).convert('RGBA')
result = Image.new('RGBA', (size, size), NAVY + (255,))
cross = img.resize((size, size), Image.LANCZOS)
gold_layer = Image.new('RGBA', (size, size), GOLD + (255,))
result.paste(gold_layer, mask=cross.split()[3])
return result
finally:
os.unlink(tmp_path)
for size in [192, 512]:
icon = generate_icon(size)
out = os.path.join(ICONS_DIR, f'icon-{size}.png')
icon.save(out, 'PNG')
print(f'Saved {out} ({size}x{size})')
print('Icons generated.')
+203
View File
@@ -0,0 +1,203 @@
/**
* Bible Reader — parses .text files and manages Bible navigation.
* Port of key Bible.java functionality.
*
* Bible.java is part of the Ponomar program.
* Copyright 2007, 2008 Aleksandr Andreev.
* GPL v3 — see LICENSE.
*/
// Bible types used for version metadata
export interface BiblePassage {
bookId: string;
chapter: number;
verses: { num: number; text: string }[];
}
export interface ParsedReading {
chapters: number[];
verses: number[];
}
/**
* Parse a passage string like "2:11-3:2, 5, 13-14" into structured data.
*/
export function parsePassage(passage: string): ParsedReading {
const chapters: number[] = [];
const verses: number[] = [];
if (!passage.includes(':')) {
// Just a chapter, e.g. "1"
const ch = parseInt(passage, 10);
chapters.push(ch);
verses.push(1);
return { chapters, verses };
}
// Composite passage: "2:11-3:2, 5, 13-14"
const parts = passage.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed.includes('-')) {
// Single verse or chapter:verse
if (trimmed.includes(':')) {
const [ch, v] = trimmed.split(':');
chapters.push(parseInt(ch, 10));
verses.push(parseInt(v, 10));
} else {
const v = parseInt(trimmed, 10);
chapters.push(0); // current chapter
verses.push(v);
}
} else {
// Range: "2:11-3:2" or "13-14"
const [start, end] = trimmed.split('-');
if (start.includes(':')) {
const [ch, v] = start.split(':');
chapters.push(parseInt(ch, 10));
verses.push(parseInt(v, 10));
} else {
chapters.push(0);
verses.push(parseInt(start, 10));
}
if (end.includes(':')) {
const [ch, v] = end.split(':');
chapters.push(parseInt(ch, 10));
verses.push(parseInt(v, 10));
} else {
chapters.push(0);
verses.push(parseInt(end, 10));
}
}
}
return { chapters, verses };
}
/**
* Parse a .text file's content into structured verses.
* Format: "#N" = chapter marker, "N|" = verse marker followed by text.
*/
export function parseTextFile(content: string): Map<number, Map<number, string>> {
const chapters = new Map<number, Map<number, string>>();
let currentChapter = 0;
let currentVerses = new Map<number, string>();
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('#')) {
// Chapter marker
if (currentChapter > 0) {
chapters.set(currentChapter, currentVerses);
}
currentChapter = parseInt(trimmed.substring(1), 10);
currentVerses = new Map();
} else if (trimmed.includes('|')) {
// Verse marker
const pipeIdx = trimmed.indexOf('|');
const verseNum = parseInt(trimmed.substring(0, pipeIdx), 10);
const verseText = trimmed.substring(pipeIdx + 1).trim();
currentVerses.set(verseNum, verseText);
}
}
// Don't forget the last chapter
if (currentChapter > 0) {
chapters.set(currentChapter, currentVerses);
}
return chapters;
}
/**
* Extract a passage from parsed chapter data.
*/
export function extractPassage(
chapters: Map<number, Map<number, string>>,
reading: ParsedReading
): { chapter: number; verse: number; text: string }[] {
const result: { chapter: number; verse: number; text: string }[] = [];
for (let i = 0; i < reading.chapters.length; i += 2) {
const startCh = reading.chapters[i];
const startV = reading.verses[i];
const endCh = reading.chapters[i + 1] ?? startCh;
const endV = reading.verses[i + 1] ?? -1;
for (const [chNum, verses] of chapters) {
if (chNum < startCh) continue;
if (chNum > endCh) break;
for (const [vNum, text] of verses) {
if (chNum === startCh && vNum < startV) continue;
if (chNum === endCh && endV > 0 && vNum > endV) continue;
result.push({ chapter: chNum, verse: vNum, text });
}
}
}
return result;
}
/**
* Format a passage for display.
*/
export function formatPassageHTML(
passages: { chapter: number; verse: number; text: string }[],
options?: { showVerseNumbers?: boolean; verseNewLine?: boolean }
): string {
const showVerseNum = options?.showVerseNumbers ?? true;
const verseNewLine = options?.verseNewLine ?? false;
let html = '';
let lastChapter = 0;
for (const p of passages) {
if (p.chapter !== lastChapter) {
if (lastChapter > 0 && !verseNewLine) html += '</p>';
if (!verseNewLine) html += '<p>';
lastChapter = p.chapter;
}
let text = p.text;
text = text.replace(/\*\*(.*?)\*\*/g, '<span class="text-red italic text-sm">$1</span>');
text = text.replace(/\*\*/g, '');
if (verseNewLine && p.verse > 0) {
const num = showVerseNum ? `<sup class="text-red text-xs">${p.verse}</sup> ` : '';
html += `<span class="block mt-1">${num}${text.charAt(0).toUpperCase() + text.slice(1)}</span>`;
} else {
if (showVerseNum && p.verse > 0) {
html += `<sup class="text-red text-xs">${p.verse}</sup> `;
}
html += text + ' ';
}
}
if (!verseNewLine && lastChapter > 0) html += '</p>';
return html;
}
/**
* Create a hyperlink for a Bible reading reference.
*/
export function createReadingLink(
bookId: string,
passage: string,
abbrevFormat: string,
abbreviations: Record<string, string>
): string {
const abbrev = abbreviations[bookId] ?? bookId;
const display = abbrevFormat
.replace('^NAME', abbrev)
.replace('^CNN', passage);
return `<a href="bible://${bookId}/${passage}" class="text-blue-underline">${display}</a>`;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Day Computer — computes all liturgical variables for a given Julian date.
* Replaces the core of Main.write() in the Java version.
*/
import { JDate } from './jdate';
import { Paschalion } from './paschalion';
import type { EvalContext, DayInfo } from './types';
export interface ComputedDay {
jdate: JDate;
gregorian: Date;
evalContext: EvalContext;
dayInfo: DayInfo;
}
/**
* Compute all liturgical variables for a given Julian date.
* Returns both the EvalContext (for expression evaluator) and structured DayInfo.
*/
export function computeDay(date: JDate): ComputedDay {
const year = date.getYear();
const dow = date.getDayOfWeek();
const doy = date.getDoy();
const month = date.getMonth();
const day = date.getDay();
const gregorian = date.toGregorian();
// Paschal distances
const pascha = Paschalion.getPascha(year);
const nday = date.difference(pascha);
const ndayP = date.difference(Paschalion.getPascha(year - 1));
const ndayF = date.difference(Paschalion.getPascha(year + 1));
const pentecost = Paschalion.getPentecost(year);
const lentStart = Paschalion.getLentStart(year);
// Determine file indices for triodion/pentecostarion
let triodionFile: number | null = null;
let pentecostarionFile: number | null = null;
if (nday >= -70 && nday < 0) {
// Great Lent period — triodion
triodionFile = Math.abs(nday);
} else if (nday < -70) {
// Before Lent — pentecostarion from last year
const lastPascha = Paschalion.getPascha(year - 1);
pentecostarionFile = date.difference(lastPascha) + 1;
} else {
// After Pascha — pentecostarion
pentecostarionFile = nday + 1;
}
// Determine liturgical state
const isLent = nday >= -48 && nday < 0;
const isBrightWeek = nday >= 0 && nday <= 6;
const isApostlesFast = nday >= 57 && nday <= Paschalion.getApostlesFastLength(year);
const isDormitionFast = month === 8 && day >= 1 && day <= 14;
const isNativityFast = (month === 11 && day >= 15) || (month === 12 && day <= 24);
// Tone computation — 8-week cycle after Pentecost
// Matches Java pentecostarion XML: tone = (⌊(nday - 7) / 7⌋ % 8) + 1
// Exceptions: Ascension (nday 39-40) and Pentecost week (nday 48-55) have no tone
let tone = -1;
if (nday >= 7) {
if ((nday >= 39 && nday <= 40) || (nday >= 48 && nday <= 55)) {
tone = -1; // Ascension or Pentecost week — no octoechos tone
} else {
tone = (Math.floor((nday - 7) / 7) % 8) + 1;
}
}
// Rank computation
let dRank = 0;
// Major feasts get higher rank
if (month === 9 && day === 14) dRank = 6; // Exaltation
else if (month === 8 && day === 29) dRank = 6; // Beheading
else if (nday === 0) dRank = 8; // Pascha
else if (nday === 49) dRank = 8; // Pentecost
else if (nday === 39) dRank = 8; // Ascension
else if (nday === -7) dRank = 8; // Palm Sunday
else if (month === 12 && day === 25) dRank = 6; // Nativity
else if (month === 1 && day === 6) dRank = 6; // Theophany
else if (month === 8 && day === 15) dRank = 6; // Dormition
else if (month === 3 && day === 25) dRank = 6; // Annunciation
else if (nday >= -48 && nday < 0) dRank = 3; // Lent
else if (isBrightWeek || nday >= 49 && nday <= 56) dRank = 5; // Bright/Pentecost week
// Paschal flag
const PFlag = nday >= 0 ? 1 : 0;
const PFlag1 = 1; // Default service inclusion
const PFlag2 = isBrightWeek || (nday >= -7 && nday < 0) ? 2 : (isLent && dow !== 0 ? 1 : 0);
// Priest status (1 = priest required, 0 = reader)
const PS = (dRank >= 3 || dow === 0) ? 1 : 0;
const dayInfo: DayInfo = {
dow,
doy,
month,
day,
year,
nday,
ndayP,
ndayF,
Easter: pascha.jdn,
Pentecost: pentecost.jdn,
LentStart: lentStart.jdn,
Tone: tone,
dRank,
PFlag,
PFlag1,
PFlag2,
PS,
isLent,
isBrightWeek,
isApostlesFast,
isDormitionFast,
isNativityFast,
triodionFile,
pentecostarionFile,
};
// Build EvalContext from DayInfo (all values as numbers for the expression evaluator)
const evalContext: EvalContext = {
dow,
doy,
nday,
ndayP,
ndayF,
Year: year,
dRank,
PFlag,
PFlag1,
PFlag2,
PS,
Tone: tone,
Easter: pascha.jdn,
Pentecost: pentecost.jdn,
LentStart: lentStart.jdn,
LS: 1, // Default language
GS: 1, // Default gospel selector
};
return { jdate: date, gregorian, evalContext, dayInfo };
}
+294
View File
@@ -0,0 +1,294 @@
/**
* Expression evaluator — recursive descent parser.
* Faithful port of Ponomar/StringOp.java to TypeScript.
* Supports: ||, &&, ==, !=, <, >, <=, >=, +, -, *, /, %, unary !, unary -, true/false, variables.
*
* StringOp.java is part of the Ponomar program.
* Copyright 2007 Aleksandr Andreev and Yuri Shardt.
* See LICENSE for redistribution terms.
*/
import type { EvalContext } from './types';
function bool2double(expr: boolean): number {
return expr ? 1.0 : 0.0;
}
/**
* Evaluate a boolean expression using dayInfo variable context.
* Returns true if the expression evaluates to a non-zero number.
*/
export function evalBool(expression: string, ctx: EvalContext): boolean {
return evalNum(expression, ctx) !== 0;
}
/**
* Evaluate a numeric expression.
* Fully obeys correct order of operations (recursive descent).
*/
export function evalNum(expression: string, ctx: EvalContext): number {
const parser = new ExprParser(expression.trim(), ctx);
return parser.parseOr();
}
class ExprParser {
private pos = 0;
private readonly src: string;
private readonly ctx: EvalContext;
constructor(expression: string, ctx: EvalContext) {
// Strip all spaces for tokenization (matches Java behavior)
this.src = expression.replace(/\s/g, '');
this.ctx = ctx;
}
private peek(): string {
return this.pos < this.src.length ? this.src[this.pos] : '';
}
private advance(): string {
const ch = this.src[this.pos];
this.pos++;
return ch;
}
private peekTwo(): string {
return this.src.substring(this.pos, this.pos + 2);
}
/**
* Read a token: number, variable name, or keyword (true/false).
*/
private readToken(): string {
// Number
if (/[0-9.]/.test(this.peek())) {
let num = '';
while (this.pos < this.src.length && /[0-9.eE+\-]/.test(this.peek())) {
num += this.advance();
}
return num;
}
// Variable or keyword
if (/[a-zA-Z_]/.test(this.peek())) {
let name = '';
while (this.pos < this.src.length && /[a-zA-Z_0-9]/.test(this.peek())) {
name += this.advance();
}
return name;
}
return '';
}
/**
* OR operator: lowest precedence.
* expr || expr
*/
parseOr(): number {
let left = this.parseAnd();
while (this.pos < this.src.length && this.peekTwo() === '||') {
this.pos += 2;
const right = this.parseAnd();
left = bool2double(evalBool(String(left !== 0), this.ctx) || evalBool(String(right !== 0), this.ctx));
}
return left;
}
/**
* AND operator.
*/
parseAnd(): number {
let left = this.parseEquality();
while (this.pos < this.src.length && this.peekTwo() === '&&') {
this.pos += 2;
const right = this.parseEquality();
left = bool2double(evalBool(String(left !== 0), this.ctx) && evalBool(String(right !== 0), this.ctx));
}
return left;
}
/**
* Equality: == and !=
*/
parseEquality(): number {
let left = this.parseComparison();
while (this.pos < this.src.length) {
if (this.peekTwo() === '==') {
this.pos += 2;
const right = this.parseComparison();
left = bool2double(left === right);
} else if (this.peekTwo() === '!=') {
this.pos += 2;
const right = this.parseComparison();
left = bool2double(left !== right);
} else {
break;
}
}
return left;
}
/**
* Comparison: <, >, <=, >=
*/
parseComparison(): number {
let left = this.parseAddSub();
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '<' || ch === '>') {
const next = this.pos + 1 < this.src.length ? this.src[this.pos + 1] : '';
if (next === '=') {
// <= or >=
if (ch === '<') {
this.pos += 2;
const right = this.parseAddSub();
left = bool2double(left <= right);
} else {
this.pos += 2;
const right = this.parseAddSub();
left = bool2double(left >= right);
}
} else {
// < or >
if (ch === '<') {
this.pos += 1;
const right = this.parseAddSub();
left = bool2double(left < right);
} else {
this.pos += 1;
const right = this.parseAddSub();
left = bool2double(left > right);
}
}
} else {
break;
}
}
return left;
}
/**
* Addition and subtraction.
* Must distinguish binary + from unary +/-.
*/
parseAddSub(): number {
let left = this.parseMulDiv();
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '+') {
this.pos += 1;
const right = this.parseMulDiv();
left = left + right;
} else if (ch === '-') {
// Check if this is a binary minus (preceded by a value) or unary
// If preceded by operator or start, it's unary — handled by parseMulDiv → parseUnary
// Here we need to check if this is a binary minus
// A minus is binary if preceded by: number, ), variable, true, false
if (this.pos > 0) {
const prevCh = this.src[this.pos - 1];
if (/[0-9)TrueFals]/.test(prevCh) || prevCh === 'e' || prevCh === 'E') {
this.pos += 1;
const right = this.parseMulDiv();
left = left - right;
} else {
break;
}
} else {
break;
}
} else {
break;
}
}
return left;
}
/**
* Multiplication, division, modulus.
*/
parseMulDiv(): number {
let left = this.parseUnary();
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '*') {
this.pos += 1;
const right = this.parseUnary();
left = left * right;
} else if (ch === '/') {
this.pos += 1;
const right = this.parseUnary();
left = left / right;
} else if (ch === '%') {
this.pos += 1;
const right = this.parseUnary();
left = left % right;
} else {
break;
}
}
return left;
}
/**
* Unary operators: !, -
*/
parseUnary(): number {
// Handle repeated unary operators (e.g., "!!", "---")
while (this.pos < this.src.length) {
const ch = this.peek();
if (ch === '!') {
this.pos += 1;
const val = this.parseUnary();
return bool2double(val === 0);
} else if (ch === '-') {
this.pos += 1;
const val = this.parseUnary();
return -val;
} else if (ch === '+') {
this.pos += 1;
continue;
} else {
break;
}
}
return this.parsePrimary();
}
/**
* Primary: number, variable, parenthesized expression, true/false.
*/
parsePrimary(): number {
// Parenthesized expression
if (this.peek() === '(') {
this.pos++; // skip (
const val = this.parseOr();
if (this.peek() === ')') {
this.pos++; // skip )
}
return val;
}
const token = this.readToken();
if (token === 'true' || token === 'True') {
return 1.0;
}
if (token === 'false' || token === 'False') {
return 0.0;
}
// Try as number
const num = Number(token);
if (!isNaN(num)) {
return num;
}
// Variable lookup
if (token in this.ctx) {
return Number(this.ctx[token]);
}
throw new Error(`Unknown variable: ${token}`);
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Fasting rules engine.
* Evaluates Fasting.xml rules against dayInfo to determine fasting level for a given day.
*
* Fasting.java is part of the Ponomar program.
* Copyright 2009 Yuri Shardt.
* GPL v3 — see LICENSE.
*/
import { evalBool } from './evaluator';
import type { EvalContext } from './types';
/** 7-bit fasting case string. Each bit: 1=permitted, 0=forbidden. */
export type FastingCase = string;
export interface FastingPeriod {
cmd: string;
rules: FastingRule[];
}
export interface FastingRule {
case: FastingCase;
cmd: string;
}
export interface FastingResult {
case: FastingCase;
level: number; // 0-6
description: string;
allowed: string[];
forbidden: string[];
}
const FOOD_NAMES_LOCALIZED: Record<string, string[]> = {
en: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'],
ru: ['мясо', 'молочные продукты', 'рыба', 'рыбная икра', 'растительное масло', 'вино', 'сухоядение'],
cu: ['мѧ́со', 'мла҆чные продꙋ́кты', 'ры́ба', 'ры́бнаѧ и҆́кра', 'є҆ле́й', 'ві́но̀', 'сухоѧденїе'],
fr: ['le viande', 'les produits lactières', 'le poisson', 'le caviar', "l'huile", 'le vin', 'des nourritures secs'],
el: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'],
zh: ['肉类和动物制品', '乳制品', '鱼', '鱼子', '油', '酒', '干食'],
'zh-Hant': ['肉類和動物製品', '乳製品', '魚', '魚子', '油', '酒', '幹食'],
};
/**
* Parse fasting XML data. The XML is pre-converted to JSON at build time.
*/
export function parseFastingData(data: FastingPeriod[]): FastingPeriod[] {
return data;
}
/**
* Evaluate fasting rules for a given day.
* Returns the fasting case (7-bit string) based on matching rules.
*/
export function evaluateFasting(
periods: FastingPeriod[],
ctx: EvalContext,
lang: string = 'en'
): FastingResult {
let currentCase: FastingCase = '1111111'; // Default: no fast
// Process periods in order; later periods can override earlier ones
for (const period of periods) {
// Check if this period applies to today
if (period.cmd && !evalBool(period.cmd, ctx)) {
continue;
}
// Evaluate rules within this period
for (const rule of period.rules) {
if (rule.cmd && !evalBool(rule.cmd, ctx)) {
continue;
}
// This rule matches — apply it
currentCase = rule.case;
}
}
return formatFastingResult(currentCase, lang);
}
/**
* Format a fasting case into a human-readable result.
*/
function formatFastingResult(fastCase: FastingCase, lang: string = 'en'): FastingResult {
const names = FOOD_NAMES_LOCALIZED[lang] || FOOD_NAMES_LOCALIZED.en;
const allowed: string[] = [];
const forbidden: string[] = [];
// Case format: meat,cheese,fish,caviar,oil,wine,dryFood
// Index 0 = meat, 6 = dry food (leftmost = highest food, rightmost = lowest)
for (let i = 0; i < 7; i++) {
const permitted = fastCase[i] === '1';
if (permitted) {
allowed.push(names[i]);
} else {
forbidden.push(names[i]);
}
}
// Determine fasting level
let level = 0;
switch (fastCase) {
case '1111111': level = 0; break; // No fast
case '0111111': level = 1; break; // Meat excluded
case '0011111': level = 2; break; // Fish permitted
case '0001111': level = 3; break; // Caviar permitted
case '0000111': level = 4; break; // Wine and oil
case '0000011': level = 5; break; // Without oil
case '0000001': level = 6; break; // Dry food only (xerophagy)
case '0000000': level = 7; break; // Complete fast
default: level = 0;
}
// Generate description
const description = generateDescription(fastCase, allowed, forbidden, lang);
return {
case: fastCase,
level,
description,
allowed,
forbidden,
};
}
function generateDescription(
fastCase: FastingCase,
allowed: string[],
forbidden: string[],
lang: string
): string {
if (fastCase === '1111111') {
return lang === 'ru' ? 'Поста нет' : lang === 'cu' ? 'Поста нѣтъ' : 'No fast';
}
if (fastCase === '0000000') {
return lang === 'ru' ? 'Полное воздержание от пищи' : lang === 'cu' ? 'Полное воздержаніе отъ пищи' : 'Complete fast (no food)';
}
const labels: Record<string, { permitted: string; forbidden: string; is: string; are: string }> = {
en: { permitted: 'Permitted', forbidden: 'Forbidden', is: 'is', are: 'are' },
ru: { permitted: 'Разрешено', forbidden: 'Запрещено', is: '', are: '' },
cu: { permitted: 'Позволено', forbidden: 'Запрещено', is: '', are: '' },
};
const L = labels[lang] || labels.en;
if (allowed.length === 0) {
return `${L.forbidden}: ${forbidden.join(', ')}`;
}
if (forbidden.length === 0) {
return `${L.permitted}: ${allowed.join(', ')}`;
}
const verb = allowed.length === 1 ? L.is : L.are;
const verbF = forbidden.length === 1 ? L.is : L.are;
return `${L.permitted} ${verb} ${allowed.join(', ')}. ${L.forbidden} ${verbF} ${forbidden.join(', ')}`;
}
+751
View File
@@ -0,0 +1,751 @@
/**
* Internationalization (i18n) module for Ponomar PWA.
* Provides translations for UI strings in English, Russian, and Church Slavonic.
*/
export type LanguageCode = 'en' | 'ru' | 'cu';
export interface Translations {
// General
appName: string;
loading: string;
error: string;
notAvailable: string;
// Navigation
nav: {
calendar: string;
service: string;
bible: string;
settings: string;
};
// Calendar
calendar: {
months: string[];
monthsGenitive: string[];
dayNames: string[];
dayNamesFull: string[];
rankLabels: Record<number, string>;
rankIcons: Record<number, string>;
pascha: string;
pentecost: string;
cleanMonday: string;
greatLent: string;
brightWeek: string;
weekAfterPentecost: string;
weekOrdSuffix: string;
apostlesFast: string;
dormitionFast: string;
nativityFast: string;
commemorations: string;
fastingRule: string;
readings: string;
noCommemorations: string;
noReadings: string;
readingTypes: Record<string, string>;
defaultBibleVersion: string;
dayReading: string[];
readSep: string;
colon: string;
dayOfYear: string;
ofYear: string;
tone: string;
gregorianDate: string;
churchDate: string;
annoMundi: string;
yearOfWorld: string;
fromYear: string;
fromAdam: string;
};
// Services
services: {
title: string;
selectService: string;
serviceProducedNoOutput: string;
errorLoading: string;
nodesAssembled: string;
greatLent: string;
brightWeek: string;
apostlesFast: string;
dormitionFast: string;
nativityFast: string;
tone: string;
roleLabel: string;
rolePriest: string;
roleReader: string;
roleAuto: string;
serviceNames: {
primes: string;
third: string;
sixth: string;
ninth: string;
vespers: string;
liturgy: string;
royalhours: string;
compline: string;
};
serviceDescriptions: {
primes: string;
third: string;
sixth: string;
ninth: string;
vespers: string;
liturgy: string;
royalhours: string;
compline: string;
};
};
// Bible
bible: {
selectBook: string;
selectChapter: string;
read: string;
loading: string;
noTextFound: string;
notAvailableOffline: string;
ensureDataConverted: string;
};
// Settings
settings: {
title: string;
language: string;
fontSettings: string;
usePonomarFont: string;
usePonomarFontDesc: string;
fontPreview: string;
fontPreviewNoteActive: string;
fontPreviewNoteInactive: string;
fontSize: string;
systemFont: string;
bibleSettings: string;
defaultTranslation: string;
showVerseNumbers: string;
verseNewLine: string;
bibleComments: {
fullBible: string;
ntOnly: string;
otOnly: string;
otProphets: string;
};
calendarType: string;
julian: string;
gregorian: string;
about: string;
aboutText: string;
aboutLicense: string;
installPwa: string;
};
// Common
common: {
priest: string;
reader: string;
deacon: string;
choir: string;
all: string;
};
}
const translations: Record<LanguageCode, Translations> = {
en: {
appName: '☦ Ponomar',
loading: 'Loading...',
error: 'Error',
notAvailable: 'Not available',
nav: {
calendar: '📅 Calendar',
service: '📖 Service',
bible: '☦ Bible',
settings: '⚙ Settings',
},
calendar: {
months: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
monthsGenitive: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
dayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
dayNamesFull: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
rankLabels: {
1: 'Troparion',
2: 'Antiphon',
3: 'Hexahymn',
4: 'Doxology',
5: 'Polyeleos',
6: 'Great Saint',
7: 'Great Feast of the Theotokos',
8: 'Great Feast of the Lord',
},
rankIcons: {
2: '🕃',
3: '🕃',
4: '🕂',
5: '🕁',
6: '🕀',
},
pascha: 'Pascha: ',
pentecost: 'Pentecost: ',
cleanMonday: 'Clean Monday: ',
greatLent: ' · Great Lent',
brightWeek: ' · Bright Week',
weekAfterPentecost: 'week after Pentecost',
weekOrdSuffix: 'th',
apostlesFast: " · Apostles' Fast",
dormitionFast: ' · Dormition Fast',
nativityFast: ' · Nativity Fast',
commemorations: 'Commemorations',
fastingRule: 'Fasting Rule',
readings: 'Readings',
noCommemorations: 'No commemorations for this day',
noReadings: 'No readings assigned',
readingTypes: {
apostol: 'Apostol',
gospel: 'Gospel',
matins: 'Matins',
vespers: 'Vespers',
primes: '1st Hour',
terce: '3rd Hour',
sext: '6th Hour',
none: '9th Hour',
liturgy: 'Liturgy',
'0': 'Vespers',
'1': 'Reading',
'2': 'Reading',
'3': 'Reading',
'4': '4th Reading',
'5': '5th Reading',
'6': '6th Reading',
'7': '7th Reading',
'8': '8th Reading',
'9': '9th Reading',
'10': '10th Reading',
'11': '11th Reading',
'12': '12th Reading',
'13': '13th Reading',
'14': '14th Reading',
'15': '15th Reading',
A: 'Reading',
},
defaultBibleVersion: 'en/bible/kjv',
dayReading: ['for Sunday', 'for Monday', 'for Tuesday', 'for Wednesday', 'for Thursday', 'for Friday', 'for Saturday'],
readSep: '; ',
colon: ': ',
dayOfYear: ' · Day ',
ofYear: ' of year',
tone: 'Tone ',
gregorianDate: 'Civil (Gregorian) Date',
churchDate: 'Church (Julian) Date',
annoMundi: 'Year of the World',
yearOfWorld: 'year of the world',
fromYear: 'from Christ',
fromAdam: 'from Adam',
},
services: {
title: 'Services',
selectService: 'Select a service to view its content.',
serviceProducedNoOutput: 'Service produced no output.',
errorLoading: 'Error loading service.',
nodesAssembled: 'nodes assembled.',
greatLent: ' · Great Lent',
brightWeek: ' · Bright Week',
apostlesFast: " · Apostles' Fast",
dormitionFast: ' · Dormition Fast',
nativityFast: ' · Nativity Fast',
tone: ' · Tone ',
roleLabel: 'Role:',
rolePriest: 'Priest',
roleReader: 'Reader',
roleAuto: 'Auto',
serviceNames: {
primes: 'First Hour',
third: 'Third Hour',
sixth: 'Sixth Hour',
ninth: 'Ninth Hour',
vespers: 'Vespers',
liturgy: 'Divine Liturgy',
royalhours: 'Royal Hours',
compline: 'Compline',
},
serviceDescriptions: {
primes: 'Prime (First Hour)',
third: 'Third Hour',
sixth: 'Sixth Hour',
ninth: 'Ninth Hour',
vespers: 'Evening service (Vespers)',
liturgy: 'Principal service (Divine Liturgy)',
royalhours: 'Royal Hours',
compline: 'Night office (Compline)',
},
},
bible: {
selectBook: 'Select a book',
selectChapter: 'Select a chapter',
read: 'Read',
loading: 'Loading...',
noTextFound: 'No text found for this passage.',
notAvailableOffline: 'Text not available for offline viewing.',
ensureDataConverted: 'Bible text files are lazy-loaded on demand. Please ensure the data has been converted and is available.',
},
settings: {
title: 'Settings',
language: 'Interface Language',
fontSettings: 'Church Slavonic Font',
usePonomarFont: 'Use Ponomar font for Church Slavonic texts',
usePonomarFontDesc: 'Use Ponomar font for Church Slavonic texts',
fontPreview: 'Preview:',
fontPreviewNoteActive: 'Ponomar font active — liturgical rank icons will render correctly',
fontPreviewNoteInactive: 'System font active — rank icons will use Ponomar font regardless',
fontSize: 'Liturgical Text Size: ',
systemFont: 'System Default',
bibleSettings: 'Bible',
defaultTranslation: 'Default Translation',
showVerseNumbers: 'Show verse numbers',
verseNewLine: 'Each verse on a new line',
bibleComments: {
fullBible: 'Full Bible',
ntOnly: 'NT only',
otOnly: 'OT only',
otProphets: 'OT Prophets',
},
calendarType: 'Default Calendar',
julian: 'Julian',
gregorian: 'Gregorian',
about: 'About Ponomar',
aboutText: 'Ponomar is an Orthodox Church calendar application providing liturgical information, service texts, and Bible readings in multiple languages.',
aboutLicense: 'Based on the Ponomar Java desktop application by Aleksandr Andreev and Yuri Shardt. Licensed under GPL v3.',
installPwa: 'Install Ponomar',
},
common: {
priest: 'Priest',
reader: 'Reader',
deacon: 'Deacon',
choir: 'Choir',
all: 'All',
},
},
ru: {
appName: '☦ Понома́рь',
loading: 'Загрузка...',
error: 'Ошибка',
notAvailable: 'Недоступно',
nav: {
calendar: '📅 Календарь',
service: '📖 Устав',
bible: '☦ Библия',
settings: '⚙ Настройки',
},
calendar: {
months: [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
],
monthsGenitive: [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
],
dayNames: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
dayNamesFull: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'],
rankLabels: {
1: 'Тропарь',
2: 'Антифон',
3: 'Шестипеснец',
4: 'Славословие',
5: 'Полиелей',
6: 'Великий святой',
7: 'Великий праздник Богородичный',
8: 'Великий праздник Господень',
},
rankIcons: {
2: '🕃',
3: '🕃',
4: '🕂',
5: '🕁',
6: '🕀',
},
pascha: 'Пасха: ',
pentecost: 'Пятидесятница: ',
cleanMonday: 'Чистый понедельник: ',
greatLent: ' · Великий пост',
brightWeek: ' · Светлая седмица',
weekAfterPentecost: 'седмица по Пятидесятнице',
weekOrdSuffix: 'я',
apostlesFast: ' · Петров пост',
dormitionFast: ' · Успенский пост',
nativityFast: ' · Рождественский пост',
commemorations: 'Память святых',
fastingRule: 'Правило поста',
readings: 'Чтения',
noCommemorations: 'Нет памятей на этот день',
noReadings: 'Чтения не назначены',
readingTypes: {
apostol: 'Апостол',
gospel: 'Евангелие',
matins: 'Утреня',
vespers: 'Вечерня',
primes: '1-й час',
terce: '3-й час',
sext: '6-й час',
none: '9-й час',
liturgy: 'Литургия',
'0': 'Вечерня',
'1': 'Вечерня',
'2': 'Вечерня',
'3': 'Вечерня',
'4': '4-е чтение',
'5': '5-е чтение',
'6': '6-е чтение',
'7': '7-е чтение',
'8': '8-е чтение',
'9': '9-е чтение',
'10': '10-е чтение',
'11': '11-е чтение',
'12': '12-е чтение',
'13': '13-е чтение',
'14': '14-е чтение',
'15': '15-е чтение',
A: 'Чтение',
},
defaultBibleVersion: 'ru/bible/synod',
dayReading: ['для воскресенья', 'для понедельника', 'для вторника', 'для среды', 'для четверга', 'для пятницы', 'для субботы'],
readSep: '; ',
colon: ': ',
dayOfYear: ' · День ',
ofYear: ' года',
tone: 'Глас ',
gregorianDate: 'По гражданскому стилю',
churchDate: 'По церковному стилю',
annoMundi: 'Лета от сотворения мира',
yearOfWorld: 'год от сотворения мира',
fromYear: 'от РХ',
fromAdam: 'от Адама',
},
services: {
title: 'Богослужения',
selectService: 'Выберите богослужение для просмотра.',
serviceProducedNoOutput: 'Богослужение не дало текста.',
errorLoading: 'Ошибка загрузки богослужения.',
nodesAssembled: 'узлов собрано.',
greatLent: ' · Великий пост',
brightWeek: ' · Светлая седмица',
apostlesFast: ' · Петров пост',
dormitionFast: ' · Успенский пост',
nativityFast: ' · Рождественский пост',
tone: ' · Глас ',
roleLabel: 'Чин:',
rolePriest: 'Священник',
roleReader: 'Чтец',
roleAuto: 'Авто',
serviceNames: {
primes: 'Первый час',
third: 'Третий час',
sixth: 'Шестой час',
ninth: 'Девятый час',
vespers: 'Вечерня',
liturgy: 'Божественная Литургия',
royalhours: 'Царские часы',
compline: 'Повечерие',
},
serviceDescriptions: {
primes: 'Час первый',
third: 'Час третий',
sixth: 'Час шестой',
ninth: 'Час девятый',
vespers: 'Вечернее богослужение (Вечерня)',
liturgy: 'Главное богослужение (Божественная Литургия)',
royalhours: 'Царские часы',
compline: 'Ночное богослужение (Повечерие)',
},
},
bible: {
selectBook: 'Выберите книгу',
selectChapter: 'Выберите главу',
read: 'Читать',
loading: 'Загрузка...',
noTextFound: 'Текст не найден для этого отрывка.',
notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.',
ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.',
},
settings: {
title: 'Настройки',
language: 'Язык интерфейса',
fontSettings: 'Шрифт церковнославянского',
usePonomarFont: 'Использовать шрифт Понома́рь для церковнославянских текстов',
usePonomarFontDesc: 'Использовать шрифт Понома́рь для церковнославянских текстов',
fontPreview: 'Предпросмотр:',
fontPreviewNoteActive: 'Шрифт Понома́рь активен — иконы чинов будут отображаться правильно',
fontPreviewNoteInactive: 'Системный шрифт активен — иконы чинов будут использовать Понома́рь в любом случае',
fontSize: 'Размер литургического текста: ',
systemFont: 'Системный шрифт',
bibleSettings: 'Библия',
defaultTranslation: 'Перевод по умолчанию',
showVerseNumbers: 'Показывать номера стихов',
verseNewLine: 'Каждый стих с новой строки',
bibleComments: {
fullBible: 'Полная Библия',
ntOnly: 'Новый Завет',
otOnly: 'Ветхий Завет',
otProphets: 'Пророки',
},
calendarType: 'Календарь по умолчанию',
julian: 'Юлианский',
gregorian: 'Григорианский',
about: 'О Понома́ре',
aboutText: 'Понома́рь — приложение православного календаря, предоставляющее литургическую информацию, тексты служб и библейские чтения на нескольких языках.',
aboutLicense: 'Основано на Java-приложении Понома́рь от Александра Андреева и Юрия Шардт. Лицензия GPL v3.',
installPwa: 'Установить Пономарь',
},
common: {
priest: 'Свящ.',
reader: 'Чтец',
deacon: 'Диакон',
choir: 'Хор',
all: 'Все',
},
},
cu: {
appName: '☦ Понома́рь',
loading: 'Загрузка...',
error: 'Ошибка',
notAvailable: 'Недоступно',
nav: {
calendar: '📅 Календарь',
service: '📖 Уставъ',
bible: '☦ Библия',
settings: '⚙ Настройки',
},
calendar: {
months: [
'Январь', 'Февраль', 'Мартъ', 'Апрель', 'Май', 'Июнь',
'Июль', 'Августъ', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
],
monthsGenitive: [
'января', 'февраля', 'марта', 'апрѣля', 'мая', 'іюня',
'іюля', 'августа', 'септемврія', 'октоврія', 'ноемврія', 'декаврія'
],
dayNames: ['Нед', 'Пон', 'Вто', 'Сре', 'Чет', 'Пят', 'Суб'],
dayNamesFull: ['Неделя', 'Понедельникъ', 'Вторникъ', 'Среда', 'Четвергъ', 'Пятница', 'Суббота'],
rankLabels: {
1: 'Тропарь',
2: 'Антифонъ',
3: 'Шестопѣснецъ',
4: 'Славословіе',
5: 'Полиелей',
6: 'Великій святой',
7: 'Великій праздникъ Богородичный',
8: 'Великій праздникъ Господень',
},
rankIcons: {
2: '🕃',
3: '🕃',
4: '🕂',
5: '🕁',
6: '🕀',
},
pascha: 'Пасха: ',
pentecost: 'Пятидесятница: ',
cleanMonday: 'Чистый понедельникъ: ',
greatLent: ' · Великій постъ',
brightWeek: ' · Светлая седмица',
weekAfterPentecost: 'седмица по Пѧтидесѧтницѣ',
weekOrdSuffix: 'ѧ',
apostlesFast: ' · Петровъ постъ',
dormitionFast: ' · Успенскій постъ',
nativityFast: ' · Рождественскій постъ',
commemorations: 'Память святыхъ',
fastingRule: 'Правило поста',
readings: 'Чтения',
noCommemorations: 'Нетъ памятей на сей день',
noReadings: 'Чтения не назначаются',
readingTypes: {
apostol: 'А҆пⷭ҇лъ',
gospel: 'Є҆ѵⷢ҇лїе',
matins: 'Оу҆́тренѧ',
vespers: 'Вече́рнѧ',
primes: 'Ча́съ пе́рвый',
terce: 'Ча́съ тре́тїй',
sext: 'Ча́съ шесты́й',
none: 'Ча́съ девѧ́тый',
liturgy: 'Лїтꙋргі́ѧ',
'0': 'Вече́рнѧ',
'1': 'Вече́рнѧ',
'2': 'Вече́рнѧ',
'3': 'Вече́рнѧ',
'4': '4-е чте́ніе',
'5': '5-е чте́ніе',
'6': '6-е чте́ніе',
'7': '7-е чте́ніе',
'8': '8-е чте́ніе',
'9': '9-е чте́ніе',
'10': '10-е чте́ніе',
'11': '11-е чте́ніе',
'12': '12-е чте́ніе',
'13': '13-е чте́ніе',
'14': '14-е чте́ніе',
'15': '15-е чте́ніе',
A: 'Чте́ніе',
},
defaultBibleVersion: 'cu/bible/elis',
dayReading: ['длѧ̀ недѣ́ли', 'длѧ̀ понедѣ́льника', 'длѧ̀ вто́рника', 'длѧ̀ сре́ды', 'длѧ̀ четвертка̀', 'длѧ̀ пѧткѝ', 'длѧ̀ сꙋббѡ́ты'],
readSep: ': ',
colon: ': ',
dayOfYear: ' · День ',
ofYear: ' года',
tone: 'Гласъ ',
gregorianDate: 'По гражданскому стилю',
churchDate: 'По церковному стилю',
annoMundi: 'Лѣта отъ сотворенiя мiра',
yearOfWorld: 'лѣта отъ сотворенiя мiра',
fromYear: 'от РХ',
fromAdam: 'от Адама',
},
services: {
title: 'Богослуженiя',
selectService: 'Изберете богослуженiе для просмотра.',
serviceProducedNoOutput: 'Богослуженiе не дало текста.',
errorLoading: 'Ошибка загрузки богослуженiя.',
nodesAssembled: 'узлов собрано.',
greatLent: ' · Великій постъ',
brightWeek: ' · Светлая седмица',
apostlesFast: ' · Петровъ постъ',
dormitionFast: ' · Успенскій постъ',
nativityFast: ' · Рождественскій постъ',
tone: ' · Гласъ ',
roleLabel: 'Чинъ:',
rolePriest: 'Священникъ',
roleReader: 'Чтецъ',
roleAuto: 'Авто',
serviceNames: {
primes: 'Первый часъ',
third: 'Третий часъ',
sixth: 'Шестой часъ',
ninth: 'Девятый часъ',
vespers: 'Вечерня',
liturgy: 'Божественная Литургия',
royalhours: 'Царскія часъ',
compline: 'Повечерiе',
},
serviceDescriptions: {
primes: 'Часъ первый',
third: 'Часъ третий',
sixth: 'Часъ шестой',
ninth: 'Часъ девятый',
vespers: 'Вечернее богослуженiе (Вечерня)',
liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)',
royalhours: 'Царскія часъ',
compline: 'Ночное богослуженiе (Повечерiе)',
},
},
bible: {
selectBook: 'Изберете книгу',
selectChapter: 'Изберете главу',
read: 'Читати',
loading: 'Загрузка...',
noTextFound: 'Текстъ не найденъ для сего отрывка.',
notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.',
ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.',
},
settings: {
title: 'Настройки',
language: 'Языкъ интерфейса',
fontSettings: 'Шрифтъ церковнославянскаго',
usePonomarFont: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ',
usePonomarFontDesc: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ',
fontPreview: 'Предпросмотръ:',
fontPreviewNoteActive: 'Шрифтъ Понома́рь активенъ — иконы чинѣвъ будутъ отображатися правильно',
fontPreviewNoteInactive: 'Системный шрифтъ активенъ — иконы чинѣвъ будутъ использовать Понома́рь въ любомъ случаи',
fontSize: 'Размеръ литургическаго текста: ',
systemFont: 'Системный шрифтъ',
bibleSettings: 'Библия',
defaultTranslation: 'Переводъ по умолчанiю',
showVerseNumbers: 'Показывать номера стиховъ',
verseNewLine: 'Каждый стихъ съ новыя строки',
bibleComments: {
fullBible: 'Полная Библія',
ntOnly: 'Новый Завѣтъ',
otOnly: 'Ветхій Завѣтъ',
otProphets: 'Пророцы',
},
calendarType: 'Календарь по умолчанию',
julian: 'Юліанскій',
gregorian: 'Григоріанскій',
about: 'О Понома́ри',
aboutText: 'Понома́рь — приложенiе православнаго календаря, предоставляющее литургическiю информацию, тексты службъ и библейскія чтения на нескольких языкахъ.',
aboutLicense: 'Основано на Java-приложенiи Понома́рь отъ Александра Андреева и Юрия Шардт. Лицензия GPL v3.',
installPwa: 'Оустановити Понома́рь',
},
common: {
priest: 'Свящ.',
reader: 'Чтецъ',
deacon: 'Диаконъ',
choir: 'Хоръ',
all: 'Все',
},
},
};
/**
* Get translations for a specific language.
* Falls back to English if language not found.
*/
export function getTranslations(lang: LanguageCode): Translations {
return translations[lang] || translations.en;
}
/**
* Get a specific translation key with fallback.
*/
export function t(lang: LanguageCode, key: string, ...args: string[]): string {
const tr = getTranslations(lang);
const keys = key.split('.');
let value: unknown = tr;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = (value as Record<string, unknown>)[k];
} else {
// Fallback to English
value = translations.en;
for (const k2 of keys) {
if (value && typeof value === 'object' && k2 in value) {
value = (value as Record<string, unknown>)[k2];
} else {
return key; // Return key if not found
}
}
break;
}
}
if (typeof value === 'string') {
return args.length > 0 ? value.replace(/{(\d+)}/g, (_, i) => args[i] ?? '') : value;
}
return key;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* JDate — Julian calendar date engine.
* Faithful port of Ponomar/JDate.java to TypeScript.
* All dates are Julian calendar. Internally stored as Julian Day Number (JDN).
*
* JDate.java is part of the Ponomar program.
* Copyright 2006, 2007 Aleksandr Andreev.
* GPL v3 — see LICENSE.
*/
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const DAYS_IN_MONTH_LEAP = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/**
* 1-based modulo. If result is 0, returns divisor.
* Matches Java JDate.mod() exactly.
*/
function mod1(divisor: number, modulo: number): number {
let temp = divisor % modulo;
if (temp === 0) temp = modulo;
return temp;
}
export class JDate {
readonly jdn: number;
/** Create from Julian month/day/year. */
constructor(monthOrJdn: number, day?: number, year?: number) {
if (day === undefined && year === undefined) {
// Constructor from JDN
this.jdn = monthOrJdn;
return;
}
const month = monthOrJdn;
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
if (day! < 0) throw new Error(`Invalid day: ${day}`);
const leap = year! % 4 === 0;
const maxDays = leap ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1];
if (day! > maxDays) throw new Error(`Invalid day ${day} for month ${month} (year ${year})`);
// Fliegel-Van Flandern formula (Julian calendar)
const a = Math.floor((14 - month) / 12);
const y = year! + 4800 - a;
const m = month + 12 * a - 3;
this.jdn = day! + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083;
}
/** Create from JavaScript Date (Gregorian). Converts to Julian calendar. */
static fromGregorian(date: Date): JDate {
return JDate.fromGregorianParts(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
/** Create from Gregorian year/month/day. */
static fromGregorianParts(gYear: number, gMonth: number, gDay: number): JDate {
const a = Math.floor((14 - gMonth) / 12);
const y = gYear + 4800 - a;
const m = gMonth + 12 * a - 3;
const jd = gDay + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045;
return new JDate(jd);
}
/** Create from today's date. */
static today(): JDate {
return JDate.fromGregorian(new Date());
}
/** Get year (Julian calendar). */
getYear(): number {
const jbar = this.jdn + 32083;
const n1 = Math.floor(jbar / 1461) * 4;
const n2 = Math.floor((jbar % 1461) / 365);
const da = jbar % 1461;
const adj = (da % 365) > 306 ? 1 : 0;
return Math.floor(n1 + n2) - 4800 + adj;
}
/** Get month (1=Jan ... 12=Dec, Julian calendar). */
getMonth(): number {
const jbar = this.jdn + 32083;
const da = jbar % 1461;
let m = mod1(da, 365);
let j = 2;
while (m > DAYS_IN_MONTH_LEAP[j]) {
m -= DAYS_IN_MONTH_LEAP[j];
j++;
if (j === 12) j = 0;
}
return j + 1;
}
/** Get day of month (Julian calendar). */
getDay(): number {
const jbar = this.jdn + 32083;
const da = mod1(jbar, 1461);
if (da === 1461) return 29; // Feb 29
let m = mod1(da, 365);
let k = 2;
while (m > DAYS_IN_MONTH[k]) {
m -= DAYS_IN_MONTH[k];
k++;
if (k === 12) k = 0;
}
return m;
}
/** Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday. */
getDayOfWeek(): number {
let temp = (this.jdn % 7) + 1;
if (temp === 7) temp = 0;
return temp;
}
/** Day of year: 0=Jan 1, ..., 364/365=Dec 31. */
getDoy(): number {
const jbar = this.jdn + 32083;
const da = jbar % 1461;
return da === 1461 ? 366 : mod1(da + 59, 365) - 1;
}
/** Convert to JavaScript Date (Gregorian calendar). */
toGregorian(): Date {
let j1: number;
if (this.jdn >= 2299160.5) {
const tmp = Math.floor(((this.jdn - 1867216.0) - 0.25) / 36524.25);
j1 = this.jdn + 1 + tmp - Math.floor(0.25 * tmp);
} else {
j1 = this.jdn;
}
const j2 = j1 + 1524.0;
const j3 = Math.floor(6680.0 + ((j2 - 2439870.0) - 122.1) / 365.25);
const j4 = Math.floor(j3 * 365.25);
const j5 = Math.floor((j2 - j4) / 30.6001);
let d = Math.floor(j2 - j4 - Math.floor(j5 * 30.6001));
let m = Math.floor(j5 - 1.0);
if (m > 12) m -= 12;
let y = Math.floor(j3 - 4715.0);
if (m > 2) --y;
if (y <= 0) --y;
return new Date(y, m - 1, d);
}
/** Days to add to get to another date. Positive if this > other. */
difference(other: JDate): number {
return this.jdn - other.jdn;
}
/** Add n days, returning a new JDate. */
addDays(n: number): JDate {
return new JDate(this.jdn + n);
}
/** Subtract n days, returning a new JDate. */
subtractDays(n: number): JDate {
return new JDate(this.jdn - n);
}
/** Days in month for this date's month/year. */
static maxDaysInMonth(month: number, year: number): number {
return (year % 4 === 0) ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1];
}
equals(other: JDate): boolean {
return this.jdn === other.jdn;
}
compareTo(other: JDate): number {
return this.jdn - other.jdn;
}
toString(): string {
const y = this.getYear();
const m = this.getMonth();
const d = this.getDay();
return `${m < 10 ? '0' + m : m}/${d < 10 ? '0' + d : d}/${y}`;
}
/** Clone — returns a new JDate with the same JDN. */
clone(): JDate {
return new JDate(this.jdn);
}
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Paschalion — Orthodox Church paschal and feast day computations.
* Faithful port of Ponomar/Paschalion.java to TypeScript.
*
* Paschalion.java is part of the Ponomar program.
* Copyright 2006, 2007 Aleksandr Andreev.
* GPL v3 — see LICENSE.
*/
import { JDate } from './jdate';
const LUNAR_MONTH = 29.52916667;
/** Age of the moon on March 1 for each year in the 19-year Metonic cycle (hours since midnight). */
const FOUNDATION = [
14.042016807, 25.462184874, 6.084033613, 17.966386555, 28.336134454,
9.210084034, 20.504201681, 1.420168067, 12.294117647, 23.168067227,
4.546218487, 15.042016807, 26.294117647, 7.630252101, 18.546218487,
29.420168067, 11.756302521, 26.210084034, 3.042016807,
];
/** 1-based modulo. If result is 0, returns divisor. */
function mod1(divisor: number, modulo: number): number {
let temp = divisor % modulo;
if (temp === 0) temp = modulo;
return temp;
}
/** Modulo for decimal numbers. */
function modF(divisor: number, modulo: number): number {
return divisor - Math.floor(divisor / modulo) * modulo;
}
export const Paschalion = {
/**
* Get the date of Julian Pascha (Easter) for a given year.
* Uses Gaussian Easter formulae.
*/
getPascha(year: number): JDate {
if (year < 33) throw new Error('Invalid year');
const a = year % 4;
const b = year % 7;
const c = year % 19;
const d = (19 * c + 15) % 30;
const e = (2 * a + 4 * b - d + 34) % 7;
const month = Math.floor((d + e + 114) / 31); // 3=March, 4=April
const day = ((d + e + 114) % 31) + 1;
return new JDate(month, day, year);
},
/** Pentecost: Pascha + 49 days. */
getPentecost(year: number): JDate {
return this.getPascha(year).addDays(49);
},
/** Clean Monday (Great Lent starts): Pascha - 48 days. */
getLentStart(year: number): JDate {
return this.getPascha(year).subtractDays(48);
},
/** Apostles' Fast starts: Pascha + 57 days (Monday after All Saints). */
getApostlesFastStart(year: number): JDate {
return this.getPascha(year).addDays(57);
},
/** Length of Apostles' Fast in days. Ends June 28 (Julian). */
getApostlesFastLength(year: number): number {
const start = this.getApostlesFastStart(year);
const end = new JDate(6, 28, year);
return end.difference(start);
},
/** Key of Boundaries (Ключ границ) — letter of the Paschal boundary. 1-30. */
getKeyOfBoundaries(year: number): number {
const date = this.getPascha(year);
const m = date.getMonth();
let g = date.getDay();
if (m === 3) {
g -= 21;
} else {
g += 10;
}
return g;
},
/** Indiction cycle. */
getIndiction(year: number): number {
if (year < 33) throw new Error('Invalid year');
return mod1(year - 312, 15);
},
/** Solar cycle. */
getSolarCycle(year: number): number {
if (year < 33) throw new Error('Invalid year');
return mod1(year + 5508, 28);
},
/** Lunar cycle (1-19). */
getLunarCycle(year: number): number {
if (year < 33) throw new Error('Invalid year');
let temp = (year + 1) % 19 - 3;
if (temp <= 0) temp += 19;
return temp;
},
/** Lunar phase for a date: 0=new moon, 0.5=full moon. */
getLunarPhase(date: JDate): number {
const year = date.getYear();
const cycle = this.getLunarCycle(year);
let diff = date.difference(new JDate(3, 1, year));
if (diff < 0) {
diff = date.difference(new JDate(3, 1, year - 1));
}
let remainder = modF(diff, LUNAR_MONTH);
remainder += FOUNDATION[cycle - 1];
while (remainder >= LUNAR_MONTH) {
remainder -= LUNAR_MONTH;
}
return remainder / LUNAR_MONTH;
},
/**
* Annual fasting array: 0=no fast, 1=fast day, 2=cheesefare.
* One entry per day of the year (index 0=Jan 1).
*/
getFasts(year: number): number[] {
if (year < 33) throw new Error('Invalid year');
const pascha = this.getPascha(year);
const numDays = (year % 4 === 0) ? 366 : 365;
// Mandatory fast days (JDN → reason string)
const mustFast = new Set<number>();
mustFast.add(new JDate(1, 5, year).jdn);
mustFast.add(new JDate(8, 29, year).jdn);
mustFast.add(new JDate(9, 14, year).jdn);
// Mandatory fast-free days
const cantFast = new Set<number>();
cantFast.add(new JDate(1, 6, year).jdn);
// Period boundaries
const SVIATKI_START = new JDate(12, 25, year);
const SVIATKI_END = new JDate(1, 4, year);
const PUB_PHAR_START = pascha.subtractDays(70);
const PUB_PHAR_END = pascha.subtractDays(63);
const CHEESE_START = pascha.subtractDays(55);
const CHEESE_END = pascha.subtractDays(49);
const LENT_START = pascha.subtractDays(48);
const LENT_END = pascha.subtractDays(1);
const BRIGHT_START = pascha;
const BRIGHT_END = pascha.addDays(6);
const PENT_START = pascha.addDays(49);
const PENT_END = pascha.addDays(56);
const APOSTLES_START = pascha.addDays(57);
const APOSTLES_END = new JDate(6, 28, year);
const DORM_START = new JDate(8, 1, year);
const DORM_END = new JDate(8, 14, year);
const ADVENT_START = new JDate(11, 15, year);
const ADVENT_END = new JDate(12, 24, year);
const retval = new Array<number>(numDays);
let dummy = new JDate(1, 1, year);
for (let i = 0; i < numDays; i++) {
let fast = 0;
const jdn = dummy.jdn;
if (mustFast.has(jdn)) {
fast = 1;
} else if (cantFast.has(jdn)) {
fast = 0;
} else if (dummy.compareTo(SVIATKI_START) >= 0 || dummy.compareTo(SVIATKI_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(PUB_PHAR_START) >= 0 && dummy.compareTo(PUB_PHAR_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(CHEESE_START) >= 0 && dummy.compareTo(CHEESE_END) <= 0) {
fast = 2;
} else if (dummy.compareTo(LENT_START) >= 0 && dummy.compareTo(LENT_END) <= 0) {
fast = 1;
} else if (dummy.compareTo(BRIGHT_START) >= 0 && dummy.compareTo(BRIGHT_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(PENT_START) >= 0 && dummy.compareTo(PENT_END) <= 0) {
fast = 0;
} else if (dummy.compareTo(APOSTLES_START) >= 0 && dummy.compareTo(APOSTLES_END) <= 0) {
fast = 1;
} else if (dummy.compareTo(DORM_START) >= 0 && dummy.compareTo(DORM_END) <= 0) {
fast = 1;
} else if (dummy.compareTo(ADVENT_START) >= 0 && dummy.compareTo(ADVENT_END) <= 0) {
fast = 1;
} else {
const dow = dummy.getDayOfWeek();
if (dow === 3 || dow === 5) fast = 1;
}
retval[i] = fast;
dummy = dummy.addDays(1);
}
return retval;
},
/**
* Major feasts for the year.
* Returns Map< JDN, feastName >.
*/
getFeasts(year: number): Map<number, string> {
if (year < 33) throw new Error('Invalid year');
const feastNames = [
'New Year', 'Theophany', 'Nativity of the Forerunner',
'Peter and Paul', 'Transfiguration', 'Dormition',
'Beheading of the Forerunner', 'Nativity of the Theotokos',
'Exaltation of the Cross', 'Protection of the Theotokos',
'Entrance of the Theotokos', 'Nativity of Christ',
'Pascha and Annunciation', 'Pascha', 'Annunciation',
'Pentecost', 'Ascension', 'Palm Sunday', 'Meeting of the Lord',
];
const feasts = new Map<number, string>();
// Fixed feasts
feasts.set(new JDate(1, 1, year).jdn, feastNames[0]);
feasts.set(new JDate(1, 6, year).jdn, feastNames[1]);
feasts.set(new JDate(6, 24, year).jdn, feastNames[2]);
feasts.set(new JDate(6, 29, year).jdn, feastNames[3]);
feasts.set(new JDate(8, 6, year).jdn, feastNames[4]);
feasts.set(new JDate(8, 15, year).jdn, feastNames[5]);
feasts.set(new JDate(8, 29, year).jdn, feastNames[6]);
feasts.set(new JDate(9, 8, year).jdn, feastNames[7]);
feasts.set(new JDate(9, 14, year).jdn, feastNames[8]);
feasts.set(new JDate(10, 1, year).jdn, feastNames[9]);
feasts.set(new JDate(11, 21, year).jdn, feastNames[10]);
feasts.set(new JDate(12, 25, year).jdn, feastNames[11]);
// Moveable feasts
const pascha = this.getPascha(year);
if (pascha.equals(new JDate(3, 25, year))) {
feasts.set(pascha.jdn, feastNames[12]);
} else {
feasts.set(pascha.jdn, feastNames[13]);
feasts.set(new JDate(3, 25, year).jdn, feastNames[14]);
}
feasts.set(pascha.addDays(49).jdn, feastNames[15]);
feasts.set(pascha.addDays(39).jdn, feastNames[16]);
feasts.set(pascha.subtractDays(7).jdn, feastNames[17]);
// Meeting of the Lord — Feb 2, but transferred to Forgiveness Sunday if it falls on Clean Monday
let meeting = new JDate(2, 2, year);
if (pascha.difference(meeting) === 48) {
meeting = meeting.subtractDays(1);
}
feasts.set(meeting.jdn, feastNames[18]);
return feasts;
},
};
+597
View File
@@ -0,0 +1,597 @@
/**
* Service Assembler — recursive template processor for liturgical services.
* Processes service XML templates (pre-converted to JSON) and renders HTML.
*
* Service.java is part of the Ponomar program.
* Copyright 2008, 2009, 2011 Yuri Shardt.
* See LICENSE for redistribution terms.
*/
import { evalBool } from './evaluator';
import type { EvalContext, ServiceNode } from './types';
export interface CommonTranslations {
priest: string;
reader: string;
deacon: string;
choir: string;
}
export interface ServiceContext {
evalCtx: EvalContext;
lang: string;
fetchText: (path: string) => Promise<string | null>;
fetchServiceNodes: (path: string) => Promise<ServiceNode[] | null>;
fetchBibleText: (book: string, passage: string, showVerseNumbers?: boolean, verseNewLine?: boolean) => Promise<string>;
fetchLives: (id: string) => Promise<Record<string, Record<string, Record<string, unknown>>> | null>;
fetchCommandText: (name: string) => Promise<string>;
resolveTimes: (times: number) => Promise<string>;
t?: { common: CommonTranslations };
}
function localizeWho(who: string, ctx: ServiceContext): string {
const t = ctx.t?.common;
if (!t) return who;
const map: Record<string, string | undefined> = {
P: t.priest,
Priest: t.priest,
R: t.reader,
Reader: t.reader,
SR: t.reader,
D: t.deacon,
Deacon: t.deacon,
C: t.choir,
Choir: t.choir,
};
return map[who] ?? who;
}
// Localized reading intro names (getreading case)
// Values from Java bible.xml Intro field
const READING_NAMES: Record<string, Record<string, string>> = {
en: {
Isa: 'A reading from the Prophecy of Isaiah',
Mica: 'A reading from the Prophecy of Micah',
Zech: 'A reading from the Prophecy of Zechariah',
Jerem: 'A reading from the Prophecy of Jeremiah',
Baruch: 'A reading from the Prophecy of Jeremiah',
Mal: 'A reading from the Prophecy of Malachi',
Zeph: 'A reading from the Prophecy of Zephaniah',
Mt: 'A reading from the Holy Gospel according to Matthew',
Mk: 'A reading from the Holy Gospel according to Mark',
Lk: 'A reading from the Holy Gospel according to Luke',
Jn: 'A reading from the Holy Gospel according to John',
Acts: 'A reading from the Acts of the Apostles',
Rom: 'A reading from the Epistle of Paul to the Romans',
Gal: 'A reading from the Epistle of Paul to the Galatians',
Tit: 'A reading from the Epistle to Titus',
Heb: 'A reading from the Epistle to the Hebrews',
},
cu: {
Isa: 'Прⷪ҇ро́чества и҆са́їина чте́нїе',
Mica: 'Прⷪ҇ро́чества мїхе́ина чте́нїе',
Zech: 'Прⷪ҇ро́чества заха́рїина чте́нїе',
Jerem: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе',
Baruch: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе',
Mal: 'Прⷪ҇ро́чества малахі́ина чте́нїе',
Zeph: 'Прⷪ҇ро́чества софо́нїева чте́нїе',
Mt: 'Ѿ матѳе́а ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Mk: 'Ѿ ма́рка ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Lk: 'Ѿ лꙋкѝ ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Jn: 'Ѿ і҆ѡа́нна ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
Acts: 'Дѣѧ̑нїй ст҃ы́хъ а҆пⷭ҇лъ чте́нїе',
Rom: 'Къ ри́млѧнѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
Gal: 'Къ гала́тѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
Tit: 'Къ ті́тꙋ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
Heb: 'Ко є҆вре́ємъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
},
fr: {
Isa: 'Une lection d\'Ésaïe',
Mica: 'Une lection de la prophétie de Michée',
Zech: 'Une lection de la prophétie de Zacharie',
Jerem: 'Une lection de la prophétie de Jérémie',
Baruch: 'Une lection de la prophétie de Jérémie',
Mal: 'Une lection de la prophétie de Malachie',
Zeph: 'Une lection de la prophétie de Sophonie',
Mt: 'Une lection du Saint Évangile selon Saint Matthieu',
Mk: 'Une lection du Saint Évangile selon Saint Marc',
Lk: 'Une lection du Saint Évangile selon Saint Luc',
Jn: 'Une lection du Saint Évangile selon Saint Jean',
Acts: 'Une lection des Actes des Apôtres',
Rom: 'Une lection de l\'Épître de Paul aux Romains',
Gal: 'Une lection de l\'Épître de Paul aux Galates',
Tit: 'Une lection de l\'Épître de Paul au Tite',
Heb: 'Une lection de l\'Épître de Paul aux Hébreux',
},
el: {
Isa: 'Προφητείας Ἡσαΐου τὸ Ἀνάγνωσμα',
Mica: 'Προφητείας Μιχαίου τὸ Ἀνάγνωσμα',
Zech: 'Προφητείας Ζαχαρίου τὸ Ἀνάγνωσμα',
Jerem: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα',
Baruch: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα',
Mal: 'Προφητείας Μαλαχίου τὸ ἀνάγνωσμα',
Zeph: 'Προφητείας Σοφονίου τὸ Ἀνάγνωσμα',
Mt: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ματθαῖον',
Mk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Μᾶρκον',
Lk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Λουκᾶν',
Jn: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ἰωάννην',
Acts: 'Πράξεων τῶν Ἀποστολων',
Rom: 'Πρὸς Ῥωμαίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
Gal: 'Πρὸς Γαλάτας ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
Tit: 'Πρὸς Τίτον ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
Heb: 'Πρὸς Ἑβραίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
},
};
// Localized book names for passage headers
const HEADER_NAMES: Record<string, Record<string, string>> = {
en: {
Psalm: 'Psalm', Isa: 'Isaiah', Jr: 'Jeremiah', Jerem: 'Jeremiah',
Ez: 'Ezekiel', Dn: 'Daniel', Mic: 'Micah', Mica: 'Micah',
Na: 'Nahum', Hab: 'Habakkuk', Zeph: 'Zephaniah', Hag: 'Haggai',
Zech: 'Zechariah', Mal: 'Malachi', Mt: 'Matthew', Mk: 'Mark',
Lk: 'Luke', Jn: 'John', Rom: 'Romans', Heb: 'Hebrews',
Gal: 'Galatians', Acts: 'Acts', Baruch: 'Baruch',
},
cu: {
Psalm: 'Ѱалти́рь', Isa: 'И҆са́їи', Jr: 'І҆еремі́и', Jerem: 'І҆еремі́и',
Ez: 'І҆езекі́ль', Dn: 'Дані́илъ', Mic: 'Мїхе́й', Mica: 'Мїхе́й',
Na: 'Наꙋ́мъ', Hab: 'Аввакꙋ́мъ', Zeph: 'Софо́нїа', Hag: 'Аггѣ́й',
Zech: 'Заха́рїа', Mal: 'Малахі́а', Mt: 'Ѿ Матѳе́а', Mk: 'Ѿ Ма́рка',
Lk: 'Ѿ лꙋкѝ', Jn: 'Ѿ і҆ѡа́нна', Rom: 'Къ ри́млѧнѡмъ', Heb: 'Ко є҆вре́ємъ',
Gal: 'Къ гала́тѡмъ', Acts: 'Дѣѧ̑нїѧ ст҃ы́хъ а҆пⷭ҇лъ', Baruch: 'Варꙋ́хъ',
},
fr: {
Psalm: 'Psaumes', Isa: 'Isaïe / Ésaïe', Jr: 'Jérémie', Jerem: 'Jérémie',
Ez: 'Ézéchiel', Dn: 'Daniel', Mic: 'Michée', Mica: 'Michée',
Na: 'Nahum', Hab: 'Habacuc', Zeph: 'Sophonie', Hag: 'Aggée',
Zech: 'Zacharie', Mal: 'Malachie', Mt: 'Matthieu', Mk: 'Marc',
Lk: 'Luc', Jn: 'Jean', Rom: 'Romains', Heb: 'Hébreux',
Gal: 'Galates', Acts: 'Actes des Apôtres', Baruch: 'Baruch',
},
el: {
Psalm: 'Ψαλμοί', Isa: 'Ἡσαΐας', Jr: 'Ἱερεμίας', Jerem: 'Ἱερεμίας',
Ez: 'Ἰεζεκιήλ', Dn: 'Δανιήλ', Mic: 'Μιχαίας', Mica: 'Μιχαίας',
Na: 'Ναούμ', Hab: 'Ἀμβακούμ', Zeph: 'Σοφονίας', Hag: 'Ἀγγαῖος',
Zech: 'Ζαχαρίας', Mal: 'Μαλαχίας', Mt: 'Εὐαγγέλιον κατὰ Ματθαῖον', Mk: 'Εὐαγγέλιον κατὰ Μᾶρκον',
Lk: 'Εὐαγγέλιον κατὰ Λουκᾶν', Jn: 'Εὐαγγέλιον κατὰ Ἰωάννην',
Rom: 'Πρὸς Ῥωμαίους', Heb: 'Πρὸς Ἑβραίους',
Gal: 'Πρὸς Γαλάτας', Acts: 'Πράξεων τῶν Ἀποστολων', Baruch: 'Βαρούχ',
},
};
function getReadingName(bookId: string, lang: string): string {
const lookupLang = lang === 'ru' ? 'cu' : lang;
const map = READING_NAMES[lookupLang] ?? READING_NAMES.en;
return map[bookId] ?? bookId;
}
function getHeaderName(bookId: string, lang: string): string {
const lookupLang = lang === 'ru' ? 'cu' : lang;
const map = HEADER_NAMES[lookupLang] ?? HEADER_NAMES.en;
return map[bookId] ?? bookId;
}
interface ImplementOptions {
header?: string;
who: string;
command?: string;
commandB?: string;
commandText?: string;
commandBText?: string;
timesText?: string;
redFirst?: boolean;
newLine?: boolean;
headerLevel?: number;
}
function str(v: unknown): string | undefined {
return typeof v === 'string' ? v : undefined;
}
function bool(v: unknown): boolean | undefined {
if (typeof v === 'boolean') return v;
if (v === '1' || v === 'true') return true;
if (v === '0' || v === 'false') return false;
return undefined;
}
function num(v: unknown): number | undefined {
if (typeof v === 'number') return v;
if (typeof v === 'string' && /^\d+$/.test(v)) return parseInt(v, 10);
return undefined;
}
/**
* Assemble a service from pre-parsed JSON template nodes.
* Returns HTML string.
*/
export async function assembleService(
nodes: ServiceNode[],
ctx: ServiceContext,
depth: number = 0
): Promise<{ html: string; whoLast: string }> {
if (depth > 10) return { html: '<!-- Max recursion depth reached -->', whoLast: '\x00' };
let html = '';
let whoLast = '\x00';
// Buffer for grouping same-who lines into a single flex container
let groupWho: string | null = null;
let groupTexts: string[] = [];
function flushGroup() {
if (!groupWho || groupTexts.length === 0) return;
const linesHtml = groupTexts.map((t, i) => {
const redPart = extractFirstLetter(t);
const line = redPart
? `<b><span class="text-red">${redPart}</span></b>${t.substring(redPart.length)}`
: t;
return (i > 0 ? '<br>' : '') + line;
}).join('');
html += `<div class="flex items-baseline gap-1"><b class="text-red shrink-0">${groupWho}:</b><span>${linesHtml}</span></div>\n`;
whoLast = groupWho;
groupWho = null;
groupTexts = [];
}
for (const raw of nodes) {
const n = raw as Record<string, unknown>;
const type = n.type as string;
// Evaluate Cmd attribute if present
const cmd = str(n.cmd);
if (cmd) {
if (!evalBool(cmd, ctx.evalCtx)) continue;
}
switch (type) {
case 'TITLE': {
flushGroup();
const value = str(n.value);
const source = str(n.source);
const comment = str(n.comment);
if (value) {
const titleText = await ctx.fetchText(`Services/Text/${value}.xml`);
if (titleText) html += `<h1 class="mt-4">${titleText}</h1>\n`;
}
if (source) {
const sourceText = await ctx.fetchText(`Services/Text/${source}.xml`);
if (sourceText) html += `<p class="text-red italic text-sm">${sourceText}</p>`;
}
if (comment) {
const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`);
if (commentText) html += `<p class="italic text-sm">${commentText}</p>`;
}
break;
}
case 'SUBTITLE': {
flushGroup();
const value = str(n.value);
const comment = str(n.comment);
if (value) {
const subText = await ctx.fetchText(`Services/Text/${value}.xml`);
if (subText) html += `<h2 class="mt-4">${subText}</h2>\n`;
}
if (comment) {
const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`);
if (commentText) html += `<p class="italic text-sm">${commentText}</p>`;
}
break;
}
case 'GET': {
flushGroup();
const file = str(n.file);
if (!file) break;
const subNodes = await ctx.fetchServiceNodes(`Services/${file}.xml`);
if (subNodes) {
const subResult = await assembleService(subNodes, ctx, depth + 1);
if (n.null && !subResult.html.trim()) {
// Null check: skip if empty
} else {
html += subResult.html;
whoLast = subResult.whoLast;
}
}
break;
}
case 'CREATE': {
const what = str(n.what) ?? '';
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
let text = '';
let header = '';
if (what) {
text = await ctx.fetchText(`Services/CommonPrayers/${what}.xml`) ?? '';
// Fallback: if fetchText returns empty, try to use the 'what' field as a key
if (!text.trim()) {
text = what; // fallback to the key itself
}
header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? '';
}
const commandName = str(n.command);
const commandBName = str(n.commandB);
const timesVal = num(n.times);
const [commandText, commandBText, timesText] = await Promise.all([
commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
]);
const opts = {
who,
commandText: commandText || undefined,
commandBText: commandBText || undefined,
timesText: timesText || undefined,
redFirst: bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(text, opts);
const headerHtml = opts.headerLevel && header
? `<h2 class="text-center text-red font-bold mt-4">${header}</h2>`
: '';
if (who) {
if (who !== groupWho || headerHtml) flushGroup();
if (headerHtml) html += headerHtml + '\n';
groupWho = who;
groupTexts.push(fmt);
} else {
flushGroup();
const br = opts.newLine ? '<br>' : '';
html += `${headerHtml}${br}<p>${fmt}</p>`;
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'TEXT': {
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
// Support both 'what' (programmatic nodes) and 'value' (converted JSON)
const textContent = str(n.what) ?? str(n.value) ?? '';
const opts = {
who,
redFirst: bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(textContent, opts);
if (who) {
if (who !== groupWho) flushGroup();
groupWho = who;
groupTexts.push(fmt);
} else {
flushGroup();
html += `<p>${fmt}</p>`;
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'GETID': {
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
let text = '';
let header = '';
const id = str(n.id);
const what = str(n.what);
const nodeType = str(n.type) ?? 'M';
if (id && what) {
const lifeId = nodeType === 'T' ? `98${id}` : id;
const lives = await ctx.fetchLives(lifeId);
if (lives) {
const lastSlash = what.lastIndexOf('/');
const nodePath = what.substring(0, lastSlash);
const typeKey = what.substring(lastSlash + 1);
const group = lives[nodePath];
if (group) {
const entry = group[typeKey];
if (entry) {
text = String(entry.text ?? '');
header = String(entry.Header ?? '');
}
}
}
}
const commandName = str(n.command);
const commandBName = str(n.commandB);
const timesVal = num(n.times);
const [commandText, commandBText, timesText] = await Promise.all([
commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
]);
const opts = {
who,
commandText: commandText || undefined,
commandBText: commandBText || undefined,
timesText: timesText || undefined,
redFirst: bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(text, opts);
const headerHtml = opts.headerLevel && header
? `<h2 class="text-center text-red font-bold mt-4">${header}</h2>`
: '';
if (who) {
if (who !== groupWho || headerHtml) flushGroup();
groupWho = who;
if (headerHtml) html += headerHtml + '\n';
groupTexts.push(fmt);
} else {
flushGroup();
const br = opts.newLine ? '<br>' : '';
html += `${headerHtml}${br}<p>${fmt}</p>`;
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'BIBLE': {
const whoRaw = str(n.who);
const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
const verseNewLine = !who;
// getreading: display a reading intro header (e.g. "Prophet Isaiah")
const getreading = str(n.getreading);
if (getreading && !str(n.verses)) {
flushGroup();
const bookName = getReadingName(getreading, ctx.lang);
const readingHeader = `<p class="italic">${bookName}</p>`;
html += readingHeader;
if (whoRaw !== undefined) whoLast = who;
break;
}
const verses = str(n.verses) ?? '';
const lastUnderscore = verses.lastIndexOf('_');
const book = lastUnderscore > 0 ? verses.substring(0, lastUnderscore) : verses;
const passage = lastUnderscore > 0 ? verses.substring(lastUnderscore + 1) : '';
// Generate header from book+passage when header=true and no twostars
let header = '';
if (n.header && !str(n.twostars)) {
const bookName = getHeaderName(book, ctx.lang);
header = passage ? `${bookName} ${passage}` : bookName;
}
let text = await ctx.fetchBibleText(book, passage, false, verseNewLine);
// Handle twostars: extract **-delimited instructions as header
const twostars = str(n.twostars);
if (twostars === '1' || twostars === '2') {
const starIdx = text.indexOf('**');
if (starIdx !== -1) {
const lastStar = text.lastIndexOf('**');
if (starIdx !== lastStar) {
const instruction = text.substring(starIdx + 2, lastStar);
if (twostars === '1') {
header = instruction;
} else if (twostars === '2') {
// Extract quoted text between ** markers
const quoteStart = instruction.indexOf('"');
if (quoteStart !== -1) {
const quoteEnd = instruction.indexOf('"', quoteStart + 1);
if (quoteEnd !== -1) {
header = instruction.substring(quoteStart + 1, quoteEnd).replace(/\.\.\./g, '');
}
}
}
// Strip ** markers from text
text = text.substring(0, starIdx) + text.substring(lastStar + 2);
}
}
}
// Strip all remaining ** markers
text = text.replace(/\*\*/g, '');
const commandName = str(n.command);
const commandBName = str(n.commandB);
const timesVal = num(n.times);
const [commandText, commandBText, timesText] = await Promise.all([
commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
]);
const opts = {
who,
commandText: commandText || undefined,
commandBText: commandBText || undefined,
timesText: timesText || undefined,
redFirst: verseNewLine ? false : bool(n.redFirst),
newLine: bool(n.newLine),
headerLevel: n.header ? 1 : 0,
};
const fmt = formatServiceContent(text, opts);
const headerHtml = opts.headerLevel && header
? `<h2 class="text-center text-red font-bold mt-4">${header}</h2>`
: '';
if (who) {
if (who !== groupWho || headerHtml) flushGroup();
groupWho = who;
if (headerHtml) html += headerHtml + '\n';
groupTexts.push(fmt);
} else {
flushGroup();
if (verseNewLine) {
const verses = fmt.split('<br>').filter(Boolean);
const linesHtml = verses.map(v => {
const redPart = extractFirstLetter(v);
return redPart ? `<b><span class="text-red">${redPart}</span></b>${v.substring(redPart.length)}` : v;
}).join('<br>');
if (headerHtml) html += headerHtml + '\n';
html += `<p>${linesHtml}</p>`;
} else {
const br = opts.newLine ? '<br>' : '';
html += `${headerHtml}${br}<p>${fmt}</p>`;
}
}
if (whoRaw !== undefined) whoLast = who;
break;
}
case 'TIMES': {
flushGroup();
break;
}
default: {
flushGroup();
// Skip wrapper nodes that aren't real service elements
if (type === 'SERVICES' || type === 'TONE') break;
const children = n.children;
if (Array.isArray(children) && children.length > 0) {
const subHtml = await assembleService(children, ctx, depth + 1);
html += subHtml;
}
break;
}
}
}
flushGroup();
return { html, whoLast };
}
/**
* Format service text content: redFirst, command rubrics, commandB, times.
* Returns formatted HTML string (no wrapping in <p>).
*/
function formatServiceContent(text: string, opts: ImplementOptions): string {
let result = text;
if (opts.redFirst && result.length > 0) {
const redPart = extractFirstLetter(result);
if (redPart) {
result = `<b><span class="text-red">${redPart}</span></b>${result.substring(redPart.length)}`;
}
}
if (opts.commandText) {
if (opts.timesText) {
result += ` <span class="rubric">(${opts.timesText} ${opts.commandText})</span>`;
} else {
result += ` <span class="rubric">(${opts.commandText})</span>`;
}
} else if (opts.timesText) {
result += ` <span class="rubric">(${opts.timesText})</span>`;
}
if (opts.commandBText) {
result = `<span class="rubric">${opts.commandBText}</span> ${result}`;
}
return result;
}
function extractFirstLetter(text: string): string {
const combiningMarks = /[\u0300-\u036f\u037a\u0384-\u0385\u0483-\u0489\u1fbd-\u1ffe\u2de0-\u2dff\ua66f-\ua67d]/;
let result = text[0] || '';
let i = 1;
while (i < text.length && combiningMarks.test(text[i])) {
result += text[i];
i++;
}
return result;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Service Rules — determines which service template to use for a given day.
* Port of Ponomar/ServiceInfo.java.
*/
import { evalBool } from './evaluator';
import type { EvalContext } from './types';
export interface ServiceRuleEntry {
type: string;
troparion?: string;
pickT?: number;
kontakion?: string;
pickK?: number;
lentenk?: number;
[key: string]: string | number | undefined;
}
export interface ServiceRulesData {
periods: {
cmd: string;
rules: {
type: string;
attrs: Record<string, string>;
}[];
}[];
}
/**
* Evaluate service rules for a given day.
* Returns the matching service configuration.
*/
export function evaluateServiceRules(
data: ServiceRulesData,
serviceType: string, // e.g., "VESPERS", "LITURGY", "MATINS"
ctx: EvalContext
): ServiceRuleEntry | null {
let result: ServiceRuleEntry | null = null;
for (const period of data.periods) {
if (period.cmd && !evalBool(period.cmd, ctx)) {
continue;
}
for (const rule of period.rules) {
if (rule.type === serviceType) {
result = { type: rule.type, ...rule.attrs };
}
}
}
return result;
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Shared type definitions for the Ponomar liturgical engine.
*/
/** All liturgical variables computed for a given day. */
export interface DayInfo {
// Date components
dow: number; // Day of week (0=Sun, 1=Mon, ..., 6=Sat)
doy: number; // Day of year (0=Jan 1)
month: number; // Julian calendar month (1-12)
day: number; // Day of month
year: number; // Julian calendar year
// Paschal cycle
nday: number; // Days from this year's Pascha (- before, 0 = Pascha, + after)
ndayP: number; // Days from last year's Pascha
ndayF: number; // Days to next year's Pascha (negative before, 0 at next Pascha)
Easter: number; // JDN of this year's Pascha
Pentecost: number; // JDN of this year's Pentecost
LentStart: number; // JDN of Clean Monday
// Liturgical state
Tone: number; // Current Octoechos tone (1-8, -1 = none)
dRank: number; // Liturgical rank (0-8, higher = more important)
PFlag: number; // Paschal flag
PFlag1: number; // Service inclusion flag
PFlag2: number; // Service type (0=Normal, 1=Lenten, 2=Holy Week/Bright Week)
PS: number; // Priest status (0=Reader, 1=Priest)
// Seasonal flags
isLent: boolean;
isBrightWeek: boolean;
isApostlesFast: boolean;
isDormitionFast: boolean;
isNativityFast: boolean;
// Data file references
triodionFile: number | null; // Index in triodion (1-70)
pentecostarionFile: number | null; // Index in pentecostarion (1-315)
}
/** Expression evaluator variable store. */
export type EvalContext = Record<string, number>;
/** A saint/feast commemoration entry from monthly day XML. */
export interface Commemoration {
sIds: string[]; // Saint IDs (comma-separated in source)
cId: string; // Commemoration ID (points to lives/{cId}.xml)
src?: string; // Source: G=Greek, R=Russian, U=Unsourced
tone?: string; // Tone expression (evaluated at runtime)
cmd?: string; // Conditional expression
}
/** A day's worth of commemoration data. */
export interface DayData {
commemorations: Commemoration[];
}
/** Saint life data (from lives/{cId}.xml). */
export interface SaintData {
name?: {
nominative: string;
short?: string;
genitive?: string;
};
grammar?: Record<string, string>;
rank?: number;
life?: {
text: string;
id?: string;
copyright?: string;
};
services?: Record<string, ServiceData>;
troparion?: { tone: number; text: string }[];
kontakion?: { tone: number; text: string }[];
scriptures?: { type: string; reading: string }[];
icon?: string;
}
/** Service data within a saint's life file. */
export interface ServiceData {
[path: string]: Record<string, { text: string; [key: string]: string }>;
}
/** Fasting rule for a day. */
export interface FastingInfo {
level: FastingLevel;
description: string;
meatFree: boolean;
fishPermitted: boolean;
caviarPermitted: boolean;
oilPermitted: boolean;
winePermitted: boolean;
strictFast: boolean;
noFood: boolean;
}
export enum FastingLevel {
NoFast = 0,
WineAndOil = 1,
Xerophagy = 2,
StrictWithOil = 3,
Strict = 4,
CompleteFast = 5,
}
/** Service template node — dynamic JSON from converted XML. */
export interface ServiceNode {
type: string;
[key: string]: unknown;
}
/** Service rules for a given liturgical period. */
export interface ServiceRule {
cmd: string;
services: Record<string, ServiceTypeConfig>;
}
export interface ServiceTypeConfig {
type: string;
troparion?: string;
pickT?: number;
kontakion?: string;
pickK?: number;
lentenk?: number;
}
/** Bible translation metadata. */
export interface BibleVersion {
id: string;
name: string;
language: string;
books: BibleBook[];
info: BibleInfo;
}
export interface BibleBook {
id: string;
name: string;
chapters: number;
short: string;
intro?: string;
}
export interface BibleInfo {
verseNumFormat: string;
verseNoNumFormat: string;
verseLink: string;
abbrevFormat: string;
chapterN: string;
headerFormat: string;
chapterNI: string;
verseNo: string[];
chapterNo: string[];
cvSep: string;
duration: string;
selectionSeparator: string;
bcSep: string;
fontFace: string;
fontSize: number;
parts: string;
orient: string;
}
/** Language info. */
export type LanguageCode = 'en' | 'ru' | 'cu';
export interface LanguageInfo {
code: LanguageCode;
name: string;
nameLocal: string;
dir: 'ltr' | 'rtl';
fontFace: string;
liturgicalFont: string;
liturgicalSize: number;
}
/** Fasting rule from Fasting.xml. */
export interface FastingRule {
case: string; // 7-bit binary string
cmd: string; // boolean expression
}
/** Fasting period from Fasting.xml. */
export interface FastingPeriod {
cmd: string;
rules: FastingRule[];
}
+5
View File
@@ -0,0 +1,5 @@
import './styles/main.css';
import { App } from './ui/app';
const app = new App(document.getElementById('app')!);
app.init();
+149
View File
@@ -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
View File
@@ -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;
}
}
+285
View File
@@ -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);
}
}
+918
View File
@@ -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>`;
}
}
}
}
+712
View File
@@ -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;
}
}
+307
View File
@@ -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`;
}
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="960"
height="1428"
viewBox="0 0 960 1428"
sodipodi:docname="OrthodoxCross.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.52941176"
inkscape:cx="479.77778"
inkscape:cy="714"
inkscape:window-width="1707"
inkscape:window-height="1007"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1" />
<defs
id="defs1" />
<g
id="g1">
<path
style="fill:#000000"
d="m 425.98129,1221.1944 -0.0187,-142.25 -56.42083,-47 -56.42084,-46.99996 -0.0605,-59.75 c -0.0332,-32.8625 0.16941,-59.75 0.45036,-59.75 0.28095,0 25.17153,20.6213 55.3124,45.825 30.14087,25.2038 55.336,46.1554 55.98918,46.5591 C 425.72796,958.39434 426,902.54294 426,714.00346 V 469.44444 H 237.5 48.999996 v -54 -54 H 237.5 426 v -54 -54 H 369.5 313 v -52 -52 h 56.5 56.5 v -45.5 -45.499996 h 54 54 v 45.499996 45.5 h 56.5 56.5 v 52 52 H 590.5 534 v 54 54 h 188.5 188.49996 v 54 54 H 722.5 534 v 289.92889 289.92887 l 56.5,47.2216 56.5,47.2217 v 59.9335 59.9335 l -3.75,-3.0186 c -2.0625,-1.6603 -27.375,-22.7863 -56.25,-46.9467 l -52.5,-43.9281 -0.25495,96.8627 -0.25495,96.8626 H 479.99505 426 Z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "Ponomar — Orthodox Calendar",
"short_name": "Ponomar",
"description": "Orthodox Church calendar and liturgical information",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f0e8",
"theme_color": "#1a1a2e",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
const CACHE = 'ponomar-v1';
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/index.html'])));
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(clients.claim());
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
});
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["src/**/*.ts", "src/**/*.css"],
"exclude": ["node_modules", "dist", "scripts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { resolve } from 'path';
export default defineConfig({
plugins: [tailwindcss()],
server: {
port: 5173,
strictPort: false
},
build: {
outDir: 'dist',
sourcemap: true
},
publicDir: 'static'
});