fix(offline): surface degraded/missing preloads, join re-entrant runs, cache manifest to IDB
- loadManifest() goes through DataCache.fetchWithFallback: the file list is written to IDB and survives SW-cache eviction; a run with the reduced fallback set is flagged and shown as degraded (new i18n en/ru/cu). - 404 answers are counted as 'missing' (deployment drift) with a preload-missing diag entry, instead of silently reporting Done. - preload() is re-entrancy safe: concurrent calls join the in-flight run. - versions.json is loaded once per session (was fetched per translation). - Write-through failures in fetchWithFallback()/fetch() log idb-write-fail. - preload-manifest.json SW route is NetworkFirst (fresh coverage online). - Manifest now also covers the shared/lives + shared/commands fallbacks.
This commit is contained in:
1 parent
1201e16d5e
commit
8c442bf7b6
7 files changed
+184
-55
No files matched your search
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LanguageCode, Translations> = {
|
||||
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<LanguageCode, Translations> = {
|
||||
offlineCalculating: 'Расчёт размера...',
|
||||
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
||||
offlineFailed: 'файлов не загружено (повторите для повторной попытки)',
|
||||
offlineDegraded: 'Не удалось загрузить полный список файлов — закэширован сокращённый набор. Проверьте соединение и повторите.',
|
||||
offlineMissing: 'Примечание: {0} файлов не найдено на сервере.',
|
||||
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
|
||||
offlineSelectAll: 'Выбрать все',
|
||||
offlineCleared: 'Кэш очищен.',
|
||||
@@ -2239,6 +2245,8 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
offlineCalculating: 'Расчётъ размѣра...',
|
||||
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
||||
offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)',
|
||||
offlineDegraded: 'Не возмо́жно бѣ̀ прочестѝ по́лный спи́сокъ фа́йловъ — закеши́рованъ сокраще́нный набо́ръ. Провѣ́рьте соедине́нїе и҆ повтори́те.',
|
||||
offlineMissing: 'Примѣча́нїе: {0} фа́йловъ не ѡ҆брѣте́но на се́рверѣ.',
|
||||
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
|
||||
offlineSelectAll: 'И҆збра́ти всѧ̑',
|
||||
offlineCleared: 'Кешъ очищенъ.',
|
||||
|
||||
+64
-32
@@ -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<Record<string, Record<string, string[]>> | 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;
|
||||
}
|
||||
})().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<Record<string, Record<string, string[]>>>('/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<any[] | null> | null = null;
|
||||
|
||||
function loadBibleVersions(): Promise<any[] | null> {
|
||||
if (!bibleVersionsPromise) {
|
||||
bibleVersionsPromise = DataCache
|
||||
.fetchWithFallback<any[]>('/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<PreloadProgress> | 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<string[]> {
|
||||
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<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`);
|
||||
}
|
||||
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<PreloadProgress> {
|
||||
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<PreloadProgress> {
|
||||
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,
|
||||
});
|
||||
|
||||
+18
-21
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user