Matins completed
This commit is contained in:
1 parent
79ac617aa8
commit
366aea76bb
27 files changed
+3200
No files matched your search
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<string, unknown>);
|
||||
} 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<string, unknown>;
|
||||
if (life.name && typeof life.name === 'object') {
|
||||
const name = life.name as Record<string, unknown>;
|
||||
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<string, unknown>) => {
|
||||
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<string, string> = {
|
||||
'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<string, unknown>) => {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (life.name && typeof life.name === 'object') {
|
||||
const name = life.name as Record<string, unknown>;
|
||||
const fixed: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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();
|
||||
@@ -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<void> {
|
||||
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();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Search across all liturgical texts.
|
||||
* Run with: npx tsx scripts/tools/search-texts.ts <query> [--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 <query> [--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();
|
||||
@@ -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<string, { files: number; size: number }>;
|
||||
}
|
||||
|
||||
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<string, { files: number; size: number }> {
|
||||
const categories: Record<string, { files: number; size: number }> = {};
|
||||
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();
|
||||
@@ -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);
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
if (!obj.name || typeof obj.name !== 'object') {
|
||||
this.addError(path, 'name', 'Missing or invalid name');
|
||||
} else {
|
||||
const name = obj.name as Record<string, unknown>;
|
||||
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<string, unknown>)) {
|
||||
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<string, unknown>)) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
this.addError(path, `[${key}]`, 'Entry must be an object');
|
||||
continue;
|
||||
}
|
||||
const obj = entry as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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();
|
||||
@@ -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<string, { nday: number; markers: string[]; template: string }> = {
|
||||
'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);
|
||||
@@ -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);
|
||||
@@ -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<string>();
|
||||
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);
|
||||
@@ -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<string, number> = {
|
||||
'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);
|
||||
@@ -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);
|
||||
@@ -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<string, number>;
|
||||
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);
|
||||
@@ -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<string, { month: number; day: number; markers: string[]; template: string }> = {
|
||||
'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);
|
||||
@@ -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<string, { nday: number; markers: string[]; template: string }> = {
|
||||
'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);
|
||||
@@ -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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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);
|
||||
@@ -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);
|
||||
@@ -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<typeof getTranslations>;
|
||||
private index: Record<string, number> = {};
|
||||
|
||||
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<string, number>;
|
||||
}
|
||||
} 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) => `
|
||||
<button class="menaion-month px-3 py-1.5 text-sm rounded border transition-colors
|
||||
border-gold/20 bg-white/50 text-navy hover:border-gold/50" data-month="${i + 1}">
|
||||
${m} <span class="text-xs text-navy-light">(${monthCounts[i]})</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="p-6 max-w-4xl xl:max-w-6xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-navy mb-2">${this.t.menaion.title}</h2>
|
||||
<p class="text-navy-light mb-4">${months[0]}</p>
|
||||
<div class="flex flex-wrap gap-1 mb-6">
|
||||
${monthHtml}
|
||||
</div>
|
||||
<div id="menaion-days" class="grid gap-1 sm:grid-cols-7 mb-6"></div>
|
||||
<div id="menaion-content" class="bg-white/50 border border-gold/20 rounded-lg p-6 min-h-[300px]">
|
||||
<p class="text-navy-light italic">${this.t.prayer.selectSection}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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(`
|
||||
<button class="menaion-day px-2 py-1 text-xs rounded border transition-colors
|
||||
${n ? 'border-gold/40 bg-gold/10 text-navy hover:border-gold' : 'border-gold/10 text-navy-light opacity-40 cursor-default'}"
|
||||
data-date="${key}" ${n ? '' : 'disabled'}>
|
||||
${d}${n ? ` <span class="text-[10px]">(${n})</span>` : ''}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
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 = `<p class="text-navy-light italic">${this.t.loading}</p>`;
|
||||
|
||||
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 += `<div class="${i > 1 ? 'mt-6 pt-4 border-t border-gold/20' : ''}">${h}</div>`;
|
||||
}
|
||||
} catch { /* skip section */ }
|
||||
}
|
||||
|
||||
const fontClass = this.getFontClass();
|
||||
contentEl.className = `prose prose-sm max-w-none liturgical-text ${fontClass}`;
|
||||
contentEl.innerHTML = html.trim() ? html : `<p class="text-navy-light italic">${this.t.prayer.empty}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getTranslations>;
|
||||
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 = `
|
||||
<div class="p-6 max-w-4xl xl:max-w-6xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-navy mb-2">${title}</h2>
|
||||
<p class="text-navy-light mb-6">${subtitle}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-2 mb-6">
|
||||
${sections.map(s => `
|
||||
<button
|
||||
class="prayer-section text-left px-4 py-3 rounded-lg border transition-colors
|
||||
${this.activeSection === s.id ? 'border-gold bg-gold/10 text-navy font-bold' : 'border-gold/20 bg-white/50 text-navy hover:border-gold/50'}"
|
||||
data-section="${s.id}"
|
||||
>
|
||||
<div class="text-sm font-bold">${s.name}</div>
|
||||
${s.description ? `<div class="text-xs text-navy-light">${s.description}</div>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div id="prayer-content" class="bg-white/50 border border-gold/20 rounded-lg p-6 min-h-[300px]">
|
||||
<p class="text-navy-light italic">${this.t.prayer.selectSection}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<p class="text-navy-light italic">${this.t.loading}</p>`;
|
||||
|
||||
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 = `<p class="text-navy-light">${this.t.prayer.notFound}</p>`;
|
||||
return;
|
||||
}
|
||||
dataPath = `/data/shared/services/paraclete/tone${m[1]}/${m[2]}.json`;
|
||||
}
|
||||
const resp = await fetch(dataPath);
|
||||
if (!resp.ok) {
|
||||
contentEl.innerHTML = `<p class="text-navy-light">${this.t.prayer.notFound}</p>`;
|
||||
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 = `<p class="text-navy-light italic">${this.t.prayer.empty}</p>`;
|
||||
}
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = `<p class="text-navy-light">${this.t.error}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user