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:
catarrh committed 2026-10-04 17:14:33 +03:00
1 parent 1201e16d5e
commit 8c442bf7b6
7 files changed
+184 -55

No files matched your search

+15
View File
@@ -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}`);
+9 -2
View File
@@ -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;
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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 => {
+60
View File
@@ -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",
+10
View File
@@ -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',