diff --git a/src/core/offline-manager.ts b/src/core/offline-manager.ts new file mode 100644 index 0000000..73e8f8f --- /dev/null +++ b/src/core/offline-manager.ts @@ -0,0 +1,181 @@ +/** + * 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: ['lives', 'calendar'], + * }); + * const stats = await OfflineManager.getStats(); + * await OfflineManager.clearCache(); + */ + +import { DataCache } from './data-cache'; + +export interface PreloadOptions { + languages: string[]; + types: ('lives' | 'bible' | 'calendar' | 'menaion')[]; +} + +export interface PreloadProgress { + current: number; + total: number; + file: string; + done: boolean; +} + +export interface CacheStats { + usageBytes: number; + quotaBytes: number; + cachedFiles: number; + cachedLanguages: string[]; + cachedTypes: string[]; +} + +/** Data files to preload per language, grouped by type. */ +const DATA_FILES: Record string[]> = { + lives: (lang) => { + const files: string[] = []; + // Month-prefixed files + for (let m = 1; m <= 12; m++) { + files.push(`/data/${lang}/lives/${String(m).padStart(2, '0')}.json`); + } + // Misc split files (by first digit) + for (let d = 0; d <= 9; d++) { + files.push(`/data/${lang}/lives/misc/${d}.json`); + } + return files; + }, + calendar: () => [ + '/data/shared/fasting.json', + '/data/shared/calendar/triodion.json', + '/data/shared/calendar/pentecostarion.json', + '/data/shared/lives-index.json', + ], + menaion: (lang) => [ + `/data/${lang}/menaion-bundle.json`, + ], + bible: (lang) => [ + // Bible version index + '/data/bible/versions.json', + // Main Bible text files per language + `/data/${lang}/bible/synod.text`, + ], +}; + +export class OfflineManager { + private static _progress: PreloadProgress | null = null; + + /** 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[] = []; + + for (const lang of options.languages) { + for (const type of options.types) { + const generator = DATA_FILES[type]; + if (generator) { + filesToFetch.push(...generator(lang)); + } + } + } + + // Deduplicate + const uniqueFiles = [...new Set(filesToFetch)]; + + const progress: PreloadProgress = { + current: 0, + total: uniqueFiles.length, + file: '', + done: false, + }; + OfflineManager._progress = progress; + + // Fetch files sequentially to avoid overwhelming the network + for (const file of uniqueFiles) { + progress.current++; + progress.file = file; + + // 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; + + // Determine type and cache + let data: unknown; + if (file.endsWith('.text')) { + data = await resp.text(); + } else { + data = await resp.json(); + } + await DataCache.set(`url:${file}`, data); + } catch { + // Skip failed files — user can retry + } + } + + progress.done = true; + OfflineManager._progress = null; + return progress; + } + + /** + * 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-') + ); + 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(); + + // Determine which languages and types are cached + 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`; + } +} \ No newline at end of file diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts index 8fd395f..c503827 100644 --- a/src/ui/settings-view.ts +++ b/src/ui/settings-view.ts @@ -3,6 +3,7 @@ */ import { getTranslations, type LanguageCode } from '../core/i18n'; +import { OfflineManager } from '../core/offline-manager'; const LANGUAGES = [ { code: 'en', name: 'English', local: 'English' }, @@ -235,6 +236,64 @@ export class SettingsView { + +
+

Offline Content

+ + +
+ +
+ + + + + +
+
+ + +
+ +
+ + + +
+
+ + +
+ Calculating storage... +
+ + +
+
+ +
+

${t.settings.about}

@@ -303,6 +362,77 @@ export class SettingsView { document.getElementById('install-pwa')?.remove(); } }); + + // Offline content - auto-select ru+cu when ru is selected + document.querySelectorAll('.offline-lang').forEach(el => { + el.addEventListener('change', () => { + const ruChecked = (document.querySelector('.offline-lang[value="ru"]') as HTMLInputElement)?.checked; + const cuCheckbox = document.querySelector('.offline-lang[value="cu"]') as HTMLInputElement; + if (ruChecked && cuCheckbox) { + cuCheckbox.checked = true; + } + }); + }); + + // Preload button + document.getElementById('offline-preload')?.addEventListener('click', async () => { + const selectedLangs = Array.from(document.querySelectorAll('.offline-lang:checked')) + .map(el => (el as HTMLInputElement).value); + const selectedTypes = Array.from(document.querySelectorAll('.offline-type:checked')) + .map(el => (el as HTMLInputElement).value) as ('lives' | 'bible' | 'calendar' | 'menaion')[]; + + if (selectedLangs.length === 0) { + const progressText = document.getElementById('offline-progress-text'); + if (progressText) progressText.textContent = 'Please select at least one language.'; + return; + } + + const progressDiv = document.getElementById('offline-progress'); + const progressBar = document.getElementById('offline-progress-bar'); + const progressText = document.getElementById('offline-progress-text'); + if (progressDiv) progressDiv.classList.remove('hidden'); + + // Start preloading in background + OfflineManager.preload({ languages: selectedLangs, types: selectedTypes }).then(() => { + if (progressBar) progressBar.style.width = '100%'; + if (progressText) progressText.textContent = 'Done! Data cached for offline use.'; + OfflineManager.getStats().then(stats => { + const statsEl = document.getElementById('offline-stats'); + if (statsEl) { + statsEl.textContent = `Cache: ${OfflineManager.formatBytes(stats.usageBytes)} / ${OfflineManager.formatBytes(stats.quotaBytes)} (${stats.cachedFiles} files)`; + } + }); + }); + + // Poll progress + const pollInterval = setInterval(() => { + const prog = OfflineManager.getProgress(); + if (prog && prog.total > 0) { + const pct = Math.round((prog.current / prog.total) * 100); + if (progressBar) progressBar.style.width = `${pct}%`; + if (progressText) progressText.textContent = `${prog.current} / ${prog.total} files — ${prog.file.substring(0, 60)}...`; + } + if (!prog || prog.done) clearInterval(pollInterval); + }, 200); + }); + + // Clear cache button + document.getElementById('offline-clear')?.addEventListener('click', async () => { + await OfflineManager.clearCache(); + const statsEl = document.getElementById('offline-stats'); + if (statsEl) statsEl.textContent = 'Cache cleared.'; + const progressDiv = document.getElementById('offline-progress'); + if (progressDiv) progressDiv.classList.add('hidden'); + }); + + // Show initial storage stats + OfflineManager.getStats().then(stats => { + const statsEl = document.getElementById('offline-stats'); + if (statsEl) { + statsEl.textContent = `Cache: ${OfflineManager.formatBytes(stats.usageBytes)} / ${OfflineManager.formatBytes(stats.quotaBytes)} (${stats.cachedFiles} files)`; + } + }); + this.updateFontPreview(); }