diff --git a/scripts/migrate-data.ts b/scripts/migrate-data.ts new file mode 100644 index 0000000..92aad68 --- /dev/null +++ b/scripts/migrate-data.ts @@ -0,0 +1,524 @@ +/** + * One-time migration script to normalize data to new format. + * Run with: npx tsx scripts/migrate-data.ts [--dry-run] + * + * Operations: + * 1. Strip @_ prefixes from lives name keys (all languages) + * 2. Remove SId from menaion entries (keep id = CId) (all languages) + * 3. Rename service directories to lowercase (cu, el, fr, zh, zh/Hans, zh/Hant, ru) + * 4. Flatten Octoecheos structure (cu, fr, zh/Hans, zh/Hant) + * 5. TEXT->text in command files (cu, fr, zh, zh/Hans, zh/Hant) + * 6. Move shared services into templates/ subdirectory + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync, cpSync, rmSync, statSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const DATA_DIR = join(__dirname, '..', 'static', 'data'); +const DRY_RUN = process.argv.includes('--dry-run'); + +interface MigrationStats { + livesProcessed: number; + livesStripped: number; + menaionProcessed: number; + menaionStripped: number; + dirsRenamed: number; + octoecheosFlattened: number; + commandsMigrated: number; + sharedServicesMoved: number; + menaionBundlesMigrated: number; + nameKeysFixed: number; + calendarMergedMigrated: number; +} + +const stats: MigrationStats = { + livesProcessed: 0, + livesStripped: 0, + menaionProcessed: 0, + menaionStripped: 0, + dirsRenamed: 0, + octoecheosFlattened: 0, + commandsMigrated: 0, + sharedServicesMoved: 0, + menaionBundlesMigrated: 0, + nameKeysFixed: 0, + calendarMergedMigrated: 0, +}; + +function log(message: string): void { + if (!DRY_RUN) { + console.log(message); + } else { + console.log(`[DRY] ${message}`); + } +} + +function writeFile(path: string, data: unknown): void { + if (!DRY_RUN) { + writeFileSync(path, JSON.stringify(data, null, 2)); + } +} + +function renameDir(oldPath: string, newPath: string): void { + if (!DRY_RUN) { + if (existsSync(newPath)) { + rmSync(newPath, { recursive: true }); + } + renameSync(oldPath, newPath); + } +} + +/** + * Strip @_ prefixes from an object's keys (recursive) + */ +function stripAtPrefix(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = key.startsWith('@_') ? key.slice(2) : key; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + result[newKey] = stripAtPrefix(value as Record); + } else { + result[newKey] = value; + } + } + return result; +} + +/** + * Migrate lives bundle - strip @_ prefixes from name keys + */ +function migrateLivesBundle(filePath: string): void { + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + let changed = false; + + for (const [cid, entry] of Object.entries(data)) { + if (typeof entry !== 'object' || entry === null) continue; + stats.livesProcessed++; + + const life = entry as Record; + if (life.name && typeof life.name === 'object') { + const name = life.name as Record; + const hasAtPrefix = Object.keys(name).some(k => k.startsWith('@_')); + + if (hasAtPrefix) { + life.name = stripAtPrefix(name); + changed = true; + stats.livesStripped++; + } + } + } + + if (changed) { + log(` Migrated: ${filePath}`); + writeFile(filePath, data); + } +} + +/** + * Migrate menaion - remove SId, keep only id = CId + */ +function migrateMenaionFile(filePath: string): void { + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + + if (!Array.isArray(data)) return; + + let changed = false; + const migrated = data.map((entry: Record) => { + if (entry.SId !== undefined) { + changed = true; + stats.menaionStripped++; + return { id: entry.CId || entry.id }; + } + return entry; + }); + + if (changed) { + log(` Migrated: ${filePath}`); + writeFile(filePath, migrated); + } + stats.menaionProcessed++; +} + +/** + * Flatten Octoecheos structure: "Tone N/Day.json" -> "toneN/day.json" + */ +function flattenOctoecheos(langDir: string): void { + const servicesDir = join(langDir, 'services'); + // Look for PascalCase "Octoecheos" directory + const octoDir = join(servicesDir, 'Octoecheos'); + if (!existsSync(octoDir)) return; + + for (const toneDir of readdirSync(octoDir)) { + if (!toneDir.startsWith('Tone ')) continue; + + const toneNum = toneDir.replace('Tone ', ''); + const srcPath = join(octoDir, toneDir); + const dstPath = join(octoDir, `tone${toneNum}`); + + if (!statSync(srcPath).isDirectory()) continue; + + mkdirSync(dstPath, { recursive: true }); + + for (const file of readdirSync(srcPath)) { + if (!file.endsWith('.json')) continue; + + const dayName = file.replace('.json', '').toLowerCase(); + const srcFile = join(srcPath, file); + const dstFile = join(dstPath, `${dayName}.json`); + + log(` Flattened: ${toneDir}/${file} -> tone${toneNum}/${dayName}.json`); + if (!DRY_RUN) { + cpSync(srcFile, dstFile); + } + stats.octoecheosFlattened++; + } + + // Remove old PascalCase directory + if (!DRY_RUN) { + rmSync(srcPath, { recursive: true }); + } + } +} + +/** + * Rename PascalCase service directories to lowercase + */ +function renameServiceDirs(langDir: string): void { + const servicesDir = join(langDir, 'services'); + if (!existsSync(servicesDir)) return; + + const renames: Record = { + 'CommonPrayers': 'prayers', + 'Text': 'texts', + 'Command': 'commands', + 'Header': 'headers', + 'Var': 'var', + 'Octoecheos': 'octoecheos', + }; + + for (const [oldName, newName] of Object.entries(renames)) { + const oldPath = join(servicesDir, oldName); + const newPath = join(servicesDir, newName); + + if (existsSync(oldPath)) { + log(` Renamed: ${oldName} -> ${newName}`); + renameDir(oldPath, newPath); + stats.dirsRenamed++; + } + } +} + +/** + * Migrate command files: TEXT -> text type + * Checks both PascalCase (Command/) and lowercase (commands/) directories + */ +function migrateCommands(langDir: string): void { + // Check both old (Command/) and new (commands/) directory names + const commandsDirs = [ + join(langDir, 'services', 'Command'), + join(langDir, 'services', 'commands'), + ]; + + for (const commandsDir of commandsDirs) { + if (!existsSync(commandsDir)) continue; + + for (const file of readdirSync(commandsDir)) { + if (!file.endsWith('.json')) continue; + + const filePath = join(commandsDir, file); + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + + if (!Array.isArray(data)) continue; + + let changed = false; + const migrated = data.map((node: Record) => { + if (node.type === 'TEXT') { + changed = true; + return { ...node, type: 'text' }; + } + return node; + }); + + if (changed) { + log(` Migrated command: ${file}`); + writeFile(filePath, migrated); + stats.commandsMigrated++; + } + } + } +} + +/** + * Move shared services files into templates/ subdirectory + */ +function moveSharedServices(): void { + const servicesDir = join(DATA_DIR, 'shared', 'services'); + if (!existsSync(servicesDir)) return; + + const templatesDir = join(servicesDir, 'templates'); + mkdirSync(templatesDir, { recursive: true }); + + // Move all JSON files from services root to templates/ + let moved = 0; + for (const file of readdirSync(servicesDir)) { + if (!file.endsWith('.json')) continue; + + const srcFile = join(servicesDir, file); + const dstFile = join(templatesDir, file); + + log(` Moved: ${file} -> templates/${file}`); + if (!DRY_RUN) { + cpSync(srcFile, dstFile); + rmSync(srcFile); + } + moved++; + } + + if (moved === 0) { + log(' Shared services already in templates/, skipping'); + } + stats.sharedServicesMoved = moved; +} + +/** + * Process all lives directories for a language + */ +function processLives(langDir: string): void { + const livesDir = join(langDir, 'lives'); + if (!existsSync(livesDir)) return; + + for (const file of readdirSync(livesDir)) { + if (file.endsWith('.json')) { + migrateLivesBundle(join(livesDir, file)); + } + } +} + +/** + * Process all menaion files for a language + */ +function processMenaion(langDir: string): void { + const menaionDir = join(langDir, 'menaion'); + if (!existsSync(menaionDir)) return; + + for (const file of readdirSync(menaionDir)) { + if (file.endsWith('.json')) { + migrateMenaionFile(join(menaionDir, file)); + } + } +} + +/** + * Migrate menaion bundle file: {dateKey: [{SId, CId}, ...]} -> {dateKey: [{id}, ...]} + */ +function migrateMenaionBundle(filePath: string): void { + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + let changed = false; + + for (const [dateKey, entries] of Object.entries(data)) { + if (!Array.isArray(entries)) continue; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] as Record; + if (entry.SId !== undefined) { + changed = true; + entries[i] = { id: entry.CId || entry.id }; + stats.menaionBundlesMigrated++; + } + } + } + + if (changed) { + log(` Migrated: ${filePath}`); + writeFile(filePath, data); + } +} + +/** + * Fix name key casing: Nominative -> nominative, Short -> short, ShortF -> shortF + */ +function fixNameKeyCase(langDir: string): void { + const livesDir = join(langDir, 'lives'); + if (!existsSync(livesDir)) return; + + for (const file of readdirSync(livesDir)) { + if (!file.endsWith('.json')) continue; + + const filePath = join(livesDir, file); + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + let changed = false; + + for (const entry of Object.values(data)) { + if (typeof entry !== 'object' || entry === null) continue; + const life = entry as Record; + if (life.name && typeof life.name === 'object') { + const name = life.name as Record; + const fixed: Record = {}; + let entryChanged = false; + for (const [key, value] of Object.entries(name)) { + const newKey = key.length > 0 ? key.charAt(0).toLowerCase() + key.slice(1) : key; + fixed[newKey] = value; + if (newKey !== key) entryChanged = true; + } + if (entryChanged) { + life.name = fixed; + changed = true; + stats.nameKeysFixed++; + } + } + } + + if (changed) { + log(` Fixed name keys: ${filePath}`); + writeFile(filePath, data); + } + } +} + +/** + * Migrate merged calendar files (triodion.json, pentecostarion.json) - SId/CId -> id + */ +function migrateCalendarMerged(filePath: string): void { + if (!existsSync(filePath)) return; + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + let changed = false; + + for (const [key, entry] of Object.entries(data)) { + if (typeof entry !== 'object' || entry === null) continue; + const e = entry as Record; + if (e.SId !== undefined) { + changed = true; + data[key] = { id: e.CId || e.id }; + stats.calendarMergedMigrated++; + } + } + + if (changed) { + log(` Migrated: ${filePath}`); + writeFile(filePath, data); + } +} + +interface LangConfig { + name: string; + path: string; +} + +function main(): void { + console.log(`\n${DRY_RUN ? 'DRY RUN - No changes will be made' : 'MIGRATING DATA'}\n`); + console.log(`Data directory: ${DATA_DIR}\n`); + + // All language directories to process (including sub-languages) + const langs: LangConfig[] = [ + { name: 'en', path: join(DATA_DIR, 'en') }, + { name: 'cu', path: join(DATA_DIR, 'cu') }, + { name: 'el', path: join(DATA_DIR, 'el') }, + { name: 'fr', path: join(DATA_DIR, 'fr') }, + { name: 'zh', path: join(DATA_DIR, 'zh') }, + { name: 'ru', path: join(DATA_DIR, 'ru') }, + { name: 'zh/Hans', path: join(DATA_DIR, 'zh', 'Hans') }, + { name: 'zh/Hant', path: join(DATA_DIR, 'zh', 'Hant') }, + { name: 'el/mono', path: join(DATA_DIR, 'el', 'mono') }, + ]; + + // Step 1: Strip @_ from lives (all languages) + console.log('1. Stripping @_ from lives name keys...'); + for (const lang of langs) { + if (existsSync(lang.path)) { + processLives(lang.path); + } + } + + // Step 2: Remove SId from menaion (all languages with menaion) + console.log('\n2. Removing SId from menaion entries...'); + for (const lang of langs) { + if (existsSync(lang.path)) { + processMenaion(lang.path); + } + } + + // Step 3: Flatten Octoecheos (cu, fr, zh/Hans, zh/Hant) + console.log('\n3. Flattening Octoecheos directories...'); + const octoLangs = ['cu', 'fr', 'zh/Hans', 'zh/Hant']; + for (const lang of octoLangs) { + const langDir = join(DATA_DIR, lang); + if (existsSync(langDir)) { + flattenOctoecheos(langDir); + } + } + + // Step 4: Rename service directories to lowercase + console.log('\n4. Renaming service directories to lowercase...'); + const renameLangs = ['cu', 'el', 'fr', 'zh', 'zh/Hans', 'zh/Hant', 'ru']; + for (const lang of renameLangs) { + const langDir = join(DATA_DIR, lang); + if (existsSync(langDir)) { + renameServiceDirs(langDir); + } + } + + // Step 5: Migrate commands TEXT -> text + console.log('\n5. Migrating commands TEXT -> text...'); + const cmdLangs = ['cu', 'fr', 'zh', 'zh/Hans', 'zh/Hant']; + for (const lang of cmdLangs) { + const langDir = join(DATA_DIR, lang); + if (existsSync(langDir)) { + migrateCommands(langDir); + } + } + + // Step 6: Move shared services into templates/ + console.log('\n6. Moving shared services into templates/...'); + moveSharedServices(); + + // Step 7: Migrate menaion bundle files (both menaion-bundle.json and {lang}-menaion-bundle.json) + console.log('\n7. Migrating menaion bundle files...'); + const bundleLangs = ['en', 'cu', 'el', 'fr', 'zh']; + for (const lang of bundleLangs) { + const langDir = join(DATA_DIR, lang); + if (!existsSync(langDir)) continue; + for (const name of ['menaion-bundle.json', `${lang}-menaion-bundle.json`]) { + const fp = join(langDir, name); + if (existsSync(fp)) migrateMenaionBundle(fp); + } + } + + // Step 8: Fix name key casing in lives files (Nominative -> nominative) + console.log('\n8. Fixing name key casing in lives files...'); + for (const lang of langs) { + if (existsSync(lang.path)) { + fixNameKeyCase(lang.path); + } + } + + // Step 9: Migrate merged calendar files (triodion.json, pentecostarion.json) + console.log('\n9. Migrating merged calendar files...'); + const calendarDir = join(DATA_DIR, 'shared', 'calendar'); + for (const name of ['triodion.json', 'pentecostarion.json']) { + migrateCalendarMerged(join(calendarDir, name)); + } + + // Summary + console.log('\n\nMIGRATION SUMMARY'); + console.log('================='); + console.log(`Lives processed: ${stats.livesProcessed}`); + console.log(`Lives with @_ stripped: ${stats.livesStripped}`); + console.log(`Menaion entries processed: ${stats.menaionProcessed}`); + console.log(`Menaion entries stripped: ${stats.menaionStripped}`); + console.log(`Octoecheos files flattened: ${stats.octoecheosFlattened}`); + console.log(`Service dirs renamed: ${stats.dirsRenamed}`); + console.log(`Commands migrated: ${stats.commandsMigrated}`); + console.log(`Shared services moved: ${stats.sharedServicesMoved}`); + console.log(`Menaion bundles migrated: ${stats.menaionBundlesMigrated}`); + console.log(`Name keys fixed: ${stats.nameKeysFixed}`); + console.log(`Calendar merged migrated: ${stats.calendarMergedMigrated}`); +} + +main(); diff --git a/scripts/render-check.ts b/scripts/render-check.ts new file mode 100644 index 0000000..6d05dd2 --- /dev/null +++ b/scripts/render-check.ts @@ -0,0 +1,116 @@ +/** + * Render-check QA: headlessly render every flat-data service and + * collection section through assembleService, verifying: + * - HTML output is non-empty + * - HEADER nodes actually render in the output (HEADER fix check) + * - No exceptions are thrown + * Run with: npx tsx scripts/render-check.ts + */ + +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { assembleService, type ServiceContext } from '../src/core/service-assembler'; +import type { EvalContext, ServiceNode } from '../src/core/types'; + +const __dirname = new URL('.', import.meta.url).pathname; +const SERVICES = join(__dirname, '..', 'static', 'data', 'shared', 'services'); + +interface ServiceNodeT { type: string; value?: string } + +let errors = 0; +let warnings = 0; +let checked = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +async function render(nodes: ServiceNode[], label: string): Promise { + const evalCtx: EvalContext = {}; + const ctx: ServiceContext = { + evalCtx, + lang: 'cu', + fetchBibleText: async () => '', + fetchLives: async () => null, + fetchCommandText: async () => '', + resolveTimes: async () => '', + fetchText: async () => null, + fetchPrayerNodes: async () => null, + fetchServiceNodes: async () => null, + }; + try { + const { html } = await assembleService(nodes, ctx); + if (!html.trim()) { + err(`${label}: empty HTML output`); + return; + } + // Verify HEADER nodes render + const headers = nodes.filter(n => n.type === 'HEADER' && n.value); + for (const h of headers.slice(0, 10)) { + const v = (h as ServiceNodeT).value ?? ''; + const snippet = v.substring(0, 30); + if (!html.includes(snippet) && v.length > 3) { + err(`${label}: HEADER not rendered: '${snippet}'`); + break; + } + } + checked++; + } catch (e) { + err(`${label}: exception: ${(e as Error).message}`); + } +} + +async function main() { + // 1. All flat-data services (subdirs containing full.json) + const serviceDirs = readdirSync(SERVICES).filter(d => existsSync(join(SERVICES, d, 'full.json'))); + for (const dir of serviceDirs.sort()) { + const nodes = JSON.parse(readFileSync(join(SERVICES, dir, 'full.json'), 'utf-8')) as ServiceNode[]; + await render(nodes, `service/${dir}`); + } + + // 2. Collection sections + const collections = ['prayer-rule', 'akathists', 'parimii', 'horologion', 'sbornik', 'irmologion', 'horologionadd']; + + // Triodion flat layout (NN.json) + { + const triDir = join(__dirname, '..', 'static', 'data', 'shared', 'triodion'); + if (existsSync(triDir)) { + for (const fn of readdirSync(triDir).sort()) { + if (!fn.endsWith('.json')) continue; + const nodes = JSON.parse(readFileSync(join(triDir, fn), 'utf-8')) as ServiceNode[]; + await render(nodes, `triodion/${fn}`); + } + } + } + const extra = [ + ['paraclete', 'services/paraclete'], + ]; + for (const [col, dir] of extra) { + const colDir = join(__dirname, '..', 'static', 'data', 'shared', dir); + if (!existsSync(colDir)) continue; + for (const tone of readdirSync(colDir).sort()) { + const toneDir = join(colDir, tone); + if (!existsSync(toneDir)) continue; + for (const day of readdirSync(toneDir).sort()) { + const p = join(toneDir, day); + if (!p.endsWith('.json')) continue; + const nodes = JSON.parse(readFileSync(p, 'utf-8')) as ServiceNode[]; + await render(nodes, `${col}/${tone}/${day}`); + } + } + } + for (const col of collections) { + const colDir = join(__dirname, '..', 'static', 'data', 'shared', col); + if (!existsSync(colDir)) continue; + for (const section of readdirSync(colDir).sort()) { + const p = join(colDir, section, 'full.json'); + if (!existsSync(p)) continue; + const nodes = JSON.parse(readFileSync(p, 'utf-8')) as ServiceNode[]; + await render(nodes, `${col}/${section}`); + } + } + + console.log(`\nRendered ${checked} services/sections successfully, ${errors} errors, ${warnings} warnings`); + process.exit(errors > 0 ? 1 : 0); +} + +main(); diff --git a/scripts/schemas/calendar-entry.schema.json b/scripts/schemas/calendar-entry.schema.json new file mode 100644 index 0000000..05ed42f --- /dev/null +++ b/scripts/schemas/calendar-entry.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Calendar Entry", + "description": "Entry in triodion, pentecostarion, or menaion", + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Commemoration ID (CId)" + } + }, + "additionalProperties": false +} diff --git a/scripts/schemas/command.schema.json b/scripts/schemas/command.schema.json new file mode 100644 index 0000000..accd130 --- /dev/null +++ b/scripts/schemas/command.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Command Text", + "description": "Rubric or instruction text", + "type": "array", + "items": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["text"], + "description": "Node type (lowercase)" + }, + "value": { + "type": "string", + "description": "Text content" + } + }, + "additionalProperties": false + } +} diff --git a/scripts/schemas/commemoration.schema.json b/scripts/schemas/commemoration.schema.json new file mode 100644 index 0000000..73a57ff --- /dev/null +++ b/scripts/schemas/commemoration.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Commemoration", + "description": "Shared commemoration data for a saint or feast", + "type": "object", + "properties": { + "SCRIPTURE": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Type": { "type": "string" }, + "Reading": { "type": "string" }, + "Pericope": { "type": "string" }, + "Cmd": { "type": "string" } + }, + "additionalProperties": true + } + }, + "NAME": { + "type": "object", + "properties": { + "Long": { "type": "string" }, + "ShortN": { "type": "string" }, + "ShortF": { "type": "string" } + }, + "additionalProperties": true + }, + "CHURCH": { + "type": "object", + "properties": { + "Rank": { "type": "string" } + }, + "additionalProperties": true + }, + "LIFE": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "Id": { "type": "string" }, + "Copyright": { "type": "string" } + }, + "additionalProperties": true + }, + "TROPARION": { + "type": "object", + "properties": { + "Tone": { "type": "string" }, + "text": { "type": "string" } + }, + "additionalProperties": true + }, + "KONTAKION": { + "type": "object", + "properties": { + "Tone": { "type": "string" }, + "text": { "type": "string" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/scripts/schemas/fasting.schema.json b/scripts/schemas/fasting.schema.json new file mode 100644 index 0000000..9983387 --- /dev/null +++ b/scripts/schemas/fasting.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Fasting Rules", + "description": "Fasting period definitions with rules", + "type": "array", + "items": { + "type": "object", + "required": ["cmd", "rules"], + "properties": { + "cmd": { + "type": "string", + "description": "Boolean expression for period inclusion" + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "required": ["case", "cmd"], + "properties": { + "case": { + "type": "string", + "pattern": "^[01]{7}$", + "description": "7-bit food permissions (meat,dairy,fish,caviar,oil,wine,dryFood)" + }, + "cmd": { + "type": "string", + "description": "Boolean expression for rule inclusion" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } +} diff --git a/scripts/schemas/life.schema.json b/scripts/schemas/life.schema.json new file mode 100644 index 0000000..38c278c --- /dev/null +++ b/scripts/schemas/life.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Life Entry", + "description": "A single saint's life data entry", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "object", + "required": ["nominative"], + "properties": { + "nominative": { "type": "string", "description": "Full name in nominative case" }, + "genitive": { "type": "string", "description": "Name in genitive case" }, + "short": { "type": "string", "description": "Short name for display" }, + "shortF": { "type": "string", "description": "Short name, feminine form" } + }, + "additionalProperties": false + }, + "life": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "Biography text (may contain HTML)" }, + "id": { "type": "string", "description": "Source identifier" }, + "copyright": { "type": "string", "description": "Copyright notice" } + }, + "additionalProperties": false + }, + "rank": { + "type": "integer", + "minimum": 0, + "maximum": 8, + "description": "Liturgical rank (0=none, 8=highest)" + }, + "services": { + "type": "object", + "description": "Liturgical service texts for this commemoration", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "Type": { "type": "string" }, + "Reading": { "type": "string" }, + "Pericope": { "type": "string" }, + "Cmd": { "type": "string" }, + "Header": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "scripture": { + "type": "array", + "description": "Scripture readings for this commemoration", + "items": { + "type": "object", + "properties": { + "Type": { "type": "string" }, + "Reading": { "type": "string" }, + "Pericope": { "type": "string" }, + "Cmd": { "type": "string" } + }, + "additionalProperties": true + } + }, + "icon": { + "type": "string", + "description": "Icon identifier" + } + }, + "additionalProperties": false +} diff --git a/scripts/schemas/lives-bundle.schema.json b/scripts/schemas/lives-bundle.schema.json new file mode 100644 index 0000000..d68fa17 --- /dev/null +++ b/scripts/schemas/lives-bundle.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Lives Bundle", + "description": "Monthly bundle of saint life entries, keyed by CId", + "type": "object", + "patternProperties": { + "^[0-9]+$": { "$ref": "life.schema.json" } + }, + "additionalProperties": false +} diff --git a/scripts/schemas/prayer.schema.json b/scripts/schemas/prayer.schema.json new file mode 100644 index 0000000..3df3d52 --- /dev/null +++ b/scripts/schemas/prayer.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Prayer Text", + "description": "A CommonPrayer or fixed text entry", + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["TEXT", "HEADER"], + "description": "Node type" + }, + "value": { + "type": "string", + "description": "Text content" + } + }, + "additionalProperties": false + } +} diff --git a/scripts/schemas/service-node.schema.json b/scripts/schemas/service-node.schema.json new file mode 100644 index 0000000..89091e5 --- /dev/null +++ b/scripts/schemas/service-node.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Service Node", + "description": "A single node in a service template", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "TITLE", "SUBTITLE", "GET", "CREATE", "TEXT", "GETID", + "BIBLE", "TIMES", "TONE", "SERVICES", "LANGUAGE" + ], + "description": "Node type discriminator" + }, + "cmd": { + "type": "string", + "description": "Boolean expression for conditional inclusion" + }, + "who": { + "type": "string", + "description": "Speaker identifier (P=Riest, R=Reader, D=Deacon, C=Choir)" + }, + "redfirst": { + "type": "boolean", + "description": "Render first letter in red" + }, + "newline": { + "type": "boolean", + "description": "Add line break before text" + }, + "header": { + "type": "boolean", + "description": "Generate section header" + }, + "null": { + "type": "boolean", + "description": "Skip if content is empty" + }, + "times": { + "type": "integer", + "minimum": 1, + "description": "Number of times to repeat" + }, + "command": { + "type": "string", + "description": "Rubric text command name" + }, + "commandB": { + "type": "string", + "description": "Prepend rubric text command name" + } + }, + "oneOf": [ + { + "properties": { + "type": { "const": "TITLE" }, + "value": { "type": "string" }, + "source": { "type": "string" }, + "comment": { "type": "string" } + }, + "required": ["value"] + }, + { + "properties": { + "type": { "const": "SUBTITLE" }, + "value": { "type": "string" }, + "comment": { "type": "string" } + }, + "required": ["value"] + }, + { + "properties": { + "type": { "const": "GET" }, + "file": { "type": "string" } + }, + "required": ["file"] + }, + { + "properties": { + "type": { "const": "CREATE" }, + "what": { "type": "string" } + }, + "required": ["what"] + }, + { + "properties": { + "type": { "enum": ["TEXT", "text"] }, + "what": { "type": "string" }, + "value": { "type": "string" } + } + }, + { + "properties": { + "type": { "const": "GETID" }, + "id": { "type": "string" }, + "what": { "type": "string" } + }, + "required": ["id", "what"] + }, + { + "properties": { + "type": { "const": "BIBLE" }, + "verses": { "type": "string" }, + "getreading": { "type": "boolean" }, + "twostars": { "type": "boolean" } + } + }, + { + "properties": { + "type": { "const": "TIMES" } + } + }, + { + "properties": { + "type": { "const": "TONE" } + } + }, + { + "properties": { + "type": { "const": "SERVICES" } + } + }, + { + "properties": { + "type": { "const": "LANGUAGE" } + } + } + ], + "additionalProperties": true +} diff --git a/scripts/schemas/service-template.schema.json b/scripts/schemas/service-template.schema.json new file mode 100644 index 0000000..1b3c1ed --- /dev/null +++ b/scripts/schemas/service-template.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Service Template", + "description": "A service template as an array of service nodes", + "type": "array", + "items": { "$ref": "service-node.schema.json" } +} diff --git a/scripts/tools/search-texts.ts b/scripts/tools/search-texts.ts new file mode 100644 index 0000000..9b68cec --- /dev/null +++ b/scripts/tools/search-texts.ts @@ -0,0 +1,105 @@ +/** + * Search across all liturgical texts. + * Run with: npx tsx scripts/tools/search-texts.ts [--lang en] [--type prayers] + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +const DATA_DIR = join(import.meta.dirname, '..', '..', 'static', 'data'); + +interface SearchResult { + file: string; + matches: { line: number; text: string }[]; +} + +function searchFile(filePath: string, query: string, relPath: string): SearchResult | null { + try { + const content = readFileSync(filePath, 'utf-8'); + const matches: { line: number; text: string }[] = []; + const lines = content.split('\n'); + const lowerQuery = query.toLowerCase(); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(lowerQuery)) { + matches.push({ line: i + 1, text: lines[i].substring(0, 100) }); + } + } + + return matches.length > 0 ? { file: relPath, matches } : null; + } catch { + return null; + } +} + +function searchDir(dirPath: string, query: string, basePath: string, results: SearchResult[]): void { + if (!existsSync(dirPath)) return; + + for (const entry of readdirSync(dirPath)) { + const fullPath = join(dirPath, entry); + if (entry.startsWith('.')) continue; + + if (statSync(fullPath).isDirectory()) { + searchDir(fullPath, query, basePath, results); + } else if (entry.endsWith('.json')) { + const result = searchFile(fullPath, query, relative(basePath, fullPath)); + if (result) results.push(result); + } + } +} + +function main() { + const args = process.argv.slice(2); + if (args.length === 0) { + console.log('Usage: npx tsx scripts/tools/search-texts.ts [--lang en] [--type prayers]'); + process.exit(1); + } + + const query = args[0]; + let lang = 'en'; + let type = ''; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--lang' && args[i + 1]) lang = args[++i]; + if (args[i] === '--type' && args[i + 1]) type = args[++i]; + } + + console.log(`\nSearching for: "${query}"`); + console.log(`Language: ${lang}`); + if (type) console.log(`Type: ${type}`); + console.log(''); + + const results: SearchResult[] = []; + + // Search in specified language + const langDir = join(DATA_DIR, lang); + if (type) { + searchDir(join(langDir, type), query, langDir, results); + } else { + searchDir(langDir, query, langDir, results); + } + + // Also search in shared + searchDir(join(DATA_DIR, 'shared'), query, DATA_DIR, results); + + // Print results + if (results.length === 0) { + console.log('No matches found.'); + } else { + console.log(`Found ${results.length} files with matches:\n`); + for (const result of results.slice(0, 20)) { + console.log(` ${result.file}`); + for (const match of result.matches.slice(0, 3)) { + console.log(` Line ${match.line}: ${match.text.substring(0, 80)}...`); + } + if (result.matches.length > 3) { + console.log(` ... and ${result.matches.length - 3} more matches`); + } + } + if (results.length > 20) { + console.log(`\n ... and ${results.length - 20} more files`); + } + } +} + +main(); diff --git a/scripts/tools/stats.ts b/scripts/tools/stats.ts new file mode 100644 index 0000000..4d5b983 --- /dev/null +++ b/scripts/tools/stats.ts @@ -0,0 +1,128 @@ +/** + * Show statistics about the data directory. + * Run with: npx tsx scripts/tools/stats.ts [--lang en] + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; +import { join } from 'path'; + +const DATA_DIR = join(import.meta.dirname, '..', '..', 'static', 'data'); + +interface Stats { + files: number; + jsonFiles: number; + textFiles: number; + totalSize: number; + languages: string[]; + categories: Record; +} + +function scanDir(dirPath: string, stats: Stats): void { + if (!existsSync(dirPath)) return; + + for (const entry of readdirSync(dirPath)) { + const fullPath = join(dirPath, entry); + if (entry.startsWith('.')) continue; + + const stat = statSync(fullPath); + if (stat.isDirectory()) { + scanDir(fullPath, stats); + } else { + stats.files++; + stats.totalSize += stat.size; + if (entry.endsWith('.json')) stats.jsonFiles++; + if (entry.endsWith('.text')) stats.textFiles++; + } + } +} + +function getCategoryStats(dirPath: string): Record { + const categories: Record = {}; + if (!existsSync(dirPath)) return categories; + + for (const entry of readdirSync(dirPath)) { + const fullPath = join(dirPath, entry); + if (entry.startsWith('.')) continue; + + const stat = statSync(fullPath); + if (stat.isDirectory()) { + const subStats = getCategoryStats(fullPath); + for (const [key, value] of Object.entries(subStats)) { + const catKey = entry + '/' + key; + categories[catKey] = value; + } + } else if (entry.endsWith('.json')) { + categories[entry] = { files: 1, size: stat.size }; + } + } + + return categories; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function main() { + const args = process.argv.slice(2); + let lang = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--lang' && args[i + 1]) lang = args[++i]; + } + + console.log('\nData Statistics\n'); + console.log(`Data directory: ${DATA_DIR}\n`); + + // Get languages + const languages = readdirSync(DATA_DIR).filter(entry => { + const fullPath = join(DATA_DIR, entry); + return statSync(fullPath).isDirectory() && !entry.startsWith('.'); + }); + + console.log(`Languages: ${languages.join(', ')}\n`); + + // Overall stats + const overallStats: Stats = { files: 0, jsonFiles: 0, textFiles: 0, totalSize: 0, languages: [], categories: {} }; + scanDir(DATA_DIR, overallStats); + + console.log('Overall:'); + console.log(` Total files: ${overallStats.files}`); + console.log(` JSON files: ${overallStats.jsonFiles}`); + console.log(` Text files: ${overallStats.textFiles}`); + console.log(` Total size: ${formatSize(overallStats.totalSize)}\n`); + + // Per-language stats + if (lang) { + console.log(`\nDetails for language: ${lang}`); + const langDir = join(DATA_DIR, lang); + if (existsSync(langDir)) { + const langStats: Stats = { files: 0, jsonFiles: 0, textFiles: 0, totalSize: 0, languages: [], categories: {} }; + scanDir(langDir, langStats); + + console.log(` Files: ${langStats.files}`); + console.log(` Size: ${formatSize(langStats.totalSize)}`); + + const categories = getCategoryStats(langDir); + if (Object.keys(categories).length > 0) { + console.log('\n Categories:'); + for (const [name, stats] of Object.entries(categories).sort((a, b) => b[1].size - a[1].size)) { + console.log(` ${name}: ${stats.files} files, ${formatSize(stats.size)}`); + } + } + } + } else { + // Show summary for each language + console.log('Per-language summary:'); + for (const language of languages) { + const langDir = join(DATA_DIR, language); + const langStats: Stats = { files: 0, jsonFiles: 0, textFiles: 0, totalSize: 0, languages: [], categories: {} }; + scanDir(langDir, langStats); + console.log(` ${language}: ${langStats.files} files, ${formatSize(langStats.totalSize)}`); + } + } +} + +main(); diff --git a/scripts/validate-canons.ts b/scripts/validate-canons.ts new file mode 100644 index 0000000..42924c0 --- /dev/null +++ b/scripts/validate-canons.ts @@ -0,0 +1,195 @@ +/** + * Validate canon data files and simulate the ServiceNode output + * that loadCanonData() produces. + * Run with: npx tsx scripts/validate-canons.ts + */ + +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const CANONS_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'services', 'canons'); + +interface CanonSubCanon { + irmos: string; + troparia: string[]; + theotokion: string; +} + +interface CanonOde { + ode: number; + canons: CanonSubCanon[]; +} + +interface CanonData { + tone: number; + odes: CanonOde[]; +} + +interface ServiceNode { + type: 'HEADER' | 'TEXT'; + value: string; +} + +const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ']; + +function simulateLoadCanonData(data: CanonData): ServiceNode[] { + const nodes: ServiceNode[] = []; + for (const ode of data.odes) { + const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode); + nodes.push({ type: 'HEADER', value: `Пѣснь ${slavNum}҃.` }); + + for (const canon of ode.canons) { + if (canon.irmos) { + nodes.push({ type: 'HEADER', value: `Ірмосъ, гла́съ ${data.tone}:` }); + nodes.push({ type: 'TEXT', value: canon.irmos }); + } + for (const trop of canon.troparia) { + nodes.push({ type: 'TEXT', value: trop }); + } + if (canon.theotokion) { + nodes.push({ type: 'HEADER', value: 'Бг҃ородиченъ:' }); + nodes.push({ type: 'TEXT', value: canon.theotokion }); + } + } + } + return nodes; +} + +let errors = 0; +let warnings = 0; + +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const entry of readdirSync(CANONS_DIR).sort()) { + // Skip non-Sunday-canon subdirectories (great-canon parts, paraclete weekday canons) + if (entry === 'great-canon' || entry === 'paraclete') continue; + const fullPath = join(CANONS_DIR, entry, 'sunday.json'); + if (!existsSync(fullPath)) { + warn(`Missing ${fullPath}`); + continue; + } + + console.log(`\n=== ${entry} ===`); + + let data: CanonData; + try { + data = JSON.parse(readFileSync(fullPath, 'utf-8')); + } catch (e) { + err(`${fullPath}: failed to parse: ${(e as Error).message}`); + continue; + } + + // Validate raw data structure + if (!data.tone || typeof data.tone !== 'number' || data.tone < 1 || data.tone > 8) + err(`${entry}: invalid tone ${data.tone}`); + if (!Array.isArray(data.odes)) + err(`${entry}: odes must be an array`); + + // Validate each ode + for (let oi = 0; oi < data.odes.length; oi++) { + const ode = data.odes[oi]; + const pfx = `${entry}/odes[${oi}]/ode${ode.ode}`; + + if (!ode.ode || typeof ode.ode !== 'number' || ode.ode < 1 || ode.ode > 9) + err(`${pfx}: invalid ode number`); + if (oi > 0 && data.odes[oi - 1].ode >= ode.ode) + err(`${pfx}: ode not in ascending order (after ${data.odes[oi - 1].ode})`); + if (!Array.isArray(ode.canons)) + err(`${pfx}: canons must be an array`); + if (ode.canons.length !== 3) + warn(`${pfx}: ${ode.canons.length} canons (expected 3)`); + + for (let ci = 0; ci < ode.canons.length; ci++) { + const c = ode.canons[ci]; + const cp = `${pfx}/canon${ci}`; + + if (typeof c.irmos !== 'string') err(`${cp}: irmos must be string`); + if (!Array.isArray(c.troparia)) err(`${cp}: troparia must be array`); + if (typeof c.theotokion !== 'string') err(`${cp}: theotokion must be string`); + if (c.troparia.length === 0) warn(`${cp}: zero troparia`); + for (let ti = 0; ti < c.troparia.length; ti++) { + if (typeof c.troparia[ti] !== 'string' || c.troparia[ti].length < 5) + err(`${cp}/troparia[${ti}]: too short (${c.troparia[ti]?.length ?? 0} chars)`); + } + + // theotokion may be empty — source text often lacks explicit Бгородиченъ: + // Cross-Resurrection canon (ci=1) and Theotokos canon (ci=2) frequently + // have no separate theotokion marker in the Oktoih source. + } + } + + // Simulate loadCanonData() and validate ServiceNode output + const nodes = simulateLoadCanonData(data); + if (nodes.length === 0) { + err(`${entry}: zero ServiceNodes produced`); + continue; + } + + let prevType = ''; + let odeCount = 0; + let canonCount = 0; + let expectingIrmosText = false; + let expectingTheotokionText = false; + let inCanon = false; + + for (let ni = 0; ni < nodes.length; ni++) { + const n = nodes[ni]; + const np = `${entry}/node[${ni}]`; + + if (n.type !== 'HEADER' && n.type !== 'TEXT') + err(`${np}: invalid type '${n.type}'`); + if (!n.value || typeof n.value !== 'string') + err(`${np}: missing or invalid value`); + + // Check ode header + if (n.type === 'HEADER' && /^Пѣснь [а-ѳ]҃\.$/.test(n.value)) { + odeCount++; + canonCount = 0; + inCanon = false; + continue; + } + + // Check irmos header + if (n.type === 'HEADER' && n.value.startsWith('Ірмосъ')) { + canonCount++; + if (canonCount > 3) warn(`${np}: more than 3 canons in ode`); + expectingIrmosText = true; + inCanon = true; + continue; + } + + // Check theotokion header + if (n.type === 'HEADER' && n.value.startsWith('Бг҃ородиченъ')) { + expectingTheotokionText = true; + continue; + } + + // Consume irmos text + if (expectingIrmosText) { + if (n.type !== 'TEXT') err(`${np}: expected TEXT after irmos HEADER`); + expectingIrmosText = false; + continue; + } + + // Consume theotokion text + if (expectingTheotokionText) { + if (n.type !== 'TEXT') err(`${np}: expected TEXT after Бг҃ородиченъ HEADER`); + expectingTheotokionText = false; + continue; + } + + prevType = n.type; + } + + if (odeCount === 0) err(`${entry}: no ode headers found`); + if (expectingIrmosText) err(`${entry}: missing irmos TEXT after last Ірмосъ HEADER`); + if (expectingTheotokionText) err(`${entry}: missing Бг҃ородиченъ TEXT after last HEADER`); + + console.log(` ${data.odes.length} odes, ${nodes.length} ServiceNodes`); +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-data.ts b/scripts/validate-data.ts new file mode 100644 index 0000000..085d846 --- /dev/null +++ b/scripts/validate-data.ts @@ -0,0 +1,268 @@ +/** + * Validate data files against JSON schemas. + * Run with: npx tsx scripts/validate-data.ts [path] + * If no path given, validates all data in static/data/ + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const DATA_DIR = join(__dirname, '..', 'static', 'data'); + +interface ValidationError { + file: string; + path: string; + message: string; +} + +class Validator { + private errors: ValidationError[] = []; + private warnings: ValidationError[] = []; + private filesChecked = 0; + + constructor(private basePath: string) {} + + validateFile(filePath: string, type: string): void { + const relPath = relative(this.basePath, filePath); + try { + const content = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + + switch (type) { + case 'life': + this.validateLife(data, relPath); + break; + case 'lives-bundle': + this.validateLivesBundle(data, relPath); + break; + case 'calendar': + this.validateCalendar(data, relPath); + break; + case 'service-template': + this.validateServiceTemplate(data, relPath); + break; + case 'prayer': + this.validatePrayer(data, relPath); + break; + case 'command': + this.validateCommand(data, relPath); + break; + case 'commemoration': + this.validateCommemoration(data, relPath); + break; + case 'fasting': + this.validateFasting(data, relPath); + break; + } + this.filesChecked++; + } catch (e) { + this.addError(relPath, '', `Failed to parse: ${(e as Error).message}`); + } + } + + validateDir(dirPath: string, type: string): void { + if (!existsSync(dirPath)) return; + for (const entry of readdirSync(dirPath)) { + const fullPath = join(dirPath, entry); + if (entry.endsWith('.json')) { + this.validateFile(fullPath, type); + } else if (statSync(fullPath).isDirectory()) { + this.validateDir(fullPath, type); + } + } + } + + validateLife(data: unknown, path: string): void { + if (typeof data !== 'object' || data === null) { + this.addError(path, '', 'Life entry must be an object'); + return; + } + const obj = data as Record; + + if (!obj.name || typeof obj.name !== 'object') { + this.addError(path, 'name', 'Missing or invalid name'); + } else { + const name = obj.name as Record; + if (!name.nominative || typeof name.nominative !== 'string') { + this.addError(path, 'name.nominative', 'Missing nominative'); + } + } + + if (obj.rank !== undefined && (typeof obj.rank !== 'number' || obj.rank < 0 || obj.rank > 8)) { + this.addWarning(path, 'rank', 'Rank should be 0-8'); + } + } + + validateLivesBundle(data: unknown, path: string): void { + if (typeof data !== 'object' || data === null) { + this.addError(path, '', 'Lives bundle must be an object'); + return; + } + for (const [cid, entry] of Object.entries(data as Record)) { + this.validateLife(entry, `${path}[${cid}]`); + } + } + + validateCalendar(data: unknown, path: string): void { + if (typeof data !== 'object' || data === null) { + this.addError(path, '', 'Calendar data must be an object'); + return; + } + for (const [key, entry] of Object.entries(data as Record)) { + if (typeof entry !== 'object' || entry === null) { + this.addError(path, `[${key}]`, 'Entry must be an object'); + continue; + } + const obj = entry as Record; + if (!obj.id || typeof obj.id !== 'string') { + this.addError(path, `[${key}].id`, 'Missing or invalid id'); + } + } + } + + validateServiceTemplate(data: unknown, path: string): void { + if (!Array.isArray(data)) { + this.addError(path, '', 'Service template must be an array'); + return; + } + for (let i = 0; i < data.length; i++) { + const node = data[i]; + if (typeof node !== 'object' || node === null) { + this.addError(path, `[${i}]`, 'Node must be an object'); + continue; + } + const obj = node as Record; + if (!obj.type || typeof obj.type !== 'string') { + this.addError(path, `[${i}].type`, 'Missing or invalid type'); + } + } + } + + validatePrayer(data: unknown, path: string): void { + if (!Array.isArray(data)) { + this.addError(path, '', 'Prayer must be an array'); + return; + } + for (let i = 0; i < data.length; i++) { + const node = data[i]; + if (typeof node !== 'object' || node === null) { + this.addError(path, `[${i}]`, 'Node must be an object'); + continue; + } + const obj = node as Record; + if (!obj.type || !['TEXT', 'HEADER'].includes(obj.type as string)) { + this.addError(path, `[${i}].type`, 'Type must be TEXT or HEADER'); + } + } + } + + validateCommand(data: unknown, path: string): void { + if (!Array.isArray(data)) { + this.addError(path, '', 'Command must be an array'); + return; + } + for (let i = 0; i < data.length; i++) { + const node = data[i]; + if (typeof node !== 'object' || node === null) { + this.addError(path, `[${i}]`, 'Node must be an object'); + continue; + } + const obj = node as Record; + if (!obj.type || !['TEXT', 'text'].includes(obj.type as string)) { + this.addError(path, `[${i}].type`, 'Type must be TEXT or text'); + } + } + } + + validateCommemoration(data: unknown, path: string): void { + if (typeof data !== 'object' || data === null) { + this.addError(path, '', 'Commemoration must be an object'); + return; + } + } + + validateFasting(data: unknown, path: string): void { + if (!Array.isArray(data)) { + this.addError(path, '', 'Fasting rules must be an array'); + return; + } + for (let i = 0; i < data.length; i++) { + const period = data[i]; + if (typeof period !== 'object' || period === null) { + this.addError(path, `[${i}]`, 'Period must be an object'); + continue; + } + const obj = period as Record; + if (!obj.cmd || typeof obj.cmd !== 'string') { + this.addError(path, `[${i}].cmd`, 'Missing cmd'); + } + if (!Array.isArray(obj.rules)) { + this.addError(path, `[${i}].rules`, 'Missing rules array'); + } + } + } + + addError(file: string, path: string, message: string): void { + this.errors.push({ file, path, message }); + } + + addWarning(file: string, path: string, message: string): void { + this.warnings.push({ file, path, message }); + } + + printReport(): void { + console.log(`\nValidated ${this.filesChecked} files.\n`); + + if (this.errors.length > 0) { + console.log(`ERRORS (${this.errors.length}):`); + for (const err of this.errors) { + console.log(` ${err.file}${err.path ? ':' + err.path : ''} - ${err.message}`); + } + } + + if (this.warnings.length > 0) { + console.log(`\nWARNINGS (${this.warnings.length}):`); + for (const warn of this.warnings) { + console.log(` ${warn.file}${warn.path ? ':' + warn.path : ''} - ${warn.message}`); + } + } + + if (this.errors.length === 0 && this.warnings.length === 0) { + console.log('All files valid!'); + } + + process.exit(this.errors.length > 0 ? 1 : 0); + } +} + +function main() { + const targetPath = process.argv[2] || DATA_DIR; + const validator = new Validator(DATA_DIR); + + console.log(`Validating data in: ${targetPath}\n`); + + // Validate shared data + const sharedDir = join(DATA_DIR, 'shared'); + if (existsSync(sharedDir)) { + console.log('Validating shared data...'); + validator.validateDir(join(sharedDir, 'lives'), 'lives-bundle'); + validator.validateDir(join(sharedDir, 'commemorations'), 'commemoration'); + validator.validateFile(join(sharedDir, 'fasting.json'), 'fasting'); + } + + // Validate language data (start with en) + const enDir = join(DATA_DIR, 'en'); + if (existsSync(enDir)) { + console.log('Validating English data...'); + validator.validateDir(join(enDir, 'lives'), 'lives-bundle'); + validator.validateDir(join(enDir, 'services', 'templates'), 'service-template'); + validator.validateDir(join(enDir, 'services', 'prayers'), 'prayer'); + validator.validateDir(join(enDir, 'services', 'commands'), 'command'); + } + + validator.printReport(); +} + +main(); diff --git a/scripts/validate-first-week.ts b/scripts/validate-first-week.ts new file mode 100644 index 0000000..d99a354 --- /dev/null +++ b/scripts/validate-first-week.ts @@ -0,0 +1,74 @@ +/** + * Validate First Week of Lent service data files. + * Run with: npx tsx scripts/validate-first-week.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared', 'services'); + +interface ServiceNode { type: string; value: string } + +const SERVICES: Record = { + 'first-week-monday': { nday: -48, markers: ['На ᲂу҆́трени', 'повече́рїи'], template: 'FirstWeekMonday' }, + 'first-week-tuesday': { nday: -47, markers: ['На ᲂу҆́трени', 'повече́рїи'], template: 'FirstWeekTuesday' }, + 'first-week-wednesday': { nday: -46, markers: ['На ᲂу҆́трени', 'повече́рїи'], template: 'FirstWeekWednesday' }, + 'first-week-thursday': { nday: -45, markers: ['На ᲂу҆́трени', 'повече́рїи'], template: 'FirstWeekThursday' }, + 'first-week-friday': { nday: -44, markers: ['преждеѡсщ҃е́нных', 'ко́лѷва', 'Да и҆спра́витсѧ'], template: 'FirstWeekFriday' }, + 'first-week-saturday': { nday: -43, markers: ['Канѡ́нъ ст҃а́гѡ ѳео́дѡра', 'На лїтꙋргі́и'], template: 'FirstWeekSaturday' }, +}; + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const [dir, spec] of Object.entries(SERVICES)) { + const path = join(ROOT, dir, 'full.json'); + if (!existsSync(path)) { err(`${dir}: missing full.json`); continue; } + + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + + if (!Array.isArray(nodes) || nodes.length < 100) { + err(`${dir}: too few nodes (${Array.isArray(nodes) ? nodes.length : 'n/a'})`); + continue; + } + + let headers = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`${dir}[${i}]: invalid type '${n.type}'`); continue; } + if (!n.value || typeof n.value !== 'string') { err(`${dir}[${i}]: missing value`); continue; } + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`${dir}: no HEADER nodes`); + + const all = nodes.map(n => n.value); + for (const m of spec.markers) { + if (!all.some(v => v.includes(m))) warn(`${dir}: marker '${m}' not found`); + } + + // Hours 1/3/6/9 + Typica presence + const hours = all.filter(v => /Ча́съ/.test(v)).length; + if (hours < 3) warn(`${dir}: only ${hours} hour markers`); + + console.log(`${dir}: ${nodes.length} nodes, ${headers} headers (nday ${spec.nday})`); +} + +for (const spec of Object.values(SERVICES)) { + if (!existsSync(join(ROOT, 'templates', `${spec.template}.json`))) { + err(`missing template: ${spec.template}.json`); + } +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-great-canon.ts b/scripts/validate-great-canon.ts new file mode 100644 index 0000000..37d7fa0 --- /dev/null +++ b/scripts/validate-great-canon.ts @@ -0,0 +1,134 @@ +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const CANONS_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'services', 'canons', 'great-canon'); + +interface GreatCanonOde { + ode: number; + irmos: string; + troparia: string[]; + trinityTroparion: string; + theotokion: string; + kontakion?: string; + saintTroparia?: string[]; +} + +interface GreatCanonPart { + part: number; + title: string; + odes: GreatCanonOde[]; +} + +interface ServiceNode { + type: 'HEADER' | 'TEXT'; + value: string; +} + +const ODE_SLAV = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃', 'ѳ҃']; + +function simulateLoadGreatCanon(data: GreatCanonPart): ServiceNode[] { + const nodes: ServiceNode[] = []; + nodes.push({ type: 'HEADER', value: 'Вели́кїй канѡ́нъ, гла́съ ѕ҃.' }); + nodes.push({ type: 'HEADER', value: 'Творе́нїе ст҃а́гѡ ѻ҆тца̀ на́шегѡ а҆ндре́а кри́тскагѡ.' }); + + for (const ode of data.odes) { + const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode); + nodes.push({ type: 'HEADER', value: `Пѣ́снь ${slavNum}.` }); + + if (ode.irmos) { + nodes.push({ type: 'HEADER', value: 'І҆рмо́съ:' }); + nodes.push({ type: 'TEXT', value: ode.irmos }); + } + + const refrain = 'Поми́лꙋй мѧ̀, бж҃е, поми́лꙋй мѧ̀.'; + for (const trop of ode.troparia) { + nodes.push({ type: 'TEXT', value: `${trop}\nПрипѣ́въ: ${refrain}` }); + } + + if (ode.trinityTroparion) { + nodes.push({ type: 'HEADER', value: 'Сла́ва, трⷪ҇ченъ:' }); + nodes.push({ type: 'TEXT', value: ode.trinityTroparion }); + } + + if (ode.theotokion) { + nodes.push({ type: 'HEADER', value: 'И҆ ны́нѣ, бг҃оро́диченъ:' }); + nodes.push({ type: 'TEXT', value: ode.theotokion }); + } + + if (ode.saintTroparia && ode.saintTroparia.length > 0) { + for (const saintTrop of ode.saintTroparia) { + nodes.push({ type: 'TEXT', value: saintTrop }); + } + } + + if (ode.kontakion) { + nodes.push({ type: 'HEADER', value: 'Конда́къ, гла́съ ѕ҃:' }); + nodes.push({ type: 'TEXT', value: ode.kontakion }); + } + } + + return nodes; +} + +let errors = 0; +let warnings = 0; + +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const entry of readdirSync(CANONS_DIR).sort()) { + const fullPath = join(CANONS_DIR, entry); + if (!entry.endsWith('.json')) continue; + + console.log(`\n=== ${entry} ===`); + + let data: GreatCanonPart; + try { + data = JSON.parse(readFileSync(fullPath, 'utf-8')); + } catch (e) { + err(`${entry}: failed to parse: ${(e as Error).message}`); + continue; + } + + if (!data.part || typeof data.part !== 'number' || data.part < 1 || data.part > 4) + err(`${entry}: invalid part ${data.part}`); + if (!Array.isArray(data.odes)) + err(`${entry}: odes must be an array`); + if (data.odes.length !== 9) + err(`${entry}: expected 9 odes, got ${data.odes.length}`); + + for (let oi = 0; oi < data.odes.length; oi++) { + const ode = data.odes[oi]; + const pfx = `${entry}/odes[${oi}]/ode${ode.ode}`; + + if (!ode.ode || typeof ode.ode !== 'number' || ode.ode < 1 || ode.ode > 9) + err(`${pfx}: invalid ode number`); + if (oi > 0 && data.odes[oi - 1].ode >= ode.ode) + err(`${pfx}: ode not in ascending order`); + if (typeof ode.irmos !== 'string' || ode.irmos.length < 10) + err(`${pfx}: irmos missing or too short (${ode.irmos.length} chars)`); + if (!Array.isArray(ode.troparia) || ode.troparia.length === 0) + err(`${pfx}: troparia missing or empty`); + if (typeof ode.trinityTroparion !== 'string' || ode.trinityTroparion.length < 10) + err(`${pfx}: trinityTroparion missing or too short`); + if (typeof ode.theotokion !== 'string' || ode.theotokion.length < 10) + err(`${pfx}: theotokion missing or too short`); + + // Ode 6 should have kontakion + if (ode.ode === 6 && (!ode.kontakion || ode.kontakion.length < 10)) + warn(`${pfx}: Ode 6 missing kontakion`); + } + + const nodes = simulateLoadGreatCanon(data); + if (nodes.length === 0) { + err(`${entry}: zero ServiceNodes produced`); + continue; + } + + console.log(` ${data.odes.length} odes, ${nodes.length} ServiceNodes`); +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-great-compline.ts b/scripts/validate-great-compline.ts new file mode 100644 index 0000000..02c0551 --- /dev/null +++ b/scripts/validate-great-compline.ts @@ -0,0 +1,89 @@ +/** + * Validate Great Compline data: prayer files referenced by the + * GreatCompline template must exist and be well-formed. + * Run with: npx tsx scripts/validate-great-compline.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data'); +const TEMPLATE = join(ROOT, 'shared', 'services', 'templates', 'GreatCompline.json'); +const PRAYERS = join(ROOT, 'cu', 'services', 'prayers'); +const TEMPLATES = join(ROOT, 'shared', 'services', 'templates'); +const BIBLE = join(ROOT, 'cu', 'bible', 'elis'); + +interface ServiceNode { + type: string; + what?: string; + file?: string; + verses?: string; + cmd?: string; +} + +let errors = 0; +let warnings = 0; + +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +const nodes: ServiceNode[] = JSON.parse(readFileSync(TEMPLATE, 'utf-8')); +console.log(`GreatCompline.json: ${nodes.length} nodes`); + +const usedPrayers = new Set(); +for (const n of nodes) { + if (n.type === 'CREATE') { + if (!n.what) { err('CREATE without what'); continue; } + usedPrayers.add(n.what); + const p = join(PRAYERS, `${n.what}.json`); + if (!existsSync(p)) { + err(`missing prayer: ${n.what}.json`); + continue; + } + const prayerNodes = JSON.parse(readFileSync(p, 'utf-8')); + if (!Array.isArray(prayerNodes) || prayerNodes.length === 0) { + err(`empty prayer: ${n.what}.json`); + continue; + } + for (const pn of prayerNodes) { + if (!pn.type || !pn.value) { + err(`${n.what}.json: node missing type/value`); + break; + } + } + } + if (n.type === 'GET') { + if (!n.file) { err('GET without file'); continue; } + if (n.file.startsWith('Var/')) continue; + const p = join(TEMPLATES, `${n.file}.json`); + if (!existsSync(p)) { + err(`missing template: ${n.file}.json`); + } + } + if (n.type === 'BIBLE') { + const v = n.verses ?? ''; + const book = v.split('_')[0]; + const p = join(BIBLE, `${book}.text`); + if (!existsSync(p)) { + err(`missing bible book: ${book}`); + } + } +} + +console.log(`\n${usedPrayers.size} unique prayers referenced`); + +// Verify the Great Canon data files exist +const CANONS = join(ROOT, 'shared', 'services', 'canons', 'great-canon'); +for (let i = 1; i <= 4; i++) { + const p = join(CANONS, `part${i}.json`); + if (!existsSync(p)) err(`missing great canon part${i}.json`); + else { + const data = JSON.parse(readFileSync(p, 'utf-8')); + if (data.odes.length !== 9) err(`part${i}.json: expected 9 odes`); + } +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-holy-week.ts b/scripts/validate-holy-week.ts new file mode 100644 index 0000000..ae58a4d --- /dev/null +++ b/scripts/validate-holy-week.ts @@ -0,0 +1,135 @@ +/** + * Validate Holy Week service data files: every full.json must be + * well-formed, and referenced templates must exist. + * Run with: npx tsx scripts/validate-holy-week.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared', 'services'); + +interface ServiceNode { type: string; value: string } + +const SERVICES: Record = { + 'palm-sunday': -7, + 'great-monday': -6, + 'great-tuesday': -5, + 'great-wednesday': -4, + 'great-thursday': -3, + 'passion-gospels': -3, + 'royal-hours-friday': -2, + 'lamentations': -1, + 'burial-vespers': -2, + 'saturday-hours': -1, + 'saturday-vespers-liturgy': -1, + 'saturday-midnight': -1, + 'pascha': 0, + 'bright-monday': 1, + 'bright-tuesday': 2, + 'bright-wednesday': 3, + 'bright-thursday': 4, + 'bright-friday': 5, + 'bright-saturday': 6, +}; + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const [dir, nday] of Object.entries(SERVICES)) { + const path = join(ROOT, dir, 'full.json'); + if (!existsSync(path)) { err(`${dir}: missing full.json`); continue; } + + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + + if (!Array.isArray(nodes) || nodes.length < 20) { + err(`${dir}: too few nodes (${Array.isArray(nodes) ? nodes.length : 'n/a'})`); + continue; + } + + let headers = 0, empty = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`${dir}[${i}]: invalid type '${n.type}'`); continue; } + if (!n.value || typeof n.value !== 'string') { err(`${dir}[${i}]: missing value`); continue; } + if (n.value.length < 3) empty++; + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`${dir}: no HEADER nodes`); + if (empty > 0) warn(`${dir}: ${empty} suspiciously short values`); + + // Service-specific presence checks + const all = nodes.map(n => n.value); + if (dir === 'passion-gospels') { + const gospels = all.filter(v => /є҆ѵⷢ҇лїе/i.test(v) && /завѣ́та/i.test(v)); + if (gospels.length !== 12) err(`${dir}: expected 12 gospel markers, got ${gospels.length}`); + const dolg = all.filter(v => v.includes('долготерпѣ́нїю')).length; + if (dolg < 11) warn(`${dir}: only ${dolg} 'Слава долготерпению' responses`); + } + if (dir === 'lamentations') { + const statii = all.filter(v => v.startsWith('Статїѧ̀')).length; + if (statii !== 3) err(`${dir}: expected 3 статии, got ${statii}`); + } + if (dir === 'royal-hours-friday') { + // Hours are marked inline: "Ча́съ пе́рвый", "Посе́мъ пое́мъ тре́тїй ча́съ", etc. + const hours = all.filter(v => /ча́съ/.test(v)); + if (hours.length < 4) warn(`${dir}: only ${hours.length} hour markers`); + } + if (dir === 'saturday-vespers-liturgy') { + // 15 paroemia readings + special hymns + if (!all.some(v => v.includes('Да молчи́тъ') || v.includes('молчи́тъ всѧ́каѧ пло́ть'))) + err(`${dir}: missing 'Да молчит всякая плоть'`); + if (!all.some(v => v.includes('рыда́й'))) + err(`${dir}: missing 'Не рыдай Мене Мати'`); + if (!all.some(v => /[ЕЄ]҆?ли́цы/.test(v) || v.includes('крести́стесѧ'))) + warn(`${dir}: 'Елицы во Христа крестистеся' not found`); + if (!all.some(v => v.includes('Воскрⷭ҇нѝ'))) + err(`${dir}: missing 'Воскресни Боже'`); + } + if (dir === 'saturday-midnight') { + const odes = all.filter(v => /Пѣ́снь [а-ѳ]/.test(v)).length; + if (odes < 8) warn(`${dir}: only ${odes} canon odes`); + } + if (dir === 'burial-vespers') { + // Burial Vespers should include the "Благообразный Иосиф" troparion + if (!all.some(v => v.includes('і҆ѡ́сиф') || v.includes('И҆ѡ́сиф'))) + warn(`${dir}: troparion of St. Joseph not found`); + } + if (dir === 'pascha') { + if (!all.some(v => v.includes('златоꙋ́стагѡ') || v.includes('Златоꙋ́стагѡ'))) + err(`${dir}: missing Catechetical homily of St. John Chrysostom`); + if (!all.some(v => v.includes('а҆́ртоса') || v.includes('артоса'))) + warn(`${dir}: Artos blessing not found`); + const antiphons = all.filter(v => v.startsWith('А҆нтїфѡ́нъ')).length; + if (antiphons < 3) err(`${dir}: expected 3 antiphons, got ${antiphons}`); + } + + console.log(`${dir}: ${nodes.length} nodes, ${headers} headers (${nday < 0 ? 'nday ' + nday : 'fixed'})`); +} + +// Verify templates referenced by services exist +const templates = [ + 'PalmSunday', 'GreatMonday', 'GreatTuesday', 'GreatWednesday', 'GreatThursday', + 'PassionGospels', 'RoyalHoursFriday', 'Lamentations', + 'BurialVespers', 'SaturdayHours', 'SaturdayVespersLiturgy', 'SaturdayMidnight', + 'Pascha', 'BrightMonday', 'BrightTuesday', 'BrightWednesday', 'BrightThursday', + 'BrightFriday', 'BrightSaturday', +]; +for (const t of templates) { + if (!existsSync(join(ROOT, 'templates', `${t}.json`))) { + err(`missing template: ${t}.json`); + } +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-libcs.ts b/scripts/validate-libcs.ts new file mode 100644 index 0000000..74017a6 --- /dev/null +++ b/scripts/validate-libcs.ts @@ -0,0 +1,74 @@ +/** + * Validate the libcs-derived content (Параклитика weekday canons, + * Irmologion) converted from the libcs "Ucs" encoding. + * Run with: npx tsx scripts/validate-libcs.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared'); + +interface ServiceNode { type: string; value: string } + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +// --- Paraclete weekday canons: 8 tones x 6 days --- +const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat']; +let canonCount = 0; +for (let tone = 1; tone <= 8; tone++) { + for (const day of DAYS) { + const p = join(ROOT, 'services', 'canons', 'paraclete', `tone${tone}`, `${day}.json`); + if (!existsSync(p)) { err(`paraclete tone${tone} ${day}: missing`); continue; } + const nodes = JSON.parse(readFileSync(p, 'utf-8')) as ServiceNode[]; + if (!Array.isArray(nodes) || nodes.length < 50) { err(`paraclete tone${tone} ${day}: too few nodes`); continue; } + // Must contain ode markers + const odes = nodes.filter(n => n.type === 'HEADER' && /^Пѣ́снь [а-ѳ]/.test(n.value)).length; + if (odes < 5) warn(`paraclete tone${tone} ${day}: only ${odes} ode markers`); + // No Hirm residue + const text = nodes.map(n => n.value).join(' '); + const hirm = ['™', '№', 'G', '6', '1', 'h', 'E', 'z'].filter(m => text.includes(m)); + if (hirm.length) err(`paraclete tone${tone} ${day}: Hirm residue: ${hirm.join(',')}`); + canonCount++; + } +} +console.log(`paraclete canons: ${canonCount}/48 valid`); + +// --- Irmologion: 24 sections --- +const IRMO_IDS = [ + 'irmos-1', 'irmos-2', 'irmos-3', 'irmos-4', 'irmos-5', 'irmos-6', 'irmos-7', 'irmos-8', + 'irmos-prefeast-nativity', 'irmos-prefeast-theophany', 'liturgy-chants', 'gospodi-vozzvah', + 'theotokia-sunday', 'theotokia-daily', 'stepenna', 'trinity-songs', 'sunday-feast-verses', + 'lent-canon-rules', 'sunday-troparia', 'sunday-prokimena', 'saturday-troparia', + 'paschal-canon', 'chosen-psalms', 'feast-refrains-ode9', +]; +let irmoCount = 0; +for (const id of IRMO_IDS) { + const p = join(ROOT, 'irmologion', id, 'full.json'); + if (!existsSync(p)) { err(`irmologion/${id}: missing`); continue; } + const nodes = JSON.parse(readFileSync(p, 'utf-8')) as ServiceNode[]; + if (!Array.isArray(nodes) || nodes.length < 5) { err(`irmologion/${id}: too few nodes`); continue; } + irmoCount++; +} +console.log(`irmologion: ${irmoCount}/${IRMO_IDS.length} sections valid`); + +// --- Paraclete day files (collection): 48 --- +let dayCount = 0; +for (let tone = 1; tone <= 8; tone++) { + for (const day of DAYS) { + const p = join(ROOT, 'services', 'paraclete', `tone${tone}`, `${day}.json`); + if (!existsSync(p)) { err(`paraclete-day tone${tone} ${day}: missing`); continue; } + const nodes = JSON.parse(readFileSync(p, 'utf-8')) as ServiceNode[]; + if (!Array.isArray(nodes) || nodes.length < 100) { err(`paraclete-day tone${tone} ${day}: too few nodes`); continue; } + dayCount++; + } +} +console.log(`paraclete day files: ${dayCount}/48 valid`); + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-menaion-daily.ts b/scripts/validate-menaion-daily.ts new file mode 100644 index 0000000..dba5044 --- /dev/null +++ b/scripts/validate-menaion-daily.ts @@ -0,0 +1,80 @@ +/** + * Validate the Daily Menaion (menaion-daily, menaion-common, menaion-add) + * converted from libcs. + * Run with: npx tsx scripts/validate-menaion-daily.ts + */ + +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared'); + +interface ServiceNode { type: string; value: string } + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +function checkSection(path: string, label: string) { + if (!existsSync(path)) { err(`${label}: missing`); return 0; } + const nodes = JSON.parse(readFileSync(path, 'utf-8')) as ServiceNode[]; + if (!Array.isArray(nodes) || nodes.length < 5) { err(`${label}: too few nodes`); return 0; } + const text = nodes.map(n => n.value).join(' '); + const hirm = ['™', '№', 'G', '6', '1', 'h', 'E', 'z'].filter(m => text.includes(m)); + if (hirm.length) err(`${label}: Hirm residue: ${hirm.join(',')}`); + return nodes.length; +} + +// --- Daily Menaion --- +const DAILY = join(ROOT, 'menaion-daily'); +let dailySections = 0; +let dailyDates = 0; +for (const dateDir of readdirSync(DAILY).sort()) { + if (dateDir === 'index.json') continue; + const dir = join(DAILY, dateDir); + if (!existsSync(dir) || !readdirSync(dir).some(f => f.endsWith('.json'))) continue; + dailyDates++; + for (const fn of readdirSync(dir).sort()) { + if (!fn.endsWith('.json')) continue; + checkSection(join(dir, fn), `menaion-daily/${dateDir}/${fn}`); + dailySections++; + } +} +console.log(`menaion-daily: ${dailySections} sections over ${dailyDates} dates`); + +// Index integrity +const index = JSON.parse(readFileSync(join(DAILY, 'index.json'), 'utf-8')) as Record; +let indexOk = 0; +for (const [dk, n] of Object.entries(index)) { + const dir = join(DAILY, dk); + const actual = existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.json')).length : 0; + if (actual !== n) err(`index mismatch ${dk}: index=${n}, actual=${actual}`); + else indexOk++; +} +console.log(`index.json: ${indexOk}/${Object.keys(index).length} dates verified`); + +// --- Common Menaion --- +let common = 0; +const COMMON = join(ROOT, 'menaion-common'); +for (const fn of readdirSync(COMMON).sort()) { + if (!fn.endsWith('.json')) continue; + checkSection(join(COMMON, fn), `menaion-common/${fn}`); + common++; +} +console.log(`menaion-common: ${common} sections`); + +// --- Additions --- +let add = 0; +const ADD = join(ROOT, 'menaion-add'); +for (const fn of readdirSync(ADD).sort()) { + if (!fn.endsWith('.json')) continue; + checkSection(join(ADD, fn), `menaion-add/${fn}`); + add++; +} +console.log(`menaion-add: ${add} sections`); + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-menaion.ts b/scripts/validate-menaion.ts new file mode 100644 index 0000000..5ee9d01 --- /dev/null +++ b/scripts/validate-menaion.ts @@ -0,0 +1,107 @@ +/** + * Validate Festal Menaion (Great Feast) service data files. + * Run with: npx tsx scripts/validate-menaion.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared', 'services'); + +interface ServiceNode { type: string; value: string } + +const SERVICES: Record = { + 'nativity-theotokos': { month: 9, day: 8, markers: ['Ржⷭ҇тво̀ прест҃ы́ѧ'], template: 'NativityTheotokos' }, + 'exaltation': { month: 9, day: 14, markers: ['воздви́женїе честна́гѡ'], template: 'Exaltation' }, + 'vvedenie': { month: 11, day: 21, markers: ['Вхо́дъ во хра́мъ'], template: 'Vvedenie' }, + 'nativity-hours': { month: 12, day: 24, markers: ['на́вечерїи ржⷭ҇тва̀'], template: 'NativityHours' }, + 'nativity': { month: 12, day: 25, markers: ['ржⷭ҇тво̀ гдⷭ҇а'], template: 'Nativity' }, + 'theophany-hours': { month: 1, day: 5, markers: ['на́вечерїи просвѣще́нїѧ'], template: 'TheophanyHours' }, + 'theophany': { month: 1, day: 6, markers: ['бг҃оѧвле́нїе гдⷭ҇а'], template: 'Theophany' }, + 'sretenie': { month: 2, day: 2, markers: ['Срѣ́тенїе гдⷭ҇а'], template: 'Sretenie' }, + 'annunciation': { month: 3, day: 25, markers: ['Бл҃говѣ́щенїе'], template: 'Annunciation' }, + 'forerunner-birth': { month: 6, day: 24, markers: ['Рождество̀ честна́гѡ'], template: 'ForerunnerBirth' }, + 'peter-paul': { month: 6, day: 29, markers: ['первоверхо́вныхъ'], template: 'PeterPaul' }, + 'transfiguration': { month: 8, day: 6, markers: ['преѡбраже́нїе гдⷭ҇а'], template: 'Transfiguration' }, + 'dormition': { month: 8, day: 15, markers: ['Оу҆спе́нїе прест҃ы́ѧ'], template: 'Dormition' }, + 'forerunner-beheading': { month: 8, day: 29, markers: ['Оу҆сѣкнове́нїе честны́ѧ'], template: 'ForerunnerBeheading' }, + 'sergius': { month: 9, day: 25, markers: ['се́ргїа'], template: 'Sergius' }, + 'johntheologian-sep': { month: 9, day: 26, markers: ['бг҃осло́ва'], template: 'JohnTheologianSep' }, + 'pokrov': { month: 10, day: 1, markers: ['Покро́въ'], template: 'Pokrov' }, + 'ambrose': { month: 10, day: 10, markers: ['а҆мвро́сїа'], template: 'Ambrose' }, + 'seventh-council-fathers': { month: 10, day: 11, markers: ['седмо́мъ'], template: 'SeventhCouncilFathers' }, + 'kazan': { month: 10, day: 22, markers: ['каза́нскїѧ'], template: 'Kazan' }, + 'demetrius': { month: 10, day: 26, markers: ['дими́трїа'], template: 'Demetrius' }, + 'michael-synaxis': { month: 11, day: 8, markers: ['мїхаи́ла'], template: 'MichaelSynaxis' }, + 'nicholas': { month: 12, day: 6, markers: ['нїкола́а'], template: 'Nicholas' }, + 'circumcision': { month: 1, day: 1, markers: ['ѡ҆брѣ́занїе'], template: 'Circumcision' }, + 'finding-head-1st': { month: 2, day: 24, markers: ['ѡ҆брѣ́тенїе'], template: 'FindingHead1st' }, + 'forty-martyrs': { month: 3, day: 9, markers: ['м҃ мч҃никъ'], template: 'FortyMartyrs' }, + 'johntheologian-may': { month: 5, day: 8, markers: ['бг҃осло́ва'], template: 'JohnTheologianMay' }, + 'nicholas-translation': { month: 5, day: 9, markers: ['нїкола́а'], template: 'NicholasTranslation' }, + 'finding-head-3rd': { month: 5, day: 25, markers: ['Тре́тїе ѡ҆брѣ́тенїе'], template: 'FindingHead3rd' }, + 'vladimir': { month: 7, day: 15, markers: ['влади́мїра'], template: 'Vladimir' }, + 'six-councils-fathers': { month: 7, day: 16, markers: ['шестѝ собо́рѡвъ'], template: 'SixCouncilsFathers' }, + 'elijah': { month: 7, day: 20, markers: ['и҆лїѝ'], template: 'Elijah' }, + 'panteleimon': { month: 7, day: 27, markers: ['пантелеи́мона'], template: 'Panteleimon' }, + 'procession-cross': { month: 8, day: 1, markers: ['Происхожде́нїе'], template: 'ProcessionCross' }, + 'forefathers-sunday': { month: 12, day: 11, markers: ['пра́ѻтєцъ'], template: 'ForefathersSunday' }, + 'holy-fathers-nativity': { month: 12, day: 18, markers: ['предъ ржⷭ҇тво́мъ'], template: 'HolyFathersNativity' }, + 'sunday-after-nativity': { month: 12, day: 26, markers: ['по ржⷭ҇твѣ̀'], template: 'SundayAfterNativity' }, +}; + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const [dir, spec] of Object.entries(SERVICES)) { + const path = join(ROOT, dir, 'full.json'); + if (!existsSync(path)) { err(`${dir}: missing full.json`); continue; } + + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + + if (!Array.isArray(nodes) || nodes.length < 50) { + err(`${dir}: too few nodes (${Array.isArray(nodes) ? nodes.length : 'n/a'})`); + continue; + } + + let headers = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`${dir}[${i}]: invalid type '${n.type}'`); continue; } + if (!n.value || typeof n.value !== 'string') { err(`${dir}[${i}]: missing value`); continue; } + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`${dir}: no HEADER nodes`); + + const all = nodes.map(n => n.value); + for (const m of spec.markers) { + if (!all.some(v => v.includes(m))) warn(`${dir}: marker '${m}' not found`); + } + + // Matins presence for full feast services (not Royal Hours) + if (!['nativity-hours', 'theophany-hours'].includes(dir)) { + if (!all.some(v => v.includes('На ᲂу҆́трени') || v.includes('на ᲂу҆́трени'))) + warn(`${dir}: no Matins marker`); + } + + console.log(`${dir}: ${nodes.length} nodes, ${headers} headers (${spec.month}/${spec.day})`); +} + +for (const spec of Object.values(SERVICES)) { + if (!existsSync(join(ROOT, 'templates', `${spec.template}.json`))) { + err(`missing template: ${spec.template}.json`); + } +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-pentecostarion.ts b/scripts/validate-pentecostarion.ts new file mode 100644 index 0000000..3894864 --- /dev/null +++ b/scripts/validate-pentecostarion.ts @@ -0,0 +1,87 @@ +/** + * Validate Pentecostarion service data files: every full.json must be + * well-formed, and referenced templates must exist. + * Run with: npx tsx scripts/validate-pentecostarion.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared', 'services'); + +interface ServiceNode { type: string; value: string } + +const SERVICES: Record = { + 'antipascha': { nday: 7, markers: ['а҆нтїпа́схи'], template: 'Antipascha' }, + 'myrrhbearers': { nday: 14, markers: ['мѷроно́сиц'], template: 'Myrrhbearers' }, + 'paralytic': { nday: 21, markers: ['разсла́бленнагѡ'], template: 'Paralytic' }, + 'prepolovenie': { nday: 25, markers: ['Преполове́нїе'], template: 'Prepolovenie' }, + 'samaritan': { nday: 28, markers: ['самарѧны́ни'], template: 'Samaritan' }, + 'blindman': { nday: 35, markers: ['слѣпо́мъ'], template: 'BlindMan' }, + 'apodosis': { nday: 38, markers: ['Ѿда́нїе'], template: 'Apodosis' }, + 'ascension': { nday: 39, markers: ['Вознесе́нїе'], template: 'Ascension' }, + 'holyfathers': { nday: 42, markers: ['нїке́и'], template: 'HolyFathers' }, + 'pentecostsaturday': { nday: 48, markers: ['пѧтдесѧ́тницы'], template: 'PentecostSaturday' }, + 'pentecost': { nday: 49, markers: ['пентико́стїи'], template: 'Pentecost' }, + 'holyspirit': { nday: 50, markers: ['ст҃а́гѡ дх҃а'], template: 'HolySpirit' }, + 'allsaints': { nday: 56, markers: ['всѣ́хъ ст҃ы́хъ'], template: 'AllSaints' }, + 'russiansaints': { nday: 56, markers: ['рѡссі́йстей'], template: 'RussianSaints' }, +}; + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const [dir, spec] of Object.entries(SERVICES)) { + const path = join(ROOT, dir, 'full.json'); + if (!existsSync(path)) { err(`${dir}: missing full.json`); continue; } + + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + + if (!Array.isArray(nodes) || nodes.length < 20) { + err(`${dir}: too few nodes (${Array.isArray(nodes) ? nodes.length : 'n/a'})`); + continue; + } + + let headers = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`${dir}[${i}]: invalid type '${n.type}'`); continue; } + if (!n.value || typeof n.value !== 'string') { err(`${dir}[${i}]: missing value`); continue; } + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`${dir}: no HEADER nodes`); + + const all = nodes.map(n => n.value); + for (const m of spec.markers) { + if (!all.some(v => v.includes(m))) warn(`${dir}: marker '${m}' not found`); + } + + // Every Sunday service should have Matins (на утрени) + if ([7, 14, 21, 28, 35, 42, 49, 56].includes(spec.nday)) { + if (!all.some(v => v.includes('на ᲂу҆́трени') || v.includes('На ᲂу҆́трени'))) + warn(`${dir}: no Matins marker`); + } + + console.log(`${dir}: ${nodes.length} nodes, ${headers} headers (nday ${spec.nday})`); +} + +// Verify templates referenced by services exist +const templates = Object.values(SERVICES).map(s => s.template); +for (const t of templates) { + if (!existsSync(join(ROOT, 'templates', `${t}.json`))) { + err(`missing template: ${t}.json`); + } +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-prayer-rule.ts b/scripts/validate-prayer-rule.ts new file mode 100644 index 0000000..0a55e0a --- /dev/null +++ b/scripts/validate-prayer-rule.ts @@ -0,0 +1,225 @@ +/** + * Validate Prayer Rule (Молитвослов) section data files. + * Run with: npx tsx scripts/validate-prayer-rule.ts + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared', 'prayer-rule'); + +interface ServiceNode { type: string; value: string } + +const SECTIONS: Record = { + 'morning': ['Мл҃тва мытарѧ̀', 'Сѷмво́лъ', 'мака́рїа'], + 'diptychs': ['Помѧ́нникъ'], + 'evening': ['на со́нъ грѧдꙋ́щымъ', 'мл҃твꙋ'], + 'three-canons': ['Канѡ́нъ покаѧ́нный', 'моле́бный', 'храни́тел'], + 'communion': ['причаще́нїю', 'Ѱало́мъ'], + 'thanksgiving': ['Бл҃года́рствєнныѧ'], + 'rule-impurity': ['ѡ҆скверне́нїѧ'], + 'litia-departed': ['ѡ҆ ᲂу҆со́пшихъ'], + 'twelve-psalms': ['двана́десѧть ѱалмѡ́въ'], + 'beginning-ending': ['предначина́тельнаѧ', 'Ѡ҆кончанїе'], +}; + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +for (const [dir, markers] of Object.entries(SECTIONS)) { + const path = join(ROOT, dir, 'full.json'); + if (!existsSync(path)) { err(`${dir}: missing full.json`); continue; } + + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + + if (!Array.isArray(nodes) || nodes.length < 10) { + err(`${dir}: too few nodes (${Array.isArray(nodes) ? nodes.length : 'n/a'})`); + continue; + } + + let headers = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`${dir}[${i}]: invalid type '${n.type}'`); continue; } + if (!n.value || typeof n.value !== 'string') { err(`${dir}[${i}]: missing value`); continue; } + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`${dir}: no HEADER nodes`); + + const all = nodes.map(n => n.value); + for (const m of markers) { + if (!all.some(v => v.includes(m))) warn(`${dir}: marker '${m}' not found`); + } + + console.log(`${dir}: ${nodes.length} nodes, ${headers} headers`); +} + + + +// --- Akathists collection --- +const AKATHISTS: Record = { + 'trinity-canon': ['трⷪ҇цѣ'], + 'jesus-compunction': ['ᲂу҆мили́тельный'], + 'akathist-jesus': ['А҆ка́ѳїстъ'], + 'jesus-penitential': ['покаѧ́нный'], + 'pascha-canon': ['па́схи'], + 'nativity-canon': ['ржⷭ҇твꙋ̀'], + 'cross-canon': ['крⷭ҇тꙋ̀'], + 'theotokos-moleben': ['моле́бный'], + 'theotokos-thanksgiving': ['бл҃года́ренъ'], + 'akathist-theotokos': ['А҆ка́ѳїстъ'], + 'theotokos-nativity': ['ржⷭ҇твꙋ̀'], + 'pokrov-canon': ['покро́ва'], + 'utoli-pechali': ['печа̑ли'], + 'skoroposlushnitsa': ['скоропослꙋ́шница'], + 'troeruchitsa': ['троерꙋ́чица'], + 'angels-canon': ['а҆рха́гг҃лѡмъ'], + 'michael-canon': ['мїхаи́ла'], + 'gabriel-canon': ['гаврїи́ла'], + 'guardian-angel': ['храни́телю'], + 'forerunner-canon': ['прⷣте́чи'], + 'nicholas-canon-akathist': ['нїкола́ю'], + 'akathist-nicholas': ['А҆ка́ѳїстъ'], + 'spiridon': ['спѷрїдѡ́на'], + 'cyprian-justina': ['кѷпрїа́на'], + 'panteleimon': ['пантелеи́мона'], + 'tryphon': ['трѵ́фѡна'], + 'sergius': ['се́ргїа'], + 'alexander-svirsky': ['а҆леѯа́ндра'], + 'seraphim': ['серафі́мꙋ'], + 'john-kronstadt': ['кроншта́дтскомꙋ'], + 'mary-egypt': ['марі́и'], + 'murom-wonderworkers': ['мꙋ́ромскихъ'], + 'saint-anne': ['а҆́нны'], +}; + +const AKATHISTS_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'akathists'); +for (const [dir, markers] of Object.entries(AKATHISTS)) { + const path = join(AKATHISTS_DIR, dir, 'full.json'); + if (!existsSync(path)) { err(`akathists/${dir}: missing full.json`); continue; } + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`akathists/${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + if (!Array.isArray(nodes) || nodes.length < 20) { + err(`akathists/${dir}: too few nodes`); + continue; + } + let headers = 0; + for (const n of nodes) { + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`akathists/${dir}: invalid type '${n.type}'`); break; } + if (!n.value || typeof n.value !== 'string') { err(`akathists/${dir}: missing value`); break; } + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`akathists/${dir}: no HEADER nodes`); + const all = nodes.map(n => n.value); + for (const m of markers) { + if (!all.some(v => v.includes(m))) warn(`akathists/${dir}: marker '${m}' not found`); + } + console.log(`akathists/${dir}: ${nodes.length} nodes, ${headers} headers`); +} + +// --- Parimii collection --- +const PARIMII: Record = { + 'sept': ['СЕПТЕ́МВРІЙ'], 'oct': ['Ѻ҆КТѠ́ВРІЙ'], 'nov': ['НОЕ́МВРІЙ'], + 'dec': ['ДЕКЕ́МВРІЙ'], 'jan': ['І҆АННꙊА́РІЙ'], 'feb': ['ФЕѴРꙊА́РІЙ'], + 'mar': ['МА́РТЪ'], 'apr': ['А҆ПРІЛЛІЙ'], 'may': ['МА́ІЙ'], + 'jun': ['І҆Ꙋ́НІЙ'], 'jul': ['І҆Ꙋ́ЛІЙ'], 'aug': ['А҆́ѴГꙊСТЪ'], + 'cheese-week': ['СЫ́РНАѦ'], + 'lent-week-1': ['ПЕ́РВАѦ СЕДМИ́ЦА'], + 'lent-week-2': ['ВТОРА́Ѧ СЕДМИ́ЦА'], + 'lent-week-3': ['ТРЕ́ТІѦ СЕДМИ́ЦА'], + 'lent-week-4': ['ЧЕТВЕ́РТАѦ СЕДМИ́ЦА'], + 'lent-week-5': ['ПѦ́ТАѦ СЕДМИ́ЦА'], + 'palm-week': ['ВА̀ІЙ'], + 'holy-week': ['ВЕЛИ́КАѦ СЕДМИ́ЦА'], + 'pentecostarion': ['ПЕНТИКО́СТІИ'], + 'common-saints': ['Ѻ҆́БЩЫѦ'], +}; + +const PARIMII_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'parimii'); +for (const [dir, markers] of Object.entries(PARIMII)) { + const path = join(PARIMII_DIR, dir, 'full.json'); + if (!existsSync(path)) { err(`parimii/${dir}: missing full.json`); continue; } + let nodes: ServiceNode[]; + try { + nodes = JSON.parse(readFileSync(path, 'utf-8')); + } catch (e) { + err(`parimii/${dir}: failed to parse: ${(e as Error).message}`); + continue; + } + if (!Array.isArray(nodes) || nodes.length < 10) { + err(`parimii/${dir}: too few nodes`); + continue; + } + let headers = 0; + for (const n of nodes) { + if (n.type !== 'HEADER' && n.type !== 'TEXT') { err(`parimii/${dir}: invalid type '${n.type}'`); break; } + if (!n.value || typeof n.value !== 'string') { err(`parimii/${dir}: missing value`); break; } + if (n.type === 'HEADER') headers++; + } + if (headers === 0) err(`parimii/${dir}: no HEADER nodes`); + const all = nodes.map(n => n.value); + for (const m of markers) { + if (!all.some(v => v.includes(m))) warn(`parimii/${dir}: marker '${m}' not found`); + } + console.log(`parimii/${dir}: ${nodes.length} nodes, ${headers} headers`); +} + +// --- Horologion collection --- +const HOROLOGION: Record = { + 'midnight-daily': ['полꙋ́нощницы'], 'midnight-saturday': ['сꙋббѡ́тнаѧ'], 'midnight-sunday': ['воскрⷭ҇наѧ'], + 'typica': ['и҆з̾ѡбрази́тельныхъ'], + 'interhour-1': ['Междоча́сїе'], 'interhour-3': ['Междоча́сїе'], 'interhour-6': ['Междоча́сїе'], 'interhour-9': ['Междоча́сїе'], + 'panagia': ['панагі́и'], 'meal-blessing': ['трапе́зы'], 'small-compline': ['ма́лагѡ повече́рїѧ'], +}; + +// --- Sbornik collection --- +const SBORNIK: Record = { + 'sunday-trop-kont': ['воскрⷭ҇ны'], 'weekday-trop-kont': ['седми́цꙋ'], 'feast-trop-kont': ['пра́здникѡвъ'], + 'lent-trop-kont': ['четыредесѧ́тницꙋ'], 'pentecost-trop-kont': ['пѧтдесѧ́тницꙋ'], 'common-trop-kont': ['ѻ҆́бщїи'], + 'theotokion-sunday': ['Бг҃оро́дичны'], 'theotokion-8tones': ['Бг҃оро́дичны'], 'theotokion-dismissal': ['ѿпꙋсти́тельныѧ'], + 'katavasia': ['Катава́сїа'], 'trinity-troparia': ['Трⷪ҇чны'], 'lamps-weekday': ['Свѣти́льны'], + 'exapostilaria-week': ['Є҆ѯапостїла́рїи'], + 'biblical-songs-feasts': ['пра́здники'], 'biblical-songs-daily': ['по всѧ̑ дни̑'], 'biblical-songs-lent': ['четыредесѧ́тницꙋ'], +}; + +const HOROLOGION_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'horologion'); +for (const [dir, markers] of Object.entries(HOROLOGION)) { + const path = join(HOROLOGION_DIR, dir, 'full.json'); + if (!existsSync(path)) { err(`horologion/${dir}: missing full.json`); continue; } + let nodes: ServiceNode[]; + try { nodes = JSON.parse(readFileSync(path, 'utf-8')); } catch { err(`horologion/${dir}: failed to parse`); continue; } + if (!Array.isArray(nodes) || nodes.length < 5) { err(`horologion/${dir}: too few nodes`); continue; } + const all = nodes.map(n => n.value); + for (const m of markers) { if (!all.some(v => v.includes(m))) warn(`horologion/${dir}: marker '${m}' not found`); } + console.log(`horologion/${dir}: ${nodes.length} nodes`); +} + +const SBORNIK_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'sbornik'); +for (const [dir, markers] of Object.entries(SBORNIK)) { + const path = join(SBORNIK_DIR, dir, 'full.json'); + if (!existsSync(path)) { err(`sbornik/${dir}: missing full.json`); continue; } + let nodes: ServiceNode[]; + try { nodes = JSON.parse(readFileSync(path, 'utf-8')); } catch { err(`sbornik/${dir}: failed to parse`); continue; } + if (!Array.isArray(nodes) || nodes.length < 5) { err(`sbornik/${dir}: too few nodes`); continue; } + const all = nodes.map(n => n.value); + for (const m of markers) { if (!all.some(v => v.includes(m))) warn(`sbornik/${dir}: marker '${m}' not found`); } + console.log(`sbornik/${dir}: ${nodes.length} nodes`); +} + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/validate-triodion.ts b/scripts/validate-triodion.ts new file mode 100644 index 0000000..77c140f --- /dev/null +++ b/scripts/validate-triodion.ts @@ -0,0 +1,38 @@ +/** + * Validate the Lenten Triodion (shared/triodion) converted from libcs. + * Run with: npx tsx scripts/validate-triodion.ts + */ + +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = new URL('.', import.meta.url).pathname; +const ROOT = join(__dirname, '..', 'static', 'data', 'shared', 'triodion'); + +interface ServiceNode { type: string; value: string } + +let errors = 0; +let warnings = 0; +function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; } +function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; } + +let count = 0; +for (const fn of readdirSync(ROOT).sort()) { + if (!fn.endsWith('.json')) continue; + const path = join(ROOT, fn); + const nodes = JSON.parse(readFileSync(path, 'utf-8')) as ServiceNode[]; + if (!Array.isArray(nodes) || nodes.length < 5) { err(`${fn}: too few nodes`); continue; } + const text = nodes.map(n => n.value ?? '').join(' '); + const hirm = ['™', '№', 'G', '6', '1', 'h', 'E', 'z'].filter(m => text.includes(m)); + if (hirm.length) err(`${fn}: Hirm residue: ${hirm.join(',')}`); + count++; +} +console.log(`triodion: ${count}/37 sections valid`); + +// Verify template + text metadata exist +const TPL = join(__dirname, '..', 'static', 'data', 'shared', 'services', 'templates', 'Triodion.json'); +if (!existsSync(TPL)) err('missing template: Triodion.json'); + +console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/src/ui/menaion-view.ts b/src/ui/menaion-view.ts new file mode 100644 index 0000000..b3fad08 --- /dev/null +++ b/src/ui/menaion-view.ts @@ -0,0 +1,139 @@ +/** + * Menaion view — browse the daily Menaion by month and day. + * Two-step selector: month → day grid → section list. + */ + +import { assembleService, type ServiceContext } from '../core/service-assembler'; +import type { EvalContext, ServiceNode } from '../core/types'; +import { getTranslations, type LanguageCode } from '../core/i18n'; +import { loadSettings, fontClass } from './settings-view'; + +export class MenaionView { + private container: HTMLElement; + private language: LanguageCode; + private t: ReturnType; + private index: Record = {}; + + constructor(container: HTMLElement, language: LanguageCode) { + this.container = container; + 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 getMonthNames(): string[] { + return this.t.calendar.monthsGenitive; + } + + async render() { + try { + const resp = await fetch('/data/shared/menaion-daily/index.json'); + if (resp.ok) { + this.index = await resp.json() as Record; + } + } catch { /* empty */ } + + const months = this.getMonthNames(); + const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const today = new Date(); + + // Count sections per month + const monthCounts: number[] = new Array(12).fill(0); + for (const [dk, n] of Object.entries(this.index)) { + const m = parseInt(dk.slice(0, 2), 10) - 1; + if (m >= 0 && m < 12) monthCounts[m] += n; + } + + const monthHtml = months.map((m, i) => ` + + `).join(''); + + this.container.innerHTML = ` +
+

${this.t.menaion.title}

+

${months[0]}

+
+ ${monthHtml} +
+
+
+

${this.t.prayer.selectSection}

+
+
+ `; + + this.container.querySelectorAll('.menaion-month').forEach(btn => { + btn.addEventListener('click', () => { + const month = parseInt(btn.getAttribute('data-month')!, 10); + this.renderMonth(month, daysInMonth[month - 1]); + }); + }); + + // Default: current month + this.renderMonth(today.getMonth() + 1, daysInMonth[today.getMonth()]); + } + + private renderMonth(month: number, maxDay: number) { + const daysEl = this.container.querySelector('#menaion-days'); + if (!daysEl) return; + const mm = String(month).padStart(2, '0'); + const days = []; + for (let d = 1; d <= maxDay; d++) { + const dd = String(d).padStart(2, '0'); + const key = `${mm}-${dd}`; + const n = this.index[key]; + days.push(` + + `); + } + daysEl.innerHTML = days.join(''); + this.container.querySelectorAll('.menaion-day:not([disabled])').forEach(btn => { + btn.addEventListener('click', () => { + this.loadDay(btn.getAttribute('data-date')!); + }); + }); + } + + private async loadDay(dateKey: string) { + const contentEl = this.container.querySelector('#menaion-content') as HTMLElement | null; + if (!contentEl) return; + contentEl.innerHTML = `

${this.t.loading}

`; + + const count = this.index[dateKey] ?? 0; + let html = ''; + for (let i = 1; i <= count; i++) { + try { + const resp = await fetch(`/data/shared/menaion-daily/${dateKey}/${i}.json`); + if (!resp.ok) continue; + const nodes: ServiceNode[] = await resp.json(); + const evalCtx: EvalContext = {}; + const ctx: ServiceContext = { + evalCtx, lang: 'cu', t: this.t, + fetchBibleText: async () => '', fetchLives: async () => null, + fetchCommandText: async () => '', resolveTimes: async () => '', + fetchText: async () => null, fetchPrayerNodes: async () => null, + fetchServiceNodes: async () => null, + }; + const { html: h } = await assembleService(nodes, ctx); + if (h.trim()) { + html += `
${h}
`; + } + } catch { /* skip section */ } + } + + const fontClass = this.getFontClass(); + contentEl.className = `prose prose-sm max-w-none liturgical-text ${fontClass}`; + contentEl.innerHTML = html.trim() ? html : `

${this.t.prayer.empty}

`; + } +} diff --git a/src/ui/prayer-view.ts b/src/ui/prayer-view.ts new file mode 100644 index 0000000..654e383 --- /dev/null +++ b/src/ui/prayer-view.ts @@ -0,0 +1,304 @@ +/** + * Prayer view — personal prayers (Молитвослов) and canons/akathists. + * Loads collection sections (HEADER/TEXT nodes) and renders them + * with the shared service assembler. + */ + +import { assembleService, type ServiceContext } from '../core/service-assembler'; +import type { EvalContext, ServiceNode } from '../core/types'; +import { getTranslations, type LanguageCode } from '../core/i18n'; +import { loadSettings, fontClass } from './settings-view'; + +export type PrayerCollection = 'prayer-rule' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'triodion'; + +interface PrayerSection { + id: string; + name: string; + description: string; +} + +export class PrayerView { + private container: HTMLElement; + private language: LanguageCode; + private collection: PrayerCollection; + private t: ReturnType; + private activeSection: string | null = null; + + constructor(container: HTMLElement, language: LanguageCode, collection: PrayerCollection = 'prayer-rule') { + this.container = container; + this.language = language; + this.collection = collection; + this.t = getTranslations(language); + } + + private getFontClass(): string { + const settings = loadSettings(); + return (this.language === 'cu' || this.language === 'ru') && settings.cuFont !== undefined ? fontClass(settings.cuFont) : ''; + } + + private getSections(): PrayerSection[] { + const s = this.t.prayer.sections; + if (this.collection === 'triodion') { + const T = this.t.triodion.sections; + const IDS: string[] = []; + for (let i = 1; i <= 37; i++) IDS.push(String(i).padStart(2, '0')); + return IDS.map((id, i) => ({ id, name: T[i] ?? id, description: '' })); + } + if (this.collection === 'irmologion') { + const I = this.t.irmologion.sections; + const IDS = [ + 'irmos-1', 'irmos-2', 'irmos-3', 'irmos-4', 'irmos-5', 'irmos-6', 'irmos-7', 'irmos-8', + 'irmos-prefeast-nativity', 'irmos-prefeast-theophany', 'liturgy-chants', 'gospodi-vozzvah', + 'theotokia-sunday', 'theotokia-daily', 'stepenna', 'trinity-songs', 'sunday-feast-verses', + 'lent-canon-rules', 'sunday-troparia', 'sunday-prokimena', 'saturday-troparia', + 'paschal-canon', 'chosen-psalms', 'feast-refrains-ode9', + ]; + return IDS.map((id, i) => ({ id, name: I[i] ?? id, description: '' })); + } + if (this.collection === 'paraclete') { + const p = this.t.paraclete; + const TONE_NAMES = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃']; + const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + const sections: PrayerSection[] = []; + for (let tone = 1; tone <= 8; tone++) { + for (let d = 0; d < 6; d++) { + const toneName = this.language === 'en' ? `Tone ${tone}` : `Гла́съ ${TONE_NAMES[tone]}`; + sections.push({ + id: `t${tone}-${DAY_KEYS[d]}`, + name: `${toneName} — ${p.days[d]}`, + description: '', + }); + } + } + return sections; + } + if (this.collection === 'horologion') { + const h = this.t.horologion.sections; + return [ + { id: 'midnight-daily', name: h.midnightDaily, description: '' }, + { id: 'midnight-saturday', name: h.midnightSaturday, description: '' }, + { id: 'midnight-sunday', name: h.midnightSunday, description: '' }, + { id: 'typica', name: h.typica, description: '' }, + { id: 'interhour-1', name: h.interhour1, description: '' }, + { id: 'interhour-3', name: h.interhour3, description: '' }, + { id: 'interhour-6', name: h.interhour6, description: '' }, + { id: 'interhour-9', name: h.interhour9, description: '' }, + { id: 'panagia', name: h.panagia, description: '' }, + { id: 'meal-blessing', name: h.mealBlessing, description: '' }, + { id: 'small-compline', name: h.smallCompline, description: '' }, + ]; + } + if (this.collection === 'sbornik') { + const b = this.t.sbornik.sections; + return [ + { id: 'sunday-trop-kont', name: b.sunday, description: '' }, + { id: 'weekday-trop-kont', name: b.weekday, description: '' }, + { id: 'feast-trop-kont', name: b.feast, description: '' }, + { id: 'lent-trop-kont', name: b.lent, description: '' }, + { id: 'pentecost-trop-kont', name: b.pentecost, description: '' }, + { id: 'common-trop-kont', name: b.common, description: '' }, + { id: 'theotokion-sunday', name: b.theotokionSunday, description: '' }, + { id: 'theotokion-8tones', name: b.theotokion8tones, description: '' }, + { id: 'theotokion-dismissal', name: b.theotokionDismissal, description: '' }, + { id: 'katavasia', name: b.katavasia, description: '' }, + { id: 'trinity-troparia', name: b.trinity, description: '' }, + { id: 'lamps-weekday', name: b.lamps, description: '' }, + { id: 'exapostilaria-week', name: b.exapostilaria, description: '' }, + { id: 'biblical-songs-feasts', name: b.songsFeasts, description: '' }, + { id: 'biblical-songs-daily', name: b.songsDaily, description: '' }, + { id: 'biblical-songs-lent', name: b.songsLent, description: '' }, + ]; + } + if (this.collection === 'parimii') { + const p = this.t.parimii.sections; + return [ + { id: 'sept', name: p.sept, description: '' }, + { id: 'oct', name: p.oct, description: '' }, + { id: 'nov', name: p.nov, description: '' }, + { id: 'dec', name: p.dec, description: '' }, + { id: 'jan', name: p.jan, description: '' }, + { id: 'feb', name: p.feb, description: '' }, + { id: 'mar', name: p.mar, description: '' }, + { id: 'apr', name: p.apr, description: '' }, + { id: 'may', name: p.may, description: '' }, + { id: 'jun', name: p.jun, description: '' }, + { id: 'jul', name: p.jul, description: '' }, + { id: 'aug', name: p.aug, description: '' }, + { id: 'cheese-week', name: p.cheeseWeek, description: '' }, + { id: 'lent-week-1', name: p.lentWeek1, description: '' }, + { id: 'lent-week-2', name: p.lentWeek2, description: '' }, + { id: 'lent-week-3', name: p.lentWeek3, description: '' }, + { id: 'lent-week-4', name: p.lentWeek4, description: '' }, + { id: 'lent-week-5', name: p.lentWeek5, description: '' }, + { id: 'palm-week', name: p.palmWeek, description: '' }, + { id: 'holy-week', name: p.holyWeek, description: '' }, + { id: 'pentecostarion', name: p.pentecostarion, description: '' }, + { id: 'common-saints', name: p.commonSaints, description: '' }, + ]; + } + if (this.collection === 'akathists') { + const a = this.t.akathists.sections; + return [ + { id: 'trinity-canon', name: a.trinity, description: '' }, + { id: 'jesus-compunction', name: a.jesusCompunction, description: '' }, + { id: 'akathist-jesus', name: a.akathistJesus, description: '' }, + { id: 'jesus-penitential', name: a.jesusPenitential, description: '' }, + { id: 'pascha-canon', name: a.pascha, description: '' }, + { id: 'nativity-canon', name: a.nativity, description: '' }, + { id: 'cross-canon', name: a.cross, description: '' }, + { id: 'theotokos-moleben', name: a.theotokosMoleben, description: '' }, + { id: 'theotokos-thanksgiving', name: a.theotokosThanksgiving, description: '' }, + { id: 'akathist-theotokos', name: a.akathistTheotokos, description: '' }, + { id: 'theotokos-nativity', name: a.theotokosNativity, description: '' }, + { id: 'pokrov-canon', name: a.pokrov, description: '' }, + { id: 'utoli-pechali', name: a.utoliPechali, description: '' }, + { id: 'skoroposlushnitsa', name: a.skoroposlushnitsa, description: '' }, + { id: 'troeruchitsa', name: a.troeruchitsa, description: '' }, + { id: 'angels-canon', name: a.angels, description: '' }, + { id: 'michael-canon', name: a.michael, description: '' }, + { id: 'gabriel-canon', name: a.gabriel, description: '' }, + { id: 'guardian-angel', name: a.guardianAngel, description: '' }, + { id: 'forerunner-canon', name: a.forerunner, description: '' }, + { id: 'nicholas-canon-akathist', name: a.nicholasCanon, description: '' }, + { id: 'akathist-nicholas', name: a.akathistNicholas, description: '' }, + { id: 'spiridon', name: a.spiridon, description: '' }, + { id: 'cyprian-justina', name: a.cyprian, description: '' }, + { id: 'panteleimon', name: a.panteleimon, description: '' }, + { id: 'tryphon', name: a.tryphon, description: '' }, + { id: 'sergius', name: a.sergius, description: '' }, + { id: 'alexander-svirsky', name: a.alexanderSvirsky, description: '' }, + { id: 'seraphim', name: a.seraphim, description: '' }, + { id: 'john-kronstadt', name: a.johnKronstadt, description: '' }, + { id: 'mary-egypt', name: a.maryEgypt, description: '' }, + { id: 'murom-wonderworkers', name: a.murom, description: '' }, + { id: 'saint-anne', name: a.saintAnne, description: '' }, + ]; + } + return [ + { id: 'morning', name: s.morning, description: s.morningDesc }, + { id: 'diptychs', name: s.diptychs, description: s.diptychsDesc }, + { id: 'evening', name: s.evening, description: s.eveningDesc }, + { id: 'three-canons', name: s.threeCanons, description: s.threeCanonsDesc }, + { id: 'communion', name: s.communion, description: s.communionDesc }, + { id: 'thanksgiving', name: s.thanksgiving, description: s.thanksgivingDesc }, + { id: 'rule-impurity', name: s.ruleImpurity, description: s.ruleImpurityDesc }, + { id: 'litia-departed', name: s.litiaDeparted, description: s.litiaDepartedDesc }, + { id: 'twelve-psalms', name: s.twelvePsalms, description: s.twelvePsalmsDesc }, + { id: 'beginning-ending', name: s.beginningEnding, description: s.beginningEndingDesc }, + ]; + } + + async render() { + const sections = this.getSections(); + const isAkathists = this.collection === 'akathists'; + const isParimii = this.collection === 'parimii'; + const isHorologion = this.collection === 'horologion'; + const isSbornik = this.collection === 'sbornik'; + const isParaclete = this.collection === 'paraclete'; + const isIrmologion = this.collection === 'irmologion'; + const isTriodion = this.collection === 'triodion'; + const title = isTriodion ? this.t.triodion.title + : isAkathists ? this.t.akathists.title + : isParimii ? this.t.parimii.title + : isHorologion ? this.t.horologion.title + : isSbornik ? this.t.sbornik.title + : isParaclete ? this.t.paraclete.title + : isIrmologion ? this.t.irmologion.title + : this.t.prayer.title; + const subtitle = isTriodion ? this.t.triodion.subtitle + : isAkathists ? this.t.akathists.subtitle + : isParimii ? this.t.parimii.subtitle + : isHorologion ? this.t.horologion.subtitle + : isSbornik ? this.t.sbornik.subtitle + : isParaclete ? this.t.paraclete.subtitle + : isIrmologion ? this.t.irmologion.subtitle + : this.t.prayer.subtitle; + + this.container.innerHTML = ` +
+

${title}

+

${subtitle}

+
+ ${sections.map(s => ` + + `).join('')} +
+
+

${this.t.prayer.selectSection}

+
+
+ `; + + this.container.querySelectorAll('.prayer-section').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-section')!; + this.activeSection = id; + this.container.querySelectorAll('.prayer-section').forEach(b => { + const bid = b.getAttribute('data-section'); + b.className = `prayer-section text-left px-4 py-3 rounded-lg border transition-colors ${bid === id ? 'border-gold bg-gold/10 text-navy font-bold' : 'border-gold/20 bg-white/50 text-navy hover:border-gold/50'}`; + }); + this.loadSection(id); + }); + }); + } + + private async loadSection(id: string) { + const contentEl = document.getElementById('prayer-content'); + if (!contentEl) return; + + contentEl.innerHTML = `

${this.t.loading}

`; + + try { + // Paraclete ids are t{tone}-{day}; others map directly + let dataPath = `/data/shared/${this.collection}/${id}/full.json`; + if (this.collection === 'paraclete') { + const m = id.match(/^t(\d)-(\w+)$/); + if (!m) { + contentEl.innerHTML = `

${this.t.prayer.notFound}

`; + return; + } + dataPath = `/data/shared/services/paraclete/tone${m[1]}/${m[2]}.json`; + } + const resp = await fetch(dataPath); + if (!resp.ok) { + contentEl.innerHTML = `

${this.t.prayer.notFound}

`; + return; + } + const nodes: ServiceNode[] = await resp.json(); + + const evalCtx: EvalContext = {}; + const ctx: ServiceContext = { + evalCtx, + lang: 'cu', + t: this.t, + fetchBibleText: async () => '', + fetchLives: async () => null, + fetchCommandText: async () => '', + resolveTimes: async () => '', + fetchText: async () => null, + fetchPrayerNodes: async () => null, + fetchServiceNodes: async () => null, + }; + + 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 = `

${this.t.prayer.empty}

`; + } + } catch (err) { + contentEl.innerHTML = `

${this.t.error}

`; + } + } +}