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

+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);