1155 lines
43 KiB
TypeScript
1155 lines
43 KiB
TypeScript
/**
|
||
* Service view — displays assembled liturgical services.
|
||
* Loads XML templates (converted to JSON) and assembles them with
|
||
* dynamic Var nodes from Octoecheos tone data.
|
||
*/
|
||
|
||
import { JDate } from '../core/jdate';
|
||
import { computeDay } from '../core/day-computer';
|
||
import { assembleService, type ServiceContext } from '../core/service-assembler';
|
||
import { evalBool } from '../core/evaluator';
|
||
import type { CanonData, GreatCanonPart, EvalContext, ServiceNode } from '../core/types';
|
||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||
import { loadSettings, fontClass } from './settings-view';
|
||
|
||
interface ServiceDef {
|
||
id: string;
|
||
name: string;
|
||
serviceType: string;
|
||
template: string;
|
||
description: string;
|
||
titleNode: ServiceNode;
|
||
}
|
||
|
||
function title(value: string, header?: string, source?: string, comment?: string): ServiceNode {
|
||
return { type: 'TITLE', value, header: header ?? value, source, comment };
|
||
}
|
||
|
||
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||
|
||
export class ServiceView {
|
||
private container: HTMLElement;
|
||
private currentDate: JDate;
|
||
private evalContext: EvalContext;
|
||
private language: LanguageCode;
|
||
private t: ReturnType<typeof getTranslations>;
|
||
private jsonCache = new Map<string, Record<string, unknown>[]>();
|
||
private nodeCache = new Map<string, ServiceNode[]>();
|
||
private notFound = new Set<string>();
|
||
private bibleCache = new Map<string, string>();
|
||
private livesCache = new Map<string, Record<string, Record<string, Record<string, unknown>>>>();
|
||
private commandCache = new Map<string, string>();
|
||
private timesCache = new Map<string, { cmd: string; value: string }[]>();
|
||
private activeService: string | null = null;
|
||
|
||
constructor(container: HTMLElement, date: JDate, language: LanguageCode = 'en') {
|
||
this.container = container;
|
||
this.currentDate = date;
|
||
this.evalContext = computeDay(date).evalContext;
|
||
this.language = language;
|
||
this.t = getTranslations(language);
|
||
}
|
||
|
||
private getFontClass(): string {
|
||
const settings = loadSettings();
|
||
return (this.language === 'cu' || this.language === 'ru') && settings.cuFont !== undefined ? fontClass(settings.cuFont) : '';
|
||
}
|
||
|
||
private get serviceLang(): string {
|
||
return this.language === 'ru' ? 'cu' : this.language;
|
||
}
|
||
|
||
async render(standalone = true) {
|
||
const computed = computeDay(this.currentDate);
|
||
const { dayInfo } = computed;
|
||
|
||
const availableServices = this.getAvailableServices(dayInfo);
|
||
const fontClass = this.getFontClass();
|
||
|
||
const contentClass = `bg-white/50 border border-gold/20 rounded-lg p-6 ${this.activeService ? 'min-h-[300px]' : ''} ${fontClass}`;
|
||
|
||
const tabHtml = `
|
||
<h3 class="font-bold text-navy mb-3">${this.t.services.title}</h3>
|
||
<div class="flex flex-wrap gap-1 mb-6 border-b border-gold/20">
|
||
${availableServices.map(s => `
|
||
<button
|
||
class="service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap
|
||
${this.activeService === s.id
|
||
? 'border-gold text-navy font-bold'
|
||
: 'border-transparent text-navy-light hover:text-navy hover:border-gold/50'}"
|
||
data-service="${s.id}"
|
||
>${s.name}</button>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<div id="service-content" class="${contentClass}">
|
||
<p class="text-navy-light italic">${this.t.services.selectService}</p>
|
||
</div>
|
||
`;
|
||
|
||
this.container.innerHTML = standalone
|
||
? `<div class="p-6 max-w-4xl xl:max-w-6xl mx-auto">
|
||
<h2 class="text-2xl font-bold text-navy mb-2">${this.t.services.title}</h2>
|
||
<p class="text-navy-light mb-4">
|
||
${this.currentDate.toString()}
|
||
${dayInfo.isLent ? this.t.services.greatLent : ''}
|
||
${dayInfo.isBrightWeek ? this.t.services.brightWeek : ''}
|
||
${dayInfo.isApostlesFast ? this.t.services.apostlesFast : ''}
|
||
${dayInfo.isDormitionFast ? this.t.services.dormitionFast : ''}
|
||
${dayInfo.isNativityFast ? this.t.services.nativityFast : ''}
|
||
${dayInfo.Tone > 0 ? `${this.t.services.tone}${dayInfo.Tone}` : ''}
|
||
</p>
|
||
${tabHtml}
|
||
</div>`
|
||
: tabHtml;
|
||
|
||
this.container.querySelectorAll('.service-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const serviceId = btn.getAttribute('data-service')!;
|
||
const service = availableServices.find(s => s.id === serviceId);
|
||
if (service) {
|
||
this.activeService = serviceId;
|
||
this.container.querySelectorAll('.service-tab').forEach(t => {
|
||
const id = t.getAttribute('data-service');
|
||
if (id === serviceId) {
|
||
t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-gold text-navy font-bold';
|
||
} else {
|
||
t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-transparent text-navy-light hover:text-navy';
|
||
}
|
||
});
|
||
this.loadService(service);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
private getAvailableServices(dayInfo: import('../core/types').DayInfo): ServiceDef[] {
|
||
const services: ServiceDef[] = [];
|
||
|
||
services.push({
|
||
id: 'primes', name: this.t.services.serviceNames.primes, serviceType: 'PRIMES',
|
||
template: 'Prime',
|
||
description: this.t.services.serviceDescriptions.primes,
|
||
titleNode: title('Primes1', this.t.services.serviceNames.primes, 'PrimeSource'),
|
||
});
|
||
|
||
services.push({
|
||
id: 'third', name: this.t.services.serviceNames.third, serviceType: 'TERCE',
|
||
template: 'ThirdHour',
|
||
description: this.t.services.serviceDescriptions.third,
|
||
titleNode: title('Third1', this.t.services.serviceNames.third),
|
||
});
|
||
|
||
services.push({
|
||
id: 'sixth', name: this.t.services.serviceNames.sixth, serviceType: 'SEXTE',
|
||
template: 'SixthHour',
|
||
description: this.t.services.serviceDescriptions.sixth,
|
||
titleNode: title('Sixth1', this.t.services.serviceNames.sixth),
|
||
});
|
||
|
||
services.push({
|
||
id: 'ninth', name: this.t.services.serviceNames.ninth, serviceType: 'NONE',
|
||
template: 'NinthHour',
|
||
description: this.t.services.serviceDescriptions.ninth,
|
||
titleNode: title('Ninth1', this.t.services.serviceNames.ninth),
|
||
});
|
||
|
||
services.push({
|
||
id: 'vespers', name: this.t.services.serviceNames.vespers, serviceType: 'VESPERS',
|
||
template: 'Vespers',
|
||
description: this.t.services.serviceDescriptions.vespers,
|
||
titleNode: title('Vespers1', this.t.services.serviceNames.vespers, 'VesperSource'),
|
||
});
|
||
|
||
services.push({
|
||
id: 'matins', name: this.t.services.serviceNames.matins, serviceType: 'MATINS',
|
||
template: 'Matins',
|
||
description: this.t.services.serviceDescriptions.matins,
|
||
titleNode: title('Matins1', this.t.services.serviceNames.matins, 'MatinsSource'),
|
||
});
|
||
|
||
services.push({
|
||
id: 'liturgy', name: this.t.services.serviceNames.liturgy, serviceType: 'LITURGY',
|
||
template: 'DivineLiturgy',
|
||
description: this.t.services.serviceDescriptions.liturgy,
|
||
titleNode: title('DivineLiturgyTitle', this.t.services.serviceNames.liturgy, 'DivineLiturgySource'),
|
||
});
|
||
|
||
if (dayInfo.dRank >= 6) {
|
||
services.push({
|
||
id: 'royalhours', name: this.t.services.serviceNames.royalhours, serviceType: 'ROYALHOURS',
|
||
template: 'RoyalHours',
|
||
description: this.t.services.serviceDescriptions.royalhours,
|
||
titleNode: title('RoyalHours1', this.t.services.serviceNames.royalhours),
|
||
});
|
||
}
|
||
|
||
// Great Compline: Lenten weekdays (Mon-Thu)
|
||
if (dayInfo.isLent && dayInfo.dow >= 1 && dayInfo.dow <= 4) {
|
||
services.push({
|
||
id: 'greatcompline', name: this.t.services.serviceNames.greatcompline, serviceType: 'GREATCOMPLINE',
|
||
template: 'GreatCompline',
|
||
description: this.t.services.serviceDescriptions.greatcompline,
|
||
titleNode: title('GreatCompline1', this.t.services.serviceNames.greatcompline),
|
||
});
|
||
}
|
||
|
||
// Standing of St. Mary of Egypt: Thursday of the 5th week of Great Lent
|
||
if (dayInfo.isLent && dayInfo.nday === -17) {
|
||
services.push({
|
||
id: 'mariasstanding', name: this.t.services.serviceNames.mariasstanding, serviceType: 'MARIASSTANDING',
|
||
template: 'MariasStanding',
|
||
description: this.t.services.serviceDescriptions.mariasstanding,
|
||
titleNode: title('MariasStanding1', this.t.services.serviceNames.mariasstanding),
|
||
});
|
||
}
|
||
|
||
return services;
|
||
}
|
||
|
||
private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null> {
|
||
const computed = computeDay(this.currentDate);
|
||
const { dayInfo } = computed;
|
||
const tone = dayInfo.Tone;
|
||
const dow = dayInfo.dow;
|
||
const mm = String(dayInfo.month).padStart(2, '0');
|
||
const dd = String(dayInfo.day).padStart(2, '0');
|
||
const dateKey = `${mm}-${dd}`;
|
||
|
||
let result: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null = null;
|
||
|
||
if (tone > 0 && tone <= 8) {
|
||
const toneNum = tone === 8 ? 0 : tone;
|
||
const weekday = WEEKDAY_NAMES[dow];
|
||
|
||
try {
|
||
const tryFetch = async (lang: string) => {
|
||
const url = `/data/${lang}/services/octoecheos/tone${toneNum}/${weekday.toLowerCase()}.json`;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
const nodes: ServiceNode[] = await resp.json();
|
||
for (const node of nodes) {
|
||
if (node.type === serviceType) {
|
||
if (!node.cmd || evalBool(node.cmd as string, this.evalContext)) {
|
||
return {
|
||
troparion1: node.troparion1 as string | undefined,
|
||
kontakion1: node.kontakion1 as string | undefined,
|
||
theotokion1: node.theotokion1 as string | undefined,
|
||
prokimenon1: node.prokimenon1 as string | undefined,
|
||
alleluia1: node.alleluia1 as string | undefined,
|
||
hypakoe1: node.hypakoe1 as string | undefined,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
result = await tryFetch(this.serviceLang);
|
||
if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
|
||
if (!result) result = await tryFetch('shared');
|
||
} catch { /* fall through to menologion */ }
|
||
}
|
||
|
||
// Menologion fallback when Octoecheos has no valid entry
|
||
if (!result) {
|
||
try {
|
||
const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => {
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
const all = await resp.json();
|
||
if (Array.isArray(all)) return all.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') }));
|
||
const entries = all[dateKey] as Record<string, unknown>[] | undefined;
|
||
if (Array.isArray(entries)) return entries.map((e: Record<string, unknown>) => ({ cid: String(e.id ?? '') }));
|
||
return null;
|
||
} catch { return null; }
|
||
};
|
||
|
||
const allEntries: { cid: string }[] = [];
|
||
|
||
const menaionEntries = await tryLoad(`/data/${this.serviceLang}/menaion-bundle.json`);
|
||
if (menaionEntries) allEntries.push(...menaionEntries);
|
||
|
||
// Load from merged calendar files
|
||
const loadCalendarEntry = async (file: string, index: number): Promise<{ cid: string }[] | null> => {
|
||
try {
|
||
const resp = await fetch(`/data/shared/calendar/${file}.json`);
|
||
if (!resp.ok) return null;
|
||
const data = await resp.json();
|
||
const key = String(index).padStart(2, '0');
|
||
const entry = data[key];
|
||
return entry ? [{ cid: String(entry.id ?? '') }] : null;
|
||
} catch { return null; }
|
||
};
|
||
|
||
if (dayInfo.triodionFile !== null) {
|
||
const triodion = await loadCalendarEntry('triodion', dayInfo.triodionFile);
|
||
if (triodion) allEntries.push(...triodion);
|
||
}
|
||
if (dayInfo.pentecostarionFile !== null) {
|
||
const pent = await loadCalendarEntry('pentecostarion', dayInfo.pentecostarionFile);
|
||
if (pent) allEntries.push(...pent);
|
||
}
|
||
|
||
const TONE_NUM = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃'];
|
||
for (const entry of allEntries) {
|
||
if (!entry.cid) continue;
|
||
try {
|
||
const shmResp = await fetch(`/data/shared/commemorations/${entry.cid}.json`);
|
||
if (!shmResp.ok) continue;
|
||
const shm = await shmResp.json();
|
||
const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined;
|
||
const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined;
|
||
if (troparion?.text || kontakion?.text) {
|
||
const key = `MEN_${entry.cid}`;
|
||
|
||
if (troparion?.text) {
|
||
const tn = TONE_NUM[parseInt(troparion.Tone ?? '1', 10)] ?? troparion.Tone ?? '1';
|
||
this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/TROPARION/${key}.json`, [
|
||
{ type: 'HEADER', value: `Тропа́рь, гла́съ ${tn}` },
|
||
{ type: 'TEXT', value: troparion.text },
|
||
]);
|
||
}
|
||
|
||
if (kontakion?.text) {
|
||
const tn = TONE_NUM[parseInt(kontakion.Tone ?? '1', 10)] ?? kontakion.Tone ?? '1';
|
||
this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/KONTAKION/${key}.json`, [
|
||
{ type: 'HEADER', value: `Конда́къ, гла́съ ${tn}` },
|
||
{ type: 'TEXT', value: kontakion.text },
|
||
]);
|
||
}
|
||
|
||
result = { troparion1: key, kontakion1: key };
|
||
break;
|
||
}
|
||
} catch { continue; }
|
||
}
|
||
} catch { /* fall through to tertiary fallback */ }
|
||
}
|
||
|
||
// Tertiary fallback: known feasts not yet in shared commemorations
|
||
if (!result) {
|
||
const MENAION_TROP: Record<string, { troparion1: string; kontakion1: string }> = {
|
||
'03-23': { troparion1: 'PROPHET', kontakion1: 'PROPHET' },
|
||
'03-22': { troparion1: 'PROPHET', kontakion1: 'PROPHET' },
|
||
};
|
||
const m = MENAION_TROP[dateKey];
|
||
if (m) result = m;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private async loadCanonData(tone: number): Promise<ServiceNode[] | null> {
|
||
if (tone < 1 || tone > 8) return null;
|
||
const toneNum = tone === 8 ? 0 : tone;
|
||
const url = `/data/shared/services/canons/tone${toneNum}/sunday.json`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
const canonData: CanonData = await resp.json();
|
||
const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ'];
|
||
|
||
const nodes: ServiceNode[] = [];
|
||
for (const ode of canonData.odes) {
|
||
const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode);
|
||
nodes.push({ type: 'HEADER', value: `Пѣснь ${slavNum}҃.` });
|
||
|
||
for (const canon of ode.canons) {
|
||
if (canon.irmos) {
|
||
nodes.push({ type: 'HEADER', value: `Ірмосъ, гла́съ ${canonData.tone}:` });
|
||
nodes.push({ type: 'TEXT', value: canon.irmos });
|
||
}
|
||
for (const trop of canon.troparia) {
|
||
nodes.push({ type: 'TEXT', value: trop });
|
||
}
|
||
if (canon.theotokion) {
|
||
nodes.push({ type: 'HEADER', value: 'Бг҃ородиченъ:' });
|
||
nodes.push({ type: 'TEXT', value: canon.theotokion });
|
||
}
|
||
}
|
||
}
|
||
return nodes;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private async loadMariasStandingData(): Promise<ServiceNode[] | null> {
|
||
const url = `/data/shared/services/marias-standing/full.json`;
|
||
try {
|
||
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> {
|
||
const computed = computeDay(this.currentDate);
|
||
const { dayInfo } = computed;
|
||
const nday = dayInfo.nday;
|
||
// Determine which part of the Great Canon:
|
||
// 1st week: nday -48 (Mon) to -45 (Thu) → parts 1-4
|
||
// Other Lenten weekdays: default to part 1
|
||
let part: number;
|
||
if (nday >= -48 && nday <= -45) {
|
||
part = nday + 49; // -48→1, -47→2, -46→3, -45→4
|
||
} else {
|
||
part = 1;
|
||
}
|
||
|
||
const url = `/data/shared/services/canons/great-canon/part${part}.json`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
const canonData: GreatCanonPart = await resp.json();
|
||
const ODE_SLAV = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃', 'ѳ҃'];
|
||
const nodes: ServiceNode[] = [];
|
||
|
||
nodes.push({ type: 'HEADER', value: 'Вели́кїй канѡ́нъ, гла́съ ѕ҃.' });
|
||
nodes.push({ type: 'HEADER', value: 'Творе́нїе ст҃а́гѡ ѻ҆тца̀ на́шегѡ а҆ндре́а кри́тскагѡ.' });
|
||
|
||
for (const ode of canonData.odes) {
|
||
const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode);
|
||
nodes.push({ type: 'HEADER', value: `Пѣ́снь ${slavNum}.` });
|
||
|
||
// Irmos
|
||
if (ode.irmos) {
|
||
nodes.push({ type: 'HEADER', value: 'І҆рмо́съ:' });
|
||
nodes.push({ type: 'TEXT', value: ode.irmos });
|
||
}
|
||
|
||
// Penitential troparia with refrain
|
||
const refrain = 'Поми́лꙋй мѧ̀, бж҃е, поми́лꙋй мѧ̀.';
|
||
for (const trop of ode.troparia) {
|
||
nodes.push({ type: 'TEXT', value: `${trop}\nПрипѣ́въ: ${refrain}` });
|
||
}
|
||
|
||
// Trinity troparion
|
||
if (ode.trinityTroparion) {
|
||
nodes.push({ type: 'HEADER', value: 'Сла́ва, трⷪ҇ченъ:' });
|
||
nodes.push({ type: 'TEXT', value: ode.trinityTroparion });
|
||
}
|
||
|
||
// Theotokion
|
||
if (ode.theotokion) {
|
||
nodes.push({ type: 'HEADER', value: 'И҆ ны́нѣ, бг҃оро́диченъ:' });
|
||
nodes.push({ type: 'TEXT', value: ode.theotokion });
|
||
}
|
||
|
||
// Saint troparia (St. Mary of Egypt / St. Andrew)
|
||
if (ode.saintTroparia && ode.saintTroparia.length > 0) {
|
||
for (const saintTrop of ode.saintTroparia) {
|
||
nodes.push({ type: 'TEXT', value: saintTrop });
|
||
}
|
||
}
|
||
|
||
// Kontakion (after Ode 6)
|
||
if (ode.kontakion) {
|
||
nodes.push({ type: 'HEADER', value: 'Конда́къ, гла́съ ѕ҃:' });
|
||
nodes.push({ type: 'TEXT', value: ode.kontakion });
|
||
}
|
||
}
|
||
|
||
return nodes;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string; hypakoe1?: string } | null, serviceType: string): Map<string, ServiceNode[]> {
|
||
const varNodes = new Map<string, ServiceNode[]>();
|
||
|
||
const prefix: Record<string, string> = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9', VESPERS: 'V', LITURGY: 'L', MATINS: 'U', GREATCOMPLINE: 'GC', MARIASSTANDING: 'MS' };
|
||
const p = prefix[serviceType] ?? '';
|
||
|
||
if (toneData?.troparion1) {
|
||
const key1 = `PTrop${p}1`;
|
||
varNodes.set(key1, [{
|
||
type: 'CREATE',
|
||
what: `TROPARION/${toneData.troparion1}`,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
if (toneData?.kontakion1) {
|
||
const key = p ? `PKont${p}` : 'PKont1';
|
||
varNodes.set(key, [{
|
||
type: 'CREATE',
|
||
what: `KONTAKION/${toneData.kontakion1}`,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
if (toneData?.theotokion1) {
|
||
const key = `PTheot${p}1`;
|
||
varNodes.set(key, [{
|
||
type: 'CREATE',
|
||
what: `THEOTOKION/${toneData.theotokion1}`,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
if (toneData?.prokimenon1) {
|
||
const key = `PProk${p}1`;
|
||
varNodes.set(key, [{
|
||
type: 'CREATE',
|
||
what: toneData.prokimenon1,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
if (toneData?.alleluia1) {
|
||
const key = `PAllel${p}1`;
|
||
varNodes.set(key, [{
|
||
type: 'CREATE',
|
||
what: toneData.alleluia1,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
if (toneData?.hypakoe1) {
|
||
const key = `PHypak${p}1`;
|
||
varNodes.set(key, [{
|
||
type: 'CREATE',
|
||
what: toneData.hypakoe1,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
// Exapostilarion uses Eothinon (11-week cycle), not tone
|
||
if (serviceType === 'MATINS') {
|
||
const computed = computeDay(this.currentDate);
|
||
const eoth = computed.dayInfo.eothinon;
|
||
if (eoth >= 1 && eoth <= 11) {
|
||
const key = `PExapost${p}1`;
|
||
varNodes.set(key, [{
|
||
type: 'CREATE',
|
||
what: `Exapostilarion${eoth}`,
|
||
who: '',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
}
|
||
|
||
// Troparion after Doxology: odd tones use TroparAfterDoxologyOdd, even use TroparAfterDoxologyEven
|
||
if (serviceType === 'MATINS') {
|
||
const computed = computeDay(this.currentDate);
|
||
const tone = computed.dayInfo.Tone;
|
||
const doxFile = (tone % 2 === 1) ? 'TroparAfterDoxologyOdd' : 'TroparAfterDoxologyEven';
|
||
varNodes.set('PTropDoxU1', [{
|
||
type: 'CREATE',
|
||
what: doxFile,
|
||
who: 'C',
|
||
header: true,
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
|
||
return varNodes;
|
||
}
|
||
|
||
private async loadService(service: ServiceDef) {
|
||
const contentEl = document.getElementById('service-content');
|
||
if (!contentEl) return;
|
||
|
||
contentEl.innerHTML = `<p class="text-navy-light italic">${this.t.loading}</p>`;
|
||
|
||
try {
|
||
const templateNodes = await this.fetchServiceNodes(`Services/${service.template}.json`);
|
||
if (!templateNodes) {
|
||
contentEl.innerHTML = `<p class="text-navy-light">Service template not found</p>`;
|
||
return;
|
||
}
|
||
|
||
const toneData = await this.loadToneData(service.serviceType);
|
||
const varNodes = this.generateVarNodes(toneData, service.serviceType);
|
||
|
||
// Load Matins canon data
|
||
if (service.serviceType === 'MATINS') {
|
||
const computed = computeDay(this.currentDate);
|
||
const canonNodes = await this.loadCanonData(computed.dayInfo.Tone);
|
||
if (canonNodes) {
|
||
varNodes.set('PCanonU', canonNodes);
|
||
}
|
||
}
|
||
|
||
// Load Great Canon data for Great Compline
|
||
if (service.serviceType === 'GREATCOMPLINE') {
|
||
const canonNodes = await this.loadGreatCanonData();
|
||
if (canonNodes) {
|
||
varNodes.set('PGreatCanon', canonNodes);
|
||
}
|
||
}
|
||
|
||
// Load Standing of St. Mary of Egypt data
|
||
if (service.serviceType === 'MARIASSTANDING') {
|
||
const standingNodes = await this.loadMariasStandingData();
|
||
if (standingNodes) {
|
||
varNodes.set('PMariasStanding', standingNodes);
|
||
}
|
||
}
|
||
|
||
// Load Epistle and Gospel readings from commemoration
|
||
const loadReadings = async () => {
|
||
const ids: string[] = [];
|
||
const mm = String(this.currentDate.getMonth()).padStart(2, '0');
|
||
const dd = String(this.currentDate.getDay()).padStart(2, '0');
|
||
const dateKey = `${mm}-${dd}`;
|
||
try {
|
||
const resp = await fetch(`/data/${this.serviceLang}/menaion-bundle.json`);
|
||
if (resp.ok) {
|
||
const bundle = await resp.json();
|
||
const entries = bundle[dateKey];
|
||
if (entries) {
|
||
for (const e of entries) {
|
||
if (e.id) ids.push(String(e.id));
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
for (const cid of ids) {
|
||
try {
|
||
const resp = await fetch(`/data/shared/commemorations/${cid}.json`);
|
||
if (!resp.ok) continue;
|
||
const data = await resp.json();
|
||
const scripts = data.SCRIPTURE;
|
||
if (!Array.isArray(scripts)) continue;
|
||
let epistlePassage = '';
|
||
let gospelPassage = '';
|
||
for (const s of scripts) {
|
||
const r = s.Reading || '';
|
||
if (s.Type === 'apostol' && r && !epistlePassage) epistlePassage = r;
|
||
if (s.Type === 'gospel' && r && !gospelPassage) gospelPassage = r;
|
||
}
|
||
if (epistlePassage) {
|
||
varNodes.set('EpistleReading', [{
|
||
type: 'BIBLE',
|
||
verses: epistlePassage,
|
||
who: 'R',
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
if (gospelPassage) {
|
||
varNodes.set('GospelReading', [{
|
||
type: 'BIBLE',
|
||
verses: gospelPassage,
|
||
who: 'D',
|
||
redfirst: true,
|
||
newline: true,
|
||
}]);
|
||
}
|
||
if (epistlePassage || gospelPassage) break;
|
||
} catch {}
|
||
}
|
||
};
|
||
await loadReadings();
|
||
|
||
const nodes: ServiceNode[] = [...templateNodes];
|
||
|
||
const computed = computeDay(this.currentDate);
|
||
const isMenologionTone = toneData?.troparion1?.startsWith('PROPHET') || toneData?.troparion1?.startsWith('MEN_');
|
||
const dayPFlag2 = computed.dayInfo.PFlag2;
|
||
const evalCtx: EvalContext = {
|
||
...this.evalContext,
|
||
PFlag1: 0,
|
||
PFlag2: isMenologionTone && dayPFlag2 !== 0 ? 0 : dayPFlag2,
|
||
PFlag3: 0,
|
||
PS: 1,
|
||
};
|
||
|
||
const ctx: ServiceContext = {
|
||
evalCtx,
|
||
lang: this.serviceLang,
|
||
t: this.t,
|
||
fetchBibleText: (book, passage, showVerseNumbers, verseNewLine) => this.fetchBibleText(book, passage, showVerseNumbers, verseNewLine),
|
||
fetchLives: (id) => this.fetchLives(id),
|
||
fetchCommandText: (name) => this.fetchCommandText(name),
|
||
resolveTimes: (times) => this.resolveTimes(times),
|
||
fetchText: this.createFetchText(),
|
||
fetchPrayerNodes: this.createFetchPrayerNodes(),
|
||
fetchServiceNodes: async (path: string): Promise<ServiceNode[] | null> => {
|
||
const varMatch = path.match(/Services\/Var\/(\w+)\.xml$/);
|
||
if (varMatch) {
|
||
return varNodes.get(varMatch[1]) || null;
|
||
}
|
||
return this.fetchServiceNodes(path);
|
||
},
|
||
};
|
||
|
||
const { html } = await assembleService(nodes, ctx);
|
||
|
||
if (html.trim()) {
|
||
const fontClass = this.getFontClass();
|
||
contentEl.className = `prose prose-sm max-w-none liturgical-text ${fontClass}`;
|
||
contentEl.innerHTML = html;
|
||
} else {
|
||
contentEl.className = 'prose prose-sm max-w-none liturgical-text';
|
||
contentEl.innerHTML = `
|
||
<div class="text-center py-8">
|
||
<p class="text-navy-light mb-2">${this.t.services.serviceProducedNoOutput}</p>
|
||
<p class="text-xs text-navy-light">${nodes.length} ${this.t.services.nodesAssembled}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (err) {
|
||
contentEl.innerHTML = `
|
||
<div class="text-center py-8">
|
||
<p class="text-navy-light mb-2">${this.t.services.errorLoading}</p>
|
||
<p class="text-xs text-navy-light">${err instanceof Error ? err.message : this.t.error}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
private bibleVersion(lang: string): string {
|
||
const map: Record<string, string> = {
|
||
en: 'kjv',
|
||
cu: 'elis',
|
||
fr: 'ls',
|
||
el: 'spt',
|
||
la: 'vulgate',
|
||
zh: 'cuv',
|
||
ar: 'svd',
|
||
};
|
||
return map[lang] || 'kjv';
|
||
}
|
||
|
||
private async fetchBibleText(book: string, passage: string, showVerseNumbers = true, verseNewLine = false): Promise<string> {
|
||
const cacheKey = `${this.language}/${book}/${passage}/${showVerseNumbers ? 'vn' : 'nn'}/${verseNewLine ? 'vb' : 'nvb'}`;
|
||
const cached = this.bibleCache.get(cacheKey);
|
||
if (cached) return cached;
|
||
|
||
const version = this.bibleVersion(this.serviceLang);
|
||
const tryFetch = async (lang: string): Promise<string | null> => {
|
||
const url = `/data/${lang}/bible/${version}/${book}.text`;
|
||
if (this.notFound.has(url)) return null;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { 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; }
|
||
return resp.text();
|
||
};
|
||
|
||
let text = await tryFetch(this.serviceLang);
|
||
if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
|
||
if (!text) text = await tryFetch('shared');
|
||
if (!text) return '';
|
||
|
||
const lines = text.split('\n');
|
||
// Parse comma-separated passage parts, each potentially: Chapter[:VerseStart[-VerseEnd]]
|
||
const parts = passage.split(',');
|
||
const ranges: { chapter: number; vStart: number; vEnd: number }[] = [];
|
||
|
||
for (const part of parts) {
|
||
const trimmed = part.trim();
|
||
if (!trimmed) continue;
|
||
|
||
let chapter: number;
|
||
let verseSpec: string;
|
||
|
||
if (trimmed.includes(':')) {
|
||
const colonIdx = trimmed.indexOf(':');
|
||
chapter = parseInt(trimmed.substring(0, colonIdx), 10);
|
||
verseSpec = trimmed.substring(colonIdx + 1);
|
||
} else {
|
||
// Bare number = chapter number (e.g. "50" = Psalm 50, read all verses)
|
||
chapter = parseInt(trimmed, 10);
|
||
verseSpec = '1-';
|
||
}
|
||
|
||
let vStart: number;
|
||
let vEnd: number;
|
||
|
||
if (verseSpec.includes('-')) {
|
||
const dashIdx = verseSpec.indexOf('-');
|
||
vStart = parseInt(verseSpec.substring(0, dashIdx), 10);
|
||
const after = verseSpec.substring(dashIdx + 1);
|
||
// Check if after contains chapter:verse (cross-chapter range)
|
||
if (after.includes(':')) {
|
||
const afterColon = after.indexOf(':');
|
||
const nextChapter = parseInt(after.substring(0, afterColon), 10);
|
||
vEnd = parseInt(after.substring(afterColon + 1), 10);
|
||
ranges.push({ chapter, vStart, vEnd: -1 }); // all remaining verses in current chapter
|
||
chapter = nextChapter;
|
||
vStart = 1;
|
||
vEnd = vEnd;
|
||
} else {
|
||
vEnd = after ? parseInt(after, 10) : -1; // empty end = all remaining verses
|
||
}
|
||
} else {
|
||
vStart = parseInt(verseSpec, 10);
|
||
vEnd = vStart;
|
||
}
|
||
|
||
ranges.push({ chapter, vStart, vEnd });
|
||
}
|
||
|
||
// Scan file and collect matching verses
|
||
let result = '';
|
||
let curChapter = 0;
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
|
||
const chapterMatch = trimmed.match(/^#(\d+)/);
|
||
if (chapterMatch) {
|
||
curChapter = parseInt(chapterMatch[1], 10);
|
||
continue;
|
||
}
|
||
|
||
const verseMatch = trimmed.match(/^(\d+)\|(.*)/);
|
||
if (verseMatch) {
|
||
const curVerse = parseInt(verseMatch[1], 10);
|
||
const verseText = verseNewLine ? verseMatch[2].replace(/^ /, '') : verseMatch[2];
|
||
|
||
for (const range of ranges) {
|
||
if (range.chapter !== curChapter) continue;
|
||
if (curVerse < range.vStart) continue;
|
||
if (range.vEnd !== -1 && curVerse > range.vEnd) continue;
|
||
if (result) result += verseNewLine ? '<br>' : ' ';
|
||
result += showVerseNumbers ? `<sup>${curVerse}</sup>${verseText}` : verseText;
|
||
break;
|
||
}
|
||
} else if (trimmed.startsWith('*')) {
|
||
if (result) result += ' ';
|
||
result += trimmed;
|
||
}
|
||
}
|
||
|
||
this.bibleCache.set(cacheKey, result);
|
||
return result;
|
||
}
|
||
|
||
private async fetchLives(id: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> {
|
||
const cacheKey = `${this.language}/${id}`;
|
||
const cached = this.livesCache.get(cacheKey);
|
||
if (cached) return cached;
|
||
|
||
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : 'misc';
|
||
const tryFetch = async (lang: string): Promise<Record<string, Record<string, Record<string, unknown>>> | null> => {
|
||
const url = `/data/${lang}/lives/${bundleName}.json`;
|
||
if (this.notFound.has(url)) return null;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { 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;
|
||
return entry?.services || null;
|
||
};
|
||
|
||
let result = await tryFetch(this.serviceLang);
|
||
if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
|
||
if (!result) result = await tryFetch('shared');
|
||
if (result) this.livesCache.set(cacheKey, result);
|
||
return result;
|
||
}
|
||
|
||
private async fetchCommandText(name: string): Promise<string> {
|
||
const cacheKey = `${this.language}/Command/${name}`;
|
||
const cached = this.commandCache.get(cacheKey);
|
||
if (cached) return cached;
|
||
|
||
const tryFetch = async (lang: string): Promise<string | null> => {
|
||
const url = `/data/${lang}/services/Command/${name}.json`;
|
||
if (this.notFound.has(url)) return null;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { 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
|
||
.filter(n => n.type === 'TEXT')
|
||
.map(n => String(n.value ?? ''))
|
||
.filter(Boolean)
|
||
.join(' ') || null;
|
||
};
|
||
|
||
let result = await tryFetch(this.serviceLang);
|
||
if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
|
||
if (!result) result = await tryFetch('shared');
|
||
if (result) this.commandCache.set(cacheKey, result);
|
||
return result || '';
|
||
}
|
||
|
||
private async resolveTimes(times: number): Promise<string> {
|
||
const cacheKey = this.language;
|
||
let rules = this.timesCache.get(cacheKey);
|
||
if (!rules) {
|
||
const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => {
|
||
const url = `/data/${lang}/commands/Times.json`;
|
||
if (this.notFound.has(url)) return null;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { 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;
|
||
if (!Array.isArray(arr)) return null;
|
||
return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] }));
|
||
};
|
||
let loaded = await tryFetch(this.serviceLang);
|
||
if (!loaded && this.serviceLang !== 'cu') loaded = await tryFetch('cu');
|
||
if (!loaded) loaded = await tryFetch('shared');
|
||
if (loaded) {
|
||
this.timesCache.set(cacheKey, loaded);
|
||
rules = loaded;
|
||
}
|
||
}
|
||
|
||
if (!rules) return `${times}×`;
|
||
|
||
for (const rule of rules) {
|
||
if (rule.cmd) {
|
||
if (evalBool(rule.cmd, { Times: times } as EvalContext)) {
|
||
return rule.value.replace('^#', String(times));
|
||
}
|
||
} else {
|
||
return rule.value.replace('^#', String(times));
|
||
}
|
||
}
|
||
return `${times}×`;
|
||
}
|
||
|
||
private createFetchText() {
|
||
return async (path: string): Promise<string | null> => {
|
||
const wantsHeader = path.endsWith('.header');
|
||
const basePath = wantsHeader ? path.slice(0, -'.header'.length) : path;
|
||
|
||
const resolvePath = (lang: string): string | null => {
|
||
let fetchPath = basePath;
|
||
if (fetchPath.startsWith('Services/')) {
|
||
fetchPath = fetchPath.slice('Services/'.length);
|
||
// Handle different path patterns
|
||
if (fetchPath.startsWith('CommonPrayers/')) {
|
||
// CommonPrayers -> prayers
|
||
fetchPath = 'prayers/' + fetchPath.slice('CommonPrayers/'.length);
|
||
} else if (fetchPath.startsWith('Text/')) {
|
||
// Text -> texts
|
||
fetchPath = 'texts/' + fetchPath.slice('Text/'.length);
|
||
} else if (fetchPath.startsWith('Command/')) {
|
||
// Command -> commands
|
||
fetchPath = 'commands/' + fetchPath.slice('Command/'.length);
|
||
} else if (fetchPath.startsWith('Header/')) {
|
||
// Header -> headers
|
||
fetchPath = 'headers/' + fetchPath.slice('Header/'.length);
|
||
} else if (fetchPath.startsWith('Var/')) {
|
||
// Var -> var (lowercase)
|
||
fetchPath = 'var/' + fetchPath.slice('Var/'.length);
|
||
} else if (fetchPath.startsWith('Octoecheos/')) {
|
||
// Octoecheos -> octoecheos
|
||
fetchPath = 'octoecheos/' + fetchPath.slice('Octoecheos/'.length);
|
||
}
|
||
fetchPath = fetchPath.replace('.xml', '.json');
|
||
}
|
||
return `/data/${lang}/services/${fetchPath}`;
|
||
};
|
||
|
||
const fetchJson = async (url: string): Promise<Record<string, unknown>[] | null> => {
|
||
if (this.notFound.has(url)) return null;
|
||
const cached = this.jsonCache.get(url);
|
||
if (cached) return cached;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { 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 = 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;
|
||
}
|
||
};
|
||
|
||
const filterNodes = (json: Record<string, unknown>[], header: boolean): string | null => {
|
||
if (header) {
|
||
return json
|
||
.filter(n => n.type === 'HEADER')
|
||
.map(n => String(n.value ?? ''))
|
||
.filter(Boolean)
|
||
.join(' ') || null;
|
||
}
|
||
return json
|
||
.filter(n => n.type === 'TEXT')
|
||
.map(n => String(n.value ?? '').replace(/\n/g, ' '))
|
||
.filter(Boolean)
|
||
.join('<br>') || null;
|
||
};
|
||
|
||
try {
|
||
let url = resolvePath(this.serviceLang);
|
||
let json = url ? await fetchJson(url) : null;
|
||
if (!json && this.serviceLang !== 'cu') {
|
||
url = resolvePath('cu');
|
||
json = url ? await fetchJson(url) : null;
|
||
}
|
||
if (!json) {
|
||
url = resolvePath('shared');
|
||
json = url ? await fetchJson(url) : null;
|
||
}
|
||
return json ? filterNodes(json, wantsHeader) : null;
|
||
} catch (err) {
|
||
console.error('Service fetchText error:', err);
|
||
return null;
|
||
}
|
||
};
|
||
}
|
||
|
||
private createFetchPrayerNodes() {
|
||
return async (path: string): Promise<ServiceNode[] | null> => {
|
||
const resolvePath = (lang: string): string | null => {
|
||
let fetchPath = path;
|
||
if (fetchPath.startsWith('Services/')) {
|
||
fetchPath = fetchPath.slice('Services/'.length);
|
||
if (fetchPath.startsWith('CommonPrayers/')) {
|
||
fetchPath = 'prayers/' + fetchPath.slice('CommonPrayers/'.length);
|
||
}
|
||
fetchPath = fetchPath.replace('.xml', '.json');
|
||
}
|
||
return `/data/${lang}/services/${fetchPath}`;
|
||
};
|
||
|
||
const tryFetch = async (url: string): Promise<ServiceNode[] | null> => {
|
||
if (this.notFound.has(url)) return null;
|
||
const cached = this.nodeCache.get(url);
|
||
if (cached) return cached;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||
const ct = resp.headers.get('content-type') || '';
|
||
if (ct.includes('text/html')) { this.notFound.add(url); return null; }
|
||
const nodes = await resp.json();
|
||
if (Array.isArray(nodes)) {
|
||
this.nodeCache.set(url, nodes);
|
||
return nodes;
|
||
}
|
||
this.notFound.add(url);
|
||
return null;
|
||
} catch {
|
||
this.notFound.add(url);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
try {
|
||
let url = resolvePath(this.serviceLang);
|
||
if (url) {
|
||
const result = await tryFetch(url);
|
||
if (result) return result;
|
||
}
|
||
if (this.serviceLang !== 'cu') {
|
||
const fbUrl = resolvePath('cu');
|
||
if (fbUrl) {
|
||
const result = await tryFetch(fbUrl);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
const sharedUrl = resolvePath('shared');
|
||
if (sharedUrl) {
|
||
return await tryFetch(sharedUrl);
|
||
}
|
||
return null;
|
||
} catch (err) {
|
||
console.error('Service fetchPrayerNodes error:', err);
|
||
return null;
|
||
}
|
||
};
|
||
}
|
||
|
||
private async fetchServiceNodes(path: string): Promise<ServiceNode[] | null> {
|
||
const resolvePath = (lang: string): string | null => {
|
||
let fetchPath = path;
|
||
if (fetchPath.startsWith('Services/')) {
|
||
fetchPath = fetchPath.slice('Services/'.length);
|
||
// Service templates are in templates/ subdirectory
|
||
// But if path contains a subdirectory, don't add templates/ prefix
|
||
if (!fetchPath.includes('/')) {
|
||
fetchPath = 'templates/' + fetchPath;
|
||
} else {
|
||
// Handle subdirectories like Var/, CommonPrayers/, etc.
|
||
if (fetchPath.startsWith('CommonPrayers/')) {
|
||
fetchPath = 'prayers/' + fetchPath.slice('CommonPrayers/'.length);
|
||
} else if (fetchPath.startsWith('Text/')) {
|
||
fetchPath = 'texts/' + fetchPath.slice('Text/'.length);
|
||
} else if (fetchPath.startsWith('Command/')) {
|
||
fetchPath = 'commands/' + fetchPath.slice('Command/'.length);
|
||
} else if (fetchPath.startsWith('Header/')) {
|
||
fetchPath = 'headers/' + fetchPath.slice('Header/'.length);
|
||
} else if (fetchPath.startsWith('Var/')) {
|
||
fetchPath = 'var/' + fetchPath.slice('Var/'.length);
|
||
} else if (fetchPath.startsWith('Octoecheos/')) {
|
||
fetchPath = 'octoecheos/' + fetchPath.slice('Octoecheos/'.length);
|
||
}
|
||
}
|
||
fetchPath = fetchPath.replace('.xml', '.json');
|
||
}
|
||
return `/data/${lang}/services/${fetchPath}`;
|
||
};
|
||
const tryFetch = async (url: string): Promise<ServiceNode[] | null> => {
|
||
if (this.notFound.has(url)) return null;
|
||
const cached = this.nodeCache.get(url);
|
||
if (cached) return cached;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) { this.notFound.add(url); return null; }
|
||
const ct = resp.headers.get('content-type') || '';
|
||
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);
|
||
if (url) {
|
||
const result = await tryFetch(url);
|
||
if (result) return result;
|
||
}
|
||
if (this.serviceLang !== 'cu') {
|
||
const fbUrl = resolvePath('cu');
|
||
if (fbUrl) {
|
||
const result = await tryFetch(fbUrl);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
const sharedUrl = resolvePath('shared');
|
||
if (sharedUrl) {
|
||
return await tryFetch(sharedUrl);
|
||
}
|
||
return null;
|
||
}
|
||
}
|