diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 9a61b34..947ce76 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -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 = { 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 = { offlineClearCache: 'Очистить кэш', offlineCalculating: 'Расчёт размера...', offlineDone: 'Готово! Данные закэшированы для офлайн-использования.', + offlineFailed: 'файлов не загружено (повторите для повторной попытки)', offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.', offlineSelectAll: 'Выбрать все', offlineCleared: 'Кэш очищен.', @@ -2190,6 +2193,7 @@ const translations: Record = { offlineClearCache: 'Очистити кешъ', offlineCalculating: 'Расчётъ размѣра...', offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.', + offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)', offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.', offlineSelectAll: 'И҆збра́ти всѧ̑', offlineCleared: 'Кешъ очищенъ.', diff --git a/src/core/offline-manager.ts b/src/core/offline-manager.ts index 00cd84b..55b25dc 100644 --- a/src/core/offline-manager.ts +++ b/src/core/offline-manager.ts @@ -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 = { '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> | 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 = { - 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> | 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[]> = { - 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 { 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 { - // 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 { + static async getStats(): Promise { const estimate = await DataCache.estimate(); const cachedCount = await DataCache.count(); - const cachedLanguages = new Set(); const cachedTypes = new Set(); @@ -419,4 +214,12 @@ static async getStats(): Promise { 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 { + const m = await loadManifest(); + return m?.[lang]?.calendar?.length ?? 0; + } } \ No newline at end of file diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts index 2aef76d..9ee4597 100644 --- a/src/ui/settings-view.ts +++ b/src/ui/settings-view.ts @@ -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) {