diff --git a/package.json b/package.json index 293149e0..548fade1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.34.0", + "version": "1.35.0", "private": true, "type": "module", "scripts": { @@ -62,7 +62,7 @@ "leaflet": "^1.9.4", "mongoose": "^9.4.1", "node-cron": "^4.2.1", - "romcal": "github:AlexBocken/romcal1962#e4731a8", + "romcal": "github:AlexBocken/romcal1962#dev", "sharp": "^0.34.5", "web-haptics": "^0.0.6" }, @@ -71,4 +71,4 @@ "esbuild" ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb21c4f3..5c440544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 2.1.8-no-fsevents.3 '@romcal/calendar.general-roman': specifier: 3.0.0-dev.125 - version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2)) + version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2)) '@sveltejs/adapter-node': specifier: ^5.5.4 version: 5.5.4(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))) @@ -54,8 +54,8 @@ importers: specifier: ^4.2.1 version: 4.2.1 romcal: - specifier: github:AlexBocken/romcal1962#e4731a8 - version: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2) + specifier: github:AlexBocken/romcal1962#dev + version: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -1782,8 +1782,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8: - resolution: {tarball: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8} + romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d: + resolution: {tarball: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d} version: 3.0.0-dev.125 engines: {node: '>=18.0.0'} @@ -2683,9 +2683,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2))': + '@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2))': dependencies: - romcal: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2) + romcal: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2) '@sec-ant/readable-stream@0.4.1': {} @@ -3545,7 +3545,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 - romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2): + romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2): dependencies: i18next: 26.0.4(typescript@6.0.2) transitivePeerDependencies: diff --git a/src/lib/calendarTypes.ts b/src/lib/calendarTypes.ts new file mode 100644 index 00000000..8e3fcf15 --- /dev/null +++ b/src/lib/calendarTypes.ts @@ -0,0 +1,86 @@ +// Shared types for the liturgical calendar. Safe to import from both server +// loaders and client components (pure type declarations). + +export interface CalendarDay { + iso: string; + id: string; + name: string; + rankName: string; + rank: string; + seasonKey: string | null; + seasonNames: string[]; + colorNames: string[]; + colorKeys: string[]; + psalterWeek: string | null; + sundayCycle: string | null; + rite1962?: Rite1962Detail; +} + +// Compact per-day shape returned for the full year so the ring / month-grid +// overview views can render without refetching. Kept small on purpose. +export interface YearDay { + iso: string; + name: string; + rank: string; + color: string; // primary color key (WHITE/RED/...) + seasonKey: string | null; +} + +export interface SeasonArc { + key: string; + name: string; + start: string; + end: string; + color: string; +} + +export interface Rite1962Commem { + key: string; + name: string; + rankName: string; + kind: 'tempora' | 'sancti'; + colorNames: string[]; + colorKeys: string[]; +} + +export interface Rite1962Detail { + class: 1 | 2 | 3 | 4; + kind: 'tempora' | 'sancti'; + commemorations: Rite1962Commem[]; + rubrics: { + gloria: boolean; + credo: boolean; + preface?: string; + lastGospel?: string; + ite?: string; + }; + octave?: { + id: string; + parentFeastId: string; + day: number; + rank: string; + }; + vigilOf?: string; + transferredFrom?: string; + properSource: string; + communeSlug?: string; + propers: ProperSection[]; + extraSections: ProperSection[]; +} + +export interface ProperSegment { + refs: string[]; + la: string; + local?: string; + // When true, `local` text comes from the Bible translation lookup because + // the propers dataset had no localized text for this segment. + fromBible?: boolean; +} + +export interface ProperSection { + key: string; + segments: ProperSegment[]; + // Aggregate list of refs across segments (for quick checks) + refs: string[]; + fromBible?: boolean; +} diff --git a/src/lib/server/bibleRefLatin.ts b/src/lib/server/bibleRefLatin.ts index 538d62ca..c01a7548 100644 --- a/src/lib/server/bibleRefLatin.ts +++ b/src/lib/server/bibleRefLatin.ts @@ -87,6 +87,19 @@ function normalizeLatinBook(raw: string): string { return raw.toLowerCase().replace(/[.\s]/g, ''); } +// Longest-prefix lookup: data uses "Joannes", "Matt", "Joann" interchangeably. +// Pre-sort keys once so fuller forms ("1joan" before "1") don't get shadowed. +const LATIN_KEYS_BY_LENGTH = Object.keys(LATIN_TO_TARGET).sort((a, b) => b.length - a.length); + +function lookupLatinBook(bookNorm: string): { en?: string; de?: string } | undefined { + const direct = LATIN_TO_TARGET[bookNorm]; + if (direct) return direct; + for (const key of LATIN_KEYS_BY_LENGTH) { + if (bookNorm.startsWith(key)) return LATIN_TO_TARGET[key]; + } + return undefined; +} + // Allioli (de) TSV uses Hebrew/modern psalm numbering; DRB (en) uses Vulgate. // Latin propers are Vulgate, so we shift for de. Only covers the clean +1 // range — Vulgate 9/10, 113–115, 146/147 involve splits/merges and are @@ -104,7 +117,7 @@ export function translateRefToTarget(ref: string, lang: TargetLang): string | nu if (!m) return null; const bookNorm = normalizeLatinBook(m[1]); const rest = m[2].trim().replace(/;.*$/, '').trim(); - const map = LATIN_TO_TARGET[bookNorm]; + const map = lookupLatinBook(bookNorm); const target = map?.[lang]; if (!target) return null; const clean = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' '); diff --git a/src/lib/server/liturgicalCalendar.ts b/src/lib/server/liturgicalCalendar.ts new file mode 100644 index 00000000..45b5d46f --- /dev/null +++ b/src/lib/server/liturgicalCalendar.ts @@ -0,0 +1,447 @@ +import { Romcal, type RomcalBundleObject } from 'romcal'; +import { + GeneralRoman_De, + GeneralRoman_En, + GeneralRoman_La +} from '@romcal/calendar.general-roman'; +import { + Switzerland_De, + Switzerland_En, + Switzerland_La +} from '@romcal/calendar.switzerland'; +import { + Romcal1962, + Switzerland, + Switzerland_Basel, + Switzerland_Chur, + Switzerland_Lausanne_Geneva_Fribourg, + Switzerland_Lugano, + Switzerland_Saint_Maurice_Abbey, + Switzerland_Sankt_Gallen, + Switzerland_Sion, + resolvePropersBlocks +} from 'romcal/1962'; +import type { + LiturgicalDay1962, + MassPropersBlocks, + MassSectionField, + PropersBlock +} from 'romcal/1962'; +import { + colorLabel1962, + rank1962Label, + season1962Label, + type CalendarLang, + type Diocese1962, + type Diocese1969 +} from '../../routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n'; +import type { + CalendarDay, + ProperSection, + ProperSegment, + Rite1962Commem, + Rite1962Detail +} from '../calendarTypes'; +import { lookupReference } from '$lib/server/bible'; +import { translateRefToTarget } from '$lib/server/bibleRefLatin'; +import { resolve as resolvePath } from 'path'; + +const bundles1969: Record> = { + general: { en: GeneralRoman_En, de: GeneralRoman_De, la: GeneralRoman_La }, + switzerland: { en: Switzerland_En, de: Switzerland_De, la: Switzerland_La } +}; + +const romcalByKey = new Map(); +function getRomcal(lang: CalendarLang, diocese: Diocese1969): Romcal { + const key = `${diocese}|${lang}`; + let r = romcalByKey.get(key); + if (r) return r; + r = new Romcal({ localizedCalendar: bundles1969[diocese][lang] }); + romcalByKey.set(key, r); + return r; +} + +const yearCache = new Map>(); + +export async function getYear( + lang: CalendarLang, + diocese: Diocese1969, + year: number +): Promise> { + const cacheKey = `${diocese}|${lang}|${year}`; + const cached = yearCache.get(cacheKey); + if (cached) return cached; + + const r = getRomcal(lang, diocese); + const raw = await r.generateCalendar(year); + const map = new Map(); + for (const [iso, entries] of Object.entries(raw)) { + const principal = entries[0]; + if (!principal) continue; + const seasonKey = (principal as unknown as { seasons?: string[] }).seasons?.[0] ?? null; + map.set(iso, { + iso, + id: principal.id, + name: principal.name, + rankName: principal.rankName, + rank: principal.rank, + seasonKey, + seasonNames: [...principal.seasonNames], + colorNames: [...principal.colorNames], + colorKeys: [...principal.colors], + psalterWeek: principal.cycles?.psalterWeek ?? null, + sundayCycle: principal.cycles?.sundayCycle ?? null + }); + } + yearCache.set(cacheKey, map); + return map; +} + +// --- 1962 rite --- + +const calendars1962 = { + general: undefined, + switzerland: Switzerland, + basel: Switzerland_Basel, + chur: Switzerland_Chur, + 'lausanne-geneva-fribourg': Switzerland_Lausanne_Geneva_Fribourg, + lugano: Switzerland_Lugano, + 'saint-maurice-abbey': Switzerland_Saint_Maurice_Abbey, + 'sankt-gallen': Switzerland_Sankt_Gallen, + sion: Switzerland_Sion +} as const satisfies Record; + +const romcal1962ByKey = new Map(); +function getRomcal1962(lang: CalendarLang, diocese: Diocese1962): Romcal1962 { + const key = `${diocese}|${lang}`; + let r = romcal1962ByKey.get(key); + if (r) return r; + // Package localizes celebration.name via i18next; structured proper + // blocks are fetched lazily via resolvePropersBlocks (below), so we + // don't ask attachPropers to pre-concatenate locale text. + const calendar = calendars1962[diocese]; + r = calendar + ? new Romcal1962({ localeId: lang, calendar }) + : new Romcal1962({ localeId: lang }); + romcal1962ByKey.set(key, r); + return r; +} + +const PROPER_ORDER: MassSectionField[] = [ + 'introit', + 'collect', + 'epistle', + 'gradual', + 'alleluia', + 'tract', + 'sequence', + 'gospel', + 'offertory', + 'secret', + 'preface', + 'communion', + 'postcommunion' +]; + +const COLOR_KEY_1962: Record = { + White: 'WHITE', + Red: 'RED', + Green: 'GREEN', + Violet: 'PURPLE', + Black: 'BLACK', + Rose: 'ROSE' +}; + +const RANK_FROM_CLASS_1962: Record<1 | 2 | 3 | 4, string> = { + 1: 'SOLEMNITY', + 2: 'FEAST', + 3: 'MEMORIAL', + 4: 'WEEKDAY' +}; + +function colorKeysFrom(c: LiturgicalDay1962): string[] { + return c.colors.map((col) => COLOR_KEY_1962[col] ?? col.toUpperCase()); +} + +function adaptCommem(c: LiturgicalDay1962, lang: CalendarLang): Rite1962Commem { + const colorKeys = colorKeysFrom(c); + return { + key: c.key, + name: c.name, + rankName: rank1962Label(c.rank1962, lang), + kind: c.kind, + colorKeys, + colorNames: colorKeys.map((k) => colorLabel1962(k, lang)) + }; +} + +function bibleTextFor(ref: string, targetLang: 'en' | 'de'): string | null { + const tsvPath = resolvePath(targetLang === 'de' ? 'static/allioli.tsv' : 'static/drb.tsv'); + const segments = ref.split(';').map((s) => s.trim()).filter(Boolean); + if (!segments.length) return null; + + let lastBook: string | null = null; + let lastChapter: string | null = null; + const parts: string[] = []; + + for (const seg of segments) { + // Detect optional leading book (letters, optional leading digit like "1 Cor") + const bookMatch = seg.match(/^(\d?\s?[A-Za-z]+\.?)\s+(.*)$/); + let book: string | null = null; + let rest = seg; + if (bookMatch) { + book = bookMatch[1]; + rest = bookMatch[2].trim(); + } + if (book) lastBook = book; + if (!lastBook) continue; + + let chapter: string; + let verseRange: string; + // Accept "118:85", "118, 85", "118:85-90", or bare "85" (inherit chapter) + const normalized = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ').trim(); + if (normalized.includes(':')) { + const [c, v] = normalized.split(':'); + chapter = c.trim(); + verseRange = v.trim(); + lastChapter = chapter; + } else if (lastChapter) { + chapter = lastChapter; + verseRange = normalized; + } else continue; + + const fullRef = `${lastBook} ${chapter}:${verseRange}`; + const translated = translateRefToTarget(fullRef, targetLang); + if (!translated) continue; + + try { + const result = lookupReference(translated, tsvPath); + if (result && result.verses.length) { + parts.push(result.verses.map((v) => `${v.verse}. ${v.text}`).join(' ')); + } + } catch { + // skip + } + } + + return parts.length ? parts.join(' ') : null; +} + +interface RawSegment { + refs: string[]; + la: string; + local: string; +} + +// Zip la / local text streams by index so la[i] and local[i] land in +// the same segment. Scripture refs attach to the la block that follows +// them; trailing refs with no following la block attach to the last +// segment so they still render. +function buildSegments(items: PropersBlock, localLang: CalendarLang): RawSegment[] { + const la: string[] = []; + const local: string[] = []; + const refsByIdx = new Map(); + let pendingRefs: string[] = []; + + for (const it of items) { + if (it.type === 'scriptureRef') { + pendingRefs.push(it.ref); + } else if (it.type === 'text') { + const val = it.value.trim(); + if (!val) continue; + if (it.lang === 'la') { + if (pendingRefs.length) { + refsByIdx.set(la.length, pendingRefs); + pendingRefs = []; + } + la.push(val); + } else if (it.lang === localLang) { + local.push(val); + } + } + } + + if (pendingRefs.length && la.length) { + const lastIdx = la.length - 1; + const existing = refsByIdx.get(lastIdx) ?? []; + refsByIdx.set(lastIdx, [...existing, ...pendingRefs]); + } + + const count = Math.max(la.length, local.length); + const segs: RawSegment[] = []; + for (let i = 0; i < count; i++) { + segs.push({ refs: refsByIdx.get(i) ?? [], la: la[i] ?? '', local: local[i] ?? '' }); + } + return segs; +} + +function propersOf(sections: MassPropersBlocks, lang: CalendarLang): ProperSection[] { + const out: ProperSection[] = []; + for (const key of PROPER_ORDER) { + const block = sections[key]; + if (!block || !block.length) continue; + const rawSegs = buildSegments(block, lang); + if (!rawSegs.length) continue; + + const segments: ProperSegment[] = []; + const allRefs: string[] = []; + let sectionFromBible = false; + + for (const raw of rawSegs) { + const seg: ProperSegment = { refs: raw.refs, la: raw.la }; + if (lang !== 'la' && raw.local) seg.local = raw.local; + + // Bible fallback: only for this segment, using only its own refs + if (!seg.local && raw.refs.length && lang !== 'la') { + const bible = bibleTextFor(raw.refs.join('; '), lang); + if (bible) { + seg.local = bible; + seg.fromBible = true; + sectionFromBible = true; + } + } + + allRefs.push(...raw.refs); + if (seg.la || seg.local || seg.refs.length) segments.push(seg); + } + + if (!segments.length) continue; + const section: ProperSection = { key, segments, refs: allRefs }; + if (sectionFromBible) section.fromBible = true; + out.push(section); + } + return out; +} + +function extraSectionsOf( + extras: Record, + lang: CalendarLang +): ProperSection[] { + const out: ProperSection[] = []; + for (const [key, block] of Object.entries(extras)) { + const buckets: Record = {}; + const refs: string[] = []; + for (const item of block) { + if (item.type === 'text') (buckets[item.lang] ??= []).push(item.value); + else if (item.type === 'scriptureRef') refs.push(item.ref); + } + const la = (buckets['la'] ?? []).join('\n\n').trim(); + const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim(); + if (!la && !local && refs.length === 0) continue; + const segment: ProperSegment = { refs, la }; + if (local) segment.local = local; + out.push({ key, segments: [segment], refs }); + } + return out; +} + +// Primary celebration may only carry the sections that override the commune +// (e.g. only `gospel` for a confessor). Merge commune sections under the +// primary so every liturgical slot has a text source. +function mergeCommunePropers( + p: LiturgicalDay1962, + sections: MassPropersBlocks, + extraSections: Record +): { sections: MassPropersBlocks; extraSections: Record } { + const slug = p.properRef.communeSlug; + if (!slug) return { sections, extraSections }; + const communeCelebration = { + ...p, + properRef: { source: `commune/${slug}` } + } as LiturgicalDay1962; + let communeResolved; + try { + communeResolved = resolvePropersBlocks(communeCelebration); + } catch { + return { sections, extraSections }; + } + const mergedSections = { ...communeResolved.sections, ...sections } as MassPropersBlocks; + const mergedExtras = { ...communeResolved.extraSections, ...extraSections }; + return { sections: mergedSections, extraSections: mergedExtras }; +} + +function adaptDay1962(entries: LiturgicalDay1962[], lang: CalendarLang): CalendarDay { + const p = entries[0]; + const commemorations = entries.slice(1); + const colorKeys = colorKeysFrom(p); + const colorNames = colorKeys.map((k) => colorLabel1962(k, lang)); + const resolved = resolvePropersBlocks(p); + const { sections, extraSections } = mergeCommunePropers( + p, + resolved.sections, + resolved.extraSections + ); + const detail: Rite1962Detail = { + class: p.classOf1962, + kind: p.kind, + commemorations: commemorations.map((c) => adaptCommem(c, lang)), + rubrics: { + gloria: p.rubrics.gloria, + credo: p.rubrics.credo, + preface: p.rubrics.preface, + lastGospel: p.rubrics.lastGospel, + ite: p.rubrics.ite + }, + ...(p.octave + ? { + octave: { + id: p.octave.id, + parentFeastId: p.octave.parentFeastId, + day: p.octave.day, + rank: p.octave.rank + } + } + : {}), + ...(p.vigil ? { vigilOf: p.vigil.of } : {}), + ...(p.transferredFromDate ? { transferredFrom: p.transferredFromDate } : {}), + properSource: p.properRef.source, + ...(p.properRef.communeSlug ? { communeSlug: p.properRef.communeSlug } : {}), + propers: propersOf(sections, lang), + extraSections: extraSectionsOf(extraSections, lang) + }; + // Pentecost octave (Pentecost Sunday + 6 days) is carved out of Paschaltide so + // it shows as its own arc in the year ring, mirroring the Easter Week octave. + const isPentecostWeek = typeof p.key === 'string' && p.key.startsWith('easter_time_7_'); + const seasonKey = isPentecostWeek ? 'Pentecost' : p.season ?? null; + const seasonNames = seasonKey ? [season1962Label(seasonKey, lang)] : []; + return { + iso: p.date, + id: p.key, + name: p.name, + rankName: rank1962Label(p.rank1962, lang), + rank: RANK_FROM_CLASS_1962[p.classOf1962], + seasonKey, + seasonNames, + colorNames, + colorKeys, + psalterWeek: null, + sundayCycle: null, + rite1962: detail + }; +} + +const yearCache1962 = new Map>(); + +export async function getYear1962( + lang: CalendarLang, + diocese: Diocese1962, + year: number +): Promise> { + const cacheKey = `${diocese}|${lang}|${year}`; + const cached = yearCache1962.get(cacheKey); + if (cached) return cached; + const resolved = await getRomcal1962(lang, diocese).generateCalendar(year); + const map = new Map(); + for (const [iso, entries] of Object.entries(resolved)) { + if (!entries.length) continue; + map.set(iso, adaptDay1962(entries, lang)); + } + yearCache1962.set(cacheKey, map); + return map; +} + +export function isoFor(year: number, month: number, day: number): string { + const mm = String(month + 1).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + return `${year}-${mm}-${dd}`; +} diff --git a/src/lib/server/romcal1962Refs.ts b/src/lib/server/romcal1962Refs.ts deleted file mode 100644 index a7fd9be5..00000000 --- a/src/lib/server/romcal1962Refs.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { readFileSync } from 'fs'; -import { createRequire } from 'module'; - -type PropersBlockItem = - | { type: 'text'; lang: string; value: string; role?: string } - | { type: 'scriptureRef'; ref: string } - | { type: 'directive'; value: string } - | { type: 'ref'; target: string } - | { type: 'rubric'; note: string } - | { type: 'separator' }; - -interface RawEntry { - id?: string; - references?: Record; - sections?: Record; -} - -const require = createRequire(import.meta.url); - -function loadJson(name: 'tempora' | 'sancti' | 'commune'): Record { - // Resolve via package entry, then hop into the sibling /data/ folder. - // The package exports field only exposes dist/ so we read the file directly. - const pkgEntry = require.resolve('romcal/1962'); - const baseIdx = pkgEntry.indexOf('/rites/roman1962/'); - if (baseIdx < 0) throw new Error('cannot locate romcal/1962 data dir'); - const dataPath = pkgEntry.slice(0, baseIdx) + `/rites/roman1962/data/${name}.json`; - return JSON.parse(readFileSync(dataPath, 'utf-8')); -} - -const kinds: Record> = { - tempora: loadJson('tempora'), - sancti: loadJson('sancti'), - commune: loadJson('commune') -}; - -// Proper key → source section name candidates (tried in order) -const SECTION_ALIASES: Record = { - introit: ['Introitus'], - collect: ['Oratio'], - epistle: ['Lectio', 'Epistola'], - gradual: ['Graduale'], - alleluia: ['GradualeP', 'Alleluia'], - tract: ['Tractus'], - sequence: ['Sequentia'], - gospel: ['Evangelium'], - offertory: ['Offertorium'], - secret: ['Secreta'], - preface: ['Praefatio'], - communion: ['Communio'], - postcommunion: ['Postcommunio'] -}; - -function lookupEntry(source: string): RawEntry | null { - const [rawKind, ...rest] = source.split('/'); - const kind = rawKind.toLowerCase(); - const id = rest.join('/'); - return kinds[kind]?.[id] ?? null; -} - -function parseTarget(target: string): { source: string; section?: string } { - const [path, section] = target.split(':'); - return { source: path, section }; -} - -function collectRefs(items: PropersBlockItem[]): string[] { - const refs: string[] = []; - for (const it of items) { - if (it.type === 'scriptureRef') refs.push(it.ref); - } - return refs; -} - -function hasTextOf(items: PropersBlockItem[], lang: string): boolean { - for (const it of items) { - if (it.type === 'text' && it.lang === lang && it.value.trim()) return true; - } - return false; -} - -function resolveSection( - source: string, - sectionName: string, - seen: Set -): PropersBlockItem[] | null { - const key = `${source}:${sectionName}`; - if (seen.has(key)) return null; - seen.add(key); - - const entry = lookupEntry(source); - if (!entry) return null; - - const topRef = entry.references?.[sectionName]; - if (topRef) { - const { source: tgtSource, section: tgtSection } = parseTarget(topRef); - return resolveSection(tgtSource, tgtSection ?? sectionName, seen); - } - - const items = entry.sections?.[sectionName]; - if (!items || items.length === 0) return null; - - for (const it of items) { - if (it.type === 'ref') { - const { source: tgtSource, section: tgtSection } = parseTarget(it.target); - const resolved = resolveSection(tgtSource, tgtSection ?? sectionName, seen); - if (resolved) return resolved; - } - } - return items; -} - -export interface ProperRefInfo { - refs: string[]; - hasLa: boolean; - hasLocal: boolean; -} - -export function getProperRefs( - source: string, - properKey: string, - localLang: 'en' | 'de' | 'la' -): ProperRefInfo { - const aliases = SECTION_ALIASES[properKey] ?? []; - for (const sectionName of aliases) { - const items = resolveSection(source, sectionName, new Set()); - if (!items) continue; - return { - refs: collectRefs(items), - hasLa: hasTextOf(items, 'la'), - hasLocal: localLang === 'la' ? true : hasTextOf(items, localLang) - }; - } - return { refs: [], hasLa: false, hasLocal: false }; -} - -export interface RawProperSegment { - refs: string[]; - la: string; - local: string; -} - -function buildSegments(items: PropersBlockItem[], localLang: string): RawProperSegment[] { - // Raw data lists la texts (with inline scriptureRefs) first, then local - // texts afterwards. Walk the stream separately per language, then zip by - // index so la[i] and local[i] end up in the same segment. Scripture refs - // are attached to the la block that follows them. - const la: string[] = []; - const local: string[] = []; - const refsByIdx = new Map(); - let pendingRefs: string[] = []; - - for (const it of items) { - if (it.type === 'scriptureRef') { - pendingRefs.push(it.ref); - } else if (it.type === 'text') { - const val = it.value.trim(); - if (!val) continue; - if (it.lang === 'la') { - if (pendingRefs.length) { - refsByIdx.set(la.length, pendingRefs); - pendingRefs = []; - } - la.push(val); - } else if (it.lang === localLang) { - local.push(val); - } - } - } - - // Trailing refs with no following la block — attach to last la segment - // so they still render, but only if there's something to attach to. - if (pendingRefs.length && la.length) { - const lastIdx = la.length - 1; - const existing = refsByIdx.get(lastIdx) ?? []; - refsByIdx.set(lastIdx, [...existing, ...pendingRefs]); - } - - const count = Math.max(la.length, local.length); - const segs: RawProperSegment[] = []; - for (let i = 0; i < count; i++) { - segs.push({ - refs: refsByIdx.get(i) ?? [], - la: la[i] ?? '', - local: local[i] ?? '' - }); - } - return segs; -} - -export function getProperSegments( - source: string, - properKey: string, - localLang: 'en' | 'de' | 'la' -): RawProperSegment[] | null { - const aliases = SECTION_ALIASES[properKey] ?? []; - for (const sectionName of aliases) { - const items = resolveSection(source, sectionName, new Set()); - if (!items) continue; - return buildSegments(items, localLang); - } - return null; -} diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts index 9fb7484c..c599c904 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts @@ -1,388 +1,34 @@ import { error, redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { Romcal } from 'romcal'; import { - GeneralRoman_De, - GeneralRoman_En, - GeneralRoman_La -} from '@romcal/calendar.general-roman'; -import { Romcal1962 } from 'romcal/1962'; -import type { Celebration1962, ResolvedDay1962 } from 'romcal/1962'; -import { - colorLabel1962, + DEFAULT_DIOCESE_1962, + DEFAULT_DIOCESE_1969, expectedSlug, - rank1962Label, + isDiocese1962, + isDiocese1969, season1962Label, type CalendarLang, + type Diocese1962, + type Diocese1969, type Rite } from '../../../../calendarI18n'; -import { getProperSegments } from '$lib/server/romcal1962Refs'; -import { lookupReference } from '$lib/server/bible'; -import { translateRefToTarget } from '$lib/server/bibleRefLatin'; -import { resolve as resolvePath } from 'path'; +import { seasonColorFor } from '../../../../calendarColors'; +import { + getYear, + getYear1962, + isoFor +} from '$lib/server/liturgicalCalendar'; +import type { CalendarDay, SeasonArc, YearDay } from '$lib/calendarTypes'; -export interface CalendarDay { - iso: string; - id: string; - name: string; - rankName: string; - rank: string; - seasonNames: string[]; - colorNames: string[]; - colorKeys: string[]; - psalterWeek: string | null; - sundayCycle: string | null; - rite1962?: Rite1962Detail; -} - -export interface Rite1962Commem { - key: string; - name: string; - rankName: string; - kind: 'tempora' | 'sancti'; - colorNames: string[]; - colorKeys: string[]; -} - -export interface Rite1962Detail { - class: 1 | 2 | 3 | 4; - kind: 'tempora' | 'sancti'; - commemorations: Rite1962Commem[]; - rubrics: { - gloria: boolean; - credo: boolean; - preface?: string; - lastGospel?: string; - ite?: string; - }; - octave?: { - id: string; - parentFeastId: string; - day: number; - rank: string; - }; - vigilOf?: string; - transferredFrom?: string; - properSource: string; - communeSlug?: string; - propers: ProperSection[]; - extraSections: ProperSection[]; -} - -const localeBundles = { - en: GeneralRoman_En, - de: GeneralRoman_De, - la: GeneralRoman_La -}; - -const romcalByLang = new Map(); -function getRomcal(lang: CalendarLang): Romcal { - let r = romcalByLang.get(lang); - if (r) return r; - r = new Romcal({ localizedCalendar: localeBundles[lang] }); - romcalByLang.set(lang, r); - return r; -} - -const yearCache = new Map>(); - -async function getYear(lang: CalendarLang, year: number): Promise> { - const cacheKey = `${lang}|${year}`; - const cached = yearCache.get(cacheKey); - if (cached) return cached; - - const r = getRomcal(lang); - const raw = await r.generateCalendar(year); - const map = new Map(); - for (const [iso, entries] of Object.entries(raw)) { - const principal = entries[0]; - if (!principal) continue; - map.set(iso, { - iso, - id: principal.id, - name: principal.name, - rankName: principal.rankName, - rank: principal.rank, - seasonNames: [...principal.seasonNames], - colorNames: [...principal.colorNames], - colorKeys: [...principal.colors], - psalterWeek: principal.cycles?.psalterWeek ?? null, - sundayCycle: principal.cycles?.sundayCycle ?? null - }); - } - yearCache.set(cacheKey, map); - return map; -} - -// --- 1962 rite --- - -const romcal1962ByLang = new Map(); -function getRomcal1962(lang: CalendarLang): Romcal1962 { - let r = romcal1962ByLang.get(lang); - if (r) return r; - const locales = lang === 'la' ? ['la'] : ['la', lang]; - r = new Romcal1962({ includePropers: true, propersLocales: locales }); - romcal1962ByLang.set(lang, r); - return r; -} - -const PROPER_ORDER = [ - 'introit', - 'collect', - 'epistle', - 'gradual', - 'alleluia', - 'tract', - 'sequence', - 'gospel', - 'offertory', - 'secret', - 'preface', - 'communion', - 'postcommunion' -] as const; - -type ProperKey = (typeof PROPER_ORDER)[number]; - -export interface ProperSegment { - refs: string[]; - la: string; - local?: string; - // When true, `local` text comes from the Bible translation lookup because - // the propers dataset had no localized text for this segment. - fromBible?: boolean; -} - -export interface ProperSection { - key: string; - segments: ProperSegment[]; - // Aggregate list of refs across segments (for quick checks) - refs: string[]; - fromBible?: boolean; -} - -const COLOR_KEY_1962: Record = { - White: 'WHITE', - Red: 'RED', - Green: 'GREEN', - Violet: 'PURPLE', - Black: 'BLACK', - Rose: 'ROSE' -}; - -const RANK_FROM_CLASS_1962: Record<1 | 2 | 3 | 4, string> = { - 1: 'SOLEMNITY', - 2: 'FEAST', - 3: 'MEMORIAL', - 4: 'WEEKDAY' -}; - -function colorKeysFrom(c: Celebration1962): string[] { - return c.colors.map((col) => COLOR_KEY_1962[col] ?? col.toUpperCase()); -} - -function localizedName(c: Celebration1962, lang: CalendarLang): string { - if (lang === 'la') return c.name; - return c.names?.[lang] ?? c.name; -} - -function adaptCommem(c: Celebration1962, lang: CalendarLang): Rite1962Commem { - const colorKeys = colorKeysFrom(c); - return { - key: c.key, - name: localizedName(c, lang), - rankName: rank1962Label(c.rank1962, lang), - kind: c.kind, - colorKeys, - colorNames: colorKeys.map((k) => colorLabel1962(k, lang)) - }; -} - -function textOf(dict: Record | undefined, locale: string): string { - const v = dict?.[locale]; - return v && v.trim() ? v : ''; -} - -function bibleTextFor(ref: string, targetLang: 'en' | 'de'): string | null { - const tsvPath = resolvePath(targetLang === 'de' ? 'static/allioli.tsv' : 'static/drb.tsv'); - const segments = ref.split(';').map((s) => s.trim()).filter(Boolean); - if (!segments.length) return null; - - let lastBook: string | null = null; - let lastChapter: string | null = null; - const parts: string[] = []; - - for (const seg of segments) { - // Detect optional leading book (letters, optional leading digit like "1 Cor") - const bookMatch = seg.match(/^(\d?\s?[A-Za-z]+\.?)\s+(.*)$/); - let book: string | null = null; - let rest = seg; - if (bookMatch) { - book = bookMatch[1]; - rest = bookMatch[2].trim(); - } - if (book) lastBook = book; - if (!lastBook) continue; - - let chapter: string; - let verseRange: string; - // Accept "118:85", "118, 85", "118:85-90", or bare "85" (inherit chapter) - const normalized = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ').trim(); - if (normalized.includes(':')) { - const [c, v] = normalized.split(':'); - chapter = c.trim(); - verseRange = v.trim(); - lastChapter = chapter; - } else if (lastChapter) { - chapter = lastChapter; - verseRange = normalized; - } else { - continue; - } - - const fullRef = `${lastBook} ${chapter}:${verseRange}`; - const translated = translateRefToTarget(fullRef, targetLang); - if (!translated) continue; - - try { - const result = lookupReference(translated, tsvPath); - if (result && result.verses.length) { - parts.push(result.verses.map((v) => `${v.verse}. ${v.text}`).join(' ')); - } - } catch { - // skip this segment - } - } - - return parts.length ? parts.join(' ') : null; -} - -function propersOf(p: Celebration1962, lang: CalendarLang): ProperSection[] { - const out: ProperSection[] = []; - const source = p.properRef.source; - for (const key of PROPER_ORDER) { - const rawSegs = getProperSegments(source, key, lang); - if (!rawSegs || !rawSegs.length) continue; - - const segments: ProperSegment[] = []; - const allRefs: string[] = []; - let sectionFromBible = false; - - for (const raw of rawSegs) { - const seg: ProperSegment = { refs: raw.refs, la: raw.la }; - if (lang !== 'la' && raw.local) seg.local = raw.local; - - // Bible fallback: only for this segment, using only its own refs - if (!seg.local && raw.refs.length && lang !== 'la') { - const bible = bibleTextFor(raw.refs.join('; '), lang); - if (bible) { - seg.local = bible; - seg.fromBible = true; - sectionFromBible = true; - } - } - - allRefs.push(...raw.refs); - if (seg.la || seg.local || seg.refs.length) segments.push(seg); - } - - if (!segments.length) continue; - const section: ProperSection = { key, segments, refs: allRefs }; - if (sectionFromBible) section.fromBible = true; - out.push(section); - } - return out; -} - -function extraSectionsOf(p: Celebration1962, lang: CalendarLang): ProperSection[] { - const extras = p.extraSections; - if (!extras) return []; - const out: ProperSection[] = []; - for (const [key, block] of Object.entries(extras)) { - const buckets: Record = {}; - const refs: string[] = []; - for (const item of block) { - if (item.type === 'text') (buckets[item.lang] ??= []).push(item.value); - else if (item.type === 'scriptureRef') refs.push(item.ref); - } - const la = (buckets['la'] ?? []).join('\n\n').trim(); - const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim(); - if (!la && !local && refs.length === 0) continue; - const segment: ProperSegment = { refs, la }; - if (local) segment.local = local; - out.push({ key, segments: [segment], refs }); - } - return out; -} - -function adaptDay1962(day: ResolvedDay1962, lang: CalendarLang): CalendarDay { - const p: Celebration1962 = day.primary; - const colorKeys = colorKeysFrom(p); - const colorNames = colorKeys.map((k) => colorLabel1962(k, lang)); - const detail: Rite1962Detail = { - class: p.classOf1962, - kind: p.kind, - commemorations: day.commemorations.map((c) => adaptCommem(c, lang)), - rubrics: { - gloria: p.rubrics.gloria, - credo: p.rubrics.credo, - preface: p.rubrics.preface, - lastGospel: p.rubrics.lastGospel, - ite: p.rubrics.ite - }, - ...(p.octave - ? { - octave: { - id: p.octave.id, - parentFeastId: p.octave.parentFeastId, - day: p.octave.day, - rank: p.octave.rank - } - } - : {}), - ...(p.vigil ? { vigilOf: p.vigil.of } : {}), - ...(day.transferredFrom ? { transferredFrom: day.transferredFrom } : {}), - properSource: p.properRef.source, - ...(p.properRef.communeSlug ? { communeSlug: p.properRef.communeSlug } : {}), - propers: propersOf(p, lang), - extraSections: extraSectionsOf(p, lang) - }; - return { - iso: day.date, - id: p.key, - name: localizedName(p, lang), - rankName: rank1962Label(p.rank1962, lang), - rank: RANK_FROM_CLASS_1962[p.classOf1962], - seasonNames: day.season ? [season1962Label(day.season, lang)] : [], - colorNames, - colorKeys, - psalterWeek: null, - sundayCycle: null, - rite1962: detail - }; -} - -const yearCache1962 = new Map>(); - -async function getYear1962( - lang: CalendarLang, - year: number -): Promise> { - const cacheKey = `${lang}|${year}`; - const cached = yearCache1962.get(cacheKey); - if (cached) return cached; - const resolved = await getRomcal1962(lang).generateCalendar(year); - const map = new Map(); - for (const [iso, day] of resolved) map.set(iso, adaptDay1962(day, lang)); - yearCache1962.set(cacheKey, map); - return map; -} - -function isoFor(year: number, month: number, day: number): string { - const mm = String(month + 1).padStart(2, '0'); - const dd = String(day).padStart(2, '0'); - return `${year}-${mm}-${dd}`; -} +export type { + CalendarDay, + ProperSection, + ProperSegment, + Rite1962Commem, + Rite1962Detail, + SeasonArc, + YearDay +} from '$lib/calendarTypes'; export const load: PageServerLoad = async ({ params, url, locals }) => { const slug = expectedSlug(params.faithLang); @@ -396,6 +42,14 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { const rite: Rite = params.rite === '1969' ? '1969' : '1962'; + const dioceseParam = url.searchParams.get('diocese'); + const diocese1962: Diocese1962 = isDiocese1962(dioceseParam) + ? dioceseParam + : DEFAULT_DIOCESE_1962; + const diocese1969: Diocese1969 = isDiocese1969(dioceseParam) + ? dioceseParam + : DEFAULT_DIOCESE_1969; + // Reject mm without yyyy, dd without yyyy+mm. Sveltekit optional routes let // gaps through so we normalize here. if ((params.mm && !params.yyyy) || (params.dd && !params.mm)) { @@ -411,7 +65,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { const month = Number.isFinite(mParam) && mParam >= 0 && mParam <= 11 ? mParam : today.getMonth(); const yearMap = - rite === '1962' ? await getYear1962(lang, year) : await getYear(lang, year); + rite === '1962' + ? await getYear1962(lang, diocese1962, year) + : await getYear(lang, diocese1969, year); const daysInMonth = new Date(year, month + 1, 0).getDate(); const monthDays: CalendarDay[] = []; for (let d = 1; d <= daysInMonth; d++) { @@ -425,6 +81,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { name: '', rankName: '', rank: 'WEEKDAY', + seasonKey: null, seasonNames: [], colorNames: [], colorKeys: ['GREEN'], @@ -437,8 +94,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { const todayIso = today.toISOString().slice(0, 10); const todayYearMap = rite === '1962' - ? await getYear1962(lang, today.getFullYear()) - : await getYear(lang, today.getFullYear()); + ? await getYear1962(lang, diocese1962, today.getFullYear()) + : await getYear(lang, diocese1969, today.getFullYear()); const todayEntry = todayYearMap.get(todayIso) ?? null; let selectedIso: string; @@ -458,16 +115,70 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { : selectedYear === today.getFullYear() ? todayYearMap : rite === '1962' - ? await getYear1962(lang, selectedYear) - : await getYear(lang, selectedYear); + ? await getYear1962(lang, diocese1962, selectedYear) + : await getYear(lang, diocese1969, selectedYear); const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0]; + // --- Year overview data for the ring / month-grid views --- + const sortedYear = [...yearMap.values()].sort((a, b) => a.iso.localeCompare(b.iso)); + + // Romcal leaves `season` undefined on sanctoral-principal days (e.g. Christmas, + // Epiphany, Circumcision) even when they fall inside a temporal season, which + // would otherwise break the ring arcs with gaps. Fill nulls from the next + // non-null day (Christmas Vigil → ChristmasTide, Epiphany → EpiphanyTide, …), + // then forward-fill any trailing nulls at end-of-year. + const filledSeasons: (string | null)[] = sortedYear.map((d) => d.seasonKey); + for (let i = filledSeasons.length - 1; i >= 0; i--) { + if (filledSeasons[i] == null && i + 1 < filledSeasons.length) { + filledSeasons[i] = filledSeasons[i + 1]; + } + } + for (let i = 1; i < filledSeasons.length; i++) { + if (filledSeasons[i] == null) filledSeasons[i] = filledSeasons[i - 1]; + } + + const yearDays: YearDay[] = sortedYear.map((d, i) => ({ + iso: d.iso, + name: d.name, + rank: d.rank, + color: d.colorKeys[0] ?? 'GREEN', + seasonKey: filledSeasons[i] + })); + + const seasonArcs: SeasonArc[] = []; + let cur: SeasonArc | null = null; + for (let i = 0; i < sortedYear.length; i++) { + const d = sortedYear[i]; + const key = filledSeasons[i]; + const name = + key && key !== d.seasonKey + ? (rite === '1962' ? season1962Label(key, lang) : key) + : d.seasonNames[0] ?? key ?? ''; + if (!key) { + if (cur) { + seasonArcs.push(cur); + cur = null; + } + continue; + } + if (cur && cur.key === key) { + cur.end = d.iso; + } else { + if (cur) seasonArcs.push(cur); + cur = { key, name, start: d.iso, end: d.iso, color: seasonColorFor(key) }; + } + } + if (cur) seasonArcs.push(cur); + return { rite, + diocese: rite === '1962' ? diocese1962 : diocese1969, wip: false, year, month, monthDays, + yearDays, + seasonArcs, today: todayEntry, todayIso, selected: selectedEntry, diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte index eaf645d3..22130c8b 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte @@ -1,6 +1,8 @@ @@ -109,6 +179,17 @@ 1962 + + {#if rite === '1969'} +

{t('rite1969SwissNote', lang)}

+ {/if} {#if wip} @@ -127,37 +208,108 @@ {/if} - {#if today} - {@const todayHex = hexFor(today.colorKeys)} -
-
- {t('today', lang)} - {formatLongDate(today.iso, lang)} -
-

{today.name}

- -
+ + + {/if} + +
+
+ + +
+
+ {#each Object.keys(LIT_COLOR_VAR) as key (key)} + {@const label = + lang === 'de' + ? { WHITE: 'Weiß', RED: 'Rot', GREEN: 'Grün', PURPLE: 'Violett', ROSE: 'Rosa', BLACK: 'Schwarz', GOLD: 'Gold' }[key] + : lang === 'la' + ? { WHITE: 'Albus', RED: 'Ruber', GREEN: 'Viridis', PURPLE: 'Violaceus', ROSE: 'Rosaceus', BLACK: 'Niger', GOLD: 'Aureus' }[key] + : { WHITE: 'White', RED: 'Red', GREEN: 'Green', PURPLE: 'Violet', ROSE: 'Rose', BLACK: 'Black', GOLD: 'Gold' }[key]} + + + {label} + + {/each} +
+
+ + {#if view === 'ring'} +
+ +
+ {:else} @@ -201,21 +356,27 @@ {@const isToday = day.iso === todayIso} {@const isSelected = day.iso === selectedIso} {@const rank = rankEmphasis(day.rank)} - {@const dayHex = hexFor(day.colorKeys)} + {@const hasFeast = rank >= 2 || (rank === 1 && !!day.name)} + {@const fillColor = day.colorKeys[0]} + {@const rankNum = rankRoman(rank)} {Number(day.iso.slice(8, 10))} - + {#if isToday} + + {:else if rankNum} + + {/if} {#if day.name} {day.name} {/if} @@ -223,165 +384,32 @@ {/each} - - {#if selected} - {@const selectedHex = hexFor(selected.colorKeys)} -
-
- {formatLongDate(selected.iso, lang)} - {#if selected.rankName} - {selected.rankName} - {/if} -
-

{selected.name}

-
- {#if selected.seasonNames.length} - {firstOr(selected.seasonNames)} - {/if} - {#if selected.colorNames.length} - - - {firstOr(selected.colorNames)} - - {/if} - {#if selected.psalterWeek} - {t('psalterWeek', lang)}: {humanizePsalterWeek(selected.psalterWeek, lang)} - {/if} - {#if selected.sundayCycle} - {t('cycle', lang)}: {humanizeSundayCycle(selected.sundayCycle)} - {/if} -
- {#if selected.rite1962} - {@const d = selected.rite1962} -
-
-
{t1962('source', lang)}
-
{d.kind}{d.properSource ? ` · ${d.properSource}` : ''}{d.communeSlug ? ` (${d.communeSlug})` : ''}
-
- {#if d.vigilOf} -
-
{t1962('vigilOf', lang)}
-
{d.vigilOf}
-
- {/if} - {#if d.octave} -
-
{t1962('octave', lang)}
-
{d.octave.id} · {t1962('octaveDay', lang)} {d.octave.day} · {d.octave.rank}
-
- {/if} - {#if d.transferredFrom} -
-
{t1962('transferredFrom', lang)}
-
{d.transferredFrom}
-
- {/if} -
-
-

{t1962('rubrics', lang)}

-
- {t1962('gloria', lang)}: {d.rubrics.gloria ? t1962('yes', lang) : t1962('no', lang)} - {t1962('credo', lang)}: {d.rubrics.credo ? t1962('yes', lang) : t1962('no', lang)} - {#if d.rubrics.preface} - {t1962('preface', lang)}: {d.rubrics.preface} - {/if} - {#if d.rubrics.lastGospel} - {t1962('lastGospel', lang)}: {d.rubrics.lastGospel} - {/if} - {#if d.rubrics.ite} - {t1962('ite', lang)}: {d.rubrics.ite} - {/if} -
-
- {#if d.commemorations.length} -
-

{t1962('commemorations', lang)}

-
    - {#each d.commemorations as c (c.key)} - {@const cHex = hexFor(c.colorKeys)} -
  • - - {c.name} - {c.rankName} -
  • - {/each} -
-
- {/if} - {#if d.propers.length} -
-

{t1962('propers', lang)}

- {#each d.propers as section (section.key)} -
-
- {properLabel(section.key, lang)} -
- {#each section.segments as seg, segIdx (segIdx)} -
- {#if seg.refs && seg.refs.length} -
- {#each seg.refs as r (r)} - {r} - {/each} -
- {/if} - {#if seg.la || seg.local} -
- {#if lang !== 'la' && seg.local && seg.fromBible} -

{t1962('bibleFallbackNote', lang)}

- {/if} - {#if seg.la} -
{seg.la}
- {/if} - {#if lang !== 'la' && seg.local} -
{seg.local}
- {/if} -
- {/if} -
- {/each} -
- {/each} -
- {/if} - {#if d.extraSections.length} -
-

{t1962('extraSections', lang)}

- {#each d.extraSections as section (section.key)} -
-
- {properLabel(section.key, lang)} -
- {#each section.segments as seg, segIdx (segIdx)} -
- {#if seg.refs && seg.refs.length} -
- {#each seg.refs as r (r)} - {r} - {/each} -
- {/if} -
-
{seg.la}
- {#if lang !== 'la' && seg.local} -
{seg.local}
- {/if} -
-
- {/each} -
- {/each} -
- {/if} - {/if} -
{/if} + {/if} diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte new file mode 100644 index 00000000..3a6f84c8 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte @@ -0,0 +1,574 @@ + + +
+
+ + {#each resolvedArcs as s (`${s.key}:${s.start}`)} + {@const lbl = labelFor(s)} + {@const isCurrent = s.key === currentSeasonKey && highlightToday} + {@const isSelected = (activeKey ?? currentSeasonKey) === s.key} + pickSeason(s.key)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + pickSeason(s.key); + } + }} + > + {#if isCurrent} + + {/if} + + {#if lbl.text} + + + + {lbl.text} + + + {/if} + + {/each} + + {#each monthDoys as doy, i (i)} + {@const a = angleFromDoy(doy)} + {@const x1 = cx + (rOuter + 4) * Math.cos(a)} + {@const y1 = cy + (rOuter + 4) * Math.sin(a)} + {@const x2 = cx + (rOuter + 14) * Math.cos(a)} + {@const y2 = cy + (rOuter + 14) * Math.sin(a)} + {@const lx = cx + (rOuter + 26) * Math.cos(a + 0.08)} + {@const ly = cy + (rOuter + 26) * Math.sin(a + 0.08)} + + + + {monthLabels[i]} + + + {/each} + + {#each feastDots as f (f.iso + f.name)} + {@const a = angleFromDoy(dayOfYear(f.iso))} + {@const x = cx + rFeasts * Math.cos(a)} + {@const y = cy + rFeasts * Math.sin(a)} + + + {fmtShort(f.iso)} · {f.name} + + + {/each} + + {#if needleIso !== null && highlightToday} + + + + + {/if} + + {T.anno} + {yearRoman} + {year} · {T.centerSub} + +
+ + {#if active} +
+ {/if} +
+ + diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.server.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.server.ts new file mode 100644 index 00000000..1aeec965 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.server.ts @@ -0,0 +1,69 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { + DEFAULT_DIOCESE_1962, + DEFAULT_DIOCESE_1969, + expectedSlug, + isDiocese1962, + isDiocese1969, + type CalendarLang, + type Diocese1962, + type Diocese1969, + type Rite +} from '../../../../../calendarI18n'; +import { getYear, getYear1962, isoFor } from '$lib/server/liturgicalCalendar'; + +export const load: PageServerLoad = async ({ params, url, locals }) => { + const slug = expectedSlug(params.faithLang); + if (slug === null) throw error(404, 'Not found'); + if (params.calendar !== slug) { + throw redirect(307, `/${params.faithLang}/${slug}`); + } + + const lang: CalendarLang = + params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de'; + + const rite: Rite = params.rite === '1969' ? '1969' : '1962'; + + const dioceseParam = url.searchParams.get('diocese'); + const diocese1962: Diocese1962 = isDiocese1962(dioceseParam) + ? dioceseParam + : DEFAULT_DIOCESE_1962; + const diocese1969: Diocese1969 = isDiocese1969(dioceseParam) + ? dioceseParam + : DEFAULT_DIOCESE_1969; + + const minYear = rite === '1962' ? 1900 : 1969; + const year = Number(params.yyyy); + const month = Number(params.mm) - 1; + const day = Number(params.dd); + + if (!Number.isFinite(year) || year < minYear || year > 2100) throw error(404, 'Not found'); + if (!Number.isFinite(month) || month < 0 || month > 11) throw error(404, 'Not found'); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + if (!Number.isFinite(day) || day < 1 || day > daysInMonth) throw error(404, 'Not found'); + + const iso = isoFor(year, month, day); + const yearMap = + rite === '1962' + ? await getYear1962(lang, diocese1962, year) + : await getYear(lang, diocese1969, year); + const entry = yearMap.get(iso); + if (!entry) throw error(404, 'Not found'); + + const today = new Date(); + const todayIso = today.toISOString().slice(0, 10); + + return { + lang, + rite, + diocese: rite === '1962' ? diocese1962 : diocese1969, + year, + month, + day, + iso, + todayIso, + day1: entry, + session: locals.session ?? (await locals.auth()) + }; +}; diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte new file mode 100644 index 00000000..0778a370 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte @@ -0,0 +1,581 @@ + + + + {day.name} — {formatLongDate(iso, lang)} + + + +
+ + +
+
+ {formatLongDate(iso, lang)} + {#if isToday} + {t('today', lang)} + {/if} +
+

{day.name}

+
+ {#if day.rankName} + {day.rankName} + {/if} + {#if day.seasonNames.length} + {day.seasonNames[0]} + {/if} + {#if day.colorNames.length} + + + {day.colorNames[0]} + + {/if} + {#if day.psalterWeek} + {t('psalterWeek', lang)}: {humanizePsalterWeek(day.psalterWeek, lang)} + {/if} + {#if day.sundayCycle} + {t('cycle', lang)}: {humanizeSundayCycle(day.sundayCycle)} + {/if} +
+
+ + {#if day.rite1962} + {@const d = day.rite1962} +
+
+
+
{t1962('source', lang)}
+
{d.kind}{d.properSource ? ` · ${d.properSource}` : ''}{d.communeSlug ? ` (${d.communeSlug})` : ''}
+
+ {#if d.vigilOf} +
+
{t1962('vigilOf', lang)}
+
{d.vigilOf}
+
+ {/if} + {#if d.octave} +
+
{t1962('octave', lang)}
+
{d.octave.id} · {t1962('octaveDay', lang)} {d.octave.day} · {d.octave.rank}
+
+ {/if} + {#if d.transferredFrom} +
+
{t1962('transferredFrom', lang)}
+
{d.transferredFrom}
+
+ {/if} +
+
+

{t1962('rubrics', lang)}

+
+ {t1962('gloria', lang)}: {d.rubrics.gloria ? t1962('yes', lang) : t1962('no', lang)} + {t1962('credo', lang)}: {d.rubrics.credo ? t1962('yes', lang) : t1962('no', lang)} + {#if d.rubrics.preface} + {t1962('preface', lang)}: {d.rubrics.preface} + {/if} + {#if d.rubrics.lastGospel} + {t1962('lastGospel', lang)}: {d.rubrics.lastGospel} + {/if} + {#if d.rubrics.ite} + {t1962('ite', lang)}: {d.rubrics.ite} + {/if} +
+
+ {#if d.commemorations.length} +
+

{t1962('commemorations', lang)}

+
    + {#each d.commemorations as c (c.key)} + {@const cHex = hexFor(c.colorKeys)} +
  • + + {c.name} + {c.rankName} +
  • + {/each} +
+
+ {/if} + {#if d.propers.length} +
+

{t1962('propers', lang)}

+ {#each d.propers as section (section.key)} +
+
+ {properLabel(section.key, lang)} +
+ {#each section.segments as seg, segIdx (segIdx)} +
+ {#if seg.refs && seg.refs.length} +
+ {#each seg.refs as r (r)} + {r} + {/each} +
+ {/if} + {#if seg.la || seg.local} +
+ {#if lang !== 'la' && seg.local && seg.fromBible} +

{t1962('bibleFallbackNote', lang)}

+ {/if} + {#if seg.la} +
{seg.la}
+ {/if} + {#if lang !== 'la' && seg.local} +
{seg.local}
+ {/if} +
+ {/if} +
+ {/each} +
+ {/each} +
+ {/if} + {#if d.extraSections.length} +
+

{t1962('extraSections', lang)}

+ {#each d.extraSections as section (section.key)} +
+
+ {properLabel(section.key, lang)} +
+ {#each section.segments as seg, segIdx (segIdx)} +
+ {#if seg.refs && seg.refs.length} +
+ {#each seg.refs as r (r)} + {r} + {/each} +
+ {/if} +
+
{seg.la}
+ {#if lang !== 'la' && seg.local} +
{seg.local}
+ {/if} +
+
+ {/each} +
+ {/each} +
+ {/if} +
+ {/if} + + +
+ + diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts new file mode 100644 index 00000000..5bd19f20 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts @@ -0,0 +1,72 @@ +// Liturgical color tokens used by the overview design. +// Maps romcal color keys to the CSS variables defined on the calendar page. + +export const LIT_COLOR_VAR: Record = { + WHITE: '--lit-white', + RED: '--lit-red', + GREEN: '--lit-green', + PURPLE: '--lit-violet', + ROSE: '--lit-rose', + BLACK: '--lit-black', + GOLD: '--lit-gold' +}; + +export const LIT_INK_VAR: Record = { + WHITE: '--lit-white-ink', + RED: '--lit-red-ink', + GREEN: '--lit-green-ink', + PURPLE: '--lit-violet-ink', + ROSE: '--lit-rose-ink', + BLACK: '--lit-black-ink', + GOLD: '--lit-gold-ink' +}; + +export function litBg(colorKey: string | undefined): string { + const v = colorKey ? LIT_COLOR_VAR[colorKey] : undefined; + return v ? `var(${v})` : 'var(--color-bg-primary)'; +} + +export function litInk(colorKey: string | undefined): string { + const v = colorKey ? LIT_INK_VAR[colorKey] : undefined; + return v ? `var(${v})` : 'var(--color-text-primary)'; +} + +// Default color per liturgical season. Used to paint ring arcs even when the +// first day of a season falls on a feast of a different color. +const SEASON_COLOR_MAP: Record = { + // 1962 seasons + Advent: 'PURPLE', + ChristmasTide: 'WHITE', + EpiphanyTide: 'GREEN', + Septuagesima: 'PURPLE', + Lent: 'PURPLE', + Passiontide: 'PURPLE', + HolyWeek: 'PURPLE', + EasterWeek: 'WHITE', + Paschaltide: 'WHITE', + AscensionTide: 'WHITE', + Pentecost: 'RED', + TimeAfterPentecost: 'GREEN', + // 1969 seasons (variant keys) + ADVENT: 'PURPLE', + CHRISTMAS_TIME: 'WHITE', + ORDINARY_TIME: 'GREEN', + LENT: 'PURPLE', + PASCHAL_TRIDUUM: 'RED', + EASTER_TIME: 'WHITE' +}; + +export function seasonColorFor(seasonKey: string | undefined, fallback = 'GREEN'): string { + if (!seasonKey) return fallback; + return SEASON_COLOR_MAP[seasonKey] ?? fallback; +} + +// Dot size in the ring scales with rank. Accepts both 1962 class labels and +// 1969 rank keys. +export function rankDotSize(rank: string): number { + if (rank === 'ClassI' || rank === 'SOLEMNITY') return 5; + if (rank === 'ClassII' || rank === 'FEAST' || rank === 'SUNDAY' || rank === 'HOLY_DAY_OF_OBLIGATION') + return 4; + if (rank === 'ClassIII' || rank === 'MEMORIAL') return 3; + return 0; // don't render ferias/weekdays as dots +} diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts index 463ddc82..d607ba45 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts @@ -118,9 +118,19 @@ export const ui = { la: 'Accuratio adhuc probanda' }, rite1962DisclaimerBody: { - en: 'The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Local proper calendars (diocese, religious order, national feasts) are not yet applied — only the general Roman calendar is rendered.', - de: 'Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Eigenkalender (Diözese, Ordensgemeinschaft, Landesfeste) sind noch nicht berücksichtigt — dargestellt wird nur der allgemeine römische Kalender.', - la: 'Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Calendaria propria localia (dioecesis, ordinis religiosi, festa nationalia) nondum adhibentur — tantum calendarium Romanum generale ostenditur.' + en: 'The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Only the Swiss diocesan propers shipped by romcal are applied; other national/local calendars are not yet available.', + de: 'Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Nur die in romcal enthaltenen Schweizer Diözesankalender werden angewendet; weitere Landes- oder Ortskalender sind noch nicht verfügbar.', + la: 'Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Tantum calendaria propria dioecesium Helvetiae a romcal provisa adhibentur; cetera calendaria nationalia vel localia nondum praesto sunt.' + }, + calendarVariant: { + en: 'Calendar', + de: 'Kalender', + la: 'Calendarium' + }, + rite1969SwissNote: { + en: 'romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.', + de: 'romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.', + la: 'Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.' } }; @@ -133,6 +143,81 @@ export function isValidRite(v: string | null): v is Rite { return v === '1969' || v === '1962'; } +// --- Diocese selection --- +// 1962 rite: 7 Swiss dioceses plus the national calendar (all shipped by romcal/1962). +// 1969 rite: romcal only ships a single national Switzerland bundle, so the dropdown +// collapses to "General Roman" or "Switzerland" — diocese sub-choices all resolve to +// the same national bundle and we flag that in the UI. +export type Diocese1962 = + | 'general' + | 'switzerland' + | 'basel' + | 'chur' + | 'lausanne-geneva-fribourg' + | 'lugano' + | 'saint-maurice-abbey' + | 'sankt-gallen' + | 'sion'; + +export type Diocese1969 = 'general' | 'switzerland'; + +export const DIOCESES_1962: Diocese1962[] = [ + 'general', + 'switzerland', + 'basel', + 'chur', + 'lausanne-geneva-fribourg', + 'lugano', + 'saint-maurice-abbey', + 'sankt-gallen', + 'sion' +]; + +export const DIOCESES_1969: Diocese1969[] = ['general', 'switzerland']; + +export const DEFAULT_DIOCESE_1962: Diocese1962 = 'chur'; +export const DEFAULT_DIOCESE_1969: Diocese1969 = 'general'; + +const DIOCESE_LABEL: Record> = { + general: { + en: 'General Roman', + de: 'Allgemeiner römischer Kalender', + la: 'Calendarium Romanum Generale' + }, + switzerland: { + en: 'Switzerland (national)', + de: 'Schweiz (national)', + la: 'Helvetia (nationalis)' + }, + basel: { en: 'Basel', de: 'Basel', la: 'Basilea' }, + chur: { en: 'Chur', de: 'Chur', la: 'Curia' }, + 'lausanne-geneva-fribourg': { + en: 'Lausanne, Geneva and Fribourg', + de: 'Lausanne, Genf und Freiburg', + la: 'Lausanna, Genavensis et Friburgensis' + }, + lugano: { en: 'Lugano', de: 'Lugano', la: 'Luganensis' }, + 'saint-maurice-abbey': { + en: 'Saint-Maurice Abbey', + de: 'Abtei Saint-Maurice', + la: 'Abbatia S. Mauritii' + }, + 'sankt-gallen': { en: 'St. Gallen', de: 'St. Gallen', la: 'Sancti Galli' }, + sion: { en: 'Sion', de: 'Sitten', la: 'Sedunensis' } +}; + +export function dioceseLabel(id: string, lang: CalendarLang): string { + return DIOCESE_LABEL[id]?.[lang] ?? id; +} + +export function isDiocese1962(v: string | null | undefined): v is Diocese1962 { + return !!v && (DIOCESES_1962 as string[]).includes(v); +} + +export function isDiocese1969(v: string | null | undefined): v is Diocese1969 { + return !!v && (DIOCESES_1969 as string[]).includes(v); +} + // --- 1962 localization helpers --- // 1962 calendar data is Latin-only at source (feast names stay Latin — // they are canonical). UI chrome (rank, season, color) is localized here. @@ -159,6 +244,7 @@ const SEASON_LABEL: Record> = { HolyWeek: { en: 'Holy Week', de: 'Karwoche', la: 'Hebdomada Sancta' }, EasterWeek: { en: 'Easter Week', de: 'Osteroktav', la: 'Hebdomada Paschae' }, Paschaltide: { en: 'Eastertide', de: 'Osterzeit', la: 'Tempus Paschale' }, + Pentecost: { en: 'Pentecost Week', de: 'Pfingstoktav', la: 'Hebdomada Pentecostes' }, TimeAfterPentecost: { en: 'after Pentecost', de: 'nach Pfingsten', la: 'post Pentecosten' } }; diff --git a/svelte.config.js b/svelte.config.js index 25284dba..5ad82c43 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -16,7 +16,12 @@ const config = { alias: { $models: 'src/models', $utils: 'src/utils', - $types: 'src/types' + $types: 'src/types', + // romcal ships the Swiss 1969 bundle inside its workspace dir but does not + // re-export it, so exports-field resolution blocks a direct import. Point + // the scoped package name at the bundle directory so both the TS types + // (index.d.ts) and the ESM entry (esm/index.js) resolve via its package.json. + '@romcal/calendar.switzerland': 'node_modules/romcal/rites/roman1969/dist/bundles/switzerland' } } };