diff --git a/src/core/service-assembler.ts b/src/core/service-assembler.ts index 3d35c14..4340090 100644 --- a/src/core/service-assembler.ts +++ b/src/core/service-assembler.ts @@ -22,6 +22,7 @@ export interface ServiceContext { lang: string; fetchText: (path: string) => Promise; fetchServiceNodes: (path: string) => Promise; + fetchPrayerNodes: (path: string) => Promise; fetchBibleText: (book: string, passage: string, showVerseNumbers?: boolean, verseNewLine?: boolean) => Promise; fetchLives: (id: string) => Promise>> | null>; fetchCommandText: (name: string) => Promise; @@ -302,14 +303,6 @@ export async function assembleService( const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : ''; let text = ''; let header = ''; - if (what) { - text = await ctx.fetchText(`Services/CommonPrayers/${what}.xml`) ?? ''; - // Fallback: if fetchText returns empty, try to use the 'what' field as a key - if (!text.trim()) { - text = what; // fallback to the key itself - } - header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? ''; - } const commandName = str(n.command); const commandBName = str(n.commandB); const timesVal = num(n.times); @@ -318,6 +311,61 @@ export async function assembleService( commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''), timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''), ]); + if (what) { + // Try fetching structured prayer nodes (preserves per-line who) + const prayerNodes = await ctx.fetchPrayerNodes(`Services/CommonPrayers/${what}.xml`); + if (prayerNodes && prayerNodes.length > 0) { + header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? ''; + const headerHtml = n.header && header + ? `

${header}

` + : ''; + if (headerHtml) html += headerHtml + '\n'; + // Check if any TEXT nodes have their own who field + const hasWhoNodes = prayerNodes.some(n => n.type === 'TEXT' && str((n as Record).who) !== undefined); + if (hasWhoNodes) { + for (const pn of prayerNodes) { + if (pn.type !== 'TEXT') continue; + const pnWhoRaw = str((pn as Record).who); + const pnWho = pnWhoRaw !== undefined ? localizeWho(pnWhoRaw, ctx) : ''; + const pnText = str((pn as Record).what) ?? str((pn as Record).value) ?? ''; + const pnOpts = { + who: pnWho, + commandText: commandText || undefined, + commandBText: commandBText || undefined, + timesText: timesText || undefined, + redFirst: bool((pn as Record).redFirst) ?? bool(n.redFirst), + newLine: bool((pn as Record).newLine) ?? bool(n.newLine), + headerLevel: 0, + }; + const pnFmt = formatServiceContent(pnText, pnOpts); + if (pnWho) { + if (pnWho !== groupWho) flushGroup(); + groupWho = pnWho; + groupTexts.push(pnFmt); + } else { + flushGroup(); + const br = pnOpts.newLine ? '
' : ''; + html += `${br}

${pnFmt}

`; + } + } + if (whoRaw !== undefined) whoLast = who; + break; + } + // No who nodes — fall back to flattening like fetchText + text = prayerNodes + .filter(n => n.type === 'TEXT') + .map(n => String(str((n as Record).value) ?? '').replace(/\n/g, ' ')) + .filter(Boolean) + .join('
') || ''; + } else { + // No structured nodes — use fetchText + text = await ctx.fetchText(`Services/CommonPrayers/${what}.xml`) ?? ''; + if (!text.trim()) { + text = what; + } + header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? ''; + } + } const opts = { who, commandText: commandText || undefined, diff --git a/src/ui/service-view.ts b/src/ui/service-view.ts index 91c831d..273024e 100644 --- a/src/ui/service-view.ts +++ b/src/ui/service-view.ts @@ -154,6 +154,20 @@ export class ServiceView { 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: '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', @@ -166,7 +180,7 @@ export class ServiceView { return services; } - private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string } | null> { + private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string } | null> { const computed = computeDay(this.currentDate); const { dayInfo } = computed; const tone = dayInfo.Tone; @@ -175,7 +189,7 @@ export class ServiceView { const dd = String(dayInfo.day).padStart(2, '0'); const dateKey = `${mm}-${dd}`; - let result: { troparion1?: string; kontakion1?: string } | null = null; + let result: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string } | null = null; if (tone > 0 && tone <= 8) { const toneNum = tone === 8 ? 0 : tone; @@ -193,6 +207,9 @@ export class ServiceView { 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, }; } } @@ -296,10 +313,10 @@ export class ServiceView { return result; } - private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string } | null, serviceType: string): Map { + private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string; theotokion1?: string; prokimenon1?: string; alleluia1?: string } | null, serviceType: string): Map { const varNodes = new Map(); - const prefix: Record = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9' }; + const prefix: Record = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9', VESPERS: 'V', LITURGY: 'L' }; const p = prefix[serviceType] ?? ''; if (toneData?.troparion1) { @@ -326,6 +343,42 @@ export class ServiceView { }]); } + 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, + }]); + } + return varNodes; } @@ -345,6 +398,62 @@ export class ServiceView { const toneData = await this.loadToneData(service.serviceType); const varNodes = this.generateVarNodes(toneData, service.serviceType); + // 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); @@ -367,6 +476,7 @@ export class ServiceView { fetchCommandText: (name) => this.fetchCommandText(name), resolveTimes: (times) => this.resolveTimes(times), fetchText: this.createFetchText(), + fetchPrayerNodes: this.createFetchPrayerNodes(), fetchServiceNodes: async (path: string): Promise => { const varMatch = path.match(/Services\/Var\/(\w+)\.xml$/); if (varMatch) { @@ -699,10 +809,71 @@ export class ServiceView { console.error('Service fetchText error:', err); return null; } - }; - } + }; + } - private async fetchServiceNodes(path: string): Promise { + private createFetchPrayerNodes() { + return async (path: string): Promise => { + 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 => { + 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 { const resolvePath = (lang: string): string | null => { let fetchPath = path; if (fetchPath.startsWith('Services/')) {