Files
ponomar-pwa/scripts/validate-data.ts
T
2026-08-01 02:43:24 +03:00

269 lines
8.4 KiB
TypeScript

/**
* 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();