200 lines
5.8 KiB
TypeScript
200 lines
5.8 KiB
TypeScript
/**
|
|
* 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`);
|
|
}
|
|
}
|