Matins completed

This commit is contained in:
catarrh committed 2026-08-01 02:43:24 +03:00
1 parent 79ac617aa8
commit 366aea76bb
27 files changed
+3200

No files matched your search

+524
View File
@@ -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();
+116
View File
@@ -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
}
+22
View File
@@ -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
}
}
+63
View File
@@ -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
}
+36
View File
@@ -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
}
}
+73
View File
@@ -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
}
+10
View File
@@ -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
}
+22
View File
@@ -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
}
}
+131
View File
@@ -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" }
}
+105
View File
@@ -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();
+128
View File
@@ -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();
+195
View File
@@ -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);
+268
View File
@@ -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();
+74
View File
@@ -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);
+134
View File
@@ -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);
+89
View File
@@ -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);
+135
View File
@@ -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);
+74
View File
@@ -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);
+80
View File
@@ -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);
+107
View File
@@ -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);
+87
View File
@@ -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);
+225
View File
@@ -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);
+38
View File
@@ -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);
+139
View File
@@ -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>`;
}
}
+304
View File
@@ -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>`;
}
}
}