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