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

+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.')