diff --git a/scripts/generate-preload-manifest.ts b/scripts/generate-preload-manifest.ts index 8a0f9e8..3944de9 100644 --- a/scripts/generate-preload-manifest.ts +++ b/scripts/generate-preload-manifest.ts @@ -48,6 +48,21 @@ function calendarFiles(lang: string): string[] { 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}`); diff --git a/src/core/data-cache.ts b/src/core/data-cache.ts index 675368a..eaacdb2 100644 --- a/src/core/data-cache.ts +++ b/src/core/data-cache.ts @@ -13,6 +13,8 @@ * const estimate = await DataCache.estimate(); */ +import { diagLog } from './diag-log'; + const DB_NAME = 'ponomar-cache'; const DB_VERSION = 1; const STORE_NAME = 'data'; @@ -220,7 +222,11 @@ export class DataCache { const ct = resp.headers.get('content-type'); if (resp.ok && !(ct && ct.includes('text/html'))) { const data: unknown = type === 'text' ? await resp.text() : await resp.json(); - await DataCache.set(`url:${url}`, data); + const stored = await DataCache.set(`url:${url}`, data); + if (!stored) { + // Online view still gets its data; the offline copy is missing. + diagLog('idb-write-fail', { url }); + } return data as T; } } catch { /* offline or invalid response — fall through to IDB */ } @@ -261,7 +267,8 @@ export class DataCache { } // Cache the fetched data - await DataCache.set(cacheKey, data, options?.ttl); + const stored = await DataCache.set(cacheKey, data, options?.ttl); + if (!stored) diagLog('idb-write-fail', { url }); return data as T; } catch { return null; diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 21339a8..a046c90 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -524,6 +524,8 @@ export interface Translations { offlineCalculating: string; offlineDone: string; offlineFailed: string; + offlineDegraded: string; + offlineMissing: string; offlineSelectLang: string; offlineSelectAll: string; offlineCleared: string; @@ -1097,6 +1099,8 @@ const translations: Record = { offlineCalculating: 'Calculating storage...', offlineDone: 'Done! Data cached for offline use.', offlineFailed: 'files failed to load (retry to attempt again)', + offlineDegraded: 'Preload could not read the full file list — a reduced set was cached. Check your connection and try again.', + offlineMissing: 'Note: {0} files were not found on the server.', offlineSelectLang: 'Please select at least one language.', offlineSelectAll: 'Select all', offlineCleared: 'Cache cleared.', @@ -1668,6 +1672,8 @@ const translations: Record = { offlineCalculating: 'Расчёт размера...', offlineDone: 'Готово! Данные закэшированы для офлайн-использования.', offlineFailed: 'файлов не загружено (повторите для повторной попытки)', + offlineDegraded: 'Не удалось загрузить полный список файлов — закэширован сокращённый набор. Проверьте соединение и повторите.', + offlineMissing: 'Примечание: {0} файлов не найдено на сервере.', offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.', offlineSelectAll: 'Выбрать все', offlineCleared: 'Кэш очищен.', @@ -2239,6 +2245,8 @@ const translations: Record = { offlineCalculating: 'Расчётъ размѣра...', offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.', offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)', + offlineDegraded: 'Не возмо́жно бѣ̀ прочестѝ по́лный спи́сокъ фа́йловъ — закеши́рованъ сокраще́нный набо́ръ. Провѣ́рьте соедине́нїе и҆ повтори́те.', + offlineMissing: 'Примѣча́нїе: {0} фа́йловъ не ѡ҆брѣте́но на се́рверѣ.', offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.', offlineSelectAll: 'И҆збра́ти всѧ̑', offlineCleared: 'Кешъ очищенъ.', diff --git a/src/core/offline-manager.ts b/src/core/offline-manager.ts index be8cd44..0c4019c 100644 --- a/src/core/offline-manager.ts +++ b/src/core/offline-manager.ts @@ -30,6 +30,10 @@ export interface PreloadProgress { done: boolean; failed: number; failedFiles?: string[]; + /** Files the server answered 404 for (deployment drift). */ + missing?: number; + /** True when the manifest was unavailable and a reduced fallback set ran. */ + manifestMissing?: boolean; aborted?: boolean; } @@ -54,26 +58,42 @@ let manifestPromise: Promise> | null> | 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; - } - })().then((m) => { - // Don't cache a failure forever — allow retry on next call - if (m === null) { - diagLog('manifest-unavailable'); - manifestPromise = null; - } - return m; - }); + // Network-first with the IDB fallback: a copy written while online + // survives SW-cache eviction, so a later (possibly offline) call can + // still read the full file list instead of the reduced fallback set. + manifestPromise = DataCache + .fetchWithFallback>>('/data/shared/preload-manifest.json') + .then((m) => { + // Don't cache a failure forever — allow retry on next call + if (m === null || typeof m !== 'object') { + diagLog('manifest-unavailable'); + manifestPromise = null; + return null; + } + return m; + }); } return manifestPromise; } +/** versions.json loaded once per session (retried on failure). */ +let bibleVersionsPromise: Promise | null = null; + +function loadBibleVersions(): Promise { + if (!bibleVersionsPromise) { + bibleVersionsPromise = DataCache + .fetchWithFallback('/data/bible/versions.json') + .then((v) => { + if (!v || !Array.isArray(v) || v.length === 0) { + bibleVersionsPromise = null; + return null; + } + return v; + }); + } + return bibleVersionsPromise; +} + function getFallbackCalendarFiles(lang: string): string[] { const files = [ `/data/${lang}/menaion-bundle.json`, @@ -89,6 +109,7 @@ function getFallbackCalendarFiles(lang: string): string[] { export class OfflineManager { private static _progress: PreloadProgress | null = null; + private static _running: Promise | null = null; private static _autoPreloadRequested = false; /** Request an automatic "preload everything" once the Settings view opens. */ @@ -110,28 +131,21 @@ export class OfflineManager { /** Resolve every Bible translation id from versions.json (single source). */ static async getAllBibleTranslationIds(): Promise { - try { - const resp = await fetch('/data/bible/versions.json'); - const versions = await resp.json() as any[]; - return versions.map((v: any) => v.id); - } catch { - return []; - } + const versions = await loadBibleVersions(); + return versions ? versions.map((v: any) => v.id) : []; } /** Discover all book files for a Bible translation from versions.json. */ static async getBibleFiles(translationPath: string): Promise { const files: string[] = []; - try { - const resp = await fetch('/data/bible/versions.json'); - const versions = await resp.json() as any[]; - const ver = versions.find((v: any) => v.id === translationPath); - if (ver && ver.books) { - for (const book of ver.books) { - files.push(`/data/${translationPath}/${book.id}.text`); - } + const versions = await loadBibleVersions(); + if (!versions) return files; + const ver = versions.find((v: any) => v.id === translationPath); + if (ver && ver.books) { + for (const book of ver.books) { + files.push(`/data/${translationPath}/${book.id}.text`); } - } catch {} + } return files; } @@ -143,8 +157,19 @@ export class OfflineManager { /** * Preload selected data files for offline use. * Returns a PreloadProgress object that can be polled for progress. + * Re-entrant calls join the run already in flight. */ static async preload(options: PreloadOptions): Promise { + if (OfflineManager._running) return OfflineManager._running; + const run = OfflineManager._preloadImpl(options) + .finally(() => { + if (OfflineManager._running === run) OfflineManager._running = null; + }); + OfflineManager._running = run; + return run; + } + + private static async _preloadImpl(options: PreloadOptions): Promise { const startedAt = Date.now(); const filesToFetch: string[] = []; const m = await loadManifest(); @@ -184,6 +209,7 @@ export class OfflineManager { done: false, failed: 0, }; + if (m === null) progress.manifestMissing = true; OfflineManager._progress = progress; // Best-effort: request persistent storage so the OS won't evict data @@ -262,15 +288,21 @@ export class OfflineManager { break; } } else { + if (notFound) progress.missing = (progress.missing ?? 0) + 1; // Success or 404 both prove connectivity netFailStreak = 0; } } + if ((progress.missing ?? 0) > 0) { + diagLog('preload-missing', { count: progress.missing }); + } + progress.done = true; diagLog('preload-done', { total: progress.total, failed: progress.failed, + missing: progress.missing ?? 0, ms: Date.now() - startedAt, aborted: progress.aborted === true, }); diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts index c4608a7..0e72e16 100644 --- a/src/ui/settings-view.ts +++ b/src/ui/settings-view.ts @@ -3,11 +3,26 @@ */ import { getTranslations, type LanguageCode } from '../core/i18n'; -import { OfflineManager } from '../core/offline-manager'; +import { OfflineManager, type PreloadProgress } from '../core/offline-manager'; import { DataCache } from '../core/data-cache'; import { diagTail } from '../core/diag-log'; import { isPwaInstalled } from '../core/pwa-install'; +/** Human message for a finished preload run (shared by manual + auto preload). */ +function preloadResultText( + prog: PreloadProgress, + s: { offlineAborted: string; offlineDone: string; offlineFailed: string; offlineDegraded: string; offlineMissing: string }, +): string { + if (prog.aborted) return s.offlineAborted; + if (prog.manifestMissing) return s.offlineDegraded; + if (prog.failed > 0) { + const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : ''; + return `${s.offlineDone} ${prog.failed} ${s.offlineFailed}${failList}`; + } + if ((prog.missing ?? 0) > 0) return s.offlineMissing.replace('{0}', String(prog.missing)); + return s.offlineDone; +} + const LANGUAGES = [ { code: 'en', name: 'English', local: 'English' }, { code: 'ru', name: 'Russian', local: 'Русский' }, @@ -563,16 +578,7 @@ export class SettingsView { bibleTranslations: bibleTrans, }).then((prog) => { if (progressBar) progressBar.style.width = '100%'; - if (progressText) { - if (prog.aborted) { - progressText.textContent = t.settings.offlineAborted; - } else if (prog.failed > 0) { - const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : ''; - progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`; - } else { - progressText.textContent = t.settings.offlineDone; - } - } + if (progressText) progressText.textContent = preloadResultText(prog, t.settings); const diagEl = document.getElementById('diag-log'); if (diagEl) diagEl.textContent = diagTail(); OfflineManager.getStats().then(stats => { @@ -696,16 +702,7 @@ export class SettingsView { bibleTranslations: 'all', }).then((prog) => { if (progressBar) progressBar.style.width = '100%'; - if (progressText) { - if (prog.aborted) { - progressText.textContent = t.settings.offlineAborted; - } else if (prog.failed > 0) { - const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : ''; - progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`; - } else { - progressText.textContent = t.settings.offlineDone; - } - } + if (progressText) progressText.textContent = preloadResultText(prog, t.settings); const diagEl = document.getElementById('diag-log'); if (diagEl) diagEl.textContent = diagTail(); OfflineManager.getStats().then(stats => { diff --git a/static/data/shared/preload-manifest.json b/static/data/shared/preload-manifest.json index d898f46..10ac975 100644 --- a/static/data/shared/preload-manifest.json +++ b/static/data/shared/preload-manifest.json @@ -26,6 +26,26 @@ "/data/shared/fasting.json", "/data/shared/calendar/triodion.json", "/data/shared/calendar/pentecostarion.json", + "/data/shared/lives/01.json", + "/data/shared/lives/03.json", + "/data/shared/lives/05.json", + "/data/shared/lives/09.json", + "/data/shared/lives/10.json", + "/data/shared/lives/11.json", + "/data/shared/lives/12.json", + "/data/shared/lives/misc/1.json", + "/data/shared/lives/misc/2.json", + "/data/shared/lives/misc/3.json", + "/data/shared/lives/misc/4.json", + "/data/shared/lives/misc/5.json", + "/data/shared/lives/misc/6.json", + "/data/shared/lives/misc/7.json", + "/data/shared/lives/misc/8.json", + "/data/shared/lives/misc/9.json", + "/data/shared/commands/DivineLiturgy.json", + "/data/shared/commands/Fasting.json", + "/data/shared/commands/ScriptureTransfers.json", + "/data/shared/commands/ServiceRules.json", "/data/en/menaion-bundle.json", "/data/shared/services/templates/AllSaints.json", "/data/shared/services/templates/Ambrose.json", @@ -1773,6 +1793,26 @@ "/data/shared/fasting.json", "/data/shared/calendar/triodion.json", "/data/shared/calendar/pentecostarion.json", + "/data/shared/lives/01.json", + "/data/shared/lives/03.json", + "/data/shared/lives/05.json", + "/data/shared/lives/09.json", + "/data/shared/lives/10.json", + "/data/shared/lives/11.json", + "/data/shared/lives/12.json", + "/data/shared/lives/misc/1.json", + "/data/shared/lives/misc/2.json", + "/data/shared/lives/misc/3.json", + "/data/shared/lives/misc/4.json", + "/data/shared/lives/misc/5.json", + "/data/shared/lives/misc/6.json", + "/data/shared/lives/misc/7.json", + "/data/shared/lives/misc/8.json", + "/data/shared/lives/misc/9.json", + "/data/shared/commands/DivineLiturgy.json", + "/data/shared/commands/Fasting.json", + "/data/shared/commands/ScriptureTransfers.json", + "/data/shared/commands/ServiceRules.json", "/data/shared/services/templates/AllSaints.json", "/data/shared/services/templates/Ambrose.json", "/data/shared/services/templates/Annunciation.json", @@ -3113,6 +3153,26 @@ "/data/shared/fasting.json", "/data/shared/calendar/triodion.json", "/data/shared/calendar/pentecostarion.json", + "/data/shared/lives/01.json", + "/data/shared/lives/03.json", + "/data/shared/lives/05.json", + "/data/shared/lives/09.json", + "/data/shared/lives/10.json", + "/data/shared/lives/11.json", + "/data/shared/lives/12.json", + "/data/shared/lives/misc/1.json", + "/data/shared/lives/misc/2.json", + "/data/shared/lives/misc/3.json", + "/data/shared/lives/misc/4.json", + "/data/shared/lives/misc/5.json", + "/data/shared/lives/misc/6.json", + "/data/shared/lives/misc/7.json", + "/data/shared/lives/misc/8.json", + "/data/shared/lives/misc/9.json", + "/data/shared/commands/DivineLiturgy.json", + "/data/shared/commands/Fasting.json", + "/data/shared/commands/ScriptureTransfers.json", + "/data/shared/commands/ServiceRules.json", "/data/cu/menaion-bundle.json", "/data/shared/services/templates/AllSaints.json", "/data/shared/services/templates/Ambrose.json", diff --git a/vite.config.ts b/vite.config.ts index 6f593f6..2a7b4ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,16 @@ export default defineConfig({ globIgnores: ['**/data/**'], maximumFileSizeToCacheInBytes: 20 * 1024 * 1024, runtimeCaching: [ + { + // The preload file list must be fresh when online; a stale copy + // silently loses coverage. Offline falls back to the SW copy. + urlPattern: /\/data\/shared\/preload-manifest\.json$/, + handler: 'NetworkFirst', + options: { + cacheName: 'ponomar-manifest', + networkTimeoutSeconds: 3, + }, + }, { urlPattern: /\/data\/.*\.json$/, handler: 'StaleWhileRevalidate',