feat: serve all precached data offline via DataCache fallback

- DataCache.fetchWithFallback: network-first with IDB write-through under
  the same url: keys the preload writes; falls back to precached IDB copy
  when offline — closes the preload(IDB) vs read(SW cache) gap that made
  the Bible reader (and every other view) fail offline despite precaching
- Convert all 33 raw /data fetches across bible/service/menaion/prayer/
  calendar/settings views; in-memory caches and notFound maps preserved
- fetchCommandText: switch to lowercase services/commands/ (already in
  preload manifest); tolerate both TEXT/text node casings per migrate-data
- Remove stale duplicate static/data/en/services/Command/ (15 files)
- fetchLives: fix misc fallback to real lives/misc/{N}.json bundles
This commit is contained in:
catarrh committed 2026-08-24 09:50:07 +03:00
1 parent 623c56a96f
commit a005e386a6
22 files changed
+118 -198

No files matched your search

+24
View File
@@ -204,6 +204,30 @@ export class DataCache {
} }
} }
/**
* Network-first fetch with IndexedDB fallback.
*
* Online: fetches, parses and writes through to IDB under the same
* `url:` keys the offline preload uses. Offline: serves the precached
* IDB copy, or null when the file was never preloaded.
*/
static async fetchWithFallback<T = unknown>(url: string, type: 'json' | 'text' = 'json'): Promise<T | null> {
try {
const resp = await fetch(url);
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);
return data as T;
}
} catch { /* offline or invalid response — fall through to IDB */ }
try {
return ((await DataCache.get(`url:${url}`)) as T) ?? null;
} catch {
return null;
}
}
/** /**
* Cache-first fetch: tries IndexedDB first, falls back to network. * Cache-first fetch: tries IndexedDB first, falls back to network.
* If the data is fetched from network, it's cached for future use. * If the data is fetched from network, it's cached for future use.
+6 -5
View File
@@ -9,6 +9,7 @@ import {
formatPassageHTML, formatPassageHTML,
} from '../core/bible-reader'; } from '../core/bible-reader';
import { getTranslations, type LanguageCode } from '../core/i18n'; import { getTranslations, type LanguageCode } from '../core/i18n';
import { DataCache } from '../core/data-cache';
const SETTINGS_KEY = 'ponomar-settings'; const SETTINGS_KEY = 'ponomar-settings';
const BIBLE_STATE_KEY = 'ponomar-bible-state'; const BIBLE_STATE_KEY = 'ponomar-bible-state';
@@ -138,8 +139,9 @@ export class BibleView {
private async loadVersions() { private async loadVersions() {
try { try {
const resp = await fetch('/data/bible/versions.json'); const versions = await DataCache.fetchWithFallback<typeof this.versions>('/data/bible/versions.json');
this.versions = await resp.json(); if (!versions || !Array.isArray(versions) || versions.length === 0) throw new Error('unavailable');
this.versions = versions;
this.sortVersionsByLanguage(); this.sortVersionsByLanguage();
} catch { } catch {
this.versions = [ this.versions = [
@@ -473,9 +475,8 @@ export class BibleView {
if (!text) { if (!text) {
// Version IDs are full paths like "en/bible/kjv" // Version IDs are full paths like "en/bible/kjv"
const resp = await fetch(`/data/${this.currentVersion}/${this.currentBook}.text`); text = (await DataCache.fetchWithFallback<string>(`/data/${this.currentVersion}/${this.currentBook}.text`, 'text')) ?? undefined;
if (!resp.ok) throw new Error('Not found'); if (!text) throw new Error('Not found');
text = await resp.text();
this.loadedText.set(cacheKey, text); this.loadedText.set(cacheKey, text);
} }
+11 -24
View File
@@ -52,12 +52,10 @@ async function loadBookAbbrevs(lang: string): Promise<void> {
bookAbbrevLang = lang; bookAbbrevLang = lang;
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
try { try {
const resp = await fetch('/data/bible/versions.json'); const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
if (!resp.ok) { if (attempt === 0) await new Promise(r => setTimeout(r, 300)); continue; } const ver = Array.isArray(versions)
const ct = resp.headers.get('content-type'); ? (versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0])
if (ct && ct.includes('text/html')) { if (attempt === 0) await new Promise(r => setTimeout(r, 300)); continue; } : null;
const versions = await resp.json() as any[];
const ver = versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0];
if (ver) { if (ver) {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
if (ver.books) { if (ver.books) {
@@ -72,8 +70,9 @@ async function loadBookAbbrevs(lang: string): Promise<void> {
return; return;
} }
} catch { } catch {
if (attempt === 0) await new Promise(r => setTimeout(r, 300)); // fall through
} }
if (attempt === 0) await new Promise(r => setTimeout(r, 300));
} }
} }
@@ -295,11 +294,7 @@ async function loadLife(lang: LanguageCode, cid: string): Promise<LifeData | nul
async function loadCommemoration(sharedId: string): Promise<CommemorationData | null> { async function loadCommemoration(sharedId: string): Promise<CommemorationData | null> {
try { try {
const resp = await fetch(`/data/shared/commemorations/${sharedId}.json`); return await DataCache.fetchWithFallback<CommemorationData>(`/data/shared/commemorations/${sharedId}.json`);
if (!resp.ok) return null;
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) return null;
return await resp.json();
} catch { } catch {
return null; return null;
} }
@@ -350,12 +345,8 @@ async function loadMenaion(lang: LanguageCode, month: number, day: number): Prom
async function loadTriodionData(): Promise<Record<string, unknown>> { async function loadTriodionData(): Promise<Record<string, unknown>> {
if (cachedTriodion) return cachedTriodion; if (cachedTriodion) return cachedTriodion;
try { try {
const resp = await fetch('/data/shared/calendar/triodion.json'); cachedTriodion = (await DataCache.fetchWithFallback<Record<string, unknown>>('/data/shared/calendar/triodion.json')) ?? {};
if (!resp.ok) return {}; return cachedTriodion;
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) return {};
cachedTriodion = await resp.json();
return cachedTriodion!;
} catch { } catch {
return {}; return {};
} }
@@ -371,12 +362,8 @@ async function loadTriodionFile(index: number): Promise<unknown[]> {
async function loadPentecostarionData(): Promise<Record<string, unknown>> { async function loadPentecostarionData(): Promise<Record<string, unknown>> {
if (cachedPentecostarion) return cachedPentecostarion; if (cachedPentecostarion) return cachedPentecostarion;
try { try {
const resp = await fetch('/data/shared/calendar/pentecostarion.json'); cachedPentecostarion = (await DataCache.fetchWithFallback<Record<string, unknown>>('/data/shared/calendar/pentecostarion.json')) ?? {};
if (!resp.ok) return {}; return cachedPentecostarion;
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) return {};
cachedPentecostarion = await resp.json();
return cachedPentecostarion!;
} catch { } catch {
return {}; return {};
} }
+6 -6
View File
@@ -7,6 +7,7 @@ import { assembleService, type ServiceContext } from '../core/service-assembler'
import type { EvalContext, ServiceNode } from '../core/types'; import type { EvalContext, ServiceNode } from '../core/types';
import { getTranslations, type LanguageCode } from '../core/i18n'; import { getTranslations, type LanguageCode } from '../core/i18n';
import { loadSettings, fontClass } from './settings-view'; import { loadSettings, fontClass } from './settings-view';
import { DataCache } from '../core/data-cache';
export class MenaionView { export class MenaionView {
private container: HTMLElement; private container: HTMLElement;
@@ -45,9 +46,9 @@ export class MenaionView {
async render() { async render() {
try { try {
const resp = await fetch('/data/shared/menaion-daily/index.json'); const index = await DataCache.fetchWithFallback<Record<string, number>>('/data/shared/menaion-daily/index.json');
if (resp.ok) { if (index) {
this.index = await resp.json() as Record<string, number>; this.index = index;
} }
} catch { /* empty */ } } catch { /* empty */ }
@@ -164,9 +165,8 @@ export class MenaionView {
let html = ''; let html = '';
for (let i = 1; i <= count; i++) { for (let i = 1; i <= count; i++) {
try { try {
const resp = await fetch(`/data/shared/menaion-daily/${dateKey}/${i}.json`); const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(`/data/shared/menaion-daily/${dateKey}/${i}.json`);
if (!resp.ok) continue; if (!nodes) continue;
const nodes: ServiceNode[] = await resp.json();
const evalCtx: EvalContext = {}; const evalCtx: EvalContext = {};
const ctx: ServiceContext = { const ctx: ServiceContext = {
evalCtx, lang: 'cu', t: this.t, evalCtx, lang: 'cu', t: this.t,
+3 -3
View File
@@ -8,6 +8,7 @@ import { assembleService, type ServiceContext } from '../core/service-assembler'
import type { EvalContext, ServiceNode } from '../core/types'; import type { EvalContext, ServiceNode } from '../core/types';
import { getTranslations, type LanguageCode } from '../core/i18n'; import { getTranslations, type LanguageCode } from '../core/i18n';
import { loadSettings, fontClass } from './settings-view'; import { loadSettings, fontClass } from './settings-view';
import { DataCache } from '../core/data-cache';
export type PrayerCollection = 'prayer-rule' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'triodion'; export type PrayerCollection = 'prayer-rule' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'triodion';
@@ -270,12 +271,11 @@ export class PrayerView {
} }
dataPath = `/data/shared/services/paraclete/tone${m[1]}/${m[2]}.json`; dataPath = `/data/shared/services/paraclete/tone${m[1]}/${m[2]}.json`;
} }
const resp = await fetch(dataPath); const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(dataPath);
if (!resp.ok) { if (!nodes) {
contentEl.innerHTML = `<p class="text-navy-light">${this.t.prayer.notFound}</p>`; contentEl.innerHTML = `<p class="text-navy-light">${this.t.prayer.notFound}</p>`;
return; return;
} }
const nodes: ServiceNode[] = await resp.json();
const evalCtx: EvalContext = {}; const evalCtx: EvalContext = {};
const ctx: ServiceContext = { const ctx: ServiceContext = {
+62 -139
View File
@@ -15,6 +15,7 @@ import {
HOLY_WEEK, holyWeekVarName, type ServiceTabDef, HOLY_WEEK, holyWeekVarName, type ServiceTabDef,
} from '../core/service-tabs'; } from '../core/service-tabs';
import { loadSettings, fontClass } from './settings-view'; import { loadSettings, fontClass } from './settings-view';
import { DataCache } from '../core/data-cache';
interface ServiceDef { interface ServiceDef {
id: string; id: string;
@@ -74,9 +75,8 @@ export class ServiceView {
// Load the daily Menaion index (date -> section count) for the Служба дня tabs // Load the daily Menaion index (date -> section count) for the Служба дня tabs
try { try {
const resp = await fetch('/data/shared/menaion-daily/index.json'); const index = await DataCache.fetchWithFallback<Record<string, number>>('/data/shared/menaion-daily/index.json');
if (resp.ok) { if (index) {
const index = await resp.json() as Record<string, number>;
for (const [dk, n] of Object.entries(index)) { for (const [dk, n] of Object.entries(index)) {
this.menaionDayCount.set(dk, n); this.menaionDayCount.set(dk, n);
} }
@@ -386,9 +386,8 @@ export class ServiceView {
try { try {
const tryFetch = async (lang: string) => { const tryFetch = async (lang: string) => {
const url = `/data/${lang}/services/octoecheos/tone${toneNum}/${weekday.toLowerCase()}.json`; const url = `/data/${lang}/services/octoecheos/tone${toneNum}/${weekday.toLowerCase()}.json`;
const resp = await fetch(url); const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(url);
if (!resp.ok) return null; if (!nodes) return null;
const nodes: ServiceNode[] = await resp.json();
for (const node of nodes) { for (const node of nodes) {
if (node.type === serviceType) { if (node.type === serviceType) {
if (!node.cmd || evalBool(node.cmd as string, this.evalContext)) { if (!node.cmd || evalBool(node.cmd as string, this.evalContext)) {
@@ -417,11 +416,10 @@ export class ServiceView {
try { try {
const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => { const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => {
try { try {
const resp = await fetch(url); const all = await DataCache.fetchWithFallback<Record<string, unknown>[]>(url);
if (!resp.ok) return null; if (!all) return null;
const all = await resp.json();
if (Array.isArray(all)) return all.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') })); if (Array.isArray(all)) return all.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') }));
const entries = all[dateKey] as Record<string, unknown>[] | undefined; const entries = (all as Record<string, unknown>)[dateKey] as Record<string, unknown>[] | undefined;
if (Array.isArray(entries)) return entries.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') })); if (Array.isArray(entries)) return entries.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') }));
return null; return null;
} catch { return null; } } catch { return null; }
@@ -435,11 +433,10 @@ export class ServiceView {
// Load from merged calendar files // Load from merged calendar files
const loadCalendarEntry = async (file: string, index: number): Promise<{ cid: string }[] | null> => { const loadCalendarEntry = async (file: string, index: number): Promise<{ cid: string }[] | null> => {
try { try {
const resp = await fetch(`/data/shared/calendar/${file}.json`); const data = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/shared/calendar/${file}.json`);
if (!resp.ok) return null; if (!data) return null;
const data = await resp.json();
const key = String(index).padStart(2, '0'); const key = String(index).padStart(2, '0');
const entry = data[key]; const entry = data[key] as { id?: unknown } | undefined;
return entry ? [{ cid: String(entry.id ?? '') }] : null; return entry ? [{ cid: String(entry.id ?? '') }] : null;
} catch { return null; } } catch { return null; }
}; };
@@ -457,9 +454,8 @@ export class ServiceView {
for (const entry of allEntries) { for (const entry of allEntries) {
if (!entry.cid) continue; if (!entry.cid) continue;
try { try {
const shmResp = await fetch(`/data/shared/commemorations/${entry.cid}.json`); const shm = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/shared/commemorations/${entry.cid}.json`);
if (!shmResp.ok) continue; if (!shm) continue;
const shm = await shmResp.json();
const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined; const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined;
const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined; const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined;
if (troparion?.text || kontakion?.text) { if (troparion?.text || kontakion?.text) {
@@ -507,9 +503,8 @@ export class ServiceView {
const toneNum = tone === 8 ? 0 : tone; const toneNum = tone === 8 ? 0 : tone;
const url = `/data/shared/services/canons/tone${toneNum}/sunday.json`; const url = `/data/shared/services/canons/tone${toneNum}/sunday.json`;
try { try {
const resp = await fetch(url); const canonData = await DataCache.fetchWithFallback<CanonData>(url);
if (!resp.ok) return null; if (!canonData) return null;
const canonData: CanonData = await resp.json();
const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ']; const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ'];
const nodes: ServiceNode[] = []; const nodes: ServiceNode[] = [];
@@ -543,62 +538,27 @@ export class ServiceView {
const dayKey = WEEKDAY[dow] ?? ''; const dayKey = WEEKDAY[dow] ?? '';
if (!dayKey) return null; if (!dayKey) return null;
const url = `/data/shared/services/canons/paraclete/tone${tone}/${dayKey}.json`; const url = `/data/shared/services/canons/paraclete/tone${tone}/${dayKey}.json`;
try { return DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
} }
private async loadMenaionDay(dateKey: string, index: number): Promise<ServiceNode[] | null> { private async loadMenaionDay(dateKey: string, index: number): Promise<ServiceNode[] | null> {
const url = `/data/shared/menaion-daily/${dateKey}/${index}.json`; const url = `/data/shared/menaion-daily/${dateKey}/${index}.json`;
try { return DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
} }
private async loadTriodion(index: number): Promise<ServiceNode[] | null> { private async loadTriodion(index: number): Promise<ServiceNode[] | null> {
const url = `/data/shared/triodion/${String(index).padStart(2, '0')}.json`; const url = `/data/shared/triodion/${String(index).padStart(2, '0')}.json`;
try { return DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
} }
private async loadMariasStandingData(): Promise<ServiceNode[] | null> { private async loadMariasStandingData(): Promise<ServiceNode[] | null> {
const url = `/data/shared/services/marias-standing/full.json`; const url = `/data/shared/services/marias-standing/full.json`;
try { return DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
} }
private async loadHolyWeekData(service: string): Promise<ServiceNode[] | null> { private async loadHolyWeekData(service: string): Promise<ServiceNode[] | null> {
const url = `/data/shared/services/${service}/full.json`; const url = `/data/shared/services/${service}/full.json`;
try { return DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url);
if (!resp.ok) return null;
const nodes: ServiceNode[] = await resp.json();
return nodes;
} catch {
return null;
}
} }
private async loadGreatCanonData(): Promise<ServiceNode[] | null> { private async loadGreatCanonData(): Promise<ServiceNode[] | null> {
@@ -617,9 +577,8 @@ export class ServiceView {
const url = `/data/shared/services/canons/great-canon/part${part}.json`; const url = `/data/shared/services/canons/great-canon/part${part}.json`;
try { try {
const resp = await fetch(url); const canonData = await DataCache.fetchWithFallback<GreatCanonPart>(url);
if (!resp.ok) return null; if (!canonData) return null;
const canonData: GreatCanonPart = await resp.json();
const ODE_SLAV = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃', 'ѳ҃']; const ODE_SLAV = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃', 'ѳ҃'];
const nodes: ServiceNode[] = []; const nodes: ServiceNode[] = [];
@@ -872,12 +831,11 @@ export class ServiceView {
const dd = String(this.currentDate.getDay()).padStart(2, '0'); const dd = String(this.currentDate.getDay()).padStart(2, '0');
const dateKey = `${mm}-${dd}`; const dateKey = `${mm}-${dd}`;
try { try {
const resp = await fetch(`/data/${this.serviceLang}/menaion-bundle.json`); const bundle = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/${this.serviceLang}/menaion-bundle.json`);
if (resp.ok) { if (bundle) {
const bundle = await resp.json();
const entries = bundle[dateKey]; const entries = bundle[dateKey];
if (entries) { if (entries) {
for (const e of entries) { for (const e of entries as Record<string, unknown>[]) {
if (e.id) ids.push(String(e.id)); if (e.id) ids.push(String(e.id));
} }
} }
@@ -885,9 +843,8 @@ export class ServiceView {
} catch {} } catch {}
for (const cid of ids) { for (const cid of ids) {
try { try {
const resp = await fetch(`/data/shared/commemorations/${cid}.json`); const data = await DataCache.fetchWithFallback<Record<string, unknown>>(`/data/shared/commemorations/${cid}.json`);
if (!resp.ok) continue; if (!data) continue;
const data = await resp.json();
const scripts = data.SCRIPTURE; const scripts = data.SCRIPTURE;
if (!Array.isArray(scripts)) continue; if (!Array.isArray(scripts)) continue;
let epistlePassage = ''; let epistlePassage = '';
@@ -1000,11 +957,9 @@ export class ServiceView {
const tryFetch = async (lang: string): Promise<string | null> => { const tryFetch = async (lang: string): Promise<string | null> => {
const url = `/data/${lang}/bible/${version}/${book}.text`; const url = `/data/${lang}/bible/${version}/${book}.text`;
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const resp = await fetch(url); const text = await DataCache.fetchWithFallback<string>(url, 'text');
if (!resp.ok) { this.notFound.add(url); return null; } if (!text) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type'); return text;
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
return resp.text();
}; };
let text = await tryFetch(this.serviceLang); let text = await tryFetch(this.serviceLang);
@@ -1103,15 +1058,12 @@ export class ServiceView {
const cached = this.livesCache.get(cacheKey); const cached = this.livesCache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : 'misc'; const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : `misc/${id.charAt(0)}`;
const tryFetch = async (lang: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> => { const tryFetch = async (lang: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> => {
const url = `/data/${lang}/lives/${bundleName}.json`; const url = `/data/${lang}/lives/${bundleName}.json`;
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const resp = await fetch(url); const bundle = await DataCache.fetchWithFallback<Record<string, Record<string, unknown>>>(url);
if (!resp.ok) { this.notFound.add(url); return null; } if (!bundle) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const bundle = await resp.json() as Record<string, Record<string, unknown>>;
const entry = bundle[id] as { services?: Record<string, Record<string, Record<string, unknown>>> } | undefined; const entry = bundle[id] as { services?: Record<string, Record<string, Record<string, unknown>>> } | undefined;
return entry?.services || null; return entry?.services || null;
}; };
@@ -1129,15 +1081,12 @@ export class ServiceView {
if (cached) return cached; if (cached) return cached;
const tryFetch = async (lang: string): Promise<string | null> => { const tryFetch = async (lang: string): Promise<string | null> => {
const url = `/data/${lang}/services/Command/${name}.json`; const url = `/data/${lang}/services/commands/${name}.json`;
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const resp = await fetch(url); const json = await DataCache.fetchWithFallback<Record<string, unknown>[]>(url);
if (!resp.ok) { this.notFound.add(url); return null; } if (!json) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const json = await resp.json() as Record<string, unknown>[];
return json return json
.filter(n => n.type === 'TEXT') .filter(n => n.type === 'TEXT' || n.type === 'text')
.map(n => String(n.value ?? '')) .map(n => String(n.value ?? ''))
.filter(Boolean) .filter(Boolean)
.join(' ') || null; .join(' ') || null;
@@ -1157,11 +1106,8 @@ export class ServiceView {
const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => { const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => {
const url = `/data/${lang}/commands/Times.json`; const url = `/data/${lang}/commands/Times.json`;
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const resp = await fetch(url); const json = await DataCache.fetchWithFallback<{ DATA?: { TIMES?: { '@_Value': string; '@_Cmd': string }[] } }>(url);
if (!resp.ok) { this.notFound.add(url); return null; } if (!json) { this.notFound.add(url); return null; }
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const json = await resp.json() as { DATA: { TIMES: { '@_Value': string; '@_Cmd': string }[] } };
const arr = json?.DATA?.TIMES; const arr = json?.DATA?.TIMES;
if (!Array.isArray(arr)) return null; if (!Array.isArray(arr)) return null;
return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] })); return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] }));
@@ -1227,22 +1173,13 @@ export class ServiceView {
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const cached = this.jsonCache.get(url); const cached = this.jsonCache.get(url);
if (cached) return cached; if (cached) return cached;
try { const json = await DataCache.fetchWithFallback<Record<string, unknown>[]>(url);
const resp = await fetch(url); if (Array.isArray(json)) {
if (!resp.ok) { this.notFound.add(url); return null; } this.jsonCache.set(url, json);
const ct = resp.headers.get('content-type'); return json;
if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
const json = JSON.parse(await resp.text());
if (Array.isArray(json)) {
this.jsonCache.set(url, json);
return json;
}
this.notFound.add(url);
return null;
} catch {
this.notFound.add(url);
return null;
} }
this.notFound.add(url);
return null;
}; };
const filterNodes = (json: Record<string, unknown>[], header: boolean): string | null => { const filterNodes = (json: Record<string, unknown>[], header: boolean): string | null => {
@@ -1254,7 +1191,7 @@ export class ServiceView {
.join(' ') || null; .join(' ') || null;
} }
return json return json
.filter(n => n.type === 'TEXT') .filter(n => n.type === 'TEXT' || n.type === 'text')
.map(n => String(n.value ?? '').replace(/\n/g, ' ')) .map(n => String(n.value ?? '').replace(/\n/g, ' '))
.filter(Boolean) .filter(Boolean)
.join('<br>') || null; .join('<br>') || null;
@@ -1293,27 +1230,18 @@ export class ServiceView {
return `/data/${lang}/services/${fetchPath}`; return `/data/${lang}/services/${fetchPath}`;
}; };
const tryFetch = async (url: string): Promise<ServiceNode[] | null> => { const tryFetch = async (url: string): Promise<ServiceNode[] | null> => {
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const cached = this.nodeCache.get(url); const cached = this.nodeCache.get(url);
if (cached) return cached; if (cached) return cached;
try { const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url); if (Array.isArray(nodes)) {
if (!resp.ok) { this.notFound.add(url); return null; } this.nodeCache.set(url, nodes);
const ct = resp.headers.get('content-type') || ''; return nodes;
if (ct.includes('text/html')) { this.notFound.add(url); return null; } }
const nodes = await resp.json(); this.notFound.add(url);
if (Array.isArray(nodes)) { return null;
this.nodeCache.set(url, nodes); };
return nodes;
}
this.notFound.add(url);
return null;
} catch {
this.notFound.add(url);
return null;
}
};
try { try {
let url = resolvePath(this.serviceLang); let url = resolvePath(this.serviceLang);
@@ -1373,15 +1301,10 @@ export class ServiceView {
if (this.notFound.has(url)) return null; if (this.notFound.has(url)) return null;
const cached = this.nodeCache.get(url); const cached = this.nodeCache.get(url);
if (cached) return cached; if (cached) return cached;
try { const nodes = await DataCache.fetchWithFallback<ServiceNode[]>(url);
const resp = await fetch(url); if (!nodes) { this.notFound.add(url); return null; }
if (!resp.ok) { this.notFound.add(url); return null; } this.nodeCache.set(url, nodes);
const ct = resp.headers.get('content-type') || ''; return nodes;
if (ct.includes('text/html')) { this.notFound.add(url); return null; }
const nodes = await resp.json();
this.nodeCache.set(url, nodes);
return nodes;
} catch { this.notFound.add(url); return null; }
}; };
let url = resolvePath(this.serviceLang); let url = resolvePath(this.serviceLang);
if (url) { if (url) {
+6 -6
View File
@@ -326,8 +326,8 @@ export class SettingsView {
// Load default Bible versions dynamically // Load default Bible versions dynamically
(async () => { (async () => {
try { try {
const resp = await fetch('/data/bible/versions.json'); const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
const versions = await resp.json(); if (!versions || !Array.isArray(versions)) return;
const userLang = this.settings.language; const userLang = this.settings.language;
// Sort by language priority // Sort by language priority
@@ -479,8 +479,8 @@ export class SettingsView {
// Load translations on first expand // Load translations on first expand
if (container.innerHTML === '') { if (container.innerHTML === '') {
try { try {
const resp = await fetch('/data/bible/versions.json'); const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
const versions = await resp.json(); if (!versions || !Array.isArray(versions)) throw new Error('unavailable');
const userLang = this.settings.language; const userLang = this.settings.language;
versions.sort((a: any, b: any) => { versions.sort((a: any, b: any) => {
const pa = versionPriority(a.language, userLang); const pa = versionPriority(a.language, userLang);
@@ -637,8 +637,8 @@ export class SettingsView {
// Count cached Bible translations // Count cached Bible translations
(async () => { (async () => {
try { try {
const resp = await fetch('/data/bible/versions.json'); const versions = await DataCache.fetchWithFallback<any[]>('/data/bible/versions.json');
const versions = await resp.json(); if (!versions || !Array.isArray(versions)) return;
const total = versions.length; const total = versions.length;
let cached = 0; let cached = 0;
for (const v of versions) { for (const v of versions) {
@@ -1 +0,0 @@
[{"type":"TEXT","value":", after each"}]
-1
View File
@@ -1 +0,0 @@
[{"type":"TEXT","value":"Bow"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Forty times)"}]
-1
View File
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Sticheron Melody: Tone 6)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"Prostration"}]
-1
View File
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Stichos 1)"}]
-1
View File
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Stichos 2)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 1)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 2)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 3)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 4)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 5)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 6)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 7)"}]
@@ -1 +0,0 @@
[{"type":"TEXT","value":"(Tone 8)"}]