Replace the hand-maintained DATA_VERSION constant with a build-time SHA-256 hash of static/data/ embedded as a 'version' field in the preload manifest. The runtime reads it via fetchWithFallback and clears the IDB cache when it differs, so any data edit auto-invalidates cached data — no manual bump to forget. The no-version (offline/stale) and IDB read-error guards are kept, so the cache is never wiped on an unreadable signal. Closes audit item L1.
262 lines
9.0 KiB
TypeScript
262 lines
9.0 KiB
TypeScript
/**
|
|
* Generates preload-manifest.json listing all existing data files
|
|
* organized by language and type. Run when data files are added or removed.
|
|
*
|
|
* Usage: npx tsx scripts/generate-preload-manifest.ts
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { createHash } from 'crypto';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const DATA_DIR = path.resolve(__dirname, '../static/data');
|
|
const LANGUAGES = ['en', 'ru', 'cu'] as const;
|
|
const dom = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
|
|
interface Manifest {
|
|
version?: string;
|
|
en?: { calendar: string[]; bible: string[] };
|
|
ru?: { calendar: string[]; bible: string[] };
|
|
cu?: { calendar: string[]; bible: string[] };
|
|
}
|
|
|
|
function exists(p: string): boolean {
|
|
return fs.existsSync(path.join(DATA_DIR, p));
|
|
}
|
|
|
|
function calendarFiles(lang: string): string[] {
|
|
const files: string[] = [];
|
|
|
|
// Lives: month files
|
|
for (let m = 1; m <= 12; m++) {
|
|
const p = `/${lang}/lives/${String(m).padStart(2, '0')}.json`;
|
|
if (exists(p)) files.push(`/data${p}`);
|
|
}
|
|
// Lives: misc files
|
|
for (let d = 0; d <= 9; d++) {
|
|
const p = `/${lang}/lives/misc/${d}.json`;
|
|
if (exists(p)) files.push(`/data${p}`);
|
|
}
|
|
|
|
// Shared lives index
|
|
if (exists('/shared/lives-index.json')) files.push('/data/shared/lives-index.json');
|
|
|
|
// Calendar shared
|
|
for (const f of ['/shared/fasting.json', '/shared/calendar/triodion.json', '/shared/calendar/pentecostarion.json']) {
|
|
if (exists(f)) files.push(`/data${f}`);
|
|
}
|
|
|
|
// Shared fallback lives/commands — normally served per-language, but the
|
|
// runtime chain ends with a 'shared' attempt; keep those copies offline too.
|
|
for (const sub of ['lives', 'commands']) {
|
|
const subDir = path.join(DATA_DIR, 'shared', sub);
|
|
if (!fs.existsSync(subDir)) continue;
|
|
const walkShared = (d: string, rel: string): void => {
|
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
const p = path.join(d, e.name);
|
|
if (e.isDirectory()) walkShared(p, `${rel}/${e.name}`);
|
|
else if (e.name.endsWith('.json')) files.push(`/data/shared/${sub}${rel}/${e.name}`);
|
|
}
|
|
};
|
|
walkShared(subDir, '');
|
|
}
|
|
|
|
// Menaion bundle
|
|
const mb = `/${lang}/menaion-bundle.json`;
|
|
if (exists(mb)) files.push(`/data${mb}`);
|
|
|
|
// Service templates
|
|
const tplDir = path.join(DATA_DIR, 'shared/services/templates');
|
|
if (fs.existsSync(tplDir)) {
|
|
for (const t of fs.readdirSync(tplDir)) {
|
|
if (t.endsWith('.json')) {
|
|
files.push(`/data/shared/services/templates/${t}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Feast data
|
|
const svcDir = path.join(DATA_DIR, 'shared/services');
|
|
if (fs.existsSync(svcDir)) {
|
|
for (const d of fs.readdirSync(svcDir)) {
|
|
const fullPath = path.join(svcDir, d, 'full.json');
|
|
if (fs.statSync(path.join(svcDir, d)).isDirectory() && fs.existsSync(fullPath) && d !== 'templates' && d !== 'canons' && d !== 'paraclete') {
|
|
files.push(`/data/shared/services/${d}/full.json`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Paraclete day files
|
|
for (let tone = 1; tone <= 8; tone++) {
|
|
for (const day of ['mon', 'tue', 'wed', 'thu', 'fri', 'sat']) {
|
|
const p = `/shared/services/paraclete/tone${tone}/${day}.json`;
|
|
if (exists(p)) files.push(`/data${p}`);
|
|
}
|
|
}
|
|
|
|
// Service canons
|
|
for (let tone = 0; tone <= 7; tone++) {
|
|
const p = `/shared/services/canons/tone${tone}/sunday.json`;
|
|
if (exists(p)) files.push(`/data${p}`);
|
|
}
|
|
for (let tone = 1; tone <= 8; tone++) {
|
|
for (const day of ['mon', 'tue', 'wed', 'thu', 'fri', 'sat']) {
|
|
const p = `/shared/services/canons/paraclete/tone${tone}/${day}.json`;
|
|
if (exists(p)) files.push(`/data${p}`);
|
|
}
|
|
}
|
|
for (let p = 1; p <= 4; p++) {
|
|
const fp = `/shared/services/canons/great-canon/part${p}.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
|
|
// Menaion daily index
|
|
if (exists('/shared/menaion-daily/index.json')) files.push('/data/shared/menaion-daily/index.json');
|
|
|
|
// Menaion daily sections
|
|
for (let m = 1; m <= 12; m++) {
|
|
for (let d = 1; d <= dom[m - 1]; d++) {
|
|
const dateKey = `${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
for (let n = 1; n <= 4; n++) {
|
|
const fp = `/shared/menaion-daily/${dateKey}/${n}.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Triodion
|
|
for (let i = 1; i <= 37; i++) {
|
|
const fp = `/shared/triodion/${String(i).padStart(2, '0')}.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
|
|
// Pentecostarion
|
|
for (let i = 1; i <= 315; i++) {
|
|
const fp = `/shared/pentecostarion/${String(i).padStart(2, '0')}.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
|
|
// Prayer collections
|
|
const prayerDirs: [string, string[]][] = [];
|
|
for (const coll of ['akathists', 'horologion', 'prayer-rule', 'sbornik', 'irmologion']) {
|
|
const collDir = path.join(DATA_DIR, 'shared', coll);
|
|
if (fs.existsSync(collDir)) {
|
|
const items = fs.readdirSync(collDir).filter(d => fs.statSync(path.join(collDir, d)).isDirectory());
|
|
prayerDirs.push([coll, items]);
|
|
}
|
|
}
|
|
for (const [coll, items] of prayerDirs) {
|
|
for (const item of items) {
|
|
const fp = `/shared/${coll}/${item}/full.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
}
|
|
|
|
// Parimii
|
|
const parimiiDir = path.join(DATA_DIR, 'shared', 'parimii');
|
|
if (fs.existsSync(parimiiDir)) {
|
|
for (const d of fs.readdirSync(parimiiDir)) {
|
|
const fp = `/shared/parimii/${d}/full.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
}
|
|
|
|
// Commemorations
|
|
for (const cid of ['134', '35', '373', '543', 'F0', 'F6']) {
|
|
const fp = `/shared/commemorations/${cid}.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
|
|
// Language-specific commands
|
|
for (const cmd of ['Times.json', 'LanguagePacks.json', 'Podobni.json', 'RuleBasedNumbers.json']) {
|
|
const fp = `/${lang}/commands/${cmd}`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
|
|
// Language-specific octoecheos
|
|
for (let tone = 0; tone <= 7; tone++) {
|
|
for (const day of ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']) {
|
|
const fp = `/${lang}/services/octoecheos/tone${tone}/${day}.json`;
|
|
if (exists(fp)) files.push(`/data${fp}`);
|
|
}
|
|
}
|
|
|
|
// Language-specific service commands (enumerate all on disk)
|
|
const cmdDir = path.join(DATA_DIR, lang, 'services', 'commands');
|
|
if (fs.existsSync(cmdDir)) {
|
|
for (const c of fs.readdirSync(cmdDir)) {
|
|
if (c.endsWith('.json')) files.push(`/data/${lang}/services/commands/${c}`);
|
|
}
|
|
}
|
|
|
|
// Dereferenceable service subtrees (Services/CommonPrayers -> prayers, etc.)
|
|
for (const sub of ['prayers', 'texts', 'headers', 'var']) {
|
|
const subDir = path.join(DATA_DIR, lang, 'services', sub);
|
|
if (!fs.existsSync(subDir)) continue;
|
|
const walk = (d: string, rel: string): void => {
|
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
const p = path.join(d, e.name);
|
|
if (e.isDirectory()) walk(p, `${rel}/${e.name}`);
|
|
else if (e.name.endsWith('.json')) files.push(`/data/${lang}/services/${sub}${rel}/${e.name}`);
|
|
}
|
|
};
|
|
walk(subDir, '');
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
const manifest: Manifest = {};
|
|
|
|
for (const lang of LANGUAGES) {
|
|
manifest[lang] = {
|
|
calendar: calendarFiles(lang),
|
|
bible: ['/data/bible/versions.json'],
|
|
};
|
|
console.log(`${lang}: ${manifest[lang]!.calendar.length} calendar files`);
|
|
}
|
|
|
|
const outPath = path.join(DATA_DIR, 'shared', 'preload-manifest.json');
|
|
|
|
/**
|
|
* SHA-256 content hash over the data tree (relative path + bytes, sorted),
|
|
* truncated to 12 hex chars. The manifest itself is excluded so regenerating
|
|
* it cannot change its own version. Used by the runtime to invalidate the
|
|
* IndexedDB cache whenever any data file changes.
|
|
*/
|
|
function computeDataVersion(dataDir: string, excludePath: string): string {
|
|
const hash = createHash('sha256');
|
|
const files: string[] = [];
|
|
const walk = (dir: string): void => {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
.filter((e) => !e.name.startsWith('.'))
|
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(full);
|
|
else if (entry.isFile()) files.push(full);
|
|
}
|
|
};
|
|
walk(dataDir);
|
|
files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
const excludeAbs = path.resolve(excludePath);
|
|
for (const file of files) {
|
|
if (path.resolve(file) === excludeAbs) continue; // exclude the manifest itself (avoids self-reference)
|
|
const rel = path.relative(dataDir, file).split(path.sep).join('/');
|
|
hash.update(rel);
|
|
hash.update('\0');
|
|
hash.update(fs.readFileSync(file));
|
|
hash.update('\0');
|
|
}
|
|
return hash.digest('hex').slice(0, 12);
|
|
}
|
|
|
|
const version = computeDataVersion(DATA_DIR, outPath);
|
|
manifest.version = version;
|
|
console.log(`\nData version: ${version}`);
|
|
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2));
|
|
console.log(`\nManifest written to ${outPath}`);
|