offline manager + settings UI for preloading data
This commit is contained in:
1 parent
8b27bd4767
commit
465e2664cc
2 files changed
+311
No files matched your search
@@ -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, (lang: string) => 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<PreloadProgress> {
|
||||||
|
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<void> {
|
||||||
|
// 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<CacheStats> {
|
||||||
|
const estimate = await DataCache.estimate();
|
||||||
|
const cachedCount = await DataCache.count();
|
||||||
|
|
||||||
|
// Determine which languages and types are cached
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||||
|
import { OfflineManager } from '../core/offline-manager';
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', name: 'English', local: 'English' },
|
{ code: 'en', name: 'English', local: 'English' },
|
||||||
@@ -235,6 +236,64 @@ export class SettingsView {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Offline Content -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-red mb-3">Offline Content</h3>
|
||||||
|
|
||||||
|
<!-- Language selection for offline -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-sm font-bold text-navy mb-2">Languages to cache</label>
|
||||||
|
<div id="offline-langs" class="flex flex-wrap gap-3">
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="ru" class="offline-lang accent-gold"> <span class="text-sm">Русский</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="cu" class="offline-lang accent-gold"> <span class="text-sm">Церковнославянский</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="en" class="offline-lang accent-gold"> <span class="text-sm">English</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="el" class="offline-lang accent-gold"> <span class="text-sm">Ελληνικά</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="fr" class="offline-lang accent-gold"> <span class="text-sm">Français</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data type selection -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-sm font-bold text-navy mb-2">Data to cache</label>
|
||||||
|
<div id="offline-types" class="flex flex-wrap gap-3">
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="lives" checked class="offline-type accent-gold"> <span class="text-sm">Lives</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="calendar" checked class="offline-type accent-gold"> <span class="text-sm">Calendar</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" value="menaion" class="offline-type accent-gold"> <span class="text-sm">Menaion</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage info and controls -->
|
||||||
|
<div class="flex items-center justify-between gap-3 mb-2">
|
||||||
|
<span id="offline-stats" class="text-sm text-navy-light">Calculating storage...</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="offline-preload" class="bg-gold text-navy rounded px-3 py-1 text-sm font-bold hover:bg-gold-dark transition-colors">Preload</button>
|
||||||
|
<button id="offline-clear" class="bg-red text-parchment rounded px-3 py-1 text-sm hover:bg-red-dark transition-colors">Clear cache</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="offline-progress" class="hidden">
|
||||||
|
<div class="w-full bg-parchment-dark rounded-full h-2 mb-1">
|
||||||
|
<div id="offline-progress-bar" class="bg-gold h-2 rounded-full" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<p id="offline-progress-text" class="text-xs text-navy-light">0 / 0 files</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- About -->
|
<!-- About -->
|
||||||
<div class="mt-8 pt-6 border-t border-gold/20">
|
<div class="mt-8 pt-6 border-t border-gold/20">
|
||||||
<h3 class="text-lg font-bold text-red mb-2">${t.settings.about}</h3>
|
<h3 class="text-lg font-bold text-red mb-2">${t.settings.about}</h3>
|
||||||
@@ -303,6 +362,77 @@ export class SettingsView {
|
|||||||
document.getElementById('install-pwa')?.remove();
|
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();
|
this.updateFontPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user