fix: actually use preload manifest, remove dead calendarDataFiles (was never wired)

This commit is contained in:
catarrh committed 2026-08-04 03:42:10 +03:00
1 parent 094b883759
commit b78e6ac09e
3 files changed
+62 -249

No files matched your search

+4
View File
@@ -511,6 +511,7 @@ export interface Translations {
offlineClearCache: string;
offlineCalculating: string;
offlineDone: string;
offlineFailed: string;
offlineSelectLang: string;
offlineSelectAll: string;
offlineCleared: string;
@@ -1072,6 +1073,7 @@ const translations: Record<LanguageCode, Translations> = {
offlineClearCache: 'Clear cache',
offlineCalculating: 'Calculating storage...',
offlineDone: 'Done! Data cached for offline use.',
offlineFailed: 'files failed to load (retry to attempt again)',
offlineSelectLang: 'Please select at least one language.',
offlineSelectAll: 'Select all',
offlineCleared: 'Cache cleared.',
@@ -1631,6 +1633,7 @@ const translations: Record<LanguageCode, Translations> = {
offlineClearCache: 'Очистить кэш',
offlineCalculating: 'Расчёт размера...',
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
offlineFailed: 'файлов не загружено (повторите для повторной попытки)',
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
offlineSelectAll: 'Выбрать все',
offlineCleared: 'Кэш очищен.',
@@ -2190,6 +2193,7 @@ const translations: Record<LanguageCode, Translations> = {
offlineClearCache: 'Очистити кешъ',
offlineCalculating: 'Расчётъ размѣра...',
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)',
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
offlineSelectAll: 'И҆збра́ти всѧ̑',
offlineCleared: 'Кешъ очищенъ.',
+50 -247
View File
@@ -27,6 +27,7 @@ export interface PreloadProgress {
total: number;
file: string;
done: boolean;
failed: number;
}
export interface CacheStats {
@@ -46,233 +47,23 @@ const BIBLE_SIZES: Record<string, string> = {
'zh/Hans/bible/cuv': '3.7 MB', 'zh/Hant/bible/cuv': '3.7 MB',
};
const SERVICE_TEMPLATES = [
'Template', 'Vespers', 'Matins', 'DivineLiturgy', 'GreatCompline', 'UsualBeginning',
'TrisagionBlock', 'Prime', 'ThirdHour', 'SixthHour', 'NinthHour', 'RoyalHours',
'PaschalHours', 'SaturdayMidnight', 'SaturdayVespersLiturgy', 'SaturdayHours',
'BurialVespers', 'MariasStanding', 'Pascha', 'Lamentations', 'PassionGospels', 'RoyalHoursFriday',
...Array.from({ length: 20 }, (_, i) => `Kathisma${i + 1}`),
'BrightMonday', 'BrightTuesday', 'BrightWednesday', 'BrightThursday', 'BrightFriday', 'BrightSaturday',
'FirstWeekMonday', 'FirstWeekTuesday', 'FirstWeekWednesday', 'FirstWeekThursday', 'FirstWeekFriday', 'FirstWeekSaturday',
'Antipascha', 'Myrrhbearers', 'Paralytic', 'Samaritan', 'BlindMan', 'Prepolovenie',
'Apodosis', 'Ascension', 'PentecostSaturday', 'HolyFathers', 'HolySpirit', 'Pentecost', 'AllSaints', 'RussianSaints',
'NativityTheotokos', 'Vvedenie', 'Exaltation', 'NativityHours', 'Nativity',
'TheophanyHours', 'Theophany', 'Sretenie', 'Annunciation', 'PalmSunday', 'Transfiguration', 'Dormition',
'Circumcision', 'ForerunnerBirth', 'PeterPaul', 'ForerunnerBeheading', 'Pokrov', 'MichaelSynaxis',
'Vladimir', 'Kazan', 'JohnTheologianSep', 'Sergius', 'Ambrose', 'Demetrius', 'Nicholas',
'NicholasTranslation', 'FindingHead1st', 'FindingHead3rd', 'FortyMartyrs', 'JohnTheologianMay',
'Panteleimon', 'Elijah', 'ProcessionCross', 'SixCouncilsFathers', 'SeventhCouncilFathers',
'ForefathersSunday', 'HolyFathersNativity', 'SundayAfterNativity', 'MenaionDay', 'Triodion',
];
let manifestPromise: Promise<Record<string, Record<string, string[]>> | null> | null = null;
const FEAST_NAMES = [
'allsaints', 'ambrose', 'annunciation', 'antipascha', 'apodosis', 'ascension', 'blindman',
'bright-friday', 'bright-monday', 'bright-saturday', 'bright-thursday', 'bright-tuesday', 'bright-wednesday',
'burial-vespers', 'circumcision', 'demetrius', 'dormition', 'elijah', 'exaltation',
'finding-head-1st', 'finding-head-3rd',
'first-week-friday', 'first-week-monday', 'first-week-saturday', 'first-week-thursday', 'first-week-tuesday', 'first-week-wednesday',
'forefathers-sunday', 'forerunner-beheading', 'forerunner-birth', 'forty-martyrs',
'great-monday', 'great-thursday', 'great-tuesday', 'great-wednesday',
'holy-fathers-nativity', 'holyfathers', 'holyspirit',
'johntheologian-may', 'johntheologian-sep', 'kazan', 'lamentations',
'marias-standing', 'michael-synaxis', 'myrrhbearers',
'nativity-hours', 'nativity-theotokos', 'nativity', 'nicholas-translation', 'nicholas',
'palm-sunday', 'panteleimon', 'paralytic', 'pascha', 'passion-gospels', 'pentecost',
'pentecostsaturday', 'peter-paul', 'pokrov', 'prepolovenie', 'procession-cross',
'royal-hours-friday', 'russiansaints', 'samaritan',
'saturday-hours', 'saturday-midnight', 'saturday-vespers-liturgy', 'sergius',
'seventh-council-fathers', 'six-councils-fathers', 'sretenie', 'sunday-after-nativity',
'theophany-hours', 'theophany', 'transfiguration', 'vladimir', 'vvedenie',
];
const PRAYER_COLLECTIONS: Record<string, string[]> = {
akathists: [
'akathist-jesus', 'akathist-nicholas', 'akathist-theotokos', 'alexander-svirsky',
'angels-canon', 'cross-canon', 'cyprian-justina', 'forerunner-canon', 'gabriel-canon',
'guardian-angel', 'jesus-compunction', 'jesus-penitential', 'john-kronstadt',
'mary-egypt', 'michael-canon', 'murom-wonderworkers', 'nativity-canon',
'nicholas-canon-akathist', 'panteleimon', 'pascha-canon', 'pokrov-canon',
'saint-anne', 'seraphim', 'sergius', 'skoroposlushnitsa', 'spiridon',
'theotokos-moleben', 'theotokos-nativity', 'theotokos-thanksgiving',
'trinity-canon', 'troeruchitsa', 'tryphon', 'utoli-pechali',
],
horologion: [
'interhour-1', 'interhour-3', 'interhour-6', 'interhour-9', 'meal-blessing',
'midnight-daily', 'midnight-saturday', 'midnight-sunday', 'panagia',
'small-compline', 'typica',
],
'prayer-rule': [
'beginning-ending', 'communion', 'diptychs', 'evening', 'litia-departed',
'morning', 'rule-impurity', 'thanksgiving', 'three-canons', 'twelve-psalms',
],
sbornik: [
'biblical-songs-daily', 'biblical-songs-feasts', 'biblical-songs-lent',
'common-trop-kont', 'exapostilaria-week', 'feast-trop-kont', 'katavasia',
'lamps-weekday', 'lent-trop-kont', 'pentecost-trop-kont', 'sunday-trop-kont',
'theotokion-8tones', 'theotokion-dismissal', 'theotokion-sunday',
'trinity-troparia', 'weekday-trop-kont',
],
irmologion: [
'chosen-psalms', 'feast-refrains-ode9', 'gospodi-vozzvah',
'irmos-1', 'irmos-2', 'irmos-3', 'irmos-4', 'irmos-5', 'irmos-6', 'irmos-7', 'irmos-8',
'irmos-prefeast-nativity', 'irmos-prefeast-theophany',
'lent-canon-rules', 'liturgy-chants', 'paschal-canon',
'saturday-troparia', 'stepenna', 'sunday-feast-verses', 'sunday-prokimena',
'sunday-troparia', 'theotokia-daily', 'theotokia-sunday', 'trinity-songs',
],
};
const PARIMII_DIRS = [
'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
'cheese-week', 'common-saints', 'holy-week', 'palm-week', 'pentecostarion',
'lent-week-1', 'lent-week-2', 'lent-week-3', 'lent-week-4', 'lent-week-5',
];
function daysInMonth(m: number): number {
return [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
}
/** Generate calendar & services data files for a given language. */
function calendarDataFiles(lang: string): string[] {
const files: string[] = [];
// Lives: month files
for (let m = 1; m <= 12; m++) {
files.push(`/data/${lang}/lives/${String(m).padStart(2, '0')}.json`);
}
// Lives: misc files
for (let d = 0; d <= 9; d++) {
files.push(`/data/${lang}/lives/misc/${d}.json`);
}
// Shared lives index
files.push('/data/shared/lives-index.json');
// Calendar shared
files.push('/data/shared/fasting.json');
files.push('/data/shared/calendar/triodion.json');
files.push('/data/shared/calendar/pentecostarion.json');
// Menaion bundle
files.push(`/data/${lang}/menaion-bundle.json`);
// Service templates
for (const t of SERVICE_TEMPLATES) {
files.push(`/data/shared/services/templates/${t}.json`);
}
// Feast data
for (const f of FEAST_NAMES) {
files.push(`/data/shared/services/${f}/full.json`);
}
// Paraclete day files (8 tones × 6 days)
for (let tone = 1; tone <= 8; tone++) {
for (const day of ['mon', 'tue', 'wed', 'thu', 'fri', 'sat']) {
files.push(`/data/shared/services/paraclete/tone${tone}/${day}.json`);
}
}
// Service canons
for (let tone = 0; tone <= 7; tone++) {
files.push(`/data/shared/services/canons/tone${tone}/sunday.json`);
}
for (let tone = 1; tone <= 8; tone++) {
for (const day of ['mon', 'tue', 'wed', 'thu', 'fri', 'sat']) {
files.push(`/data/shared/services/canons/paraclete/tone${tone}/${day}.json`);
}
}
files.push('/data/shared/services/canons/great-canon/part1.json');
files.push('/data/shared/services/canons/great-canon/part2.json');
files.push('/data/shared/services/canons/great-canon/part3.json');
files.push('/data/shared/services/canons/great-canon/part4.json');
// Menaion daily index + sections
files.push('/data/shared/menaion-daily/index.json');
for (let m = 1; m <= 12; m++) {
for (let d = 1; d <= daysInMonth(m); d++) {
const dateKey = `${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
for (let n = 1; n <= 4; n++) {
files.push(`/data/shared/menaion-daily/${dateKey}/${n}.json`);
function loadManifest(): Promise<Record<string, Record<string, string[]>> | null> {
if (!manifestPromise) {
manifestPromise = (async () => {
try {
const resp = await fetch('/data/shared/preload-manifest.json');
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
})();
}
// Triodion
for (let i = 1; i <= 37; i++) {
files.push(`/data/shared/triodion/${String(i).padStart(2, '0')}.json`);
}
// Pentecostarion
for (let i = 1; i <= 315; i++) {
files.push(`/data/shared/pentecostarion/${String(i).padStart(2, '0')}.json`);
}
// Menaion common + add
for (let i = 1; i <= 42; i++) files.push(`/data/shared/menaion-common/${String(i).padStart(2, '0')}.json`);
for (let i = 1; i <= 51; i++) files.push(`/data/shared/menaion-add/${String(i).padStart(2, '0')}.json`);
// Prayer collections
for (const [collection, items] of Object.entries(PRAYER_COLLECTIONS)) {
for (const item of items) {
files.push(`/data/shared/${collection}/${item}/full.json`);
}
}
// Horologion additions
const horologionAddItems = [
'exapostilaria', 'katavasia', 'lamps', 'songs-daily', 'songs-feasts', 'songs-lent',
'theotokia-8tones', 'theotokia-dismissal', 'theotokia-sunday', 'trinity-8tones',
'trop-common', 'trop-daily', 'trop-lent', 'trop-pentecost', 'trop-sunday',
];
for (const item of horologionAddItems) {
files.push(`/data/shared/horologionadd/${item}/full.json`);
}
for (const m of ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']) {
files.push(`/data/shared/horologionadd/trop-feasts-${m}/full.json`);
}
// Parimii
for (const dir of PARIMII_DIRS) {
files.push(`/data/shared/parimii/${dir}/full.json`);
}
// Commemorations
for (const id of ['134', '35', '373', '543', 'F0', 'F6']) {
files.push(`/data/shared/commemorations/${id}.json`);
}
// Language-specific commands
files.push(`/data/${lang}/commands/Times.json`);
files.push(`/data/${lang}/commands/LanguagePacks.json`);
files.push(`/data/${lang}/commands/Podobni.json`);
files.push(`/data/${lang}/commands/RuleBasedNumbers.json`);
// Language-specific octoecheos (8 tones × 7 days)
for (let tone = 0; tone <= 7; tone++) {
for (const day of ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']) {
files.push(`/data/${lang}/services/octoecheos/tone${tone}/${day}.json`);
}
}
// Language-specific service commands
for (let tone = 1; tone <= 8; tone++) {
files.push(`/data/${lang}/services/commands/Tone${tone}.json`);
}
files.push(`/data/${lang}/services/commands/AfterEach.json`);
files.push(`/data/${lang}/services/commands/Bow.json`);
files.push(`/data/${lang}/services/commands/Prostration.json`);
files.push(`/data/${lang}/services/commands/S1.json`);
files.push(`/data/${lang}/services/commands/S2.json`);
return files;
return manifestPromise;
}
/** Data files to preload per language, grouped by type. */
const DATA_FILES: Record<string, (lang: string) => string[]> = {
calendar: (lang) => calendarDataFiles(lang),
bible: () => ['/data/bible/versions.json'],
};
export class OfflineManager {
private static _progress: PreloadProgress | null = null;
@@ -308,12 +99,15 @@ export class OfflineManager {
*/
static async preload(options: PreloadOptions): Promise<PreloadProgress> {
const filesToFetch: string[] = [];
const m = await loadManifest();
for (const lang of options.languages) {
for (const type of options.types) {
const generator = DATA_FILES[type];
if (generator) {
filesToFetch.push(...generator(lang));
if (type === 'calendar') {
const list = m?.[lang]?.calendar ?? [];
filesToFetch.push(...list);
} else if (type === 'bible') {
filesToFetch.push('/data/bible/versions.json');
}
}
}
@@ -337,6 +131,7 @@ export class OfflineManager {
total: uniqueFiles.length,
file: '',
done: false,
failed: 0,
};
OfflineManager._progress = progress;
@@ -345,29 +140,33 @@ export class OfflineManager {
progress.current++;
progress.file = file;
// Skip if already cached
const cacheKey = `url:${file}`;
if (await DataCache.get(cacheKey) !== null) continue;
// Use DataCache.fetch to cache the file in IndexedDB
// The service worker will also cache it via runtime caching
try {
const resp = await fetch(file);
if (!resp.ok) continue;
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) continue;
let success = false;
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, 1000));
try {
const resp = await fetch(file);
if (resp.status === 404) break;
if (!resp.ok) continue;
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) break;
// Determine type and cache
let data: unknown;
if (file.endsWith('.text')) {
data = await resp.text();
} else {
data = await resp.json();
let data: unknown;
if (file.endsWith('.text')) {
data = await resp.text();
} else {
data = await resp.json();
}
await DataCache.set(`url:${file}`, data);
success = true;
break;
} catch {
// Retry on network error
}
await DataCache.set(`url:${file}`, data);
} catch {
// Skip failed files — user can retry
}
if (!success) progress.failed++;
}
progress.done = true;
@@ -379,10 +178,7 @@ export class OfflineManager {
* Clear all cached data from IndexedDB and service worker caches.
*/
static async clearCache(): Promise<void> {
// Clear IndexedDB cache
await DataCache.clear();
// Clear service worker caches
const cacheNames = await caches.keys();
const ponomarCaches = cacheNames.filter(name =>
name.startsWith('ponomar-')
@@ -395,10 +191,9 @@ export class OfflineManager {
/**
* Get cache usage statistics.
*/
static async getStats(): Promise<CacheStats> {
static async getStats(): Promise<CacheStats> {
const estimate = await DataCache.estimate();
const cachedCount = await DataCache.count();
const cachedLanguages = new Set<string>();
const cachedTypes = new Set<string>();
@@ -419,4 +214,12 @@ static async getStats(): Promise<CacheStats> {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Get the expected calendar file count for a language from the manifest.
*/
static async getCalendarFileCount(lang: string): Promise<number> {
const m = await loadManifest();
return m?.[lang]?.calendar?.length ?? 0;
}
}
+8 -2
View File
@@ -522,9 +522,15 @@ export class SettingsView {
languages: selectedLangs,
types: selectedTypes,
bibleTranslations: bibleTrans,
}).then(() => {
}).then((prog) => {
if (progressBar) progressBar.style.width = '100%';
if (progressText) progressText.textContent = t.settings.offlineDone;
if (progressText) {
if (prog.failed > 0) {
progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}`;
} else {
progressText.textContent = t.settings.offlineDone;
}
}
OfflineManager.getStats().then(stats => {
const statsEl = document.getElementById('offline-stats');
if (statsEl) {