Files
ponomar-pwa/scripts/generate-preload-manifest.ts
T
catarrh baf2baf65d feat(version): derive data cache version from content hash of the data tree
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.
2026-11-02 20:53:20 +03:00

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}`);