Files
ponomar-pwa/src/core/offline-manager.ts
T

225 lines
6.5 KiB
TypeScript

/**
* 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<string, string> = {
'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<Record<string, Record<string, string[]>> | null> | null = null;
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;
}
})();
}
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<string[]> {
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<PreloadProgress> {
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<void> {
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<CacheStats> {
const estimate = await DataCache.estimate();
const cachedCount = await DataCache.count();
const cachedLanguages = new Set<string>();
const cachedTypes = new Set<string>();
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<number> {
const m = await loadManifest();
return m?.[lang]?.calendar?.length ?? 0;
}
}