/** * Offline data manager — preloads data files for offline use. * * Allows users to select which languages and data types to cache * for offline access. Shows progress during preloading and * storage usage statistics. * * Usage: * const progress = await OfflineManager.preload({ * languages: ['ru', 'cu'], * types: ['calendar', 'bible'], * }); * const stats = await OfflineManager.getStats(); * await OfflineManager.clearCache(); */ import { DataCache } from './data-cache'; export interface PreloadOptions { languages: string[]; types: ('calendar' | 'bible')[]; bibleTranslations?: string[]; } export interface PreloadProgress { current: number; total: number; file: string; done: boolean; failed: number; } export interface CacheStats { usageBytes: number; quotaBytes: number; cachedFiles: number; cachedLanguages: string[]; cachedTypes: string[]; } /** Bible translation sizes (pre-computed). */ const BIBLE_SIZES: Record = { 'en/bible/kjv': '5.1 MB', 'en/bible/brenton': '4.1 MB', 'en/bible/nonkjv': '0.9 MB', 'ru/bible/synod': '7.3 MB', 'ru/bible/kassian': '1.5 MB', 'ru/bible/yungerov': '1.7 MB', 'cu/bible/elis': '8.4 MB', 'el/bible/spt': '8.6 MB', 'fr/bible/ls': '5.2 MB', 'la/bible/vulgate': '4.4 MB', 'ar/bible/svd': '7.9 MB', 'zh/Hans/bible/cuv': '3.7 MB', 'zh/Hant/bible/cuv': '3.7 MB', }; let manifestPromise: Promise> | null> | null = 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; } })(); } return manifestPromise; } export class OfflineManager { private static _progress: PreloadProgress | null = null; /** Get size string for a Bible translation. */ static getBibleSize(translationPath: string): string { return BIBLE_SIZES[translationPath] || '?'; } /** 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`); } } } catch {} return files; } /** Get current preload progress */ static getProgress(): PreloadProgress | null { return OfflineManager._progress; } /** * Preload selected data files for offline use. * Returns a PreloadProgress object that can be polled for progress. */ 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) { if (type === 'calendar') { const list = m?.[lang]?.calendar ?? []; filesToFetch.push(...list); } else if (type === 'bible') { filesToFetch.push('/data/bible/versions.json'); } } } // Deduplicate const uniqueFiles = [...new Set(filesToFetch)]; // Add Bible translation files for (const trans of options.bibleTranslations || []) { const bibleFiles = await OfflineManager.getBibleFiles(trans); for (const f of bibleFiles) { if (!uniqueFiles.includes(f)) uniqueFiles.push(f); } } if (!uniqueFiles.includes('/data/bible/versions.json')) { uniqueFiles.unshift('/data/bible/versions.json'); } const progress: PreloadProgress = { current: 0, total: uniqueFiles.length, file: '', done: false, failed: 0, }; OfflineManager._progress = progress; // Fetch files sequentially to avoid overwhelming the network for (const file of uniqueFiles) { progress.current++; progress.file = file; const cacheKey = `url:${file}`; if (await DataCache.get(cacheKey) !== null) 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; 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 } } if (!success) progress.failed++; } progress.done = true; OfflineManager._progress = null; return progress; } /** * Clear all cached data from IndexedDB and service worker caches. */ static async clearCache(): Promise { await DataCache.clear(); const cacheNames = await caches.keys(); const ponomarCaches = cacheNames.filter(name => name.startsWith('ponomar-') ); await Promise.all( ponomarCaches.map(name => caches.delete(name)) ); } /** * Get cache usage statistics. */ static async getStats(): Promise { const estimate = await DataCache.estimate(); const cachedCount = await DataCache.count(); const cachedLanguages = new Set(); const cachedTypes = new Set(); return { usageBytes: estimate?.usage ?? 0, quotaBytes: estimate?.quota ?? 0, cachedFiles: cachedCount, cachedLanguages: [...cachedLanguages], cachedTypes: [...cachedTypes], }; } /** * Format bytes to human-readable string. */ static formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; 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; } }