Learn about Liturgical Seasons
The Church year is divided into seasons — Advent, Christmas, Lent, Easter, and Ordinary Time. Each has its own color, focus, and mood. Green = growth. Purple = preparation. White/Gold = celebration. Learn more → dismiss
Saint of the Day
Loading saint of the day.
Tap to learn more →
Today's Gospel
Loading today's Gospel.
Read full readings & reflection
Every day the Church reads from Scripture together — this is called the Liturgy of the Word. The readings follow a 3-year cycle covering most of the Bible. Sunday Mass has three readings; weekdays have two. See today's readings → dismiss
All episodes ↗
Browse all
See all
Guide to Mass
What to expect, step by step
Read the Gospels
Matthew, Mark, Luke & John
Prayers
Classic Catholic prayers
Prayer Requests
Share & pray for one another
Learning Paths
Guided journeys for your stage
Find a Parish
Catholic churches near you
The Sacraments
All 7 explained plainly
Authors
Converts and scholars who made the case
Eucharistic Miracles
The science that stunned skeptics
Communion of Saints
The Body of Christ across death
Catholic Terms
Plain-English glossary
Daily Readings
Sat · May 3
Verse of the Day
"Ask and it will be given to you; seek and you will find; knock and the door will be opened to you."
Matthew 7:7
Loading today's readings.
←
Search
Try “rosary”, “confession”, a saint's name, or “dark mode”.
All
Doubt & Struggle
Mass
Sacraments
Prayer
Holiness
Mary
History
Faith
←
Quick Questions
📍 Mass times near you ↗
🏛️ Find a Catholic parish ↗
Your first time?
What Happens at Mass
A friendly walkthrough so nothing catches you off guard
The Mass has kept people present for 2,000 years. Between standing, kneeling, singing, responding, and watching — your whole body is engaged, not just your ears. There might be a reason for that.
Your library of good things — podcasts, channels, playlists, sounds. Curated, no feeds, no rabbit holes.
Books
Early Church writings — the Didache, 1 Clement & more
›
My Playlists
Custom episode queues from any show
›
Podcasts
Curated shows · play the next episode
›
YouTube
Your channels · one video at a time, no rabbit hole
›
Background Sounds
Chant, ocean, rain & more — layer a mix under prayer or reading
›
History
Everything you've listened to or watched recently
›
←
Support Haven
Free for everyone
Help keep the lights on
Haven has no subscription and never will — nothing should stand between anyone and the basics of the faith. If Haven has been part of your walk and you'd like to help cover the servers and keep new features coming, a small gift does exactly that.
Support Haven's development →
Can't give right now? You already help by being here. Share Haven with someone who's curious, and say a prayer for the people who use it.
Begin Your Journey
Learning Paths
Guided routes for newcomers, Protestant explorers, and returning Catholics.
Choose your path →
⌄
Prayer Requests
Share a request & pray for the community
›
The Mass
What happens and what it means
›
Find a Parish
Catholic churches near you
›
⌄
Authors
Chesterton, Hahn, Barron, Fr. Mike & more
›
Church Fathers & Doctors
Augustine, Aquinas & the great teachers of the faith
›
⌄
Find Your Path
Get a starting point tailored to where you are
›
Prayers
12 classic Catholic prayers with full text
›
Prayer Vault
Your saved prayers, kept close
›
Journal
A few lines a day, kept on this device
›
Random Passage
Close your eyes and flip to a page
›
Catholic Terms
Plain-English glossary
›
Liturgical Year
Seasons, colors, and what they mean
›
Saints Library
312 saints · Roman, Eastern & Orthodox
›
⌄
The 7 Sacraments
What each one is and who it's for
›
The Rosary
How to pray it and why it matters
›
Eucharistic Miracles
Lanciano, Buenos Aires, Tixtla — the evidence
›
⌄
Settings & About
Theme, display, newbie mode, app info
›
Send Feedback
A suggestion, feature request, or question
›
←
Prayers

Jesus told his disciples to pray always — not as a ritual performance, but as a conversation with a Father who already knows what you need before you ask.

These structured prayers are not substitutes for that direct conversation. They are guides — words worn smooth by centuries of souls who prayed them in grief, in gratitude, in darkness, and in joy. Let them train your heart, then let your heart speak freely.

Pray the Rosary
Guided, step by step — today's mysteries →
What is the Rosary? →
Stations of the Cross
Walk the way of the Cross, station by station →
Divine Mercy Chaplet
Pray the Chaplet, guided step by step →
Ready to pray it?
Try Hallow
Hallow is a Catholic prayer app with guided Rosaries, Examen, and sleep content — featuring Fr. Mike Schmitz, Bishop Barron, and others. Haven explains the prayers. Hallow walks you through them daily.
Learn about Hallow →
←
Prayer Vault

Your saved prayers, kept close. Tap any prayer to read it; tap the ★ to remove it from your vault.

←
Journal
Today
Past entries
Verses and paragraphs you've saved from the Gospels and Books — write a reflection on any of them, any time.
←
Prayer Requests

Share what's on your heart, and let this community lift it up in prayer. Post by name or anonymously.

Requests are reviewed before they appear, then shared publicly with other Haven users. Please don't include sensitive personal details.
Loading requests…
←
Send Feedback

Have a suggestion, want a new feature, or curious to learn more about something? Tell us — we read every note.

←
Catholic Terms
←
Liturgical Year
←
Learning Paths
←
Path
←
Authors

Most didn't start Catholic — scholars, pastors, and skeptics who followed their questions and found them leading to Rome.

←
Author
←
The 7 Sacraments
←
Eucharistic Miracles
Physical Evidence
The Science Behind the Sacrament
Across twelve centuries and three continents, independent scientific analysis of claimed Eucharistic miracles has returned the same findings: human cardiac tissue, blood type AB, signs of extreme trauma — and no natural explanation for why none of it has decayed. These aren't folk legends. They're peer-reviewed findings.
←
Install Haven
←
Settings
Dark Mode
Navy
Warm
Slate
Scheme applies in dark mode · Accent color follows the liturgical season
Newbie Mode
Shows explainer hints throughout the app
New to Catholicism? Turn this on to see context for unfamiliar terms and practices.
Daily readings reminder
A gentle daily nudge to read today's Gospel
A browser notification each morning. You can turn it off any time.
Voice
The voice the “Listen” buttons use to read aloud. Tap Preview to hear it.
Response voice
The second voice for the congregation’s parts — the psalm refrain and the Alleluia in “Listen to all readings.”
Microsoft Edge voices stream online and cache after the first play. Kokoro voices run on your device (about 90MB download the first time) and work offline once loaded. If synthesis fails, Haven falls back to your device's built-in voice.
Serif
Sans-Serif
Serif uses EB Garamond for a liturgical feel · Sans-Serif uses Inter throughout for easier reading
Install Haven
Add to your Home Screen for quick access & offline use
›
Haven

Haven helps you follow Jesus more closely through the richness of the Catholic tradition. For newcomers, returning Catholics, and anyone curious about faith — most content is available without an account.

Content reflects authentic Catholic teaching, written to be honest and accessible — not to pressure, but to invite.

Version 1.0 · Haven Faith Companion

←
Saints Library
←
Podcasts
Hand-picked teaching shows for wherever you are on the journey. No feed, no rabbit hole — just press play.
←
Starred Episodes
←
History
←
Background Sounds
←
←
My Playlists
←
Playlist
←
YouTube
Your channels, one video at a time. No search, no recommendations — just press play.
←
←
The Gospels
Loading…
←
Random Passage
←
Prayer
←
Fathers & Doctors
←
←
Books
Public-domain writings of the early Church — each with a TL;DR guide, free to read or listen with your device's built-in voice.
Loading…
←
Loading…
), then _esc encodes the // result for the HTML attribute. The browser decodes entities, then sees a // proper JSON string literal — the original value arrives intact. // Use as onclick="f(${_jsArg(x)})" — no surrounding quotes. function _jsArg(v) { return _esc(JSON.stringify(String(v))); } function _annotateTerms(html) { var terms = Object.keys(GLOSSARY).map(function(k) { return { key: k, term: GLOSSARY[k].term }; }).sort(function(a, b) { return b.term.length - a.term.length; }); var parts = html.split(/(<[^>]*>)/g); var used = {}; var insideAnchor = 0; for (var i = 0; i < parts.length; i++) { if (i % 2 === 1) { if (/^]/i.test(parts[i])) insideAnchor++; else if (/^<\/a>/i.test(parts[i])) insideAnchor = Math.max(0, insideAnchor - 1); continue; } if (insideAnchor > 0) continue; terms.forEach(function(t) { if (used[t.key]) return; var escaped = t.term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); var rx = new RegExp('\\b' + escaped + '\\b', 'i'); if (rx.test(parts[i])) { parts[i] = parts[i].replace(rx, `$&`); used[t.key] = true; } }); } return parts.join(''); } const BIBLE_BOOKS = { 'genesis':'GEN','gen':'GEN','gn':'GEN','exodus':'EXO','exod':'EXO','exo':'EXO','ex':'EXO','leviticus':'LEV','lev':'LEV','lv':'LEV','numbers':'NUM','num':'NUM','nm':'NUM','deuteronomy':'DEU','deut':'DEU','dt':'DEU', 'joshua':'JOS','josh':'JOS','judges':'JDG','judg':'JDG','jgs':'JDG','ruth':'RUT','1 samuel':'1SA','1 sam':'1SA','2 samuel':'2SA','2 sam':'2SA','1 kings':'1KI','1 kgs':'1KI','2 kings':'2KI','2 kgs':'2KI','1 chronicles':'1CH','1 chr':'1CH','2 chronicles':'2CH','2 chr':'2CH','ezra':'EZR','neh':'NEH','nehemiah':'NEH','tobit':'TOB','tob':'TOB','tb':'TOB','judith':'JDT','jdt':'JDT','esther':'EST','est':'EST','1 maccabees':'1MA','1 macc':'1MA','1 mc':'1MA','2 maccabees':'2MA','2 macc':'2MA','2 mc':'2MA','job':'JOB','jb':'JOB','psalm':'PSA','psalms':'PSA','ps':'PSA','proverbs':'PRO','prov':'PRO','prv':'PRO','ecclesiastes':'ECC','eccl':'ECC','song of songs':'SNG','sg':'SNG','wisdom':'WIS','wis':'WIS','sirach':'SIR','sir':'SIR','isaiah':'ISA','isa':'ISA','jeremiah':'JER','jer':'JER','lamentations':'LAM','lam':'LAM','baruch':'BAR','bar':'BAR','ezekiel':'EZK','ezek':'EZK','ez':'EZK','daniel':'DAN','dan':'DAN','dn':'DAN','hosea':'HOS','hos':'HOS','joel':'JOL','jl':'JOL','amos':'AMO','am':'AMO','obadiah':'OBA','ob':'OBA','jonah':'JON','jon':'JON','micah':'MIC','mi':'MIC','nahum':'NAM','na':'NAM','habakkuk':'HAB','hb':'HAB','zephaniah':'ZEP','zep':'ZEP','haggai':'HAG','hg':'HAG','zechariah':'ZEC','zec':'ZEC','malachi':'MAL','mal':'MAL', 'matthew':'MAT','matt':'MAT','mt':'MAT','mark':'MRK','mk':'MRK','luke':'LUK','lk':'LUK','john':'JHN','jn':'JHN','acts':'ACT','romans':'ROM','rom':'ROM','1 corinthians':'1CO','1 cor':'1CO','2 corinthians':'2CO','2 cor':'2CO','galatians':'GAL','gal':'GAL','ephesians':'EPH','eph':'EPH','philippians':'PHP','phil':'PHP','colossians':'COL','col':'COL','1 thessalonians':'1TH','1 thes':'1TH','2 thessalonians':'2TH','2 thes':'2TH','1 timothy':'1TI','1 tim':'1TI','1 tm':'1TI','2 timothy':'2TI','2 tim':'2TI','2 tm':'2TI','titus':'TIT','ti':'TIT','philemon':'PHM','phlm':'PHM','hebrews':'HEB','heb':'HEB','james':'JAS','jas':'JAS','1 peter':'1PE','1 pet':'1PE','1 pt':'1PE','2 peter':'2PE','2 pet':'2PE','2 pt':'2PE','1 john':'1JN','1 jn':'1JN','2 john':'2JN','2 jn':'2JN','3 john':'3JN','3 jn':'3JN','jude':'JUD','revelation':'REV','rev':'REV','rv':'REV' }; const BIBLE_BOOK_RE = Object.keys(BIBLE_BOOKS).sort((a,b) => b.length - a.length).map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); const BIBLE_REF_RE = new RegExp('\\b(' + BIBLE_BOOK_RE + ')\\s+(\\d{1,3}(?:[–-]\\d{1,3})?)(?::(\\d{1,3}[a-z]?(?:[–-]\\d{1,3}[a-z]?)?(?:,\\s*\\d{1,3}[a-z]?(?:[–-]\\d{1,3}[a-z]?)?)*))?\\b', 'gi'); function _youVersionRefUrl(book, chapter, verse) { const code = BIBLE_BOOKS[String(book).toLowerCase().replace(/\s+/g, ' ')]; if (!code) return null; const cleanChapter = String(chapter ?? '').replace(/–/g, '-'); const firstVerse = verse ? String(verse).split(',')[0].replace(/[a-z]/gi, '').replace(/–/g, '-') : ''; const cleanVerse = firstVerse ? '.' + firstVerse : ''; return `https://www.bible.com/bible/463/${code}.${cleanChapter}${cleanVerse}.NABRE`; } function _scriptureRefLink(match, book, chapter, verse) { if (!/[A-Z]/.test(String(book))) return _esc(match); const url = _youVersionRefUrl(book, chapter, verse); return url ? `${_esc(match)}` : _esc(match); } function _linkScriptureRefsText(text) { if (text == null) return ''; const raw = String(text ?? ''); let out = '', last = 0; raw.replace(BIBLE_REF_RE, (match, book, chapter, verse, offset) => { out += _esc(raw.slice(last, offset)); out += _scriptureRefLink(match, book, chapter, verse); last = offset + match.length; }); return out + _esc(raw.slice(last)); } function _linkScriptureRefsInElement(root) { if (!root) return; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach(node => { if (node.parentElement && node.parentElement.closest('a')) return; const html = _linkScriptureRefsText(node.nodeValue); if (!html || html === _esc(node.nodeValue)) return; const span = document.createElement('span'); span.innerHTML = html; node.replaceWith(...span.childNodes); }); } function _safeUsccbLink(rawLink) { const fallback = 'https://bible.usccb.org/daily-bible-reading'; if (!rawLink) return fallback; try { const url = new URL(String(rawLink), window.location.origin); if (url.protocol !== 'https:') return fallback; if (url.hostname !== 'bible.usccb.org') return fallback; return url.toString(); } catch { return fallback; } } // Reading texts stored by renderReadings for the "Listen to all" button. let _dailyReadingTexts = []; // Last rendered daily payload (so the translation toggle can re-fetch its date). let _lastDailyData = null; // Selected Bible translation for the readings: 'nabre' (default) or 'web'. let _readingsTranslation = (() => { try { return localStorage.getItem('haven.readings.translation') === 'web' ? 'web' : 'nabre'; } catch { return 'nabre'; } })(); // Switch translation, re-fetch the currently shown date, and re-render. async function setReadingsTranslation(t) { const next = t === 'web' ? 'web' : 'nabre'; // The toggle label reflects the payload's ACTUAL translation, which can differ // from the selected one: a NABRE request for a date outside the USCCB RSS // window is served a WEB fallback (tagged translation:'WEB'). In that state the // row reads "World English Bible · [View in NABRE]" while _readingsTranslation // is already 'nabre', so a plain `next === _readingsTranslation` guard would // make the button a no-op. Only treat it as a no-op when the SETTING and the // shown translation both already match the request. const shown = (_lastDailyData && _lastDailyData.mass && _lastDailyData.mass.translation) === 'WEB' ? 'web' : 'nabre'; if (next === _readingsTranslation && next === shown) return; // Re-requesting the translation we're already set to (because the server fell // back to the other one) — bypass the per-translation client cache AND the CDN // so we re-hit the origin, which may now have the requested translation (e.g. // NABRE once the RSS window has caught up). const reRequestSame = next === _readingsTranslation; _readingsTranslation = next; try { localStorage.setItem('haven.readings.translation', next); } catch {} const date = (_lastDailyData && _lastDailyData.date) || _isoDate(); const cached = reRequestSame ? null : cacheGet(date); if (cached) { renderDailySection(cached); } try { const data = await _fetchDate(date === _isoDate() ? null : date, { fresh: reRequestSame }); if (data) { cacheSet(date, data); renderDailySection(data); } } catch (_e) { /* keep whatever is shown; toggle is best-effort */ } } // Liturgical-style spoken intro for a reading, derived from its type + citation. // Gives each reading a clear announcement ("A reading from…") and — because each // is a separate TTS segment — a natural break between readings. function _readingIntro(sec) { const cit = String((sec && sec.citation) || '').trim(); const m = cit.match(/^([1-3]?\s?[A-Za-z][A-Za-z.\s]*?)\s+\d/); const book = m ? m[1].trim() : ''; const type = String((sec && sec.type) || '').toLowerCase(); const header = String((sec && sec.header) || '').toLowerCase(); if (type === 'gospel' || header.includes('gospel')) { return book ? `A reading from the holy Gospel according to ${book}.` : 'A reading from the holy Gospel.'; } if (type === 'psalm' || header.includes('psalm')) { return 'The Responsorial Psalm.'; } // The Gospel Acclamation / Alleluia is sung, not introduced with "A reading // from …" — let its refrain speak for itself. if (type === 'alleluia' || type === 'gospel_acclamation' || header.includes('alleluia') || header.includes('acclamation')) { return ''; } return book ? `A reading from ${book}.` : 'A reading.'; } // Play all daily reading sections sequentially. Each reading is preceded by a // short intro segment ("A reading from …"); separate segments give a natural // break and let the reading bodies reuse the pre-warmed per-reading TTS cache. // // For the responsorial psalm and the gospel acclamation (alleluia), the "R." // marker prefixes the congregation's response. Those lines are read in a // second voice (the configurable "Response voice" in Settings, default Aria — // female) to distinguish the congregation's reply from the psalmist's verses, // matching the dialogic nature of the liturgy. See _responseVoice(). function _isResponsorialType(type) { return type === 'psalm' || type === 'alleluia' || type === 'gospel_acclamation'; } // Marker for the congregation's refrain: "R." or "℟", optionally followed by a // verse marker like "(17b)". Matched WHEREVER it appears, not just at a line // start: the NABRE feed puts each refrain on its own line, but the WEB scrape // can run the whole psalm onto one line — a line-based split then misses the // inner markers and (previously) read the entire psalm in the response voice // while speaking every "R." aloud. Splitting on the marker fixes both. // (? { const bl = b.toLowerCase(); const rl = r.toLowerCase(); if (bl.startsWith(rl)) { b = b.slice(r.length).replace(/^[\s,;:—-]+/, '').trim(); return true; } const rCore = r.replace(/[.!?…]+$/, '').trim(); if (rCore && bl.startsWith(rCore.toLowerCase())) { const re = new RegExp('^' + rCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[.!?…]?[\\s,;:—-]*', 'i'); const m = b.match(re); if (m) { b = b.slice(m[0].length).trim(); return true; } } return false; }; while (stripOnce()) {} return b; } function _splitPsalmSegments(text, label, response) { // Ordered TTS segments for a responsorial psalm / acclamation. Each "R."/"℟" // marks a refrain, read by the congregation (response) voice using the clean // pre-extracted `response`; the verses between markers are read by the default // (reader) voice. Markers are always stripped, so "R." is never spoken. When // there's no pre-extracted refrain (e.g. the WEB text carries the markers but // no separate response), everything is read by the reader voice, markers // stripped — degraded but correct, never the whole psalm in one wrong voice. const rv = _responseVoice(); // Strip alternate-refrain constructs ("or:" + marker + a short alternate like // "R. Alleluia.") BEFORE splitting. Without this, the marker split glues the // trailing "or:" onto the refrain block and the alternate's text onto the // next verse block — so the reader voice audibly says "or:" and "Alleluia." // mid-psalm (seen live on 3 of 8 RSS days in a 30-day scan). The alternate is // display-only; TTS always uses the primary refrain. Requires a marker right // after "or:", so a bare "or" inside a verse can never match. const raw = String(text || '').replace(/\bor:?\s*(? String(s || '').replace(/\s+/g, ' ').trim(); // Collapse the refrain like the blocks are collapsed — an internal newline or // double space in the feed's `response` would otherwise never startsWith-match // and the refrain would be spoken twice (congregation voice, then reader). const refrain = collapse(response); if (!markerCount) { const body = collapse(raw); return body ? [{ text: body, label }] : []; } const blocks = raw.split(_PSALM_MARKER).map(collapse); const out = []; if (blocks[0]) out.push({ text: blocks[0], label }); // preamble before first marker (rare) for (let i = 1; i < blocks.length; i++) { const block = blocks[i]; if (!block || /^or:?$/i.test(block)) continue; // empty/trailing markers, "or:" lead-in if (!refrain) { // no clean refrain — reader voice, marker stripped out.push({ text: block, label }); continue; } out.push({ text: refrain, label, voice: rv }); // refrain in the congregation voice const verse = _stripRefrainFromBlock(block, refrain, collapse); if (verse) out.push({ text: verse, label }); } return out.length ? out : [{ text: collapse(raw), label }]; } // Build the ordered TTS segments for ONE reading: the spoken intro ("A reading // from …"), the body (dual-voice split for the psalm/acclamation so the // congregation voice reads only the refrain), and the short liturgical framing — // the congregation's response in the response voice ("Thanks be to God." after // a reading; "Glory to you, O Lord." before and "Praise to you, Lord Jesus // Christ." after the Gospel). Used by both the per-reading Listen button and // "Listen to all", so they behave identically. // Split a reading body into sentence-grouped chunks so no single /api/tts // request is huge. A long Gospel (e.g. Matthew 13:1-23) as one segment can be // slow enough to time out the function or exceed the warm-cron budget — the // segment then errors and only the framing acclamations play. Small chunks // synthesize fast, pre-warm reliably, and stream with the same one-ahead // prefetch the audiobook uses. Chunks share the reading's elId, so the // read-along highlight tracks forward across them (see ReadAlong._locate). // Kept in lockstep with readingChunks() in api/warm-tts.js (parity test). const _TTS_CHUNK_MAX = 800; function _ttsReadingChunks(text, maxLen) { const s = String(text || '').replace(/\s+/g, ' ').trim(); const max = maxLen || _TTS_CHUNK_MAX; if (!s) return []; if (s.length <= max) return [s]; const sents = s.match(/[^.!?]+[.!?]+["'”’)\]]*\s*|[^.!?]+$/g) || [s]; const chunks = []; let cur = ''; for (const raw of sents) { const piece = raw.trim(); if (!piece) continue; if (cur && cur.length + 1 + piece.length > max) { chunks.push(cur); cur = piece; } else cur = cur ? cur + ' ' + piece : piece; } if (cur) chunks.push(cur); return chunks; } function _readingSegments(r) { const type = String(r.type || '').toLowerCase(); const label = r.label; const rv = _responseVoice(); const elId = r.elId || ''; // read-along target; framing segments won't match the body text and simply don't highlight const segs = []; if (r.intro) segs.push({ text: r.intro, label }); if (type === 'gospel') segs.push({ text: 'Glory to you, O Lord.', label, voice: rv }); if (_isResponsorialType(type)) { _splitPsalmSegments(r.text, label, r.response).forEach(seg => segs.push({ ...seg, elId })); } else { _ttsReadingChunks(r.text).forEach(chunk => segs.push({ text: chunk, label, elId })); } if (type === 'reading') { segs.push({ text: 'The word of the Lord.', label }); segs.push({ text: 'Thanks be to God.', label, voice: rv }); } else if (type === 'gospel') { segs.push({ text: 'The Gospel of the Lord.', label }); segs.push({ text: 'Praise to you, Lord Jesus Christ.', label, voice: rv }); } return segs; } function readAllReadings(btn) { if (!_dailyReadingTexts.length) return; const items = []; _dailyReadingTexts.forEach(r => _readingSegments(r).forEach(seg => items.push(seg))); _ttsAudio.playList(items, btn, 'Today’s readings'); } // Play a single reading (per-section Listen button) — same intro + dual-voice // split + framing as "Listen to all", so tapping just the psalm reads the // refrains in the congregation voice and every reading gets its spoken intro. function readOneReading(i, btn) { const r = _dailyReadingTexts[i]; if (!r) return; _ttsAudio.playList(_readingSegments(r), btn, r.label); } // Small "New American Bible (NABRE) · Year A [View in WEB]" row shown above the // readings: the Bible translation in use, the lectionary cycle, and a one-tap // toggle to the other translation. function _readingsMetaRow(mass, cycle) { const translation = (mass && mass.translation) === 'WEB' ? 'WEB' : 'NABRE'; const versionName = translation === 'WEB' ? 'World English Bible (WEB)' : 'New American Bible, Revised Edition (NABRE)'; const other = translation === 'WEB' ? 'nabre' : 'web'; const otherName = translation === 'WEB' ? 'NABRE' : 'WEB'; const cycleLabel = cycle && cycle.label ? cycle.label : ''; // NABRE (from the USCCB feed) only covers a rolling ~10-day window ending // today, so future dates — and dates that have aged out — have no NABRE and // the server serves WEB instead. When the reader is asking for NABRE but only // WEB is available, say so plainly rather than offering a "View in NABRE" // toggle that can't deliver. const nabreUnavailable = translation === 'WEB' && _readingsTranslation === 'nabre'; const control = nabreUnavailable ? `NABRE not available for this date` : ``; return `
${_esc(versionName)}${cycleLabel ? ' · ' + _esc(cycleLabel) : ''} ${control}
`; } function renderReadings(sections, mass, cycle, date) { try { if (typeof VerseSelect !== 'undefined') VerseSelect.clear(); } catch (_e) {} // re-render invalidates any selection const usccbLink = _safeUsccbLink(mass && mass.usccbLink); const dateKey = String(date || ''); // keys the day's reading highlights (hl:reading::…) if (!sections || !sections.length) { _dailyReadingTexts = []; return `
${_icon('book-open', 16)} Readings unavailable

Visit bible.usccb.org for today's Mass readings.

`; } // "Citation only" reflects whether text is actually MISSING — not the source // label. The fallback source can now carry resolved text, so keying off // mass.source === 'cpbjr' would wrongly hide readings that do have text. const isCitationOnly = !sections.some(s => String(s && s.text || '').trim()); const cta = `

Read full text at USCCB

`; // "Also on USCCB.org" lives on the readings page (moved off the home screen). const usccbBtn = `📖Also on USCCB.org↗`; // Build reading-text store for the "Read All" button. Responsorial types keep // their line structure (newlines) so the congregation-voice split can separate // each "R." refrain line from its verse stanza; other readings collapse to one // block. `response` (the clean refrain) is carried for the congregation voice. _dailyReadingTexts = isCitationOnly ? [] : sections .map((s, si) => { if (!String(s && s.text || '').trim()) return null; const type = String(s.type || '').toLowerCase(); const raw = String(s.text || ''); const text = _isResponsorialType(type) ? raw.replace(/[ \t]+/g, ' ').replace(/\n{2,}/g, '\n').trim() : raw.replace(/\s+/g, ' ').trim(); return { label: s.header + (s.citation ? ', ' + s.citation : ''), intro: _readingIntro(s), text, response: String(s.response || '').trim(), type, elId: 'reading-text-' + si, // the card body below — read-along target }; }) .filter(Boolean); let _ri = 0; // running index into _dailyReadingTexts (text-bearing sections) const cards = sections.map((sec, i) => { const title = _esc(sec.header) + (sec.citation ? ' — ' + _linkScriptureRefsText(sec.citation) : ''); const text = String(sec && sec.text || '').trim(); const textId = 'reading-text-' + i; const hasText = !isCitationOnly && !!text; const rIdx = hasText ? _ri++ : -1; // aligns with _dailyReadingTexts order // Tap-to-select: each reading paragraph carries a date-scoped highlight id // (hl:reading::
:) and registers its passage so Save / // Note / Copy / Share (VerseSelect) work here just like the Gospels/Books // readers. Ids embed the date because a reading is that day's snapshot. const refLabel = String(sec.citation || '').trim() || sec.header || ''; const body = isCitationOnly || !text ? `

Citation only in fallback mode.

` : `
${text.split(/\n\n+/).map((pt, pi) => { const hlId = 'hl:reading:' + dateKey + ':' + i + ':' + pi; _hlRegistry[hlId] = { ref: { type: 'reading', date: dateKey, section: i, para: pi }, refLabel, workTitle: sec.header || '', quote: pt.replace(/\s+/g, ' ').trim(), link: usccbLink }; return `

${_esc(pt).replace(/\n/g, ' ')}

`; }).join('')}
`; const headerListen = hasText ? `${listenBtnReading(rIdx, 'Listen to ' + sec.header)}` : ''; return `
${_readingIcon(sec.type)}${title}${headerListen}
${body}
`; }).join(''); const readAllBtn = (!isCitationOnly && _dailyReadingTexts.length > 1) ? `` : ''; const metaRow = _readingsMetaRow(mass, cycle); if (!isCitationOnly) return metaRow + readAllBtn + cards + usccbBtn; return metaRow + `
${_icon('info', 16)}Fallback mode

Full reading text is temporarily unavailable. Showing citations from backup source.

${cta}
${cards}${usccbBtn}`; } function renderDailyContent(data) { _clearDailyLoadingStatuses(); const { date, season, mass, verseOfDay } = data; const d = new Date(date + 'T12:00:00'); const fmt = o => d.toLocaleDateString('en-US', o); const shortDate = fmt({ weekday:'long', month:'long', day:'numeric' }); const longDate = fmt({ weekday:'long', month:'long', day:'numeric', year:'numeric' }); _set('season-date', shortDate); if (season) { const banner = document.getElementById('season-banner'); // Background is now CSS-driven (theme- and season-aware) via the // attribute set below — no inline style, so it adapts // to light/dark mode instead of being locked to a dark gradient. // Update data-season so CSS season-tinting (banner + shell) uses the API's // exact boundary, not the IIFE approximation. document.documentElement.setAttribute('data-season', season.cssClass || 'ordinary'); } const massTitle = mass && mass.title; // Weekday pattern: "Monday of the Nth Week of Season" or "Nth Sunday in Season" — not a saint feast const isWeekdayTitle = massTitle && (/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b/i.test(massTitle) || /(?:Sunday|Week)\s+(?:in|of)\s+/i.test(massTitle)); if (massTitle) { // The liturgical day line in the banner shows the full mass title for every // day (e.g. "Thirteenth Sunday in Ordinary Time" / "Friday of the Twelfth // Week in Ordinary Time" / a feast name) — it already names the season. _set('season-day', massTitle); } else { // No mass title — still clear the skeleton using the season week label so // the banner doesn't stay in an indefinite loading state. _set('season-day', (season && (season.week || season.name)) || 'Liturgical Season'); } if (massTitle) { // Only use the mass title as the saint-card name when it's an actual // celebration, not a plain weekday/Sunday label. if (!isWeekdayTitle) { _set('saint-name', massTitle); _set('saint-short-desc', season ? season.week : ''); } const badge = document.getElementById('feast-badge'); if (badge) { const type = massTitle.includes('Solemnity') ? 'Solemnity' : massTitle.includes('Feast') ? 'Feast Day' : massTitle.includes('Memorial') ? 'Memorial' : (isWeekdayTitle || /\bSunday\b/.test(massTitle)) ? null : 'Feast Day'; badge.style.display = type ? '' : 'none'; if (type) badge.textContent = type; } } if (mass && mass.sections) { const sections = mass.sections; const _hasText = s => s && s.text && s.text.trim(); const isTrueGospelCandidate = s => { const type = String(s && s.type || '').toUpperCase(); if (type === 'GOSPEL') return true; const marker = (String(s && s.header || '') + ' ' + String(s && s.citation || '')).toLowerCase(); const mentionsGospel = /gospel/.test(marker); const isAcclamation = /acclamation|alleluia|verse before the gospel/.test(marker); return mentionsGospel && !isAcclamation; }; const gospelCandidates = sections.filter(s => _hasText(s) && isTrueGospelCandidate(s)) .sort((a, b) => String(b && b.text || '').trim().length - String(a && a.text || '').trim().length); const gospel = gospelCandidates[0] || null; const gospelText = (gospel && gospel.text ? gospel.text : '').replace(/\s+/g,' ').trim(); _todayGospelText = gospelText || (verseOfDay && verseOfDay.text) || _todayGospelText; // Home surfaces the short Verse of the Day highlight; the full readings live // on the Daily tab. The Listen button still reads the whole Gospel aloud via // dataset.tts, so the audio is unchanged even though the text is condensed. const _verseEl = document.getElementById('gospel-verse'); if (verseOfDay && verseOfDay.text) { if (verseOfDay.reference != null) _setHtml('gospel-citation', _linkScriptureRefsText(verseOfDay.reference)); _set('gospel-verse', '“' + verseOfDay.text + '”'); _set('gospel-synopsis', ''); if (_verseEl) _verseEl.dataset.tts = gospelText || verseOfDay.text; } else if (gospel && gospelText) { if (gospel.citation != null) _setHtml('gospel-citation', _linkScriptureRefsText(gospel.citation)); _set('gospel-verse', gospelText); _set('gospel-synopsis', ''); if (_verseEl && _verseEl.dataset) delete _verseEl.dataset.tts; } else { _set('gospel-citation', 'Unavailable'); _set('gospel-verse', 'Unable to load today\'s Gospel.'); _set('gospel-synopsis', ''); if (_verseEl && _verseEl.dataset) delete _verseEl.dataset.tts; } } // Daily-tab content (date header, verse, readings, season practice, reflect) // is gated to the viewed date — a background _fetchDate for X must not repaint // Daily while the user browses Y. Home fields above already updated. if (date === (_dailyViewDate || _isoDate())) renderDailySection(data); // “Praying today's readings” reaction — rendered at the top of Home // (just above the daily Gospel), with the same live count. const dpc = document.getElementById('home-pray-container'); if (dpc && date) { const dailyId = 'daily-' + date; dpc.innerHTML = `
${renderPrayBtn(dailyId)}praying today's readings
`; initPrayBtn(dailyId); } _applySaintCard(data); // Note: the Quote of the Day is NOT re-rendered here (it would swap mid-read). // It refreshes — and picks up the Gospel-tie — the next time Home is entered. } // ── Reading threads (pre-generated connecting-thread descriptions) ──────────── let _readingThreads = null; // null = not yet loaded; {} = loaded but empty async function _loadReadingThreads() { if (_readingThreads !== null) return _readingThreads; try { const r = await fetch('/reading-threads.json'); if (!r.ok) throw new Error('http ' + r.status); _readingThreads = await r.json(); } catch (_e) { _readingThreads = {}; } return _readingThreads; } // Per-day, reading-specific reflection prompts ({ "YYYY-MM-DD": {prompt, action} }). // Days not covered fall back to the general REFLECTIONS rotation. let _readingReflections = null; async function _loadReadingReflections() { if (_readingReflections !== null) return _readingReflections; try { const r = await fetch('/reading-reflections.json'); if (!r.ok) throw new Error('http ' + r.status); _readingReflections = await r.json(); } catch (_e) { _readingReflections = {}; } return _readingReflections; } // Remembered so "Journal on this" can seed the entry with the shown prompt. let _currentReflect = null; // Pre-fetch threads as soon as the Daily tab is first visited (no-op after that). function _prewarmReadingThreads() { if (_readingThreads === null) _loadReadingThreads(); } // Renders ONLY the Daily-tab section for a given day's payload (date header, // verse of the day, readings, season-practice, reflect). Does not touch Home. function renderDailySection(data) { const { date, season, mass, verseOfDay } = data || {}; if (!date) return; const d = new Date(date + 'T12:00:00'); const shortDate = d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); _set('daily-date-header', shortDate); if (verseOfDay) { _set('verse-text', '“' + verseOfDay.text + '”'); const verseEl = document.getElementById('verse-text'); if (verseEl) verseEl.dataset.tts = verseOfDay.text; // plain text for TTS (no curly quotes) if (verseOfDay.reference != null) _setHtml('verse-ref', _linkScriptureRefsText(verseOfDay.reference)); } _lastDailyData = data; // so the translation toggle can re-fetch this date const container = document.getElementById('readings-container'); if (container && mass) { container.innerHTML = renderReadings(mass.sections, mass, data && data.lectionaryCycle, date); if (mass.sections && mass.sections.length) { setTimeout(() => _prewarmReadingsTTS(mass.sections).then(() => _prewarmTomorrowTTS()), 2000); } } // Connecting thread card — filled async after threads JSON loads const tc = document.getElementById('thread-container'); if (tc) { tc.innerHTML = ''; _loadReadingThreads().then(threads => { const thread = threads && date && threads[date]; if (thread && tc.isConnected) { tc.innerHTML = ``; } }); } const sp = document.getElementById('season-practice-container'); if (sp && season) { const key = season.cssClass || 'ordinary'; const prac = SEASON_PRACTICE[key] || SEASON_PRACTICE.ordinary; sp.innerHTML = ``; } const rc = document.getElementById('reflect-container'); if (rc) { rc.innerHTML = ''; const _d = new Date(date + 'T12:00:00'); if (!isNaN(_d)) { const _start = Date.UTC(_d.getFullYear(), 0, 1); const doy = Math.floor((Date.UTC(_d.getFullYear(), _d.getMonth(), _d.getDate()) - _start) / 86400000) + 1; _loadReadingReflections().then(reflections => { if (!rc.isConnected) return; // Prefer a reading-specific reflection for this date; otherwise fall back // to the general rotation so every day still has a prompt. const ref = (reflections && reflections[date]) || REFLECTIONS[(doy - 1) % REFLECTIONS.length]; _currentReflect = { date, prompt: ref.prompt }; const reflectTts = _esc(ref.prompt + ' ' + ref.action); rc.innerHTML = `
🤍 Reflect
${_esc(ref.prompt)}
${_esc(ref.action)}
${listenBtnPremium('reflect-prompt-tts', 'Listen to reflection')}
`; }); } } } // Open the journal for the reflection's day and, if the entry is still empty, // seed it with the prompt as a gentle starting line. function journalOnReflection() { const r = _currentReflect; const d = (r && r.date) || _todayYmd(); openJournal(d); if (r && r.prompt && !Journal.has(d)) { const ta = document.getElementById('journal-text'); if (ta) { const seed = 'Reflecting on: ' + r.prompt + '\n\n'; ta.value = seed; _journalSaveNow(); try { ta.focus(); ta.setSelectionRange(seed.length, seed.length); } catch (_e) {} } } } /* ─── Daily calendar week strip ───────────────────────────────────────────── A row of the current week's dates on the Daily tab. Tap a day to load its readings; the current day is ringed and the day you're viewing is filled. Swipe (or use the ‹ › arrows) to move between weeks. The Home tab always stays on today — only the Daily section follows the selected day. */ let _dailyViewDate = null; // YYYY-MM-DD shown in the Daily tab (null → today) let _calWeekStart = null; // Date (local noon) of the Monday of the shown week let _calReqDate = null; // in-flight request guard function _ymd(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } function _mondayOf(d) { const x = new Date(d); x.setHours(12, 0, 0, 0); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); // back up to Monday return x; } function _renderCalendarStrip() { const days = document.getElementById('cal-days'); if (!days) return; const today = new Date(); today.setHours(12, 0, 0, 0); if (!_calWeekStart) _calWeekStart = _mondayOf(_dailyViewDate ? new Date(_dailyViewDate + 'T12:00:00') : today); const todayStr = _ymd(today); const selStr = _dailyViewDate || todayStr; const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; let html = ''; let first, last; for (let i = 0; i < 7; i++) { const d = new Date(_calWeekStart); d.setDate(d.getDate() + i); d.setHours(12, 0, 0, 0); const ds = _ymd(d); if (i === 0) first = d; if (i === 6) last = d; const journaled = (typeof Journal !== 'undefined') && Journal.has(ds); const cls = 'cal-day' + (ds === selStr ? ' sel' : '') + (ds === todayStr ? ' today' : '') + (journaled ? ' journaled' : ''); const label = d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) + (journaled ? ' — journaled' : ''); html += ``; } days.innerHTML = html; if (typeof _renderJournalCard === 'function') _renderJournalCard(); } function _shiftWeek(dir) { if (!_calWeekStart) _calWeekStart = _mondayOf(new Date()); _calWeekStart = new Date(_calWeekStart); _calWeekStart.setDate(_calWeekStart.getDate() + dir * 7); _renderCalendarStrip(); } async function selectDailyDate(dateStr, opts) { if (!dateStr) return; // awaitNetwork: pull-to-refresh wants the spinner to stay up until the network // round-trip finishes, not dismiss on the instant cache hit (mirrors // loadDailyContent's today path). const awaitNetwork = !!(opts && opts.awaitNetwork); // Selecting any date cancels a pending today-retry — a late success would // otherwise repaint Daily over the date the user is now viewing. clearTimeout(_retryTimer); // Today keeps the null sentinel; a calendar tap on today freezes it to a // string otherwise, which is the decay the spec forbids. _dailyViewDate = (dateStr === _isoDate()) ? null : dateStr; _renderCalendarStrip(); _calReqDate = dateStr; const container = document.getElementById('readings-container'); // Serve cache instantly; otherwise show a brief loading card while fetching. const cached = cacheGet(dateStr); if (cached) { renderDailySection(cached); // Still refresh in the background — exactly as loadDailyContent does for // today — so a stale or partial cached entry (e.g. one cached before a // server-side fix, or a WEB fallback from before NABRE became available for // this date) self-heals on navigation instead of only on a manual // pull-to-refresh. const bg = _fetchDate(dateStr).then(data => { if (_calReqDate !== dateStr) return; // user navigated away const current = cacheGet(dateStr); if (!data || !_isBetterOrEqualData(data, current)) return; cacheSet(dateStr, data); if (_dataChanged(data, current)) renderDailySection(data); }).catch(() => {}); if (awaitNetwork) await bg; return; } if (container) { container.innerHTML = `

Loading readings…

`; } try { const data = await _fetchDate(dateStr); if (_calReqDate !== dateStr) return; // superseded by a newer tap if (data) { cacheSet(dateStr, data); renderDailySection(data); } } catch (_e) { if (_calReqDate === dateStr && container) { container.innerHTML = `

Couldn't load those readings. Check your connection and try again.

`; } } } // Horizontal swipe on the week strip moves between weeks. (function () { let x0 = null, y0 = null; document.addEventListener('touchstart', e => { const inStrip = e.target.closest && e.target.closest('#cal-strip'); if (inStrip && e.touches[0]) { x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; } else x0 = null; }, { passive: true }); document.addEventListener('touchend', e => { if (x0 == null || !e.changedTouches[0]) return; const dx = e.changedTouches[0].clientX - x0; const dy = e.changedTouches[0].clientY - y0; x0 = null; if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.5) _shiftWeek(dx < 0 ? 1 : -1); }, { passive: true }); })(); function _clearDailyLoadingStatuses() { const saintLoading = document.getElementById('saint-loading-status'); if (saintLoading) saintLoading.remove(); const gospelLoading = document.getElementById('gospel-loading-status'); if (gospelLoading) gospelLoading.remove(); } function _renderNoCacheDailyState(attempt, delay, date) { const retrySeconds = Math.round(delay / 1000); _clearDailyLoadingStatuses(); // Two independent gates, mirroring renderDailyContent: // forToday — the failure is about today → Home banner/verse get the // "unavailable" copy (those cards always mean "today"). // forViewed — the failed date is the one the Daily tab is showing → // the date header + readings list get the error state. // A failed prefetch/viewing of another day leaves the visible UI untouched. const forToday = (date === _isoDate()); const forViewed = (date === (_dailyViewDate || _isoDate())); if (forToday) { // No data loaded — fill the banner date locally so it isn't stuck on a skeleton. try { _set('season-date', new Date().toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric' })); } catch { _set('season-date', 'Today'); } _set('season-day', 'Readings unavailable'); _set('saint-name', 'Saint unavailable'); _set('saint-short-desc', 'Check your connection.'); _set('gospel-citation', 'Unavailable'); _set('gospel-verse', 'We could not load today\'s readings.'); _set('gospel-synopsis', 'Check your connection. We will retry automatically in about ' + retrySeconds + 's.'); _set('verse-text', 'We could not load today\'s verse.'); _set('verse-ref', 'Unavailable'); const badge = document.getElementById('feast-badge'); if (badge) badge.style.display = 'none'; } if (forViewed) { const label = forToday ? 'Daily readings unavailable' : 'Readings for ' + new Date(date + 'T12:00:00').toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric' }) + ' unavailable'; _set('daily-date-header', label); const c = document.getElementById('readings-container'); if (c) c.innerHTML = `
${_icon('hourglass', 16)}Fetching readings…

Attempt ${attempt} failed. Retrying automatically in about ${retrySeconds}s.

You can also read the Mass readings at USCCB daily readings.

`; } } // ── Fetch with retry ────────────────────────────────────────────────────── let _retryAttempt = 0; let _retryTimer = null; async function _fetchDate(date, opts) { const qs = new URLSearchParams(); if (date) qs.set('date', date); if (_readingsTranslation === 'web') qs.set('translation', 'web'); // Cache-bust the CDN (s-maxage=3600) when forcing a fresh translation request, // otherwise the toggle would just get the same cached fallback for up to an hour. if (opts && opts.fresh) qs.set('_', String(Date.now())); const q = qs.toString(); const res = await fetch('/api/daily' + (q ? '?' + q : ''), (opts && opts.fresh) ? { cache: 'no-store' } : undefined); if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); } // Returns true if newData is at least as complete as oldData for caching purposes. // Prevents a degraded API response (e.g. null mass.title) from overwriting a // good cached response that already has feast information. function _isBetterOrEqualData(newData, oldData) { if (!oldData) return true; // nothing to protect const isWeekday = (title) => /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b/i.test(title) || /(?:Sunday|Week)\s+(?:in|of)\s+/i.test(title); const oldHasFeast = oldData.mass && oldData.mass.title && !isWeekday(oldData.mass.title); const newHasFeast = newData.mass && newData.mass.title && !isWeekday(newData.mass.title); if (oldHasFeast && !newHasFeast) return false; // would lose feast info — reject return true; } // Returns true if the user-visible parts of two data objects differ. // Used to skip re-rendering on background refresh when content is unchanged. function _dataChanged(a, b) { if (!b) return true; const sig = d => JSON.stringify({ t: d.mass && d.mass.title, s: d.mass && d.mass.sections, c: d.season && d.season.cssClass, sn: d.season && d.season.name, sw: d.season && d.season.week, sd: d.season && d.season.description, v: d.verseOfDay }); return sig(a) !== sig(b); } async function loadDailyContent(opts) { // awaitNetwork: callers like pull-to-refresh want the spinner to stay up // until the network round-trip finishes, not dismiss on the instant cache hit. const awaitNetwork = !!(opts && opts.awaitNetwork); const today = _isoDate(); // Serve from cache immediately if available (instant paint) const cached = cacheGet(today); if (cached) { renderDailyContent(cached); // Still re-fetch in the background to refresh the CDN copy. // Guards: // 1. Never overwrite or re-render with degraded data (lost feast info). // 2. Skip re-render entirely if content is identical — avoids mid-read flicker. const bg = _fetchDate(today).then(data => { const current = cacheGet(today); if (!_isBetterOrEqualData(data, current)) return; // degraded — discard silently cacheSet(today, data); if (_dataChanged(data, current)) renderDailyContent(data); // only repaint if something changed }).catch(() => {}); // silent — user already has good data _prefetchWeek(); if (awaitNetwork) await bg; return; } // No cache — fetch now, with periodic retry on failure await _attemptLoad(today); } async function _attemptLoad(date) { try { const data = await _fetchDate(date); cacheSet(date, data); renderDailyContent(data); _retryAttempt = 0; clearTimeout(_retryTimer); _prefetchWeek(); } catch (err) { console.warn('[Haven] Daily content failed (attempt ' + (_retryAttempt + 1) + '):', err.message); const delay = RETRY_DELAYS[Math.min(_retryAttempt, RETRY_DELAYS.length - 1)]; _retryAttempt++; _renderNoCacheDailyState(_retryAttempt, delay, date); _retryTimer = setTimeout(() => _attemptLoad(date), delay); } } // ── Pre-fetch rolling 7-day window ──────────────────────────────────────── async function _prefetchWeek() { const dates = []; for (let i = 1; i <= 6; i++) { const date = _isoDate(i); if (cacheGet(date)) continue; dates.push(date); } for (let i = 0; i < dates.length; i += 2) { await Promise.allSettled(dates.slice(i, i + 2).map(date => _fetchDate(date).then(data => cacheSet(date, data)).catch(() => {}) )); } } document.addEventListener('DOMContentLoaded', loadDailyContent); /* ─── Explore grid: reveal / collapse the curated overflow ──────────── */ function toggleExploreAll(){ const grid = document.querySelector('#s-home .explore-grid'); const link = document.getElementById('explore-toggle'); if(!grid) return; const showing = grid.classList.toggle('show-all'); if(link) link.textContent = showing ? 'Show less' : 'See all'; } /* ─── Sticky header: minimize on scroll ────────────────────────────── Each .screen is its own scroll container. Listen in the capture phase so a single document-level handler catches scroll from ANY screen (current or future, since scroll doesn't bubble). Toggle .scrolled on the screen once it passes a small threshold — the CSS condenses its sticky header. Only writes the class on an actual state change, so it's cheap on every scroll tick. */ (function(){ var THRESHOLD = 10; document.addEventListener('scroll', function(e){ var s = e.target; if(!s || s.nodeType !== 1 || !s.classList || !s.classList.contains('screen')) return; var scrolled = s.scrollTop > THRESHOLD; if(scrolled !== s.classList.contains('scrolled')) s.classList.toggle('scrolled', scrolled); }, true); })(); /* ─── Glossary bottom sheet ──────────────────────────────────────── */ function openGlossary(id) { const entry = GLOSSARY[id]; if (!entry) return; document.getElementById('glossary-term-name').textContent = entry.term; document.getElementById('glossary-term-def').textContent = entry.def; const moreEl = document.getElementById('glossary-term-more'); if (entry.more) { moreEl.style.display = 'block'; moreEl.onclick = () => { closeGlossary(); setTimeout(() => openQuestion(entry.more), 320); }; } else { moreEl.style.display = 'none'; } const overlay = document.getElementById('glossary-overlay'); overlay.style.display = 'flex'; overlay.style.alignItems = 'flex-end'; requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('open'))); } function closeGlossary(event) { if (event && event.target !== document.getElementById('glossary-overlay')) return; const overlay = document.getElementById('glossary-overlay'); overlay.classList.remove('open'); setTimeout(() => { overlay.style.display = 'none'; }, 310); } /* ─── Color scheme ───────────────────────────────────────────────── */ function setScheme(s) { document.documentElement.setAttribute('data-scheme', s); _storageSet('haven-scheme', s); document.querySelectorAll('.scheme-btn[id^="scheme-btn-"]').forEach(b => b.classList.remove('active')); document.getElementById('scheme-btn-' + s)?.classList.add('active'); if (window.__havenSyncChrome) window.__havenSyncChrome(); } function _initScheme() { const saved = _storageGet('haven-scheme') || 'navy'; document.documentElement.setAttribute('data-scheme', saved); // Delay UI update until DOM ready requestAnimationFrame(() => { document.querySelectorAll('.scheme-btn[id^="scheme-btn-"]').forEach(b => b.classList.remove('active')); document.getElementById('scheme-btn-' + saved)?.classList.add('active'); }); } _initScheme(); /* ─── Font picker ────────────────────────────────────────────────── */ function setFont(f) { if (f === 'sans') { document.documentElement.setAttribute('data-font', 'sans'); } else { document.documentElement.removeAttribute('data-font'); } _storageSet('haven-font', f); document.querySelectorAll('.scheme-btn[id^="font-btn-"]').forEach(b => b.classList.remove('active')); document.getElementById('font-btn-' + f)?.classList.add('active'); } function _initFont() { const saved = _storageGet('haven-font') || 'sans'; if (saved === 'sans') document.documentElement.setAttribute('data-font', 'sans'); requestAnimationFrame(() => { document.querySelectorAll('.scheme-btn[id^="font-btn-"]').forEach(b => b.classList.remove('active')); document.getElementById('font-btn-' + saved)?.classList.add('active'); }); } _initFont(); /* ─── Explore tour ──────────────────────────────────────────────── A short, skippable look at where things live — shown once on first launch, never again. Replaces the old "choose a verse that speaks to you" picker: this doesn't ask the user to self-categorize, it just shows what's here so people actually find the Library/Search/Daily tabs instead of only ever seeing Home. */ const TOUR_KEY = 'haven-tour-v1'; const TOUR_STEPS = [ { icon: 'home', eyebrow: 'Welcome to Haven', headline: 'A quick look around', sub: "Three things worth knowing — then we'll get out of your way.", cta: 'Show me' }, { icon: 'book-open', eyebrow: 'Daily', headline: "Today's Mass readings", sub: 'Full readings, a reflection, and "Listen" buttons that read them aloud — on the Daily tab.', cta: 'Next' }, { icon: 'landmark', eyebrow: 'Library', headline: 'Podcasts, channels & sounds', sub: 'Curated podcasts, YouTube channels, your playlists, and ambient background sounds all live in your Library.', cta: 'Next' }, { icon: 'sliders', eyebrow: 'More', headline: 'Everything else lives in More', sub: 'Saints, prayers, the Journal, learning paths, community — plus Settings (More → Settings & About) for voices, themes, and sync.', cta: 'Next' }, { icon: 'search', eyebrow: 'Search', headline: 'Ask anything', sub: 'Search jumps straight to any saint, prayer, question, or show — tap the magnifying glass at the top any time.', cta: "Got it — let's go" }, ]; let _tourStep = 0; function _renderTourStep() { const s = TOUR_STEPS[_tourStep]; const iconEl = document.getElementById('tour-icon'); // decorative — set directly, not via if (iconEl) iconEl.innerHTML = _icon(s.icon, 40); // _set(), which strips aria-hidden _set('tour-eyebrow', s.eyebrow); _set('tour-headline', s.headline); _set('tour-sub', s.sub); _set('tour-next-btn', s.cta); const dots = document.getElementById('tour-dots'); if (dots) dots.innerHTML = TOUR_STEPS.map((_step, i) => ``).join(''); } function tourNext() { _tourStep++; if (_tourStep >= TOUR_STEPS.length) { closeTour(); return; } _renderTourStep(); } function closeTour() { const el = document.getElementById('tour-overlay'); if (el) { el.style.opacity = '0'; el.style.transition = 'opacity 250ms ease'; _modalClosed(); // un-inert the background + restore focus to whatever opened the tour setTimeout(() => { el.style.display = 'none'; }, 260); } try { localStorage.setItem(TOUR_KEY, '1'); } catch {} } function openTour() { const el = document.getElementById('tour-overlay'); if (!el) return; _tourStep = 0; _renderTourStep(); el.style.opacity = '0'; el.style.transition = 'opacity 300ms ease'; el.style.display = 'flex'; _modalOpened(el); // inert the background + push the focus stack (overridden below) // Double-rAF ensures the display:flex paint happens before the opacity transition. requestAnimationFrame(() => requestAnimationFrame(() => { el.style.opacity = '1'; el.querySelector('.tour-next')?.focus(); // prefer the primary CTA over _modalOpened's default (Skip) })); } (function _showTourIfNew() { try { if (!localStorage.getItem(TOUR_KEY)) setTimeout(openTour, 1200); } catch {} })(); /* ─── Saints Library ──────────────────────────────────────────────── */ let _saintsLib = null; // full array, loaded once let _saintsFilter = 'all'; // current church filter let _saintsWikiCache = {}; // Wikipedia extract cache let _saintsExtractToken = 0; // cancels stale Wikipedia fetches function _feastLabel(mmdd) { if (!mmdd) return ''; const [m, d] = mmdd.split('-').map(Number); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return months[m - 1] + '\u00a0' + d; } function _centuryLabel(c) { if (!c) return ''; const n = Number(c); const suffix = (n % 100 >= 11 && n % 100 <= 13) ? 'th' : ({ 1:'st', 2:'nd', 3:'rd' }[n % 10] || 'th'); return n + suffix + ' century'; } function _saintMeta(s) { // Prefer explicit birth–death years ("the timeframe they lived"); fall back // to a century label when only the century is known. const when = (s && s.years) || _centuryLabel(s && s.century); return [when, s && s.region].filter(Boolean).join(' · '); } async function _loadSaintsLibrary() { if (_saintsLib) return; const list = document.getElementById('saints-list'); try { const res = await fetch('/saints-library.json'); if (!res.ok) throw new Error('fetch failed'); _saintsLib = await res.json(); } catch { if (list) list.innerHTML = '
Could not load saints.
'; } } function openSaintsLibrary() { pushScreen('s-saints-library'); if (_saintsLib) { _renderSaintsLibrary(); } else { document.getElementById('saints-list').innerHTML = '
Loading…
'; _loadSaintsLibrary().then(_renderSaintsLibrary); } } function filterSaintsLibrary() { _renderSaintsLibrary(); } function setSaintsFilter(filter, el) { _saintsFilter = filter; document.querySelectorAll('#saints-filter-pills .category-pill').forEach(p => { p.classList.remove('active'); p.setAttribute('aria-checked', 'false'); p.setAttribute('tabindex', '-1'); }); el.classList.add('active'); el.setAttribute('aria-checked', 'true'); el.setAttribute('tabindex', '0'); _renderSaintsLibrary(); } function handleSaintsFilterKey(event, filter, el) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSaintsFilter(filter, el); return; } if (!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return; event.preventDefault(); const pills = Array.from(document.querySelectorAll('#saints-filter-pills .category-pill')); const current = pills.indexOf(el); const next = event.key === 'Home' ? 0 : event.key === 'End' ? pills.length - 1 : event.key === 'ArrowLeft' ? (current - 1 + pills.length) % pills.length : (current + 1) % pills.length; pills[next].focus(); pills[next].click(); } function _renderSaintsLibrary() { const list = document.getElementById('saints-list'); if (!list || !_saintsLib) return; const query = (document.getElementById('saints-search')?.value || '').toLowerCase().trim(); const filtered = _saintsLib.filter(s => { const churchMatch = _saintsFilter === 'all' || (s.church || []).includes(_saintsFilter); if (!churchMatch) return false; if (!query) return true; return [s.name, s.tagline, s.region, _centuryLabel(s.century)].filter(Boolean).join(' ').toLowerCase().includes(query); }).sort((a, b) => a.name.localeCompare(b.name)); if (!filtered.length) { list.innerHTML = '
No saints found.
Try a different name or filter.
'; const cnt = document.getElementById('saints-count'); if (cnt) cnt.textContent = '0 saints'; return; } const cnt = document.getElementById('saints-count'); if (cnt) cnt.textContent = filtered.length + (filtered.length === (_saintsLib || []).length ? ' saints' : ' of ' + (_saintsLib || []).length); const CHURCH_ICON = { roman: 'cross', orthodox: 'cross-orthodox', 'eastern-catholic': 'star' }; const CHURCH_LABELS = { roman: 'Roman', orthodox: 'Orthodox', 'eastern-catholic': 'Eastern' }; const CHURCH_CLASS = { roman: 'church-badge-roman', orthodox: 'church-badge-orthodox', 'eastern-catholic': 'church-badge-eastern' }; list.innerHTML = filtered.map(s => { const churches = (s.church || ['roman']); const primaryChurch = churches[0]; const icon = _icon(CHURCH_ICON[primaryChurch] || 'cross', 18); const meta = _saintMeta(s); const badges = churches.map(c => `${CHURCH_LABELS[c] || c}` ).join(' '); const json = _esc(JSON.stringify(s)); return `
${icon}
${_esc(s.name)}
${s.feastDay ? `${_feastLabel(s.feastDay)}` : ''}${meta ? `${_esc(meta)}` : ''}${_esc(s.tagline)}
${badges}
`; }).join(''); } function openSaintDetail(saint) { const overlay = document.getElementById('saints-wiki-overlay'); if (!overlay) return; overlay.style.display = 'flex'; const modal = document.getElementById('saints-wiki-modal'); if (modal) modal.scrollTop = 0; requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('open'))); _modalOpened(overlay); const eyebrowText = saint.canonizedBy ? saint.canonizedBy : 'Saint'; const meta = _saintMeta(saint); const content = document.getElementById('saints-wiki-content'); content.innerHTML = `
${_esc(eyebrowText)}
${_esc(saint.name)}
${meta ? `
${_esc(meta)}
` : ''}
${_esc(saint.tagline)}
Loading…
${saint.wikiSlug ? `Read full article on Wikipedia →` : ''}`; _saintsExtractToken++; // cancel any pending extract for a previous saint if (saint.wikiSlug) _fetchSaintExtract(saint.wikiSlug); else document.getElementById('saints-extract-slot').innerHTML = ''; } async function _fetchSaintExtract(slug) { const token = ++_saintsExtractToken; if (_saintsWikiCache[slug]) { const slot = document.getElementById('saints-extract-slot'); if (slot && token === _saintsExtractToken) slot.innerHTML = _buildExtractHtml(_saintsWikiCache[slug]); return; } try { const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(slug)}`); if (!res.ok) throw new Error('404'); const data = await res.json(); const entry = { extract: data.extract || '', thumb: data.thumbnail?.source || null }; _saintsWikiCache[slug] = entry; if (token === _saintsExtractToken) { const slot = document.getElementById('saints-extract-slot'); if (slot) slot.innerHTML = _buildExtractHtml(entry); } } catch { if (token === _saintsExtractToken) { const slot = document.getElementById('saints-extract-slot'); if (slot) slot.innerHTML = '
Additional details unavailable.
'; } } } function _buildExtractHtml(entry) { const thumb = entry.thumb ? `` : ''; const extractId = 'saints-wiki-extract'; const extract = entry.extract || ''; const listenButton = extract ? `
${listenBtnPremium(extractId, 'Listen to saint biography')}
` : ''; return `${thumb}${listenButton}
${_annotateTerms(_esc(extract))}
`; } /* ─── Saints of the Day ──────────────────────────────────────────── */ let _saintsData = null; let _activeHomeSaint = null; // the saint currently shown on the home card async function _loadSaints() { const CACHE_KEY = 'haven-saints-v2'; const LEGACY_CACHE_KEY = 'haven-saints-v1'; const SAINTS_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days let fallback = null; try { const hit = localStorage.getItem(CACHE_KEY); if (hit) { const cached = JSON.parse(hit); if (cached && cached.data && cached.ts) { fallback = cached.data; if (Date.now() - cached.ts < SAINTS_TTL) { _saintsData = cached.data; return; } } } else { const legacy = _storageGet(LEGACY_CACHE_KEY); if (legacy) fallback = JSON.parse(legacy); // legacy raw-cache shape; keep as offline fallback } } catch {} try { const res = await fetch('/saints.json'); if (res.ok) { _saintsData = await res.json(); try { localStorage.setItem(CACHE_KEY, JSON.stringify({ data: _saintsData, ts: Date.now() })); } catch {} } } catch {} if (!_saintsData && fallback) _saintsData = fallback; } function _mmdd(d) { const dt = d instanceof Date ? d : new Date(); return String(dt.getMonth() + 1).padStart(2, '0') + '-' + String(dt.getDate()).padStart(2, '0'); } function _getSaintToday() { return _saintsData ? (_saintsData[_mmdd()] || null) : null; } /* ─── Saint of the Day / Wikipedia modal ────────────────────────── */ let _wikiCache = {}; /* ─── Gospels reader (public-domain World English Bible) ────────────── Loads /gospels.json on first open and lets the reader move through the four Gospels by book and chapter, any time — not just the daily excerpt. */ const GOSPELS_POS_LS = 'haven.gospels.pos'; const _GOSPEL_BOOK_KEYS = { matthew: 'matthew', mark: 'mark', luke: 'luke', john: 'john' }; let _gospelsData = null, _gBook = 'matthew', _gChap = 1; // Restore the last reading position so the reader opens where you left off. // Only accept known book keys — a corrupted/edited value would otherwise leave // the reader with no active book tab and keep re-saving the bad key. try { const _gSaved = JSON.parse(localStorage.getItem(GOSPELS_POS_LS) || 'null'); if (_gSaved && _GOSPEL_BOOK_KEYS[_gSaved.book]) { _gBook = _gSaved.book; _gChap = Math.max(1, +_gSaved.chap || 1); } } catch (_e) {} function _gSavePos() { try { localStorage.setItem(GOSPELS_POS_LS, JSON.stringify({ book: _gBook, chap: _gChap })); } catch (_e) {} } async function openGospels(bookKey, chap, fromTab) { if (fromTab) showScreen('s-gospels', false); else pushScreen('s-gospels'); if (bookKey && _GOSPEL_BOOK_KEYS[bookKey]) _gBook = bookKey; if (chap) _gChap = chap; if (bookKey || chap) _gSavePos(); const body = document.getElementById('gospels-body'); if (!_gospelsData) { if (body) body.innerHTML = '
Loading the Gospels…
'; try { const r = await fetch('/gospels.json'); if (!r.ok) throw new Error('fetch failed'); _gospelsData = await r.json(); } catch { if (body) body.innerHTML = '
Could not load the Gospels.
Check your connection and try again.
'; return; } } _renderGospels(); } // Open the reader at the book/chapter named in a citation like "John 1:1-18". function _gBookObj() { return (_gospelsData.books.find(b => b.key === _gBook)) || _gospelsData.books[0]; } function _gSelectBook(k) { _gBook = k; _gChap = 1; _gSavePos(); _renderGospels(); } function _gSelectChap(n) { _gChap = n; _gSavePos(); _renderGospels(); } function _renderGospels() { const body = document.getElementById('gospels-body'); if (!body || !_gospelsData) return; try { VerseSelect.clear(); } catch (_e) {} // re-render invalidates the selection's elements const book = _gBookObj(); const maxCh = book.chapters; _gChap = Math.min(Math.max(1, _gChap), maxCh); const tabs = _gospelsData.books.map(b => `` ).join(''); const chips = Array.from({ length: maxCh }, (_, i) => i + 1).map(n => `` ).join(''); const verses = (book.text[_gChap - 1] || []).map((t, i) => { const id = 'hl:gospel:' + book.key + ':' + _gChap + ':' + (i + 1); _hlRegistry[id] = { ref: { type: 'gospel', book: book.key, chapter: _gChap, verse: i + 1 }, refLabel: book.name + ' ' + _gChap + ':' + (i + 1), workTitle: 'The Gospel of ' + book.name, quote: t }; return `

${i + 1}${_esc(t)}

`; }).join(''); body.innerHTML = `
${tabs}
${chips}
${_esc(book.name)} ${_gChap}
${verses}
${_esc(_gospelsData.translation)}
`; const screen = document.getElementById('s-gospels'); if (screen) screen.scrollTop = 0; } /* ─── Books reader (public-domain Apostolic Fathers texts) ───────────────── Loads /church-fathers-books.json on first open — a light INDEX of book metadata (title/author/about, no chapter text; see scripts/import_books.py). Each book's full text lives in /books/.json and is fetched lazily the first time that book is opened (via _loadBook), so opening one book never downloads the others (the City of God alone is ~2.3MB). Chapter navigation mirrors the Gospels reader above; each paragraph gets a free-tier Listen button (device voice via listenBtn/readAloudEl — the same "free first pass" TTS the rest of the app uses, not the server-side Edge neural voice) plus a highlight/save-to-Journal button. A future Strata integration (see docs/strata-integration.md) can add narrated audio and commentary editions alongside this free-text reader. */ let _cfBooksData = null, _cfSlug = null, _cfChap = 1; const _cfBookLoads = {}; // slug -> in-flight/settled Promise, dedupes concurrent loads async function _loadChurchFathersBooks() { if (_cfBooksData) return true; try { const r = await fetch('/church-fathers-books.json'); if (!r.ok) throw new Error('fetch failed'); _cfBooksData = await r.json(); return true; } catch { return false; } } // True once a book's full text is present (so callers can skip the "Loading…" // spinner on an already-hydrated book). function _cfBookIsLoaded(slug) { const b = _cfBooksData && _cfBooksData.books.find(x => x.slug === slug); return !!(b && Array.isArray(b.chapters)); } // Fetch a book's full chapter text and graft it onto its index entry, so every // existing `book.chapters` reader/audio/download path works unchanged once the // book is loaded. Idempotent and deduped: a book already carrying chapters (or a // load in flight) is reused. Returns the book object, or null if it can't load. async function _loadBook(slug) { if (!slug || !(await _loadChurchFathersBooks())) return null; const meta = _cfBooksData.books.find(b => b.slug === slug); if (!meta) return null; if (Array.isArray(meta.chapters)) return meta; // already hydrated if (!_cfBookLoads[slug]) { _cfBookLoads[slug] = (async () => { const r = await fetch('/books/' + encodeURIComponent(slug) + '.json'); if (!r.ok) throw new Error('book fetch failed'); const full = await r.json(); // Guard against a mismatched/corrupt payload silently showing the wrong // book's text: the file must be for THIS slug and actually carry chapters. if (!full || full.slug !== slug || !Array.isArray(full.chapters) || !full.chapters.length) { throw new Error('book payload mismatch'); } meta.chapters = full.chapters; // hydrate the index entry in place return meta; })(); // A failed load must not poison the cache — drop it so a later open retries. _cfBookLoads[slug].catch(() => { delete _cfBookLoads[slug]; }); } try { return await _cfBookLoads[slug]; } catch { return null; } } async function openBooks() { pushScreen('s-books'); const list = document.getElementById('books-list'); if (!_cfBooksData) { if (list) list.innerHTML = '
Loading…
'; const ok = await _loadChurchFathersBooks(); if (!ok) { if (list) list.innerHTML = '
Could not load Books.
Check your connection and try again.
'; return; } } _renderBooksList(); } function _cfTldrPreview(tldr, max) { if (!tldr) return ''; const plain = String(tldr).replace(/\*\*/g, '').replace(/\s+/g, ' ').trim(); const cap = max || 140; if (plain.length <= cap) return plain; const cut = plain.slice(0, cap); return (cut.slice(0, cut.lastIndexOf(' ')) || cut) + '…'; } // Turn editorial about text (plain + **bold** markers, blank-line paragraphs) into HTML. function _cfAboutHtml(text) { if (!text) return ''; return String(text).split(/\n\s*\n/).map(block => { const escaped = _esc(block.trim()); return '

' + escaped.replace(/\*\*([^*]+)\*\*/g, '$1') + '

'; }).join(''); } // Plain text for TTS from an about field (strip markdown, collapse whitespace). function _aboutTtsPlain(text) { return String(text || '').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim(); } // Build chunked TTS segments for all about sections (TL;DR, summary, history, significance). function _aboutTtsSegments(about, bookTitle, elId) { if (!about) return []; const label = 'About — ' + bookTitle; const voice = _edgeVoice(); const order = [ ['tldr', 'TL;DR guide'], ['summary', 'Summary'], ['history', 'History'], ['significance', 'Why it matters'], ]; const out = []; for (const [key, heading] of order) { const text = _aboutTtsPlain(about[key]); if (!text) continue; const parts = text.length > 800 ? _splitTtsText(text) : [text]; parts.forEach((part, i) => { out.push({ text: (i === 0 ? heading + '. ' : '') + part, label, voice, elId: elId || 'cf-about-body', }); }); } return out; } function _renderBooksList() { const list = document.getElementById('books-list'); if (!list || !_cfBooksData) return; const byAuthor = new Map(); _cfBooksData.books.forEach((b) => { const author = b.author || 'Unknown author'; if (!byAuthor.has(author)) byAuthor.set(author, []); byAuthor.get(author).push(b); }); const authors = [...byAuthor.keys()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); list.innerHTML = authors.map((author) => { const books = byAuthor.get(author).slice().sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })); const sub = books.length === 1 ? books[0].era : books.length + ' works'; return '
' + '
' + _esc(sub) + '
' + '
' + books.map((b) => { const tldr = b.about && b.about.tldr; const preview = tldr ? `
${_esc(_cfTldrPreview(tldr))}
` : ''; const hasAbout = b.about && _aboutTtsSegments(b.about, b.title).length; const listen = hasAbout ? `` : ''; return `
${_esc(b.title)}
${_esc(b.era)}
${preview}
${listen}
›
`; }).join('') + '
'; }).join(''); } function _cfBook() { return (_cfBooksData.books.find(b => b.slug === _cfSlug)) || _cfBooksData.books[0]; } function _cfSelectChap(n) { _cfChap = n; // Moving to a chapter sets the resume point to its start — unless an audiobook // session for THIS book is live (then the player owns the resume point, so // peeking at another chapter mustn't clobber where the audio is, including // during the brief pauses while the next paragraph loads). if (!BookAudio.hasSession(_cfSlug)) { BookPos.set(_cfSlug, { chapter: n, seg: 0, t: 0 }); } _renderBookReader(); } /* ── Book highlight re-anchoring (survives a text re-import) ──────────────── A saved book highlight is keyed by paragraph position (hl:father:slug:chap: para). If a book is ever re-imported with edited text — a paragraph added, split, or merged — those positions shift and a saved reflection would point at the wrong passage. Each highlight also stores a `quote` snapshot (a ≤300-char snippet of the paragraph). When the text at a highlight's recorded position no longer matches its stored quote, we re-locate it by that quote and re-point the highlight. In the normal case (text unchanged) every highlight matches in place and this is a pure no-op. */ function _bookQuoteMatches(storedQuote, paraText) { const q = String(storedQuote || '').replace(/\s+/g, ' ').trim().replace(/…+$/, '').trim(); const p = String(paraText || '').replace(/\s+/g, ' ').trim(); if (q.length < 20 || !p) return false; // too short to match safely // Stored quote is a leading snippet of the paragraph, so a paragraph matches // when its text starts with the snippet (or, if the paragraph itself got // shorter than the snippet, the snippet starts with the paragraph). return p === q || p.startsWith(q) || q.startsWith(p); } function _reanchorBookHighlights(slug) { const book = _cfBooksData && _cfBooksData.books.find(b => b.slug === slug); if (!book || !Array.isArray(book.chapters)) return; const paras = []; book.chapters.forEach(ch => (ch.paragraphs || []).forEach((t, i) => paras.push({ chapter: ch.number, para: i + 1, text: t }))); if (!paras.length) return; Highlights.list().forEach(h => { const r = h.ref; if (!r || r.type !== 'father' || r.slug !== slug) return; const cur = paras.find(p => p.chapter === r.chapter && p.para === r.para); if (cur && _bookQuoteMatches(h.quote, cur.text)) return; // still in place const hits = paras.filter(p => _bookQuoteMatches(h.quote, p.text)); if (hits.length !== 1) return; // gone or ambiguous — leave as-is const m = hits[0]; if (m.chapter === r.chapter && m.para === r.para) return; // matched its own spot const ch = book.chapters.find(c => c.number === m.chapter); const refLabel = book.title + ' ' + m.chapter + (ch && ch.paragraphs.length > 1 ? '.' + m.para : ''); Highlights.reanchor(h.id, 'hl:father:' + slug + ':' + m.chapter + ':' + m.para, { type: 'father', slug, chapter: m.chapter, para: m.para }, refLabel); }); } const _reanchoredBooks = new Set(); // run the migration at most once per book per session async function openBookReader(slug, chap) { pushScreen('s-book-reader'); const body = document.getElementById('book-reader-body'); if (!_cfBooksData || !(slug ? _cfBookIsLoaded(slug) : false)) { if (body) body.innerHTML = '
Loading…
'; const ok = await _loadChurchFathersBooks(); if (!ok) { if (body) body.innerHTML = '
Could not load this book.
Check your connection and try again.
'; return; } } _cfSlug = slug || (_cfBooksData.books[0] && _cfBooksData.books[0].slug); // Fetch the book's full text (chapters) before rendering — the index only // carries metadata, so the reader/re-anchor/render below need it hydrated. if (!(await _loadBook(_cfSlug))) { if (body) body.innerHTML = '
Could not load this book.
Check your connection and try again.
'; return; } // Reconcile saved highlights against the current text once per book (see // _reanchorBookHighlights) before rendering, so any re-pointed highlight // lights the right paragraph on this very render. if (_cfSlug && !_reanchoredBooks.has(_cfSlug)) { _reanchoredBooks.add(_cfSlug); try { _reanchorBookHighlights(_cfSlug); } catch (_e) {} } // Open where you left off (last chapter read/listened), unless a chapter is // explicitly requested (e.g. from History or a resume tap). const saved = BookPos.get(_cfSlug); _cfChap = chap || (saved && saved.chapter) || 1; _renderBookReader(); } function _renderBookReader() { const body = document.getElementById('book-reader-body'); if (!body || !_cfBooksData) return; try { VerseSelect.clear(); } catch (_e) {} // re-render invalidates the selection's elements const book = _cfBook(); _set('book-reader-title', book && book.title || 'Book'); // Guard against a book with no chapters yet (e.g. a "coming soon" entry added // to the JSON) — render a friendly note instead of throwing on chapter[-1]. if (!book || !Array.isArray(book.chapters) || !book.chapters.length) { body.innerHTML = '
This book isn’t available to read yet.
'; return; } const maxCh = book.chapters.length; _cfChap = Math.min(Math.max(1, _cfChap), maxCh); const chapter = book.chapters[_cfChap - 1]; const chips = book.chapters.map(c => `` ).join(''); // "About this book" — editorial TL;DR/summary/history/significance, shown // expanded on chapter 1 (an intro) and collapsed thereafter (still one tap // away). Its own Edge-voice Listen button reads the TL;DR guide, summary, // history, and significance (chunked for long guides). let aboutHtml = ''; const about = book.about; if (about && (about.tldr || about.summary || about.history || about.significance)) { const rows = []; if (about.tldr) rows.push(`
TL;DR guide.${_cfAboutHtml(about.tldr)}
`); if (about.summary) rows.push(`

Summary. ${_esc(about.summary)}

`); if (about.history) rows.push(`

History. ${_esc(about.history)}

`); if (about.significance) rows.push(`

Why it matters. ${_esc(about.significance)}

`); const aboutListen = ``; aboutHtml = `
About this book
${aboutListen}
${rows.join('')}
`; } // Playback bar. Primary "Listen to this book" plays the whole book start to // finish (Edge voice, auto-advancing chapter→chapter); secondary buttons play // just the open chapter or resume where you left off. const pos = BookPos.get(book.slug); const canResume = pos && (pos.seg > 0 || pos.t > 0 || pos.chapter !== _cfChap); const resumeBtn = canResume ? `` : ''; const playBar = `
${resumeBtn}
`; const paras = chapter.paragraphs.map((t, i) => { const elId = 'cf-para-' + _cfChap + '-' + i; const hlId = 'hl:father:' + book.slug + ':' + chapter.number + ':' + (i + 1); const refLabel = book.title + ' ' + chapter.number + (chapter.paragraphs.length > 1 ? '.' + (i + 1) : ''); _hlRegistry[hlId] = { ref: { type: 'father', slug: book.slug, chapter: chapter.number, para: i + 1 }, refLabel, workTitle: book.title, quote: t }; return `

${listenBtnPremium(elId, 'Listen')}${_esc(t)}

`; }).join(''); body.innerHTML = `
${chips}
${aboutHtml}
${_esc(chapter.title)}
${playBar}
${_cfDownloadHtml(book.slug)}
${paras}
${book.translator ? 'Translated by ' + _esc(book.translator) + ' · ' : ''}${_esc(book.source)}
`; const screen = document.getElementById('s-book-reader'); if (screen) screen.scrollTop = 0; try { _syncBookPlaybar(); } catch (_e) {} // reflect a live listen on the freshly-rendered play bar } // Offline-download control shown under the play bar. Reflects idle / downloading // (with %, cancellable) / downloaded (tap to remove) for the current voice. function _cfDownloadHtml(slug) { if (!_bookDownloadSupported()) { return `

Offline download needs a Microsoft Edge voice — switch in Settings → Read Aloud.

`; } if (BookDownload.isDownloading(slug)) { const pct = Math.round(BookDownload.progress(slug) * 100); return ``; } if (BookDownload.isDownloaded(slug)) { return ``; } return ``; } function _cfRemoveDownload(slug) { if (window.confirm('Remove this book’s offline download?')) BookDownload.remove(slug); } // Live-update just the download control as its state changes (avoids re-rendering // the whole reader on every downloaded-paragraph tick). document.addEventListener('bookdl:change', () => { const row = document.getElementById('cf-dlrow'); if (!(row && typeof currentScreen !== 'undefined' && currentScreen === 's-book-reader' && typeof _cfSlug !== 'undefined' && _cfSlug)) return; // While downloading, this event fires per paragraph — update the existing // button's text in place instead of rebuilding it, so a keyboard user's // focus on Cancel isn't destroyed every couple of seconds. const btn = row.querySelector('.cf-dl-btn.downloading'); if (btn && BookDownload.isDownloading(_cfSlug)) { const pct = Math.round(BookDownload.progress(_cfSlug) * 100); const textNode = [...btn.childNodes].find(n => n.nodeType === 3 && /Downloading/.test(n.nodeValue)); if (textNode) { textNode.nodeValue = `\n Downloading… ${pct}% · Cancel`; return; } } row.innerHTML = _cfDownloadHtml(_cfSlug); }); /* ─── Verse selection: tap → Save / Note / Copy / Share ───────────────────── Tap a verse in the Gospels (or a paragraph in Books) to select it; tap more to extend. A calm action bar docks above the nav: Save toggles the gold highlight (Journal), Note jumps to that highlight's Journal note, Copy puts a shareable citation on the clipboard, Share hands it to the system sheet. Selection is keyed by the same highlight ids the readers already register (hl:gospel:book:chap:verse / hl:father:slug:chap:para), so Save/Note reuse _hlRegistry and the existing Highlights store untouched. */ const VerseSelect = (() => { let ctxKey = null; // "type:key:chapter" — selections never span chapters let ctx = null; // { type, key, chapter } const sel = new Set(); // selected verse/paragraph numbers let bar = null, labelEl = null, saveBtn = null; function _parseId(id) { // reading ids carry the date in the `key` slot (hl:reading:2026-07-11:0:1). const m = /^hl:(gospel|father|reading):([A-Za-z0-9-]+):(\d+):(\d+)$/.exec(id || ''); return m ? { type: m[1], key: m[2], chapter: +m[3], n: +m[4] } : null; } function _id(n) { return 'hl:' + ctx.type + ':' + ctx.key + ':' + ctx.chapter + ':' + n; } function _elFor(n) { // Scope to reader blocks — the Journal's .hl-card-note textareas carry the // same data-hl-id and sit earlier in the DOM, so an unscoped query could // return a hidden textarea instead of the visible verse/paragraph. const id = _id(n); return document.querySelector('.g-verse[data-hl-id="' + id + '"], .cf-para[data-hl-id="' + id + '"], .reading-text[data-hl-id="' + id + '"]'); } // "3", "3-5", "3-5, 7" — consecutive runs collapse. Pure (tested offline). function _rangeStr(ns, sep) { ns = [...ns].sort((a, b) => a - b); const parts = []; for (let i = 0; i < ns.length;) { let j = i; while (j + 1 < ns.length && ns[j + 1] === ns[j] + 1) j++; parts.push(i === j ? String(ns[i]) : ns[i] + '-' + ns[j]); i = j + 1; } return parts.join(sep || ', '); } function _selRange(sep) { return _rangeStr([...sel], sep); } // "Matthew 5:3-5" / "First Epistle of Clement… 2.1" — built from the first // selected verse's registered refLabel, swapping its verse number for the range. function _refLabel() { const first = [...sel].sort((a, b) => a - b)[0]; const meta = _hlRegistry[_id(first)]; const base = (meta && meta.refLabel) || ''; // Daily readings carry the Mass citation verbatim (e.g. "John 3:16-21") — // its paragraphs aren't numbered verses, so there's no range to collapse. if (ctx.type === 'reading') return base; const m = /^(.*[:.])(\d+)$/.exec(base); if (!m) return base; // single-paragraph chapter labels carry no number return m[1] + _selRange(', '); } function _translationTag() { if (ctx.type !== 'gospel' || !_gospelsData || !_gospelsData.translation) return ''; const t = String(_gospelsData.translation); if (/world english bible/i.test(t)) return 'WEB'; const m = /\(([A-Z]{2,8})\)/.exec(t); // "New American Bible (NABRE)" → NABRE return m ? m[1] : ''; } // The 3rd citation line: a Haven deep link for the permanent readers, or the // day's USCCB link for a (date-scoped) daily reading, which has no stable // in-app deep link. function _citationLink() { if (ctx.type === 'reading') { const meta = _hlRegistry[_id([...sel].sort((a, b) => a - b)[0])]; return (meta && meta.link) || ''; } return location.origin + '/#v/' + ctx.type + '/' + ctx.key + '/' + ctx.chapter + '/' + _selRange(','); } function _citation() { const ns = [...sel].sort((a, b) => a - b); const quote = ns.map(n => { const meta = _hlRegistry[_id(n)]; return (meta && meta.quote) || ''; }) .filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); const tag = _translationTag(); const link = _citationLink(); return '“' + quote + '”\n' + _refLabel() + (tag ? ' ' + tag : '') + (link ? '\n' + link : ''); } function _allSaved() { return [...sel].every(n => Highlights.has(_id(n))); } function _build() { if (bar) return; bar = document.createElement('div'); bar.className = 'vs-bar'; bar.hidden = true; bar.innerHTML = ``; document.body.appendChild(bar); labelEl = bar.querySelector('.vs-bar-label'); saveBtn = bar.querySelector('[data-vs="save"]'); bar.addEventListener('click', (e) => { const b = e.target.closest('[data-vs]'); if (!b) return; ({ save: _actSave, note: _actNote, copy: _actCopy, share: _actShare, close: clear })[b.dataset.vs](); }); // Save-button state follows the store (covers stars toggled elsewhere too). document.addEventListener('highlights:change', () => { if (sel.size) _render(); }); } function _render() { _build(); if (!sel.size) { bar.hidden = true; return; } const tag = _translationTag(); labelEl.textContent = 'Selected: ' + _refLabel() + (tag ? ' ' + tag : ''); const saved = _allSaved(); saveBtn.classList.toggle('on', saved); saveBtn.querySelector('span').textContent = saved ? 'Saved' : 'Save'; saveBtn.setAttribute('aria-pressed', String(saved)); // Dock above the bottom nav — and above a docked mini player when one is up. const mini = document.querySelector('.hp-mini:not([hidden])'); bar.style.bottom = 'calc(var(--nav-h, 56px) + ' + (mini ? 76 : 10) + 'px + env(safe-area-inset-bottom, 0px))'; bar.hidden = false; // Underline exactly the selected verses. document.querySelectorAll('.vs-sel').forEach(el => el.classList.remove('vs-sel')); sel.forEach(n => { const el = _elFor(n); if (el) el.classList.add('vs-sel'); }); } function toggle(hlId) { const p = _parseId(hlId); if (!p) return; const key = p.type + ':' + p.key + ':' + p.chapter; if (key !== ctxKey) { sel.clear(); ctxKey = key; ctx = p; } if (sel.has(p.n)) sel.delete(p.n); else sel.add(p.n); if (!sel.size) { clear(); return; } _render(); } function clear() { sel.clear(); ctxKey = null; ctx = null; document.querySelectorAll('.vs-sel').forEach(el => el.classList.remove('vs-sel')); if (bar) bar.hidden = true; } function _actSave() { // All saved → unsave all; otherwise save the missing ones. const removeAll = _allSaved(); let ok = true, touched = 0; [...sel].sort((a, b) => a - b).forEach(n => { const id = _id(n); const has = Highlights.has(id); if (removeAll ? !has : has) return; const meta = _hlRegistry[id]; if (!removeAll && !meta) { ok = false; return; } const r = Highlights.toggle({ id, ref: meta && meta.ref, refLabel: meta && meta.refLabel, workTitle: meta && meta.workTitle, quote: meta && meta.quote }); if (!r.ok) ok = false; else touched++; }); if (!ok) { _toast('Couldn’t save — storage may be full.'); return false; } _toast(removeAll ? 'Removed from your Journal' : (touched > 1 ? touched + ' verses saved to your Journal' : 'Saved — find it in your Journal')); _render(); return true; } function _actNote() { if (!sel.size) return; // A note needs the highlight to exist — bail if the save failed (opening // the Journal would just no-op on a card that was never created). if (!_allSaved() && !_actSave()) return; const first = [...sel].sort((a, b) => a - b)[0]; const id = _id(first); clear(); openJournal(); setTimeout(() => { const ta = document.querySelector('.hl-card-note[data-hl-id="' + id + '"]'); if (!ta) return; ta.scrollIntoView({ block: 'center', behavior: 'auto' }); try { ta.focus({ preventScroll: true }); } catch (_e) { ta.focus(); } }, 350); } async function _actCopy() { const text = _citation(); let ok = false; try { await navigator.clipboard.writeText(text); ok = true; } catch (_e) { try { // clipboard API needs a secure context — textarea fallback const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (_e2) { ok = false; } } _toast(ok ? 'Copied' : 'Couldn’t copy on this device.'); } async function _actShare() { const text = _citation(); if (navigator.share) { try { await navigator.share({ text }); } catch (_e) { /* user cancelled */ } } else { _actCopy(); } } // Tap-to-select on verse/paragraph/reading text (buttons inside keep their // own jobs — e.g. the Books reader's per-paragraph Listen button). document.addEventListener('click', (e) => { if (e.target.closest('button, a, textarea, input, select')) return; const p = e.target.closest('.g-verse[data-hl-id], .cf-para[data-hl-id], .reading-text[data-hl-id]'); if (!p) return; toggle(p.dataset.hlId); }); // Navigating anywhere (tab switch, back, opening another screen) clears it. document.addEventListener('screen:change', clear); return { toggle, clear, active() { return sel.size > 0; }, _citation, _rangeStr, _refLabel }; })(); /* Deep links: #v/gospel/matthew/5/3-5 or #v/father/first-clement/2/1 — open the reader at that chapter, scroll to the first verse, flash the range. These are the links the Copy citation produces. */ function _expandVerseRanges(s) { const out = []; String(s || '').split(',').forEach(part => { const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim()); if (!m) return; const a = +m[1], b = m[2] ? +m[2] : a; for (let n = a; n <= Math.min(b, a + 200); n++) out.push(n); }); return out; } async function _openVerseLink() { const m = /^#v\/(gospel|father)\/([A-Za-z0-9-]+)\/(\d+)\/([\d,-]+)$/.exec(location.hash || ''); if (!m) return; const [, type, key, chapStr, ranges] = m; const chap = +chapStr; const ns = _expandVerseRanges(ranges); if (!ns.length) return; // Validate the book/slug BEFORE opening — a typo'd shared link would // otherwise silently land on the default book with no feedback. if (type === 'gospel') { if (!_GOSPEL_BOOK_KEYS[key]) { _toast('Couldn’t find that passage.'); return; } await openGospels(key, chap); } else { if (!(await _loadChurchFathersBooks()) || !_cfBooksData.books.some(b => b.slug === key)) { _toast('Couldn’t find that passage.'); return; } await openBookReader(key, chap); } setTimeout(() => { const reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; let scrolled = false; ns.forEach(n => { // Scope to reader blocks (see _elFor) — never a hidden Journal textarea. const sel = 'hl:' + type + ':' + key + ':' + chap + ':' + n; const el = document.querySelector('.g-verse[data-hl-id="' + sel + '"], .cf-para[data-hl-id="' + sel + '"], .reading-text[data-hl-id="' + sel + '"]'); if (!el) return; if (!scrolled) { el.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' }); scrolled = true; } el.classList.add('vs-flash'); setTimeout(() => el.classList.remove('vs-flash'), 2200); }); if (!scrolled) _toast('Couldn’t find that verse in this chapter.'); }, 400); } window.addEventListener('hashchange', _openVerseLink); setTimeout(_openVerseLink, 700); // handle a deep link present at app load /* ─── Books audiobook (Edge neural voice) ────────────────────────────────── Plays a book chapter as a queued Edge-voice playlist via _ttsAudio.playList (which gives caching, prefetch, the mini/full player, lock-screen controls), auto-advances chapter→chapter for hands-free listening, and persists a resume point per book ({chapter, seg, t}) that syncs across devices (bookPositions section in public/js/sync.js). The About summary reads through the same engine as a one-off (not tracked as a resume point). */ const BOOKPOS_LS = 'haven-bookpos'; const BookPos = { _read() { try { const v = JSON.parse(localStorage.getItem(BOOKPOS_LS) || '{}'); return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {}; } catch (_e) { return {}; } }, get(slug) { const p = this._read()[slug]; return (p && typeof p === 'object') ? p : null; }, set(slug, pos) { const map = this._read(); map[slug] = { chapter: Math.max(1, +pos.chapter || 1), seg: Math.max(0, +pos.seg || 0), t: Math.max(0, Math.floor(+pos.t || 0)), updatedAt: Date.now() }; try { localStorage.setItem(BOOKPOS_LS, JSON.stringify(map)); } catch (_e) {} document.dispatchEvent(new CustomEvent('bookpos:change')); }, clear(slug) { const map = this._read(); if (!(slug in map)) return; delete map[slug]; try { localStorage.setItem(BOOKPOS_LS, JSON.stringify(map)); } catch (_e) {} document.dispatchEvent(new CustomEvent('bookpos:change')); }, }; let _bookCtx = null; // { slug, chapter } currently being played (null = idle) let _bookSaveThrottle = 0; let _bookPrefetchGen = 0; // bumped per chapter start; halts stale next-chapter prefetch loops const BOOK_PREFETCH_AHEAD = 3; // how many of the next chapter's segments (chunks) to pre-warm (enough for a gapless boundary + lead-in; the rest warm one-ahead once playing) const BOOK_DL_CONCURRENCY = 4; // parallel synthesis requests when downloading a book for offline (bounded so /api/tts isn't flooded) const BOOK_DL_SCHEME = 2; // offline-download cache layout: 2 = per-chunk keys (1 = pre-chunking whole-paragraph keys). A book downloaded under an older scheme is treated as not-downloaded so playback doesn't silently miss the chunk cache. // ── Offline "download this book" ─────────────────────────────────────────── // Warms (and pins) every paragraph's audio into the TTS cache so the whole book // plays with no network. Device-local (the cache is per-device), keyed by the // current voice + TTS_REV, so switching voices offers a fresh download. const BOOK_DL_LS = 'haven-book-downloads'; const BookDownload = { _read() { try { const v = JSON.parse(localStorage.getItem(BOOK_DL_LS) || '{}'); return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {}; } catch (_e) { return {}; } }, _write(m) { try { localStorage.setItem(BOOK_DL_LS, JSON.stringify(m)); } catch (_e) {} document.dispatchEvent(new CustomEvent('bookdl:change')); }, isDownloaded(slug) { const e = this._read()[slug]; return !!(e && e.voice === _edgeVoice() && e.rev === _TTS_REV && e.scheme === BOOK_DL_SCHEME); }, _active: null, // { slug, done, total, cancelled } isDownloading(slug) { return !!(this._active && this._active.slug === slug); }, progress(slug) { if (this._active && this._active.slug === slug) return this._active.done / Math.max(1, this._active.total); return this.isDownloaded(slug) ? 1 : 0; }, _key(text, voice) { return voice + '|' + _TTS_REV + '|' + String(text || '').slice(0, 5000); }, // Every synthesis unit in the book, in order — chunked exactly like playback // (_bookChapterSegments) so the pinned offline cache keys match what playback // fetches. (Caching whole paragraphs here while playback fetches chunks would // make the offline copy unusable.) _paras(book) { const out = []; (book.chapters || []).forEach(ch => _bookChapterSegments(ch).forEach(s => out.push(s.text))); return out; }, async download(slug) { if (!_bookDownloadSupported()) { _toast('Offline download requires a Microsoft Edge voice. Switch in Settings → Read Aloud.'); return; } if (this._active) { _toast('A download is already in progress.'); return; } if (!(await BookAudio._ensure(slug))) { _toast('Could not load this book.'); return; } const book = BookAudio._findBook(slug); if (!book) return; const voice = _edgeVoice(), paras = this._paras(book); // Re-downloading after a voice change: delete the previous voice's pinned // audio first, or it becomes permanently unreachable (remove() only knows // the CURRENT record's voice, and prune() never expires pinned entries). const prev = this._read()[slug]; if (prev && prev.voice && prev.voice !== voice) { for (const t of paras) { try { await _ttsCache.del(this._key(t, prev.voice)); } catch (_e) {} } } // Clean up orphaned WHOLE-PARAGRAPH pinned audio from the pre-chunking // download scheme — playback now fetches chunk keys, so those old keys would // otherwise sit pinned forever (prune never expires pinned entries). Only // delete paragraph keys that aren't ALSO a chunk key (a short paragraph // chunks to itself — keep that one). const chunkKeys = new Set(paras.map(t => this._key(t, voice))); for (const ch of (book.chapters || [])) { for (const p of (ch.paragraphs || [])) { const t = String(p || '').replace(/\s+/g, ' ').trim(); if (!t) continue; const k = this._key(t, voice); if (!chunkKeys.has(k)) { try { await _ttsCache.del(k); } catch (_e) {} } } } const state = { slug, done: 0, total: paras.length, cancelled: false }; this._active = state; document.dispatchEvent(new CustomEvent('bookdl:change')); // Resume, don't restart: an interrupted download already pinned every unit // it finished, so figure out what's already cached and pick up from there. // Already-present units count as done immediately (re-pinned if a playback // put left one unpinned); only the missing ones are fetched below — so the // progress bar starts where it left off instead of re-climbing from 0%. const todo = []; for (const t of paras) { if (state.cancelled) break; const key = this._key(t, voice); try { const e = await _ttsCache.get(key); if (e && e.buf && e.marks) { if (!e.pinned) await _ttsCache.put(key, { buf: e.buf, marks: e.marks, ts: Date.now(), pinned: true }); state.done++; } else { todo.push(t); } } catch (_e) { todo.push(t); } } document.dispatchEvent(new CustomEvent('bookdl:change')); // reflect the resumed progress // Fetch only the missing units, with a small concurrency pool — a long book // is dozens of ~3-minute paragraphs (now chunked), and sequential synthesis // makes the download crawl. BOOK_DL_CONCURRENCY keeps /api/tts load bounded. let cursor = 0; const worker = async () => { while (!state.cancelled) { const i = cursor++; if (i >= todo.length) return; const text = todo[i], key = this._key(text, voice); try { const got = await _ttsFetchRetry(text, voice, 2); if (got.buf && got.buf.byteLength) await _ttsCache.put(key, { buf: got.buf, marks: got.marks, ts: Date.now(), pinned: true }); } catch (_e) { /* verified (and retried) below */ } state.done++; document.dispatchEvent(new CustomEvent('bookdl:change')); } }; await Promise.all(Array.from({ length: Math.min(BOOK_DL_CONCURRENCY, todo.length) }, () => worker())); let failed = 0; if (!state.cancelled) { // Verify every paragraph really is cached AND pinned before declaring the // book available offline. This both counts fetch failures (a flaky // connection must not record a book with silent gaps as "downloaded") // and repairs the pin race where concurrent playback's unpinned put // landed after ours (that entry would silently expire in the 7-day prune). for (const t of paras) { const key = this._key(t, voice); try { let e = await _ttsCache.get(key); if (!e || !e.buf || !e.marks) { // missing, or markless (same predicate as the loop) — retry const got = await _ttsFetchRetry(t, voice, 2); if (got.buf && got.buf.byteLength) { e = { buf: got.buf, marks: got.marks, ts: Date.now(), pinned: true }; await _ttsCache.put(key, e); } } else if (!e.pinned) { await _ttsCache.put(key, { buf: e.buf, marks: e.marks, ts: Date.now(), pinned: true }); } if (!e || !e.buf) failed++; // markless-but-audible isn't a failure — the server may simply have no marks for it } catch (_e) { failed++; } } } const finished = !state.cancelled && failed === 0; this._active = null; if (finished) { const m = this._read(); m[slug] = { voice, rev: _TTS_REV, scheme: BOOK_DL_SCHEME, at: Date.now() }; this._write(m); _toast('Downloaded — available offline'); } else { if (!state.cancelled) _toast('Download incomplete — ' + failed + ' section' + (failed === 1 ? '' : 's') + ' failed. Tap Download to retry.'); document.dispatchEvent(new CustomEvent('bookdl:change')); } }, cancel(slug) { if (this._active && this._active.slug === slug) this._active.cancelled = true; }, async remove(slug) { if (!(await BookAudio._ensure(slug))) return; const book = BookAudio._findBook(slug); if (!book) return; const voice = (this._read()[slug] || {}).voice || _edgeVoice(); for (const t of this._paras(book)) { try { await _ttsCache.del(this._key(t, voice)); } catch (_e) {} } const m = this._read(); delete m[slug]; this._write(m); _toast('Removed offline download'); }, }; // Generate an Audible-style cover for a book (no cover images exist for these // public-domain texts). A deterministic gradient from the slug + serif title + // author, rendered to a data-URL used both in the player and as the lock-screen // media-session artwork. Cached per slug so it's drawn once. const _bookCoverCache = Object.create(null); function _bookCoverDataUrl(book) { if (!book) return ''; const slug = book.slug || book.title || ''; if (_bookCoverCache[slug]) return _bookCoverCache[slug]; try { const S = 512, c = document.createElement('canvas'); c.width = S; c.height = S; const ctx = c.getContext('2d'); if (!ctx) return ''; let h = 0; for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) % 360; const g = ctx.createLinearGradient(0, 0, S, S); g.addColorStop(0, `hsl(${h},40%,32%)`); g.addColorStop(1, `hsl(${(h + 38) % 360},44%,15%)`); ctx.fillStyle = g; ctx.fillRect(0, 0, S, S); ctx.strokeStyle = 'rgba(245,239,224,0.22)'; ctx.lineWidth = 3; ctx.strokeRect(24, 24, S - 48, S - 48); ctx.fillStyle = 'rgba(201,168,74,0.92)'; ctx.fillRect(S / 2 - 44, 168, 88, 3); // Title — serif, wrapped to fit, centred in the upper-middle. ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; const words = String(book.title || '').toUpperCase().split(/\s+/).filter(Boolean); let size = 52; let lines = []; for (; size >= 30; size -= 3) { ctx.font = `600 ${size}px Georgia, "Times New Roman", serif`; lines = []; let line = ''; for (const w of words) { const t = line ? line + ' ' + w : w; if (ctx.measureText(t).width > S - 96 && line) { lines.push(line); line = w; } else line = t; } if (line) lines.push(line); if (lines.length <= 5) break; } ctx.fillStyle = '#f5efe0'; const lh = size + 8, startY = S / 2 - ((lines.length - 1) * lh) / 2 - 6; lines.forEach((ln, i) => ctx.fillText(ln, S / 2, startY + i * lh)); ctx.fillStyle = 'rgba(245,239,224,0.82)'; ctx.font = 'italic 27px Georgia, serif'; ctx.fillText(String(book.author || ''), S / 2, S - 66, S - 80); const url = c.toDataURL('image/png'); _bookCoverCache[slug] = url; return url; } catch (_e) { return ''; } } const BookAudio = { // Ensure the index is loaded and, when a slug is given, that book's full text // is hydrated (chapters live in the per-book file, fetched on demand). async _ensure(slug) { if (!(await _loadChurchFathersBooks())) return false; if (slug) return !!(await _loadBook(slug)); return true; }, _findBook(slug) { return (_cfBooksData && _cfBooksData.books.find(b => b.slug === slug)) || null; }, // A live audiobook session (from playChapter until it ends or is stopped), // independent of el.paused — the element pauses between paragraph fetches and // during chapter handoff, so el.paused would falsely read "not playing" there. // _ttsAudio.stop() clears _bookCtx, so a manual stop ends the session. hasSession(slug) { return !!(_bookCtx && (!slug || _bookCtx.slug === slug)); }, // "Listen to this book" — play the whole book start to finish from chapter 1. // playChapter already auto-advances chapter→chapter, so this is the explicit // "from the beginning" entry point (distinct from Resume, which continues). playBook(slug, btn) { return this.playChapter(slug, 1, 0, 0, btn); }, // The headline "Listen to this book" control is a play/pause toggle: if this // book is the live session, pause/resume it; otherwise start from the top. // (Ignores taps while the first paragraph is still buffering so a double-tap // can't restart it.) togglePlay(slug, btn) { if (btn && btn.classList.contains('is-loading')) return; if (_bookCtx && _bookCtx.slug === slug && _ttsAudio.el && _ttsAudio._list) { _ttsAudio.pauseResume(); return; } this.playBook(slug, btn); }, _chapterCount(slug) { const b = this._findBook(slug); return (b && b.chapters) ? b.chapters.length : 0; }, // "+Clip" — bookmark the paragraph currently being read to the Journal, reusing // the same highlight store/ids as the reader's highlight buttons. clipCurrent() { if (!_bookCtx) { _toast('Play a book chapter first.'); return; } const slug = _bookCtx.slug, chapNum = _bookCtx.chapter; const book = this._findBook(slug); const chapter = book && book.chapters[chapNum - 1]; if (!chapter || !chapter.paragraphs || !chapter.paragraphs.length) { _toast('Nothing to clip yet.'); return; } // Clip the paragraph the listener is actually hearing. _playingI lags _listI, // which is bumped in onended before the next segment's audio starts. The // playlist is now chunked, so map the segment index back to its paragraph. const cur = Number.isInteger(_ttsAudio._playingI) ? _ttsAudio._playingI : (_ttsAudio._listI || 0); const segPara = this._segPara || []; const segIntro = this._segIntro || []; // The spoken chapter heading isn't clippable content — clipping it during the // brief announcement should bookmark the paragraph it introduces (the first), // never quote "Chapter N. Title." back into the Journal. const onIntro = !!segIntro[cur]; // Map the segment index to its paragraph. If _segPara is momentarily out of // range (a race at a chapter handoff), fall back to the last known paragraph // in this chapter — never to the raw segment index, which isn't a paragraph. const rawPara = Number.isInteger(segPara[cur]) ? segPara[cur] : (segPara.length ? segPara[Math.min(cur, segPara.length - 1)] : 0); const paraIdx = Math.max(0, Math.min(chapter.paragraphs.length - 1, Number.isInteger(rawPara) ? rawPara : 0)); const para = paraIdx + 1; // Clip the sentence being spoken (read-along keeps the exact position), // falling back to the whole paragraph. Guard that the read-along's current // chunk really belongs to THIS paragraph (a substring of it) — a race at a // segment boundary must not attach another paragraph's sentence to this id. // (Never during the heading: read-along isn't tracking it, so use the paragraph.) let quote = chapter.paragraphs[paraIdx]; try { const raSentence = ReadAlong.currentQuote(); const raText = ReadAlong.currentText(); const paraNorm = (quote || '').replace(/\s+/g, ' ').trim(); if (!onIntro && raSentence && raText && paraNorm.indexOf(raText) >= 0 && raSentence.length < paraNorm.length) quote = raSentence; } catch (_e) { /* whole-paragraph quote */ } const id = 'hl:father:' + slug + ':' + chapNum + ':' + para; if (Highlights.has(id)) { _toast('Already clipped — see your Journal'); return; } const refLabel = book.title + ' ' + chapNum + (chapter.paragraphs.length > 1 ? '.' + para : ''); const { saved, ok } = Highlights.toggle({ id, ref: { type: 'father', slug, chapter: chapNum, para }, refLabel, workTitle: book.title, quote }); _toast(ok ? (saved ? 'Clipped to your Journal' : 'Removed clip') : 'Couldn’t save — storage may be full.'); // Brief confirm flash on the button (a persistent "saved" state would mislead — the // target paragraph moves as playback advances). const btn = _ttsMini && _ttsMini.fullClip; if (ok && saved && btn) { btn.classList.add('clipped'); setTimeout(() => btn.classList.remove('clipped'), 900); } }, // ── Chapter timeline (for the whole-chapter scrubber + "time left") ──────── // Paragraph durations are exact once a paragraph has played; the rest are // estimated from character count at a self-calibrating chars-per-second, so // the scrubber and time labels are accurate as playback proceeds. _tl: null, // Chars-per-second calibration, persisted across sessions so a COLD resume // (no per-segment durations yet) maps its saved time to a chunk using a // real-world rate learned from prior listening — not just the 14.5 default. _loadCps() { try { const v = parseFloat(localStorage.getItem('haven-book-cps')); if (v >= 5 && v <= 40) return v; } catch (_e) {} return 14.5; }, _initTimeline(slug, chapterNumber) { const book = this._findBook(slug); const ch = book && book.chapters[chapterNumber - 1]; // Segment (chunk) granularity so _listI — a segment index — lines up with the // char/duration arrays used by the scrubber and "time left". const segs = _bookChapterSegments(ch || {}); const prevCps = (this._tl && this._tl.cps) || this._loadCps(); // carry calibration across chapters / sessions this._tl = { slug, chapter: chapterNumber, chars: segs.map(s => s.text.length), dur: [], cps: prevCps }; }, _tlActive() { return !!(this._tl && _bookCtx && _bookCtx.slug === this._tl.slug && _bookCtx.chapter === this._tl.chapter); }, _noteDuration(i, d) { const t = this._tl; if (!t || !(d > 0) || !isFinite(d)) return; if (t.dur[i] === d) return; t.dur[i] = d; let sc = 0, sd = 0; for (let k = 0; k < t.dur.length; k++) if (t.dur[k] > 0) { sc += t.chars[k] || 0; sd += t.dur[k]; } if (sd > 2 && sc > 20) { t.cps = sc / sd; try { localStorage.setItem('haven-book-cps', String(t.cps)); } catch (_e) {} } // recalibrate + persist }, _segDur(i) { const t = this._tl; return (t.dur[i] > 0) ? t.dur[i] : Math.max(0.4, (t.chars[i] || 0) / t.cps); }, _chapterTotal() { const t = this._tl; let s = 0; for (let i = 0; i < t.chars.length; i++) s += this._segDur(i); return s; }, // { total, elapsed, remainChapter, remainBook, frac } for the current chapter, or null. timeline() { if (!this._tlActive()) return null; const t = this._tl, el = _ttsAudio.el, i = Math.max(0, _ttsAudio._listI || 0); let cum = 0; for (let k = 0; k < i && k < t.chars.length; k++) cum += this._segDur(k); const cur = (el && isFinite(el.currentTime)) ? el.currentTime : 0; const total = this._chapterTotal(); const elapsed = Math.max(0, Math.min(total, cum + cur)); const remainChapter = Math.max(0, total - elapsed); const book = this._findBook(t.slug); let later = 0; if (book && book.chapters) { for (let c = t.chapter; c < book.chapters.length; c++) { let ch = 0; for (const p of (book.chapters[c].paragraphs || [])) ch += (p || '').length; later += ch / t.cps; } } return { total, elapsed, remainChapter, remainBook: remainChapter + later, frac: total > 0 ? elapsed / total : 0 }; }, // Seek to an absolute time within the chapter, mapping it to the containing // chunk + in-chunk offset (durations are exact for played chunks, estimated // for the rest). The scrubber and the ±15s rewind/forward both go through here. _seekToAbs(target) { if (!this._tlActive()) return; const durs = this._tl.chars.map((_c, k) => this._segDur(k)); const { seg, offset } = _bookSegAtTime(durs, target); _ttsAudio.seekTo(seg, offset); }, timelineSeek(frac) { if (this._tlActive()) this._seekToAbs((frac || 0) * this._chapterTotal()); }, // Rewind / fast-forward by ±seconds ACROSS chunk boundaries. A plain // element-level seek is trapped inside the current ~30s chunk (can't rewind // past its start); this converts the current position to absolute chapter time // first, so it crosses into earlier/later chunks. seekRelative(delta) { const a = _ttsAudio.el; if (!this._tlActive() || !a) { // no chapter timeline (e.g. a one-off reading) — stay in-element if (a && isFinite(a.duration)) a.currentTime = Math.min(Math.max(0, a.currentTime + (delta || 0)), a.duration); return; } const i = Math.max(0, _ttsAudio._listI || 0); let cum = 0; for (let k = 0; k < i && k < this._tl.chars.length; k++) cum += this._segDur(k); const cur = isFinite(a.currentTime) ? a.currentTime : 0; this._seekToAbs(cum + cur + (delta || 0)); }, // Player prev/next-chapter controls: jump within the live audiobook session. // playChapter → playList → stop() closes the full player, so re-open it after // the jump (once _bookCtx points at the new chapter) to keep the player up. _jumpChapter(delta) { if (!_bookCtx) return; const slug = _bookCtx.slug, target = _bookCtx.chapter + delta; if (target < 1 || target > this._chapterCount(slug)) return; const reopen = !!(_ttsMini.fullEl && !_ttsMini.fullEl.hidden); this.playChapter(slug, target, 0, 0, null).then(() => { if (reopen) _ttsMini.openFull(); }); }, nextChapter() { this._jumpChapter(1); }, prevChapter() { this._jumpChapter(-1); }, async playChapter(slug, chapterNumber, fromSeg, fromT, btn) { // Toggle-off: re-tapping the button that's currently playing must stop // cleanly BEFORE any side effects run — otherwise BookPos.set below wipes // the mid-chapter resume point, history logs a phantom listen, the next // chapter gets prefetched, and _bookCtx is re-set into a ghost session // (hasSession() true while idle blocks resume-point saves in the reader). if (btn && _ttsAudio.btn === btn && _ttsAudio.el) { _ttsAudio.stop(); return; } // A user-initiated start clears any leftover end-of-chapter sleep flag from // an earlier session (auto-advance passes btn=null, so a live "End of // chapter" timer still stops the book at the boundary it was armed for). if (btn && typeof _ttsSleep !== 'undefined' && _ttsSleep.atChapterEnd()) _ttsSleep.clear(); if (!(await this._ensure(slug))) { _toast('Could not load this book.'); return; } const book = this._findBook(slug); if (!book || !book.chapters || !book.chapters.length) return; chapterNumber = Math.min(Math.max(1, +chapterNumber || 1), book.chapters.length); const chapter = book.chapters[chapterNumber - 1]; const label = book.title + ' — Chapter ' + chapterNumber; // elId points at the reader's paragraph so the read-along can follow when // the reader is open on this chapter (ReadAlong verifies the text matches, // so a reader showing a different book/chapter is simply left alone). // Chunk long paragraphs so the first bit of audio starts fast (see // _bookChapterSegments). Each chunk's elId points at its PARAGRAPH element so // read-along still follows on the visible text; _segPara maps a chunk (segment) // index back to its paragraph for +Clip. The resume point (fromSeg) is a // PARAGRAPH index, mapped to a chunk below. const segs = _bookChapterSegments(chapter); this._segPara = segs.map(s => s.para); // Mark which segments are the spoken chapter heading (not a reader paragraph) // so +Clip can clip the upcoming first paragraph instead of the announcement. this._segIntro = segs.map(s => !!s.intro); // The spoken chapter heading isn't a reader paragraph, so it has no read-along // target; real chunks point at their paragraph element. const items = segs.map(s => ({ text: s.text, label, voice: _edgeVoice(), elId: s.intro ? '' : 'cf-para-' + chapterNumber + '-' + s.para })); this._initTimeline(slug, chapterNumber); // Keep the visible reader in step with what's playing (for continuous listen). if (currentScreen === 's-book-reader' && _cfSlug === slug && _cfChap !== chapterNumber) { _cfChap = chapterNumber; _renderBookReader(); } // BookPos.seg stays a PARAGRAPH index and t is seconds spoken WITHIN that // paragraph — the same meaning t had when a paragraph was a single audio clip, // so resume points saved by older builds still work with no migration. Now // that a paragraph spans several chunks, map (paragraph, t) to a specific // chunk + in-chunk offset for the playlist start, and save t as the running // cumulative-into-paragraph time. const fromPara = Math.max(0, +fromSeg || 0); const resumeT = Math.max(0, +fromT || 0); const segParas = segs.map(s => Number.isInteger(s.para) ? s.para : 0); const durOf = (k) => this._segDur(k); // exact once played, else char/cps estimate const { startIndex, startOffset } = _bookResumeSeek(segParas, durOf, fromPara, resumeT); BookPos.set(slug, { chapter: chapterNumber, seg: fromPara, t: resumeT }); _bookLogHistory(book, chapter); // Capture slug/chapter in the callbacks so position saving and auto-advance // don't depend on _bookCtx surviving _ttsAudio.stop() (which nulls it at the // completion→advance handoff). bookSlug lets the reader's play bar reflect // this session even across auto-advance (which plays with btn=null). _ttsAudio.playList(items, btn || null, label, { album: book.title, art: _bookCoverDataUrl(book), bookSlug: slug, startIndex, startOffset, onSegment: (i) => BookPos.set(slug, { chapter: chapterNumber, seg: segParas[i] || 0, t: _bookCumIntoPara(segParas, durOf, i, 0) }), onProgress: (t, i) => { const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); if (now - _bookSaveThrottle < 4000) return; _bookSaveThrottle = now; BookPos.set(slug, { chapter: chapterNumber, seg: segParas[i] || 0, t: _bookCumIntoPara(segParas, durOf, i, t) }); }, onComplete: (playedAny) => this._advanceFrom(slug, chapterNumber, playedAny), }); // Mark the live session AFTER playList — its synchronous stop() (clearing any // prior playback) nulls _bookCtx, so setting it beforehand would be wiped and // hasSession() would read false for the entire chapter (defeating the guard // that protects the resume point on chapter taps, and the "now playing" state). _bookCtx = { slug: slug, chapter: chapterNumber }; // Immediate buffering feedback: playList has synchronously set _cb.bookSlug, // so the headline shows the "Starting…" spinner right now — before any audio // exists — no matter which control (Listen / chapter / resume) started it. try { _syncBookPlaybar(); } catch (_e) {} // Warm the NEXT chapter's audio in the background while this one plays, so the // chapter→chapter handoff is gapless (within-chapter segments are already // prefetched one-ahead by _ttsAudio). Starts from the next chapter's first // paragraph, so even a long current chapter has its boundary ready in time. this._prefetchChapter(slug, chapterNumber + 1); }, // Background-cache every paragraph of a chapter, in order, halting if the book // session ends or a newer prefetch supersedes this one (generation guard). _prefetchChapter(slug, chapterNumber) { const book = this._findBook(slug); if (!book || !book.chapters || chapterNumber < 1 || chapterNumber > book.chapters.length) return; const chapter = book.chapters[chapterNumber - 1]; if (!chapter || !chapter.paragraphs || !chapter.paragraphs.length) return; // Only the first few segments — enough for a gapless boundary and lead-in. // Once playback reaches this chapter, _ttsAudio's per-segment one-ahead // prefetch warms the rest as it plays, so a long chapter isn't synthesized // in full up front (keeps background /api/tts load bounded). Uses the same // chunking as playback so the warmed cache keys match what playback fetches. const items = _bookChapterSegments(chapter).slice(0, BOOK_PREFETCH_AHEAD).map(s => ({ text: s.text, voice: _edgeVoice() })); const gen = ++_bookPrefetchGen; // Halt when a newer prefetch supersedes this one, the session ends/switches // books, or we're no longer on the chapter whose successor this warms. _ttsAudio.prefetchItems(items, () => gen === _bookPrefetchGen && typeof _bookCtx !== 'undefined' && !!_bookCtx && _bookCtx.slug === slug && _bookCtx.chapter === chapterNumber - 1); }, _advanceFrom(slug, chapter, playedAny) { // Called after a chapter's queue finishes. The chapter produced no audio // (TTS/network down, or play() rejected) — halt rather than skip silently // through the rest of the book; the resume point stays put. if (!playedAny) { _bookCtx = null; return; } const book = this._findBook(slug); const hasNext = book && chapter + 1 <= book.chapters.length; // Sleep timer set to "end of chapter": stop here instead of auto-advancing, // leaving the resume point at the start of the next chapter. if (typeof _ttsSleep !== 'undefined' && _ttsSleep.atChapterEnd()) { _ttsSleep.clear(); if (hasNext) BookPos.set(slug, { chapter: chapter + 1, seg: 0, t: 0 }); else BookPos.clear(slug); _bookCtx = null; return; } if (hasNext) { this.playChapter(slug, chapter + 1, 0, 0, null); // continuous audiobook } else { BookPos.clear(slug); // reached the end of the book _bookCtx = null; } }, async playAbout(slug, btn) { if (!(await this._ensure(slug))) return; const book = this._findBook(slug); if (!book || !book.about) return; const label = 'About — ' + book.title; const segs = _aboutTtsSegments(book.about, book.title, 'cf-about-body'); if (!segs.length) return; _bookCtx = null; // the About read is a one-off; never a resume point _ttsAudio.playList(segs, btn || null, label, { album: book.title, art: _bookCoverDataUrl(book) }); }, }; function _bookLogHistory(book, chapter) { try { if (window.Haven && window.Haven.history && window.Haven.history.log) { window.Haven.history.log({ kind: 'book', showId: book.slug, showTitle: book.title, showImage: '', item: { title: book.title + ' — Chapter ' + chapter.number, slug: book.slug, chapter: chapter.number, chapterTitle: chapter.title || '' }, playedAt: Date.now(), }); } } catch (_e) {} } // Reopen a book from History (or elsewhere) and start listening. Resumes from // the saved mid-chapter offset when the target chapter matches the resume point; // otherwise starts the requested chapter from the top. window.Haven = window.Haven || {}; window.Haven.openBookListen = async function (slug, chapter) { const pos = BookPos.get(slug); const ch = chapter || (pos && pos.chapter) || 1; const seg = (pos && pos.chapter === ch) ? (pos.seg || 0) : 0; const t = (pos && pos.chapter === ch) ? (pos.t || 0) : 0; await openBookReader(slug, ch); BookAudio.playChapter(slug, ch, seg, t, null); }; const RANDOM_BIBLE_QUOTES = [ '“Ignorance of Scripture is ignorance of Christ.” — St. Jerome', '“When we pray, we speak to God; when we read, God speaks to us.” — St. Jerome', '“The sacred page is like a letter from Almighty God to His creature.” — St. Gregory the Great', '“The words of the Lord are pure words: silver tried by the fire.” — St. Augustine', '“Read the divine Scriptures frequently; never let the sacred volume out of your hand.” — St. Jerome', '“In the sacred books, the Father who is in heaven meets His children with great love.” — St. Ambrose' ]; let _randomPassageQuoteIndex = -1; const CATHOLIC_BIBLE_BOOKS = [ { name: 'Genesis', chapters: 50, testament: 'Old Testament', desc: 'Beginnings: creation, covenant, and the patriarchs of Israel.' }, { name: 'Exodus', chapters: 40, testament: 'Old Testament', desc: 'God frees Israel from Egypt and gives the Law through Moses.' }, { name: 'Leviticus', chapters: 27, testament: 'Old Testament', desc: 'Priestly worship, holiness, and covenant life before God.' }, { name: 'Numbers', chapters: 36, testament: 'Old Testament', desc: 'Israel’s wilderness journey, trials, and divine guidance.' }, { name: 'Deuteronomy', chapters: 34, testament: 'Old Testament', desc: 'Moses renews the covenant before Israel enters the land.' }, { name: 'Joshua', chapters: 24, testament: 'Old Testament', desc: 'Israel enters and settles the promised land.' }, { name: 'Judges', chapters: 21, testament: 'Old Testament', desc: 'Cycles of rebellion, rescue, and judges raised by God.' }, { name: 'Ruth', chapters: 4, testament: 'Old Testament', desc: 'A story of fidelity, providence, and David’s family line.' }, { name: '1 Samuel', chapters: 31, testament: 'Old Testament', desc: 'Samuel, Saul, and the rise of King David.' }, { name: '2 Samuel', chapters: 24, testament: 'Old Testament', desc: 'David’s reign, victories, sins, and covenant promise.' }, { name: '1 Kings', chapters: 22, testament: 'Old Testament', desc: 'Solomon’s kingdom and the beginning of division.' }, { name: '2 Kings', chapters: 25, testament: 'Old Testament', desc: 'The fall of Israel and Judah into exile.' }, { name: '1 Chronicles', chapters: 29, testament: 'Old Testament', desc: 'A priestly retelling of Israel up to David.' }, { name: '2 Chronicles', chapters: 36, testament: 'Old Testament', desc: 'From Solomon to exile with focus on temple worship.' }, { name: 'Ezra', chapters: 10, testament: 'Old Testament', desc: 'Return from exile and restoration of covenant life.' }, { name: 'Nehemiah', chapters: 13, testament: 'Old Testament', desc: 'Rebuilding Jerusalem’s walls and renewing the people.' }, { name: 'Tobit', chapters: 14, testament: 'Old Testament', desc: 'A family story of providence, fidelity, and healing.' }, { name: 'Judith', chapters: 16, testament: 'Old Testament', desc: 'Courageous trust in God delivers Israel from threat.' }, { name: 'Esther', chapters: 16, testament: 'Old Testament', desc: 'God preserves His people through hidden providence.' }, { name: '1 Maccabees', chapters: 16, testament: 'Old Testament', desc: 'The Maccabean revolt and struggle for faithful worship.' }, { name: '2 Maccabees', chapters: 15, testament: 'Old Testament', desc: 'Martyrdom, resurrection hope, and God’s mercy in crisis.' }, { name: 'Job', chapters: 42, testament: 'Old Testament', desc: 'A righteous man wrestles with suffering and trust.' }, { name: 'Psalms', chapters: 150, testament: 'Old Testament', desc: 'Israel’s prayer book of lament, praise, and hope.' }, { name: 'Proverbs', chapters: 31, testament: 'Old Testament', desc: 'Wisdom sayings for reverent and practical living.' }, { name: 'Ecclesiastes', chapters: 12, testament: 'Old Testament', desc: 'A meditation on life’s limits and fear of God.' }, { name: 'Song of Songs', chapters: 8, testament: 'Old Testament', desc: 'Poetry of covenant love, longing, and delight.' }, { name: 'Wisdom', chapters: 19, testament: 'Old Testament', desc: 'Wisdom, justice, and God’s guidance in history.' }, { name: 'Sirach', chapters: 51, testament: 'Old Testament', desc: 'Practical and spiritual wisdom for faithful daily life.' }, { name: 'Isaiah', chapters: 66, testament: 'Old Testament', desc: 'Prophecy of judgment, consolation, and the coming Messiah.' }, { name: 'Jeremiah', chapters: 52, testament: 'Old Testament', desc: 'A prophet’s warning, lament, and promise of a new covenant.' }, { name: 'Lamentations', chapters: 5, testament: 'Old Testament', desc: 'Poetic grief over Jerusalem’s fall and prayer for mercy.' }, { name: 'Baruch', chapters: 6, testament: 'Old Testament', desc: 'Confession, wisdom, and hope for exiles.' }, { name: 'Ezekiel', chapters: 48, testament: 'Old Testament', desc: 'Visions of judgment, restoration, and God’s glory.' }, { name: 'Daniel', chapters: 14, testament: 'Old Testament', desc: 'Faithful witness amid empire, with apocalyptic hope.' }, { name: 'Hosea', chapters: 14, testament: 'Old Testament', desc: 'Covenant love that pursues an unfaithful people.' }, { name: 'Joel', chapters: 3, testament: 'Old Testament', desc: 'A call to repentance and promise of God’s Spirit.' }, { name: 'Amos', chapters: 9, testament: 'Old Testament', desc: 'Justice for the poor and true worship before God.' }, { name: 'Obadiah', chapters: 1, testament: 'Old Testament', desc: 'Judgment on pride and vindication of God’s people.' }, { name: 'Jonah', chapters: 4, testament: 'Old Testament', desc: 'Mercy for enemies and a prophet’s reluctant mission.' }, { name: 'Micah', chapters: 7, testament: 'Old Testament', desc: 'Judgment and hope: do justice, love mercy, walk humbly.' }, { name: 'Nahum', chapters: 3, testament: 'Old Testament', desc: 'Oracle against Nineveh and assurance of divine justice.' }, { name: 'Habakkuk', chapters: 3, testament: 'Old Testament', desc: 'A dialogue on evil, faith, and waiting for God.' }, { name: 'Zephaniah', chapters: 3, testament: 'Old Testament', desc: 'Day of the Lord warning and promise of renewal.' }, { name: 'Haggai', chapters: 2, testament: 'Old Testament', desc: 'Encouragement to rebuild the temple and hope in God’s future.' }, { name: 'Zechariah', chapters: 14, testament: 'Old Testament', desc: 'Visions of restoration, kingship, and messianic hope.' }, { name: 'Malachi', chapters: 4, testament: 'Old Testament', desc: 'A final prophetic call to covenant fidelity.' }, { name: 'Matthew', key: 'matthew', chapters: 28, testament: 'New Testament', desc: 'Jesus the Messiah fulfills the Law and prophets.' }, { name: 'Mark', key: 'mark', chapters: 16, testament: 'New Testament', desc: 'A fast-paced proclamation of Jesus’ mission and cross.' }, { name: 'Luke', key: 'luke', chapters: 24, testament: 'New Testament', desc: 'Christ’s mercy for all nations, especially the poor.' }, { name: 'John', key: 'john', chapters: 21, testament: 'New Testament', desc: 'The Word made flesh, revealing the Father’s love.' }, { name: 'Acts', chapters: 28, testament: 'New Testament', desc: 'The Holy Spirit sends the Church to the nations.' }, { name: 'Romans', chapters: 16, testament: 'New Testament', desc: 'Paul on grace, faith, and life in Christ.' }, { name: '1 Corinthians', chapters: 16, testament: 'New Testament', desc: 'Pastoral correction on unity, worship, and resurrection.' }, { name: '2 Corinthians', chapters: 13, testament: 'New Testament', desc: 'Paul defends his ministry in weakness and hope.' }, { name: 'Galatians', chapters: 6, testament: 'New Testament', desc: 'Freedom in Christ apart from the yoke of the Law.' }, { name: 'Ephesians', chapters: 6, testament: 'New Testament', desc: 'The Church as Christ’s body, called to unity and holiness.' }, { name: 'Philippians', chapters: 4, testament: 'New Testament', desc: 'Joy and humility in Christ amid suffering.' }, { name: 'Colossians', chapters: 4, testament: 'New Testament', desc: 'The supremacy of Christ over all creation.' }, { name: '1 Thessalonians', chapters: 5, testament: 'New Testament', desc: 'Encouragement in faith, love, and expectation of the Lord.' }, { name: '2 Thessalonians', chapters: 3, testament: 'New Testament', desc: 'Perseverance, discernment, and steady Christian work.' }, { name: '1 Timothy', chapters: 6, testament: 'New Testament', desc: 'Pastoral guidance for teaching, prayer, and church order.' }, { name: '2 Timothy', chapters: 4, testament: 'New Testament', desc: 'Paul’s final charge to remain faithful to the Gospel.' }, { name: 'Titus', chapters: 3, testament: 'New Testament', desc: 'Healthy doctrine expressed in good works.' }, { name: 'Philemon', chapters: 1, testament: 'New Testament', desc: 'A short appeal for mercy and Christian brotherhood.' }, { name: 'Hebrews', chapters: 13, testament: 'New Testament', desc: 'Christ the true high priest and perfect sacrifice.' }, { name: 'James', chapters: 5, testament: 'New Testament', desc: 'Faith that proves itself through patient action.' }, { name: '1 Peter', chapters: 5, testament: 'New Testament', desc: 'Hope and holiness while enduring trials.' }, { name: '2 Peter', chapters: 3, testament: 'New Testament', desc: 'A call to growth in truth and vigilance.' }, { name: '1 John', chapters: 5, testament: 'New Testament', desc: 'God is love: walk in light, truth, and charity.' }, { name: '2 John', chapters: 1, testament: 'New Testament', desc: 'A brief exhortation to remain in truth and love.' }, { name: '3 John', chapters: 1, testament: 'New Testament', desc: 'Commendation of faithful hospitality and integrity.' }, { name: 'Jude', chapters: 1, testament: 'New Testament', desc: 'Contend for the faith once delivered to the saints.' }, { name: 'Revelation', chapters: 22, testament: 'New Testament', desc: 'Apocalyptic vision of Christ’s victory and new creation.' } ]; function _randomBibleBook() { return CATHOLIC_BIBLE_BOOKS[Math.floor(Math.random() * CATHOLIC_BIBLE_BOOKS.length)]; } function _bookSlug(name) { return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } function _nextBibleQuote() { _randomPassageQuoteIndex = (_randomPassageQuoteIndex + 1) % RANDOM_BIBLE_QUOTES.length; return RANDOM_BIBLE_QUOTES[_randomPassageQuoteIndex]; } function openRandomPassage() { const book = _randomBibleBook(); if (!book) return; const chapter = Math.floor(Math.random() * book.chapters) + 1; if (book.key && _GOSPEL_BOOK_KEYS[book.key]) { openGospels(book.key, chapter); return; } const ref = book.name + ' ' + chapter; const usccb = 'https://bible.usccb.org/bible/' + _bookSlug(book.name) + '/' + chapter; const quote = _nextBibleQuote(); const body = document.getElementById('bible-passage-body'); _set('bible-passage-title', ref); if (body) { body.innerHTML = `
${_icon('book-open', 16)}${_esc(ref)}
${_esc(book.testament)}
${_esc(book.name)}
${_esc(book.desc)}
↗ Read on USCCB
🎲 Try another passage
${_esc(quote)}
`; } if (currentScreen === 's-bible-passage') { showScreen('s-bible-passage'); return; } switchTab('more'); pushScreen('s-bible-passage'); } /* ─── Church Fathers ───────────────────────────────────────────────── */ function openRandomFather() { var list = (typeof CHURCH_FATHERS !== 'undefined' && CHURCH_FATHERS.length) ? CHURCH_FATHERS : []; if (!list.length) return; var father = list[Math.floor(Math.random() * list.length)]; _toast('Opening ' + father.name + '…'); openFatherDetail(father.id); } function openFathers() { var body = document.getElementById('fathers-body'); if (!body) return; var eras = [ { key: 'apostolic', label: 'The Apostolic Fathers', sub: 'c. 50–150 AD — those who knew the Apostles' }, { key: 'apologist', label: 'The Apologists', sub: 'c. 150–250 AD — first systematic defenses of the faith' }, { key: 'councils', label: 'Fathers of the Great Councils', sub: 'c. 300–430 AD — shapers of the creeds' }, { key: 'doctors', label: 'Doctors of the Church', sub: '4th century onward — officially recognized teachers of the faith' } ]; body.innerHTML = eras.map(function(era) { var fathers = CHURCH_FATHERS.filter(function(f) { return f.era === era.key; }); return '
' + '
' + _esc(era.sub) + '
' + '
' + fathers.map(function(f) { return `
` + '
' + _esc(f.name) + '
' + '
' + _esc(f.role) + ' · ' + _esc(f.dates) + '
' + '
›
'; }).join('') + '
'; }).join(''); pushScreen('s-fathers'); } function openFatherDetail(id) { var f = CHURCH_FATHERS.find(function(x) { return x.id === id; }); if (!f) return; document.getElementById('father-detail-title').textContent = f.name; var quoteHtml = f.quotes.map(function(q) { return '
' + '
' + _esc(q.text) + '
' + '
' + _esc(q.source) + '
' + '
'; }).join(''); var linksHtml = f.links && f.links.length ? '
' + f.links.map(function(l) { return `
` + '' + _esc(l.label) + '' + '↗
'; }).join('') + '
' : ''; var worksHtml = f.works && f.works.length ? '
' + f.works.map(function(w) { return '
' + '
' + _esc(w.title) + '
' + '
' + _esc(w.written) + '
' + '
' + _esc(w.summary) + '
' + '
' + '
' + `
` + 'Read free online' + '↗
'; }).join('') : ''; document.getElementById('father-detail-content').innerHTML = '
' + '
' + _esc(f.role) + ' · ' + _esc(f.dates) + '
' + quoteHtml + '
' + _esc(f.context) + '
' + worksHtml + linksHtml; pushScreen('s-father-detail'); } /* ─── Guided prayer companion ───────────────────────────────────────── A reusable step-through engine (Sign of the Cross → … → Amen) shared by the Rosary, Stations of the Cross, and Divine Mercy Chaplet. Each step is { eyebrow?, title, subtitle?, text, meta?, repeat? } — repeat renders a row of tap-to-count beads. */ const PRAYER_TEXT = { sign: `In the name of the Father, and of the Son, and of the Holy Spirit. Amen.`, creed: `I believe in God, the Father almighty, Creator of heaven and earth, and in Jesus Christ, his only Son, our Lord, who was conceived by the Holy Spirit, born of the Virgin Mary, suffered under Pontius Pilate, was crucified, died and was buried; he descended into hell; on the third day he rose again from the dead; he ascended into heaven, and is seated at the right hand of God the Father almighty; from there he will come to judge the living and the dead. I believe in the Holy Spirit, the holy catholic Church, the communion of saints, the forgiveness of sins, the resurrection of the body, and life everlasting. Amen.`, our_father: `Our Father, who art in heaven, hallowed be thy name; thy kingdom come; thy will be done on earth as it is in heaven. Give us this day our daily bread, and forgive us our trespasses, as we forgive those who trespass against us; and lead us not into temptation, but deliver us from evil. Amen.`, hail_mary: `Hail Mary, full of grace, the Lord is with thee. Blessed art thou among women, and blessed is the fruit of thy womb, Jesus. Holy Mary, Mother of God, pray for us sinners, now and at the hour of our death. Amen.`, glory_be: `Glory be to the Father, and to the Son, and to the Holy Spirit; as it was in the beginning, is now, and ever shall be, world without end. Amen.`, fatima: `O my Jesus, forgive us our sins, save us from the fires of hell; lead all souls to heaven, especially those in most need of thy mercy. Amen.`, hail_holy_queen: `Hail, holy Queen, Mother of mercy, our life, our sweetness, and our hope. To thee do we cry, poor banished children of Eve; to thee do we send up our sighs, mourning and weeping in this valley of tears. Turn then, most gracious advocate, thine eyes of mercy toward us; and after this our exile, show unto us the blessed fruit of thy womb, Jesus. O clement, O loving, O sweet Virgin Mary. Pray for us, O holy Mother of God, that we may be made worthy of the promises of Christ. Amen.`, }; const ROSARY_ORDINALS = ['First', 'Second', 'Third', 'Fourth', 'Fifth']; const ROSARY_MYSTERIES = { joyful: { name: 'Joyful', mysteries: [ { title: 'The Annunciation', fruit: 'Humility', text: 'The angel Gabriel announces to Mary that she is to be the Mother of God; she answers, "Let it be done to me according to your word." (Luke 1)' }, { title: 'The Visitation', fruit: 'Love of neighbor', text: 'Mary visits her cousin Elizabeth, who greets her as "the mother of my Lord"; Mary proclaims the Magnificat. (Luke 1)' }, { title: 'The Nativity', fruit: 'Poverty of spirit', text: 'Jesus is born in Bethlehem and laid in a manger, for there was no room at the inn. (Luke 2)' }, { title: 'The Presentation', fruit: 'Obedience', text: 'Mary and Joseph present the infant Jesus in the Temple; Simeon proclaims him a light to the nations. (Luke 2)' }, { title: 'The Finding in the Temple', fruit: 'Joy in finding Jesus', text: 'After three days of searching, Mary and Joseph find the boy Jesus among the teachers in the Temple. (Luke 2)' }, ] }, sorrowful: { name: 'Sorrowful', mysteries: [ { title: 'The Agony in the Garden', fruit: 'Sorrow for sin', text: 'In Gethsemane, Jesus prays in anguish, "Father, not my will but yours be done." (Luke 22)' }, { title: 'The Scourging at the Pillar', fruit: 'Purity', text: 'Jesus is bound and cruelly scourged at the command of Pilate. (John 19)' }, { title: 'The Crowning with Thorns', fruit: 'Courage', text: 'The soldiers weave a crown of thorns, press it on his head, and mock him as king. (Matthew 27)' }, { title: 'The Carrying of the Cross', fruit: 'Patience', text: 'Jesus takes up his cross and carries it along the way to Calvary. (John 19)' }, { title: 'The Crucifixion', fruit: 'Perseverance', text: 'Jesus is nailed to the cross and, after three hours, gives up his spirit for our salvation. (Luke 23)' }, ] }, glorious: { name: 'Glorious', mysteries: [ { title: 'The Resurrection', fruit: 'Faith', text: 'On the third day Jesus rises from the dead, conquering sin and death. (Matthew 28)' }, { title: 'The Ascension', fruit: 'Hope', text: 'Forty days after rising, Jesus ascends to the right hand of the Father. (Acts 1)' }, { title: 'The Descent of the Holy Spirit', fruit: 'Wisdom', text: 'The Holy Spirit descends upon Mary and the apostles at Pentecost in tongues of fire. (Acts 2)' }, { title: 'The Assumption', fruit: 'Devotion to Mary', text: 'At the end of her earthly life, Mary is taken body and soul into heavenly glory.' }, { title: 'The Coronation of Mary', fruit: 'Eternal happiness', text: 'Mary is crowned Queen of Heaven and Earth, the first among the saints.' }, ] }, luminous: { name: 'Luminous', mysteries: [ { title: 'The Baptism in the Jordan', fruit: 'Openness to the Spirit', text: 'As Jesus is baptized, the Father’s voice declares, "This is my beloved Son." (Matthew 3)' }, { title: 'The Wedding at Cana', fruit: 'To Jesus through Mary', text: 'At Mary’s request, Jesus changes water into wine — his first sign. (John 2)' }, { title: 'The Proclamation of the Kingdom', fruit: 'Repentance', text: 'Jesus calls all to conversion: "The kingdom of God is at hand; repent and believe." (Mark 1)' }, { title: 'The Transfiguration', fruit: 'Desire for holiness', text: 'Jesus is transfigured in glory before Peter, James, and John. (Matthew 17)' }, { title: 'The Institution of the Eucharist', fruit: 'Adoration', text: 'At the Last Supper, Jesus gives his Body and Blood under the form of bread and wine. (Luke 22)' }, ] }, }; // Traditional assignment of mystery sets to days of the week (0 = Sunday). const ROSARY_BY_DAY = ['glorious', 'joyful', 'sorrowful', 'glorious', 'luminous', 'sorrowful', 'joyful']; function buildRosarySteps(setKey) { const set = ROSARY_MYSTERIES[setKey]; const steps = [ { eyebrow: 'To begin', title: 'Sign of the Cross', text: PRAYER_TEXT.sign }, { eyebrow: 'On the crucifix', title: 'The Apostles’ Creed', text: PRAYER_TEXT.creed }, { eyebrow: 'First large bead', title: 'Our Father', text: PRAYER_TEXT.our_father }, { title: 'Three Hail Marys', subtitle: 'For an increase of faith, hope, and charity', text: PRAYER_TEXT.hail_mary, repeat: 3 }, { title: 'Glory Be', text: PRAYER_TEXT.glory_be }, ]; set.mysteries.forEach((m, i) => { const tag = `Decade ${i + 1} of 5`; steps.push({ eyebrow: `${ROSARY_ORDINALS[i]} ${set.name} Mystery`, title: m.title, subtitle: 'Fruit of the mystery: ' + m.fruit, text: m.text, meta: tag }); steps.push({ title: 'Our Father', text: PRAYER_TEXT.our_father, meta: tag }); steps.push({ title: 'Ten Hail Marys', subtitle: 'Meditating on ' + m.title, text: PRAYER_TEXT.hail_mary, repeat: 10, meta: tag }); steps.push({ title: 'Glory Be', text: PRAYER_TEXT.glory_be, meta: tag }); steps.push({ title: 'Fatima Prayer', text: PRAYER_TEXT.fatima, meta: tag }); }); steps.push({ eyebrow: 'To conclude', title: 'Hail, Holy Queen', text: PRAYER_TEXT.hail_holy_queen }); steps.push({ title: 'Sign of the Cross', text: PRAYER_TEXT.sign }); return steps; } function startRosary(setKey) { const key = (setKey && ROSARY_MYSTERIES[setKey]) ? setKey : ROSARY_BY_DAY[new Date().getDay()]; startGuidedPrayer(`Rosary · ${ROSARY_MYSTERIES[key].name} Mysteries`, buildRosarySteps(key)); } /* ── Stations of the Cross ── */ const STATION_VERSICLE = `V. We adore you, O Christ, and we praise you.\nR. Because by your holy Cross you have redeemed the world.`; const STATIONS = [ { title: 'Jesus is condemned to death', text: 'Though Pilate finds no guilt in him, he yields to the crowd and hands Jesus over to be crucified.' }, { title: 'Jesus takes up his Cross', text: 'Jesus receives the heavy wood of the Cross and embraces the Father’s will for our salvation.' }, { title: 'Jesus falls the first time', text: 'Weak from scourging, Jesus falls beneath the weight of the Cross — and rises again to go on.' }, { title: 'Jesus meets his Mother', text: 'On the way of sorrows, Jesus meets Mary, whose heart is pierced as Simeon foretold.' }, { title: 'Simon of Cyrene helps Jesus', text: 'Simon is pressed into service to carry the Cross — a reminder that we are called to help bear one another’s burdens.' }, { title: 'Veronica wipes the face of Jesus', text: 'A woman steps from the crowd to wipe his face, and his image is left upon her cloth.' }, { title: 'Jesus falls the second time', text: 'Again Jesus falls, and again he rises, refusing to abandon the work of love.' }, { title: 'Jesus meets the women of Jerusalem', text: 'Jesus consoles the weeping women: "Do not weep for me, but for yourselves and for your children."' }, { title: 'Jesus falls the third time', text: 'Utterly spent, Jesus falls a third time, yet still presses on toward Calvary for our sake.' }, { title: 'Jesus is stripped of his garments', text: 'Jesus is stripped of his clothing, bearing humiliation with silent dignity.' }, { title: 'Jesus is nailed to the Cross', text: 'His hands and feet are nailed to the wood; he prays, "Father, forgive them, for they know not what they do."' }, { title: 'Jesus dies on the Cross', text: 'After three hours, Jesus cries out, "It is finished," bows his head, and gives up his spirit.' }, { title: 'Jesus is taken down from the Cross', text: 'His body is lowered and placed in the arms of his Mother.' }, { title: 'Jesus is laid in the tomb', text: 'They lay Jesus in a new tomb and roll a stone across it — and await the dawn of the Resurrection.' }, ]; function buildStationsSteps() { const steps = [{ eyebrow: 'To begin', title: 'The Way of the Cross', text: 'We walk with Jesus on the road to Calvary, pausing at each station to adore him and give thanks for his love. At each: "We adore you, O Christ, and we praise you."' }]; STATIONS.forEach((st, i) => { steps.push({ eyebrow: `Station ${i + 1} of 14`, title: st.title, subtitle: STATION_VERSICLE, text: st.text }); }); steps.push({ eyebrow: 'To conclude', title: 'Closing Prayer', text: 'Lord Jesus, by your Passion and Cross you have redeemed the world. Help us who have walked this way to carry our own crosses with faith, and to rise with you to new life. Amen.' }); return steps; } function startStations() { startGuidedPrayer('Stations of the Cross', buildStationsSteps()); } /* ── Divine Mercy Chaplet (prayed on rosary beads) ── */ const CHAPLET_TEXT = { eternal_father: `Eternal Father, I offer you the Body and Blood, Soul and Divinity of your dearly beloved Son, our Lord Jesus Christ, in atonement for our sins and those of the whole world.`, for_the_sake: `For the sake of his sorrowful Passion, have mercy on us and on the whole world.`, holy_god: `Holy God, Holy Mighty One, Holy Immortal One, have mercy on us and on the whole world.`, }; function buildChapletSteps() { const steps = [ { eyebrow: 'To begin', title: 'Sign of the Cross', text: PRAYER_TEXT.sign }, { title: 'Our Father', text: PRAYER_TEXT.our_father }, { title: 'Hail Mary', text: PRAYER_TEXT.hail_mary }, { title: 'The Apostles’ Creed', text: PRAYER_TEXT.creed }, ]; for (let d = 1; d <= 5; d++) { const tag = `Decade ${d} of 5`; steps.push({ eyebrow: `Decade ${d}`, title: 'On the “Our Father” bead', subtitle: 'Eternal Father', text: CHAPLET_TEXT.eternal_father, meta: tag }); steps.push({ title: 'On the ten beads', subtitle: 'For the sake of his sorrowful Passion', text: CHAPLET_TEXT.for_the_sake, repeat: 10, meta: tag }); } steps.push({ eyebrow: 'To conclude', title: 'Holy God (three times)', text: CHAPLET_TEXT.holy_god, repeat: 3 }); steps.push({ title: 'Closing', text: 'Jesus, I trust in you.' }); return steps; } function startChaplet() { startGuidedPrayer('Divine Mercy Chaplet', buildChapletSteps()); } /* ── Engine ── */ let _guidedSteps = [], _guidedIdx = 0, _guidedTitle = ''; function startGuidedPrayer(title, steps) { _guidedTitle = title; _guidedSteps = Array.isArray(steps) ? steps : []; _guidedIdx = 0; // Set the heading before pushScreen so the route announcer reads the real // prayer title (e.g. "Rosary · Joyful Mysteries"), not the placeholder. const t = document.getElementById('guided-title'); if (t) t.textContent = title; pushScreen('s-guided'); _renderGuided(); } function guidedPrev() { if (_guidedIdx > 0) { _guidedIdx--; _renderGuided(); } } function guidedNext() { if (_guidedIdx < _guidedSteps.length - 1) { _guidedIdx++; _renderGuided(); } else { popScreen(); } } function _tapBead(i) { const beads = document.querySelectorAll('#guided-beads .guided-bead'); beads.forEach((b, idx) => b.classList.toggle('filled', idx <= i)); } function _renderGuided() { const s = _guidedSteps[_guidedIdx]; if (!s) return; const total = _guidedSteps.length; const titleEl = document.getElementById('guided-title'); if (titleEl) titleEl.textContent = _guidedTitle; const prog = document.getElementById('guided-progress'); if (prog) prog.innerHTML = `
` + `
${s.meta ? _esc(s.meta) + ' · ' : ''}Step ${_guidedIdx + 1} of ${total}
`; const beads = s.repeat ? `
${Array.from({ length: s.repeat }, (_, i) => ``).join('')}
` + `
Tap each bead as you pray · ${s.repeat}×
` : ''; const body = document.getElementById('guided-body'); if (body) body.innerHTML = `${s.eyebrow ? `
${_esc(s.eyebrow)}
` : ''}` + `
${_esc(s.title)}
` + `${s.subtitle ? `
${_esc(s.subtitle)}
` : ''}` + `${beads}` + `
${_esc(s.text).replace(/\n/g, '
')}
`; const prev = document.getElementById('guided-prev'); if (prev) prev.disabled = _guidedIdx === 0; const next = document.getElementById('guided-next'); if (next) next.textContent = _guidedIdx === total - 1 ? 'Finish ✓' : 'Next ›'; const sc = document.getElementById('s-guided'); if (sc) sc.scrollTop = 0; // Announce each step concisely to screen readers (the route announcer only // fires on screen change, not on Back/Next within the guided prayer). const ann = document.getElementById('route-announcer'); if (ann) ann.textContent = (s.eyebrow ? s.eyebrow + ' — ' : '') + s.title; } /* ─── Modal focus management ────────────────────────────────────────── Inert the interactive background and move focus into a modal on open; restore focus to the trigger on close. The overlays live outside .screen-stack, so inerting these regions never disables the modal itself. */ const _MODAL_BG_SELECTORS = ['.sidebar', '.screen-stack', '.bottom-nav']; const _modalFocusStack = []; function _modalOpened(overlayEl) { _modalFocusStack.push(document.activeElement); _MODAL_BG_SELECTORS.forEach(sel => { const el = document.querySelector(sel); if (el) el.inert = true; }); const focusEl = overlayEl && (overlayEl.querySelector('.wiki-modal-close') || overlayEl.querySelector('button, a, [tabindex]')); if (focusEl) { try { focusEl.focus({ preventScroll: true }); } catch (_e) { focusEl.focus(); } } } function _modalClosed() { _MODAL_BG_SELECTORS.forEach(sel => { const el = document.querySelector(sel); if (el) el.inert = false; }); const prev = _modalFocusStack.pop(); if (prev && prev.focus) { try { prev.focus({ preventScroll: true }); } catch (_e) {} } } // Famous quotes keyed by wikiSlug (shared key in saints.json and // saints-library.json). Each is fact-checked against primary sources; // `work` names the source text and `url` (where present) links to a public // edition. Saints without a verified quote are omitted — the modal then // renders normally without a quote block. const SAINT_QUOTES = { "Augustine_of_Hippo": { text: "You have made us for yourself, O Lord, and our heart is restless until it rests in you.", attr: "St. Augustine", work: "Confessions, Book I", url: "https://www.newadvent.org/fathers/110101.htm" }, "Thomas_Aquinas": { text: "All that I have written seems to me like straw compared to what has now been revealed to me.", attr: "St. Thomas Aquinas", work: "reported in his canonization testimony, 1273", url: "https://en.wikiquote.org/wiki/Thomas_Aquinas" }, "Francis_of_Assisi": { text: "Praised be You, my Lord, through all Your creatures, especially Sir Brother Sun.", attr: "St. Francis of Assisi", work: "Canticle of the Sun", url: "https://en.wikisource.org/wiki/Canticle_of_the_Sun" }, "Teresa_of_Ávila": { text: "Let nothing disturb you, let nothing frighten you; all things are passing, God never changes. Patience obtains all things. Whoever has God lacks nothing — God alone suffices.", attr: "St. Teresa of Ávila", work: "Nada te turbe", url: "https://en.wikiquote.org/wiki/Teresa_of_%C3%81vila" }, "Thérèse_of_Lisieux": { text: "I will spend my heaven doing good upon earth.", attr: "St. Thérèse of Lisieux", work: "Last Conversations" }, "Ignatius_of_Loyola": { text: "Take, Lord, and receive all my liberty, my memory, my understanding, and my entire will.", attr: "St. Ignatius of Loyola", work: "Spiritual Exercises (the Suscipe)", url: "https://en.wikiquote.org/wiki/Ignatius_of_Loyola" }, "Catherine_of_Siena": { text: "If you are what you should be, you will set the whole world on fire.", attr: "St. Catherine of Siena", work: "Letter 368", url: "https://en.wikiquote.org/wiki/Catherine_of_Siena" }, "John_of_the_Cross": { text: "When evening comes, you will be examined in love.", attr: "St. John of the Cross", work: "Sayings of Light and Love, 60" }, "Jerome": { text: "Ignorance of Scripture is ignorance of Christ.", attr: "St. Jerome", work: "Commentary on Isaiah, Prologue", url: "https://en.wikiquote.org/wiki/Jerome" }, "Ambrose": { text: "We address Him when we pray, we hear Him when we read the sacred oracles of God.", attr: "St. Ambrose", work: "On the Duties of the Clergy, I.20", url: "https://www.newadvent.org/fathers/34011.htm" }, "Benedict_of_Nursia": { text: "Listen carefully, my son, and incline the ear of your heart.", attr: "St. Benedict", work: "Rule of St. Benedict, Prologue", url: "https://archive.osb.org/rb/text/rbejms1.html" }, "Faustina_Kowalska": { text: "Jesus, I trust in You.", attr: "St. Faustina", work: "Diary, 47" }, "Padre_Pio": { text: "Pray, hope, and don't worry.", attr: "St. Pio of Pietrelcina", work: "personal spiritual direction, Letters, Vol. I" }, "Pio_of_Pietrelcina": { text: "Pray, hope, and don't worry.", attr: "St. Pio of Pietrelcina", work: "personal spiritual direction, Letters, Vol. I" }, "Mother_Teresa": { text: "In this life we cannot do great things. We can only do small things with great love.", attr: "St. Teresa of Calcutta", work: "attributed; In the Heart of the World (1997)" }, "Pope_John_Paul_II": { text: "Do not be afraid! Open wide the doors to Christ.", attr: "St. John Paul II", work: "inaugural homily, October 22, 1978", url: "https://www.vatican.va/content/john-paul-ii/en/homilies/1978/documents/hf_jp-ii_hom_19781022_inizio-pontificato.html" }, "Joan_of_Arc": { text: "If I am not in God's grace, may God put me there; and if I am, may God so keep me.", attr: "St. Joan of Arc", work: "trial testimony, Third public examination, 24 February 1431", url: "https://en.wikiquote.org/wiki/Joan_of_Arc" }, "Bernadette_Soubirous": { text: "The Blessed Virgin used me like a broom. When the work is done, the broom is put back behind the door.", attr: "St. Bernadette Soubirous (attributed)", work: "oral tradition recorded in hagiographic accounts" }, "Anthony_of_Padua": { text: "The life of the body is the soul; the life of the soul is God.", attr: "St. Anthony of Padua", work: "Sermons" }, "Maximilian_Kolbe": { text: "Love lives through sacrifice and is nourished by giving.", attr: "St. Maximilian Kolbe", work: "attributed in Franciscan Conventual writings" }, "Polycarp": { text: "Eighty-six years I have served Him, and He never did me any wrong. How then can I blaspheme my King who saved me?", attr: "St. Polycarp", work: "Martyrdom of Polycarp, 9", url: "https://www.newadvent.org/fathers/0102.htm" }, "Ignatius_of_Antioch": { text: "I am the wheat of God, and let me be ground by the teeth of the wild beasts, that I may be found the pure bread of Christ.", attr: "St. Ignatius of Antioch", work: "Letter to the Romans, 4", url: "https://www.newadvent.org/fathers/0107.htm" }, "Clare_of_Assisi": { text: "Go forth without fear, for He who created you has made you holy, has always protected you, and loves you as a mother loves her child.", attr: "St. Clare of Assisi", work: "deathbed words, in Thomas of Celano's Legend of St. Clare" }, "Vincent_de_Paul": { text: "Let us love God, brothers, let us love God, but let it be with the strength of our arms and the sweat of our brows.", attr: "St. Vincent de Paul", work: "Conference of May 30, 1659", url: "https://via.library.depaul.edu/vincentian_ebooks/37/" }, "Francis_de_Sales": { text: "Have patience with every one, but especially with yourself.", attr: "St. Francis de Sales", work: "The Spirit of St. Francis de Sales, 'Upon Discouragement'", url: "https://en.wikiquote.org/wiki/Francis_de_Sales" }, "Philip_Neri": { text: "Cheerfulness strengthens the heart and makes us persevere in a good life.", attr: "St. Philip Neri", work: "Maxims and Sayings" }, "John_Vianney": { text: "Prayer is nothing else but union with God.", attr: "St. John Vianney", work: "Catechetical Instructions" }, "Edith_Stein": { text: "God is there in these moments of rest and can give us, in a single instant, exactly what we need.", attr: "St. Teresa Benedicta of the Cross", work: "Wege zur inneren Stille (Paths to Inner Stillness)" }, "Damien_of_Molokai": { text: "I make myself a leper with the lepers to gain all to Jesus Christ.", attr: "St. Damien of Molokai", work: "letter to his brother, 1873" }, "Justin_Martyr": { text: "We who once hated and destroyed one another now live together and pray for our enemies.", attr: "St. Justin Martyr", work: "First Apology, 14", url: "https://www.newadvent.org/fathers/0126.htm" }, "Irenaeus": { text: "The glory of God is a living man, and the life of man is the vision of God.", attr: "St. Irenaeus", work: "Against Heresies, 4.20.7", url: "https://www.newadvent.org/fathers/0103420.htm" }, "Athanasius_of_Alexandria": { text: "He became man that we might become divine.", attr: "St. Athanasius", work: "On the Incarnation, 54", url: "https://www.ccel.org/ccel/athanasius/incarnation.ix.html" }, "Paul_the_Apostle": { text: "We know that in everything God works for good with those who love him, who are called according to his purpose.", attr: "St. Paul the Apostle", work: "Letter to the Romans 8:28", url: "https://www.biblegateway.com/passage/?search=Romans+8%3A28&version=RSVCE" }, "Peter_the_Apostle": { text: "Cast all your anxieties on him, for he cares about you.", attr: "St. Peter the Apostle", work: "1 Peter 5:7", url: "https://www.biblegateway.com/passage/?search=1+Peter+5%3A7&version=RSVCE" }, "John_Chrysostom": { text: "Do you wish to honour the body of Christ? Neglect Him not when naked. Do not honour Him here with silken garments, while neglecting Him perishing outside of cold and nakedness.", attr: "St. John Chrysostom", work: "Homilies on Matthew, 50", url: "https://www.newadvent.org/fathers/200150.htm" }, "Basil_of_Caesarea": { text: "The bread you are holding back is for the hungry; the clothes you keep put away are for the naked.", attr: "St. Basil the Great", work: "Homily, 'I Will Tear Down My Barns'", url: "https://www.mystagogyresourcecenter.com/2023/11/i-will-pull-down-my-barns-homily-of.html" }, "Pope_Gregory_I": { text: "The government of souls is the art of arts.", attr: "Pope St. Gregory the Great", work: "The Book of Pastoral Rule, I.1", url: "https://www.newadvent.org/fathers/36011.htm" }, "Gregory_of_Nazianzus": { text: "Illumination is the splendour of souls, the conversion of the life, the question put to the Godward conscience.", attr: "St. Gregory of Nazianzus", work: "Oration 40, On Holy Baptism", url: "https://www.newadvent.org/fathers/310240.htm" }, "Alphonsus_Liguori": { text: "He who prays is certainly saved; he who prays not is certainly lost.", attr: "St. Alphonsus Liguori", work: "The Great Means of Salvation", url: "https://www.ecatholic2000.com/cts/untitled-549.shtml" }, "Bonaventure": { text: "Let us die, then, and pass over with the crucified Christ from this world to the Father.", attr: "St. Bonaventure", work: "The Mind's Road to God, ch. 7", url: "https://www.ccel.org/ccel/bonaventure/mindsroad.xii.html" }, "Cyprian": { text: "He can no longer have God for his Father, who has not the Church for his mother.", attr: "St. Cyprian of Carthage", work: "On the Unity of the Church, 6", url: "https://www.newadvent.org/fathers/050701.htm" }, "Saint_Patrick": { text: "More and more did the love of God, and my fear of Him and faith increase.", attr: "St. Patrick", work: "Confessio, 16", url: "https://www.ccel.org/ccel/patrick/confession.iv.html" }, "Monica_of_Hippo": { text: "Lay this body anywhere; let not the care of it trouble you at all. This only I ask, that you will remember me at the Lord's altar, wherever you be.", attr: "St. Monica", work: "her dying words, in Augustine's Confessions IX.11", url: "https://www.newadvent.org/fathers/110109.htm" }, "Josemaría_Escrivá": { text: "Don't let your life be barren. Be useful. Make yourself felt. Shine forth with the torch of your faith and your love.", attr: "St. Josemaría Escrivá", work: "The Way, 1", url: "https://escriva.org/en/camino/1/" }, "Cyril_of_Jerusalem": { text: "The root of all good works is the hope of the Resurrection.", attr: "St. Cyril of Jerusalem", work: "Catechetical Lectures, 18", url: "https://www.newadvent.org/fathers/310118.htm" }, "Hilary_of_Poitiers": { text: "The errors of heretics and blasphemers force us to deal with unlawful matters, to scale perilous heights, to speak unutterable words, to trespass on forbidden ground.", attr: "St. Hilary of Poitiers", work: "On the Trinity, II.2", url: "https://www.newadvent.org/fathers/330202.htm" }, "Pope_Leo_I": { text: "Christian, acknowledge your dignity, and becoming a partner in the Divine nature, refuse to return to the old baseness by degenerate conduct.", attr: "St. Leo the Great", work: "Sermon 21, On the Nativity", url: "https://www.newadvent.org/fathers/360321.htm" }, "Bernard_of_Clairvaux": { text: "In dangers, in doubts, in difficulties, think of Mary, call upon Mary. Following her, you will not go astray.", attr: "St. Bernard of Clairvaux", work: "Homily In Praise of the Virgin Mother", url: "https://veterumsapientia.org/st-bernard-of-clairvaux-on-the-holy-name-of-mary/" }, "Robert_Bellarmine": { text: "He who lives well, dies well; and he who lives ill, dies ill.", attr: "St. Robert Bellarmine", work: "The Art of Dying Well, I.1", url: "https://archive.org/details/theartofdyingwel00belluoft" }, "Gregory_of_Nyssa": { text: "The soul is an essence created, and living, and intellectual, transmitting from itself to an organized and sentient body the power of living and of grasping objects of sense.", attr: "St. Gregory of Nyssa", work: "On the Soul and the Resurrection", url: "https://www.newadvent.org/fathers/2915.htm" }, "Cyril_of_Alexandria": { text: "Since the holy Virgin brought forth corporally God made one with flesh according to nature, for this reason we also call her Mother of God.", attr: "St. Cyril of Alexandria", work: "Second Letter to Nestorius", url: "https://www.newadvent.org/fathers/3810.htm" }, "Ephrem_the_Syrian": { text: "O Lord and Master of my life, give me not the spirit of sloth, idle curiosity, lust for power, and idle talk. But grant unto me, Thy servant, a spirit of chastity, humility, patience, and love.", attr: "St. Ephrem the Syrian", work: "the Lenten Prayer of St. Ephrem", url: "https://orthodoxwiki.org/Prayer_of_Saint_Ephrem" }, "Anthony_the_Great": { text: "Here am I, Antony; I flee not from your stripes, for even if you inflict more nothing shall separate me from the love of Christ.", attr: "St. Anthony the Great", work: "Athanasius, Life of Antony, 9", url: "https://www.newadvent.org/fathers/2811.htm" }, "Saint_Stephen": { text: "Lord Jesus, receive my spirit. … Lord, do not hold this sin against them.", attr: "St. Stephen, the First Martyr", work: "Acts 7:59–60", url: "https://www.biblegateway.com/passage/?search=Acts+7%3A59-60&version=RSVCE" }, "John_of_Damascus": { text: "The Deity, therefore, is ineffable and incomprehensible.", attr: "St. John of Damascus", work: "Exposition of the Orthodox Faith, I.1", url: "https://www.newadvent.org/fathers/33041.htm" }, "Albertus_Magnus": { text: "Nor could He have commanded anything more lovable, for this sacrament produces love and union.", attr: "St. Albert the Great", work: "Commentary on the Gospel of Luke", url: "https://www.liturgies.net/saints/albertusmagnus/readings.htm" }, "Peter_Chrysologus": { text: "Blessed Peter, who both lives and presides in his own see, provides to those who seek it the truth of faith.", attr: "St. Peter Chrysologus", work: "Letter to Eutyches", url: "https://theseeofpeter.com/document/letter-xxv-from-peter-chrysologus-to-eutyches/" }, "Hildegard_of_Bingen": { text: "O human, who are fragile dust of the earth and ashes of ashes! Cry out and speak of the origin of pure salvation.", attr: "St. Hildegard of Bingen", work: "Scivias, I.1", url: "https://www.culturefrontier.com/hildegard-of-bingen/" }, "Dominic_de_Guzmán": { text: "Have charity for one another, guard humility, and make a treasure of voluntary poverty.", attr: "St. Dominic (attributed; his last words to the friars)", work: "recorded in the Dominican tradition", url: "https://opwest.org/blog/the-death-of-saint-dominic" }, "Anselm_of_Canterbury": { text: "I do not seek to understand that I may believe, but I believe in order to understand.", attr: "St. Anselm of Canterbury", work: "Proslogion, ch. 1", url: "https://en.wikisource.org/wiki/Proslogium_and_Monologium/Proslogium/Chapter1" }, "Bede": { text: "Amidst the observance of monastic rule, and the daily charge of singing in the church, I always took delight in learning, or teaching, or writing.", attr: "St. Bede the Venerable", work: "Ecclesiastical History, V.24", url: "https://www.ccel.org/ccel/bede/history.v.v.xxiii.html" }, "Margaret_Mary_Alacoque": { text: "To thee, O sacred Heart of Jesus! I devote and offer up my life, thoughts, words, actions, pains, and sufferings.", attr: "St. Margaret Mary Alacoque", work: "Act of Consecration to the Sacred Heart of Jesus", url: "https://en.wikisource.org/wiki/The_Catholic_Prayer_Book_and_Manual_of_Meditations/Act_of_Consecration_to_the_Sacred_Heart_of_Jesus" }, "Gertrude_the_Great": { text: "O Jesus, you who are immensely dear to me, be with me always, so that my heart may stay with you and that your love may endure with me with no possibility of division; and bless my passing, so that my spirit, freed from the bonds of the flesh, may immediately find rest in you.", attr: "St. Gertrude the Great", work: "Exercitia Spiritualia (Spiritual Exercises)", url: "https://www.vatican.va/content/benedict-xvi/en/audiences/2010/documents/hf_ben-xvi_aud_20101006.html" }, "Bridget_of_Sweden": { text: "I have chosen you for myself; love me with all your heart, and prefer nothing to my love.", attr: "St. Bridget of Sweden", work: "Revelations, Book I, Chapter 1", url: "https://www.saintsbooks.net/books/St.%20Bridget%20(Birgitta)%20of%20Sweden%20-%20Prophecies%20and%20Revelations.html" }, "Charles_Borromeo": { text: "My brothers, you must realize that for us churchmen nothing is more necessary than meditation. We must meditate before, during and after everything we do.", attr: "St. Charles Borromeo", work: "Pastoral conference (Acta Ecclesiae Mediolanensis, 1599)", url: "https://www.crossroadsinitiative.com/media/articles/prayer-must-come-first-charles-borromeo/" }, "Peter_Canisius": { text: "In the end, as if you were opening to me the heart of the Most Sacred Body, which it seemed to me I saw before me, you commanded me to drink from that source, inviting me, as it were, to draw the waters of my salvation from your founts, O my Saviour.", attr: "St. Peter Canisius", work: "Spiritual Journal, 4 September 1549", url: "https://www.vatican.va/content/benedict-xvi/en/audiences/2011/documents/hf_ben-xvi_aud_20110209.html" }, "Peter_Claver": { text: "Peter Claver, slave of the Africans forever.", attr: "St. Peter Claver", work: "Jesuit solemn profession vow signature (1622)", url: "https://www.gaudiumpress.ca/saint-peter-claver-slave-of-the-slaves/" }, "Vincent_Ferrer": { text: "You will clearly perceive that I purpose not to speak here of that purity which excludes from the heart those criminal thoughts that are interdicted to all; but of that strict purity which separates man, as far as it is possible in this mortal life, from all frivolous thoughts, and allows him to think only of God, or what will lead him to Him.", attr: "St. Vincent Ferrer", work: "Treatise on the Spiritual Life, Chapter III", url: "https://www.svfsermons.org/TreatiseCC123.htm" }, "John_of_Ávila": { text: "Holiness does not consist in such things, but in a humble love of God and our neighbour.", attr: "St. John of Ávila", work: "Letters, Letter I (to St. Teresa)", url: "https://archive.org/stream/lettersofblessed00johnuoft/lettersofblessed00johnuoft_djvu.txt" }, "Rose_of_Lima": { text: "Apart from the cross there is no other ladder by which we may get to heaven.", attr: "St. Rose of Lima", work: "Vita mirabilis (Hansen, 1664); cited in Catechism of the Catholic Church §618", url: "https://en.wikiquote.org/wiki/Rose_of_Lima" }, "Thomas_More": { text: "to-morrow long I to go to God: it were a day very meet and convenient for me.", attr: "St. Thomas More", work: "Last letter to Margaret Roper, 5 July 1535", url: "https://www.luminarium.org/renlit/morelast.htm" }, "Martin_of_Tours": { text: "I am the soldier of Christ: it is not lawful for me to fight.", attr: "St. Martin of Tours", work: "Sulpicius Severus, Life of Saint Martin, ch. 4", url: "https://www.newadvent.org/fathers/3501.htm" }, "John_the_Baptist": { text: "He must increase, but I must decrease.", attr: "St. John the Baptist", work: "John 3:30", url: "https://www.biblegateway.com/passage/?search=John+3%3A30&version=RSVCE" }, "Mary_Magdalene": { text: "I have seen the Lord.", attr: "St. Mary Magdalene", work: "John 20:18", url: "https://www.biblegateway.com/passage/?search=John+20%3A18&version=RSVCE" }, "Thomas_the_Apostle": { text: "My Lord and my God!", attr: "St. Thomas the Apostle", work: "John 20:28", url: "https://www.biblegateway.com/passage/?search=John+20%3A24-29&version=RSVCE" }, "Elizabeth_of_Hungary": { text: "The world with all its joys is now dead to me.", attr: "St. Elizabeth of Hungary", work: "reported speech, Catholic Encyclopedia vol. 5", url: "https://www.newadvent.org/cathen/05389a.htm" }, "Francis_Xavier": { text: "Ah! what a multitude of souls is through your fault shut out of heaven and falling into hell!", attr: "St. Francis Xavier", work: "Letter to the Society of Jesus, Cochin, December 1543", url: "https://sourcebooks.fordham.edu/mod/1543xavier1.asp" }, "Lawrence_of_Rome": { text: "Turn over the part of my body that has been sufficiently burned, and see what your burning Vulcan has accomplished.", attr: "St. Lawrence of Rome", work: "Prudentius, Peristephanon (Crowns of Martyrdom), Hymn II", url: "https://thelatinlibrary.com/prudentius/prud2.shtml" }, "Perpetua_and_Felicitas": { text: "So can I call myself nought other than that which I am, a Christian.", attr: "Saint Perpetua", work: "The Passion of Saints Perpetua and Felicity, Chapter 3 (Perpetua's own prison journal), 203 AD", url: "https://sourcebooks.fordham.edu/source/perpetua.asp" }, "John_Bosco": { text: "Your charity, blessed by the grace of God, has dried up many a fountain of tears and saved a great number of souls.", attr: "St. John Bosco", work: "Last Will and Testament to Salesian Co-Operators, in The Venerable Don Bosco, the Apostle of Youth, ch. XXVII", url: "https://en.wikisource.org/wiki/The_Venerable_Don_Bosco,_the_Apostle_of_Youth/Chapter_XXVII" }, "Luke_the_Evangelist": { text: "it seemed good to me also, having followed all things closely for some time past, to write an orderly account for you, most excellent Theophilus, that you may know the truth concerning the things of which you have been informed.", attr: "Saint Luke the Evangelist", work: "Luke 1:3-4 (RSVCE)", url: "https://www.biblegateway.com/passage/?search=Luke+1%3A1-4&version=RSVCE" }, "Andrew_the_Apostle": { text: "We have found the Messiah (which means Christ).", attr: "Saint Andrew the Apostle", work: "John 1:41 (RSVCE)", url: "https://www.biblegateway.com/passage/?search=John+1%3A40-42&version=RSVCE" }, "Philip_the_Apostle": { text: "Lord, show us the Father, and we shall be satisfied.", attr: "Saint Philip the Apostle", work: "John 14:8 (RSVCE)", url: "https://www.biblegateway.com/passage/?search=John+14%3A8&version=RSVCE" }, "Jude_the_Apostle": { text: "But you, beloved, build yourselves up on your most holy faith; pray in the Holy Spirit; keep yourselves in the love of God; wait for the mercy of our Lord Jesus Christ unto eternal life.", attr: "Saint Jude the Apostle", work: "Jude 1:20-21 (RSVCE)", url: "https://www.biblegateway.com/passage/?search=Jude+1%3A20-21&version=RSVCE" }, "Barnabas": { text: "I beseech you, entreating this as a favour. While yet you are in this fair vessel, do not fail in any one of those things, but unceasingly seek after them, and fulfil every commandment; for these things are worthy.", attr: "Saint Barnabas", work: "Epistle of Barnabas, Chapter 21", url: "https://www.newadvent.org/fathers/0124.htm" }, "Thomas_Becket": { text: "Here I am, no traitor, but archbishop and priest of God.", attr: "Saint Thomas Becket", work: "Final words at Canterbury Cathedral, 29 December 1170 (eyewitness accounts)", url: "https://www.newadvent.org/cathen/14676a.htm" }, "Mark_the_Evangelist": { text: "The beginning of the gospel of Jesus Christ, the Son of God.", attr: "Saint Mark the Evangelist", work: "Mark 1:1 (RSVCE)", url: "https://www.biblegateway.com/passage/?search=Mark+1%3A1&version=RSVCE" }, "Pope_John_XXIII": { text: "Peace on Earth—which man throughout the ages has so longed for and sought after—can never be established, never guaranteed, except by the diligent observance of the divinely established order.", attr: "Pope John XXIII", work: "Encyclical Pacem in Terris (1963), §1", url: "https://www.vatican.va/content/john-xxiii/en/encyclicals/documents/hf_j-xxiii_enc_11041963_pacem.html" }, "Pope_Pius_X": { text: "We proclaim that We have no other program in the Supreme Pontificate but that 'of restoring all things in Christ,' so that 'Christ may be all and in all.'", attr: "Pope Pius X", work: "Encyclical E Supremi (1903), §4", url: "https://www.vatican.va/content/pius-x/en/encyclicals/documents/hf_p-x_enc_04101903_e-supremi.html" }, "Pope_Pius_V": { text: "Let all everywhere adopt and observe what has been handed down by the Holy Roman Church, the Mother and Teacher of the other churches, and let Masses not be sung or read according to any other formula than that of this Missal published by Us.", attr: "Pope Pius V", work: "Apostolic Constitution Quo Primum (1570)", url: "https://www.papalencyclicals.net/Pius05/p5quopri.htm" }, "Frances_Cabrini": { text: "Take courage, stand aside and run without stopping and be afraid to look back.", attr: "Saint Frances Xavier Cabrini", work: "Letter to her nuns, aboard steamship to New York (1889)", url: "https://en.wikiquote.org/wiki/Frances_Xavier_Cabrini" }, "Gianna_Beretta_Molla": { text: "Love is the most beautiful sentiment the Lord has put into the soul of men and women.", attr: "Saint Gianna Beretta Molla", work: "Letter to Pietro Molla, September 1955 (quoted in John Paul II's canonization homily, 16 May 2004)", url: "https://www.vatican.va/content/john-paul-ii/en/homilies/2004/documents/hf_jp-ii_hom_20040516_canonizations.html" }, "Óscar_Romero": { text: "We have never preached violence, except the violence of love, which left Christ nailed to a cross, the violence that we must each do to ourselves to overcome our selfishness and such cruel inequalities among us.", attr: "Saint Óscar Romero", work: "The Violence of Love (1977)", url: "https://en.wikiquote.org/wiki/%C3%93scar_Romero" }, "Josephine_Bakhita": { text: "If I were to meet those who kidnapped me, and even those who tortured me, I would kneel and kiss their hands. For, if these things had not happened, I would not have been a Christian and a religious today.", attr: "Saint Josephine Bakhita", work: "Bakhita Tells Her Story (autobiography, Canossiane Figlie della Carità, Roma, 1993)", url: "https://en.wikipedia.org/wiki/Josephine_Bakhita" }, "Carlo_Acutis": { text: "To be always united to Jesus, this is my program for life.", attr: "Blessed Carlo Acutis", work: "carloacutis.com official biography", url: "https://www.carloacutis.com/en/association/carlo-s-biography" }, "Juan_Diego": { text: "I am a nobody, I am a small rope, a tiny ladder, the tail end, a leaf.", attr: "Saint Juan Diego Cuauhtlatoatzin", work: "Nican Mopohua (Second Apparition), attr. Antonio Valeriano, c. 1555; published 1649", url: "https://www.sancta.org/nican.html" }, "Elizabeth_Ann_Seton": { text: "But God has given me a great deal to do, and I have always and hope always to prefer his will to every wish of my own.", attr: "Saint Elizabeth Ann Seton", work: "Letter to Julia Scott", url: "https://www.franciscanmedia.org/saint-of-the-day/saint-elizabeth-ann-seton/" }, "Seraphim_of_Sarov": { text: "Acquire a peaceful spirit, and around you thousands will be saved.", attr: "Saint Seraphim of Sarov", work: "Teachings recorded in oral traditions and the Conversation with Motovilov", url: "https://orthodoxwiki.org/Seraphim_of_Sarov" }, "Sergius_of_Radonezh": { text: "Brethren, be attentive to yourselves. Have first the fear of God, purity of soul and unhypocritical love for each other.", attr: "Saint Sergius of Radonezh", work: "Life of Saint Sergius (hagiographical record, Epiphanius the Wise, final words, 1392)", url: "https://www.oca.org/saints/lives/2014/09/25/102725-repose-of-venerable-sergius-the-wonderworker-abbot-of-radonezh" }, "Edmund_Campion": { text: "The expense is reckoned, the enterprise is begun; it is of God; it cannot be withstood.", attr: "Saint Edmund Campion", work: "Challenge to the Privy Council (Campion's Brag), 1580, section viii", url: "https://www.ewtn.com/catholicism/library/campions-brag-5297" }, "John_Fisher": { text: "The virtue and strength of the grace of God is marvellous, that, where it once pierceth and entereth into the soul of any creature, it maketh him bold and full of hope.", attr: "Saint John Fisher", work: "Sermons on the Seven Penitential Psalms, sermon on the First Psalm (1508, ed. 1888)", url: "https://archive.org/details/sermonsonsevenpe0000bles" }, "John_Neumann": { text: "Most assuredly, I can work miracles. At every Mass I celebrate, at every absolution I give in the confessional, at every exorcism of the evil one, I work miracles. The Catholic priest can and does work miracles on these and any other proper occasions.", attr: "Saint John Neumann", work: "Life of Right Rev. John N. Neumann (Berger/Grimm, 1884), Part II, Chapter VI, from ministry interchange", url: "https://archive.org/details/LifeOfJohnNNeumann" }, "Joachim_and_Anne": { text: "Alas! To what have I been likened? I am not like the fowls of the heaven, because even the fowls of the heaven are productive before You, O Lord.", attr: "Saint Anne (mother of Mary)", work: "Protoevangelium of James, Chapter 3 (Ante-Nicene Fathers, vol. 8, trans. Walker)", url: "https://www.newadvent.org/fathers/0847.htm" }, "Mary_mother_of_Jesus": { text: "Behold I am the handmaid of the Lord; be it unto me according to thy word.", attr: "Mary at the Annunciation", work: "Gospel of Luke 1:38", url: "https://www.biblegateway.com/passage/?search=Luke+1%3A38" }, "John_the_Apostle": { text: "In the beginning was the Word, and the Word was with God, and the Word was God.", attr: "Saint John the Apostle & Evangelist", work: "Gospel of John 1:1", url: "https://www.biblegateway.com/passage/?search=John+1%3A1" }, "Scholastica": { text: "Why do you wish to deny me? I asked a favor of you, and you refused. I asked it of my God, and He has granted it.", attr: "Saint Scholastica to her brother Saint Benedict", work: "Saint Gregory the Great, Dialogues, Book II", url: "https://www.tertullian.org/fathers/gregory_02_dialogues_book2.htm" }, "Aelred_of_Rievaulx": { text: "Friendship is that virtue by which spirits are bound by ties of love and sweetness and out of many are made one.", attr: "Saint Aelred of Rievaulx", work: "Spiritual Friendship (De Spirituali Amicitia)", url: "https://archive.org/details/spiritualfriends0000aelr" }, "Angela_Merici": { text: "Let us direct all our thoughts, words, and actions to the glory of God and the good of souls.", attr: "Saint Angela Merici", work: "Testament of Angela Merici", url: "https://www.istitutosecolareangelamerici.org/the-writings/?lang=en" }, "Boniface": { text: "And now I ask you to do still more for me and write out in gold the Epistles of my lord, St. Peter the Apostle, that I may visibly impress honor and reverence for the sacred Scriptures on the carnal minds to whom I preach.", attr: "Saint Boniface", work: "Letter to Abbess Eadburga, c. 735 AD", url: "https://epistolae.ctl.columbia.edu/letter/355.html" }, "Paul_of_the_Cross": { text: "All your faults, compared with divine goodness, are less than a bit of tattered thread thrown into a sea of fire.", attr: "Saint Paul of the Cross", work: "Letters of St. Paul of the Cross, 18th century", url: "https://passionistarchives.org/introduction-to-the-letters-of-st-paul-of-the-cross/" }, "Arsenius_the_Great": { text: "Flee, be silent, pray always, for these are the roots of sinlessness.", attr: "Saint Arsenius the Great", work: "Apophthegmata Patrum (Sayings of the Desert Fathers)", url: "https://erenow.org/common/the-desert-fathers/3.php" }, "Isidore_of_Seville": { text: "A wise person is called from wisdom, because first comes wisdom, then a wise person.", attr: "Saint Isidore of Seville", work: "Etymologies, Book X, Chapter 1", url: "https://archive.org/details/isidori01isiduoft" }, "Francis_of_Paola": { text: "Fix your minds on the passion of our Lord Jesus Christ who so burned with love for us that he came down from heaven to redeem us. He himself gave us an example of perfect patience and love. We, then, are to be patient in adversity.", attr: "Saint Francis of Paola", work: "Letter preserved by the Vatican", url: "https://www.vatican.va/spirit/documents/spirit_20010402_francesco-paola_en.html" }, "Pope_Clement_I": { text: "Let the whole body be saved in Christ Jesus, and let each of you be subject to his neighbor according to the grace given to him.", attr: "Pope Saint Clement I", work: "Letter to the Corinthians, Chapter 38", url: "https://www.newadvent.org/fathers/0101.htm" }, "Cecilia_of_Rome": { text: "My heart is a temple consecrated to the Lord. Nothing that is foreign shall enter there.", attr: "Saint Cecilia of Rome", work: "Acts of Saint Cecilia, Chapter 3", url: "https://www.newadvent.org/cathen/03287a.htm" }, "Edmund_of_East_Anglia": { text: "The glory of life is not in prolonging days, but in the constancy of faith.", attr: "Saint Edmund of East Anglia", work: "Life and Martyrdom of St. Edmund", url: "https://www.newadvent.org/cathen/05221a.htm" }, "Hippolytus": { text: "I am bound to confess the truth. I refuse to deny Christ.", attr: "Saint Hippolytus", work: "Martyrdom accounts", url: "https://www.newadvent.org/fathers/050107.htm" }, "James_the_Greater": { text: "If you are able to drink the cup that I shall drink... you shall indeed drink my cup.", attr: "Saint James the Greater & Jesus", work: "Gospel of Mark 10:39", url: "https://www.biblegateway.com/passage/?search=Mark+10%3A39&version=RSVCE" }, "Jean_de_Brebeuf": { text: "The Indians are not by nature either vicious or dissolute. They are very kind and charitable to one another.", attr: "Saint Jean de Brébeuf", work: "Jesuit Relations and Allied Documents, 1636", url: "https://archive.org/details/jesuitrelationsa10thwa" }, "Macarius_the_Great": { text: "The light of Christ shines through those who have put on Christ completely and have become temples of the Holy Spirit.", attr: "Saint Macarius the Great", work: "Spiritual Homilies, Homily 15", url: "https://www.elpenor.org/macarius/homilies.asp" }, "Nilus_of_Sinai": { text: "Prayer is the ascent of the mind towards God. It is a spiritual work that befits the human mind more than any other preoccupation.", attr: "Saint Nilus of Sinai", work: "On Prayer (Peri Proseuches)", url: "https://novoscriptorium.com/2018/04/06/on-prayer-saint-nilus-the-ascetic/" }, "Simeon_Stylites": { text: "Whoever has truly repented, his sins are covered, though they were as numerous as the stars and more intense than the fires of Gehenna.", attr: "Saint Simeon Stylites", work: "Life and Sayings of Simeon Stylites", url: "https://www.newadvent.org/cathen/14001a.htm" }, "Lactantius": { text: "Those, then, to whom are committed the divine offices ought to excel all others in every virtue.", attr: "Lactantius (Church Father)", work: "Divine Institutes, Book V", url: "https://www.newadvent.org/fathers/0704.htm" }, "Methodius_of_Olympus": { text: "The resurrection of the dead is a resurrection, the second and true life is restoration and immortality and incorruptibility of the whole man.", attr: "Methodius of Olympus", work: "Symposium, Discourse IX", url: "https://www.ccel.org/ccel/methodius/banquet.html" }, "Optatus_of_Milevis": { text: "The Church is one and the schismatics separate themselves from it.", attr: "Optatus of Milevis", work: "Against the Donatists, Book I", url: "https://www.newadvent.org/fathers/0640.htm" }, "Papias_of_Hierapolis": { text: "The elders used to say...Matthew compiled the sayings in the Hebrew dialect, and each one interpreted them as best he could.", attr: "Papias of Hierapolis", work: "Exposition of the Sayings of the Lord, Fragment 3", url: "https://www.earlychristianwritings.com/papias.html" }, "Peter_of_Alexandria": { text: "Hear me, all you faithful, and turn from the evil works of those who depart from the faith.", attr: "Saint Peter of Alexandria", work: "Canonical Letter", url: "https://www.newadvent.org/fathers/0814.htm" }, "Avitus_of_Vienne": { text: "The Church is the dwelling place of God, and its traditions are the sure foundation of faith.", attr: "Avitus of Vienne", work: "Epistulary letters", url: "https://www.newadvent.org/cathen/02222a.htm" }, "Alcuin_of_York": { text: "Let light be multiplied, let darkness be dispelled, let truth be established, and let error be cast out.", attr: "Blessed Alcuin of York", work: "Letters and Educational Writings", url: "https://archive.org/details/lettersofalcuin00pageuoft" }, "Agapitus_I": { text: "The pontiff must shepherd his flock with vigilance and love, defending the faith against all corruption.", attr: "Pope Agapitus I", work: "Papal Letters and Teachings", url: "https://www.newadvent.org/cathen/01214a.htm" }, "Aidan_of_Lindisfarne": { text: "In faith and holy poverty we find true riches; in service to God we find perfect freedom.", attr: "Saint Aidan of Lindisfarne", work: "Life and Sayings, Bede's Ecclesiastical History", url: "https://www.gutenberg.org/ebooks/45803" }, "Alypius_of_Tagaste": { text: "No one sins by being ignorant; but everyone sins by voluntary neglect and refusal to be instructed.", attr: "Alypius of Tagaste", work: "Letters and Confessional Accounts", url: "https://www.newadvent.org/fathers/110109.htm" }, "Andrew_of_Caesarea": { text: "The Revelation of Christ is the unveiling of mysteries hidden since the foundation of the world.", attr: "Andrew of Caesarea", work: "Commentary on Revelation", url: "https://www.newadvent.org/fathers/1417.htm" }, "Columbanus": { text: "Desiring God's kingdom is the beginning of all good; the journey of pilgrimage draws us ever closer to Him.", attr: "Saint Columbanus", work: "Letters, Letter 1", url: "https://celt.ucc.ie/published/L201054.html" }, "Damascus_I": { text: "The veneration of the saints draws us nearer to the divine and strengthens our faith.", attr: "Pope Damascus I", work: "Letters and papal teachings", url: "https://www.newadvent.org/cathen/04651a.htm" }, "Deusdedit_of_Rome": { text: "The office of the pontiff is to guard the faith and shepherd the flock of Christ.", attr: "Pope Deusdedit of Rome", work: "Papal teachings and letters", url: "https://www.newadvent.org/cathen/04738a.htm" }, "Pseudo_Dionysius": { text: "The divine darkness is more luminous than light; it is the highest union with God.", attr: "Pseudo-Dionysius the Areopagite", work: "Mystical Theology, Chapter 1", url: "https://ccel.org/ccel/dionysius/works/works.i.iii.i.html" }, "Dorotheus_of_Gaza": { text: "If you desire to be healed of your passions, flee from distraction and cultivate stillness of heart.", attr: "Dorotheus of Gaza", work: "Spiritual Guidance and Letters, Discourse 3", url: "https://www.trueorthodoxy.org/teachings/spir_stdorotheos_on_enduring_temptation.html" }, }; function openSaintModal(saintArg) { const overlay = document.getElementById('wiki-modal-overlay'); if (!overlay) return; overlay.style.display = 'flex'; requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('open'))); _modalOpened(overlay); // Accept an explicit saint object (from library) or fall back to the active home card saint const saint = saintArg || _activeHomeSaint || _getSaintToday(); const displayName = _esc(saint ? saint.name : 'Saint of the Day'); const tagline = _esc(saint ? saint.tagline : ''); // Eyebrow: library saints show feast info; daily saints show "Saint of the Day" const eyebrow = saintArg ? (saintArg.feastDay ? `Feast: ${_esc(saintArg.feastDay)}` : 'Saint') : 'Saint of the Day'; // Lead with the saint's most famous quote when we have one on file. const q = saint && saint.wikiSlug ? SAINT_QUOTES[saint.wikiSlug] : null; let quoteHtml = ''; if (q) { let cite = _esc(q.attr); if (q.work) { cite += ' · ' + (q.url ? `${_esc(q.work)} ↗` : _esc(q.work)); } quoteHtml = `
“${_esc(q.text)}”
${cite}
`; } const content = document.getElementById('wiki-modal-content'); content.innerHTML = `
${eyebrow}
${displayName}
${tagline ? `
${tagline}
` : ''} ${quoteHtml}
Loading…
See today's readings → ${saint && saint.wikiSlug ? `Read full article on Wikipedia →` : ''}`; // Bump the extract-staleness token on EVERY open — a pending fetch for a // previous saint must not write into this modal's slot even when this saint // has no wikiSlug (the slot is cleared but the token is what cancels the write). _saintsExtractToken++; if (saint && saint.wikiSlug) _fetchWikiExtract(saint.wikiSlug); else { const slot = document.getElementById('wiki-extract-slot'); if (slot) slot.innerHTML = ''; } } async function _fetchWikiExtract(slug) { // Same staleness guard as _fetchSaintExtract — a slow earlier fetch must not // overwrite the extract of a modal opened after it. const token = ++_saintsExtractToken; const slot = document.getElementById('wiki-extract-slot'); if (!slot) return; if (_wikiCache[slug]) { if (token === _saintsExtractToken) slot.innerHTML = _buildWikiBody(_wikiCache[slug]); return; } try { const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(slug)}`); if (!res.ok) throw new Error('not found'); const data = await res.json(); const entry = { extract: data.extract || '', thumb: data.thumbnail?.source || null }; _wikiCache[slug] = entry; if (token === _saintsExtractToken) slot.innerHTML = _buildWikiBody(entry); } catch { if (token === _saintsExtractToken) slot.innerHTML = `
Additional details unavailable right now.
`; } } function _buildWikiBody(entry) { const thumb = entry.thumb ? `` : ''; return `${thumb}
${_annotateTerms(_esc(entry.extract || ''))}
`; } function closeSaintsModal() { const overlay = document.getElementById('saints-wiki-overlay'); if (!overlay) return; overlay.classList.remove('open'); setTimeout(() => { overlay.style.display = 'none'; }, 280); _modalClosed(); } function closeSaintModal() { const overlay = document.getElementById('wiki-modal-overlay'); if (!overlay) return; overlay.classList.remove('open'); setTimeout(() => { overlay.style.display = 'none'; }, 280); _modalClosed(); } document.addEventListener('keydown', e => { if (e.key === 'Escape') { // Bottom sheets stack ABOVE the full player — close the topmost sheet // first, or Escape hides the player underneath and strands focus. for (const [id, picker] of [['sleep-picker', _sleepPicker], ['chapter-picker', _chapterPicker], ['voice-picker', _voicePicker]]) { const el = document.getElementById(id); if (el && !el.hidden) { picker.close(); return; } } if (typeof VerseSelect !== 'undefined' && VerseSelect.active()) { VerseSelect.clear(); return; } if (_ttsMini.fullEl && !_ttsMini.fullEl.hidden) { _ttsMini.closeFull(); return; } if (document.getElementById('glossary-overlay')?.classList.contains('open')) closeGlossary(); else if (document.getElementById('saints-wiki-overlay')?.classList.contains('open')) closeSaintsModal(); else if (document.getElementById('wiki-modal-overlay')?.classList.contains('open')) closeSaintModal(); } }); /* ─── Newbie mode ────────────────────────────────────────────────── */ const NEWBIE_KEY = 'haven-newbie-v1'; function _newbieEnabled() { return _storageGet(NEWBIE_KEY) === 'true'; } function toggleNewbie() { const next = !_newbieEnabled(); _storageSet(NEWBIE_KEY, String(next)); _applyNewbie(next); } function _applyNewbie(enabled) { document.documentElement.setAttribute('data-newbie', enabled ? 'true' : 'false'); const tog = document.getElementById('newbie-toggle'); if (tog) { if (enabled) tog.classList.add('on'); else tog.classList.remove('on'); } const row = tog?.closest('[role="switch"]'); if (row) row.setAttribute('aria-checked', String(enabled)); } function dismissHint(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; try { localStorage.setItem('haven-hint-' + id, '1'); } catch {} } (function _initNewbie() { const enabled = _newbieEnabled(); _applyNewbie(enabled); // Hide any individually dismissed hints document.querySelectorAll('.newbie-hint[id]').forEach(el => { try { if (localStorage.getItem('haven-hint-' + el.id) === '1') el.style.display = 'none'; } catch {} }); })(); /* ─── Install Haven (PWA) ───────────────────────────────────────── No beforeinstallprompt on iOS Safari (Apple doesn't expose one) — manual Share-sheet steps are the only path there. Android/desktop Chrome/Edge fire beforeinstallprompt; we capture it and offer a real one-tap Install button instead of generic menu instructions. */ let _deferredInstallPrompt = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); _deferredInstallPrompt = e; if (document.getElementById('s-pwa-install')?.classList.contains('active')) renderPwaInstall(); }); window.addEventListener('appinstalled', () => { _deferredInstallPrompt = null; const b = document.getElementById('home-pwa-banner'); if (b) b.style.display = 'none'; }); function _pwaStandalone() { return (window.navigator.standalone === true) || window.matchMedia('(display-mode: standalone)').matches; } function _pwaPlatform() { const ua = navigator.userAgent || ''; if (/iPhone|iPad|iPod/.test(ua)) return 'ios'; if (/Android/.test(ua)) return 'android'; return 'desktop'; } function openPwaInstall() { pushScreen('s-pwa-install'); renderPwaInstall(); } async function _tryNativeInstall() { if (!_deferredInstallPrompt) return; _deferredInstallPrompt.prompt(); try { await _deferredInstallPrompt.userChoice; } catch {} _deferredInstallPrompt = null; renderPwaInstall(); } function renderPwaInstall() { const el = document.getElementById('pwa-install-body'); if (!el) return; if (_pwaStandalone()) { el.innerHTML = `
✅

Haven is already installed on this device.

`; return; } const platform = _pwaPlatform(); if (platform === 'ios') { el.innerHTML = `

Add Haven to your Home Screen for quick access, a full-screen view, and offline reading.

  1. Tap the Share button in Safari's toolbar
  2. Scroll down and tap Add to Home Screen
  3. Tap Add — Haven now opens like any other app

Must be opened in Safari — Chrome and other iOS browsers can't add to the Home Screen.

`; } else if (platform === 'android' && _deferredInstallPrompt) { el.innerHTML = `

Install Haven for quick access, a full-screen view, and offline reading.

`; } else if (platform === 'android') { el.innerHTML = `

Add Haven to your Home Screen for quick access, a full-screen view, and offline reading.

  1. Tap the menu (⋮) in Chrome's toolbar
  2. Tap Add to Home screen or Install app
  3. Confirm — Haven now opens like any other app
`; } else { el.innerHTML = `

Install Haven as a desktop app for quick access.

  1. Look for an install icon in your browser's address bar
  2. Click it, then Install

Available in Chrome, Edge, and most Chromium-based browsers.

`; } } // Home banner: a one-time, dismissible nudge toward installing — never // shown again once dismissed, and not shown at all if already installed, // running in the native shell, or this browser can't install PWAs. function dismissPwaBanner() { const el = document.getElementById('home-pwa-banner'); if (el) el.style.display = 'none'; try { localStorage.setItem('haven-pwa-banner-dismissed', '1'); } catch {} } (function _initPwaBanner() { try { if (_isNativeApp()) return; if (_pwaStandalone()) return; if (_storageGet('haven-pwa-banner-dismissed') === '1') return; const el = document.getElementById('home-pwa-banner'); if (!el) return; const plat = _pwaPlatform(); const platEl = document.getElementById('pwa-banner-platform'); if (platEl) platEl.textContent = plat === 'ios' ? 'iPhone/iPad' : (plat === 'android' ? 'Android' : 'device'); el.style.display = 'flex'; } catch {} })(); /* ─── Daily reminder (web push) ─────────────────────────────────────── Opt-in browser notification with today's readings. Needs the VAPID public key from /api/push-config (set server-side); degrades gracefully when push is unsupported or not configured. */ let _vapidPublicKey = null, _reminderReady = false, _reminderInit = false; function _pushSupported() { return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; } function _urlB64ToUint8(base64) { const pad = '='.repeat((4 - (base64.length % 4)) % 4); const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(b64); const arr = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); return arr; } function _setReminderUI(state, msg) { const toggle = document.getElementById('reminder-toggle'); const row = document.getElementById('reminder-row'); const note = document.getElementById('reminder-note'); if (toggle) toggle.classList.toggle('on', state === 'on'); if (row) row.setAttribute('aria-checked', String(state === 'on')); if (note && msg != null) note.textContent = msg; } function _disableReminderRow(msg) { const row = document.getElementById('reminder-row'); if (row) { row.style.opacity = '0.55'; row.style.pointerEvents = 'none'; } const note = document.getElementById('reminder-note'); if (note) note.textContent = msg; } async function _initReminder() { if (_reminderInit) return; _reminderInit = true; if (!_pushSupported()) { _disableReminderRow("Notifications aren't supported on this device or browser."); return; } try { const cfg = await fetch('/api/push-config').then(r => r.json()).catch(() => ({})); _vapidPublicKey = cfg && cfg.publicKey; } catch (_e) {} if (!_vapidPublicKey) { _disableReminderRow("Daily reminders aren't available yet."); return; } _reminderReady = true; try { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); _setReminderUI(sub ? 'on' : 'off', sub ? "On — you'll get a daily nudge with today's Gospel." : 'A browser notification each morning. You can turn it off any time.'); } catch (_e) { _setReminderUI('off'); } } async function toggleDailyReminder() { if (!_reminderReady) return; const reg = await navigator.serviceWorker.ready; const existing = await reg.pushManager.getSubscription(); if (existing) { try { await fetch('/api/push-subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'remove', endpoint: existing.endpoint }) }); } catch (_e) {} try { await existing.unsubscribe(); } catch (_e) {} _setReminderUI('off', 'Reminders are off.'); return; } let perm = Notification.permission; if (perm === 'default') { try { perm = await Notification.requestPermission(); } catch (_e) {} } if (perm !== 'granted') { _setReminderUI('off', 'Notifications are blocked. Enable them in your browser settings to get reminders.'); return; } try { const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: _urlB64ToUint8(_vapidPublicKey) }); const resp = await fetch('/api/push-subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'add', subscription: sub }) }); if (!resp || !resp.ok) throw new Error('subscription not saved'); _setReminderUI('on', "On — you'll get a daily nudge with today's Gospel."); } catch (_e) { // Persisting failed — drop the orphaned browser subscription so the UI and // server state stay consistent, then report the failure. try { const orphan = await reg.pushManager.getSubscription(); if (orphan) await orphan.unsubscribe(); } catch (_e2) {} _setReminderUI('off', 'Could not enable reminders. Please try again.'); } } /* ─── Saints card overlay ────────────────────────────────────────── */ // Normalize a saint name for comparison: lowercase, fold diacritics (so an // ASCII USCCB title like "Teresa of Avila" matches "Teresa of Ávila"), then // strip the "St." / "Sts." / "Saint" prefix. function _normSaintName(s) { return s.toLowerCase() .normalize('NFD').replace(/[̀-ͯ]/g, '') .replace(/^(sts?\.|saints?\s+)/i, '').trim(); } // Strip liturgical prefix ("Memorial of", "Feast of", "Solemnity of") from a mass title function _cleanFeastName(massTitle) { return massTitle.replace(/^(Solemnity of (the\s+)?|Feast of (the\s+)?|Memorial of (the\s+)?|Optional Memorial of (the\s+)?)/i, '').trim(); } // True when `needle` appears inside `hay` on whole-word boundaries (or they // are equal). A boundary is the start/end of the string or any non-alphanumeric // char, so a trailing comma or honorific clause in a USCCB feast title // ("…Teresa of Avila, Virgin and Doctor…") still matches the saint name, while // partial-word false positives like "ann" inside "joanna" are still rejected. function _nameWordMatch(hay, needle) { if (!hay || !needle) return false; if (hay === needle) return true; const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp('(^|[^a-z0-9])' + esc + '([^a-z0-9]|$)').test(hay); } // Find a saint in our local data whose name matches the USCCB mass title. // Match on whole-word boundaries and prefer the LONGEST (most specific) name, // so e.g. a title for "St. John of the Cross" can't borrow the "St. John" entry // and a title for "Elizabeth Ann Seton" can't collapse to "Elizabeth". function _findSaintByTitle(massTitle) { if (!_saintsData) return null; const norm = _normSaintName(massTitle); let best = null, bestLen = -1; for (const s of Object.values(_saintsData)) { const sn = _normSaintName(s.name); if ((_nameWordMatch(norm, sn) || _nameWordMatch(sn, norm)) && sn.length > bestLen) { best = s; bestLen = sn.length; } } return best; } function _findSaintLibraryMatch(name) { if (!_saintsLib || !name) return null; const norm = _normSaintName(name); // Exact normalized-name match only. Loose substring matching wrongly paired // e.g. "Elizabeth Ann Seton" with "Elizabeth", injecting the wrong dates. return _saintsLib.find(s => _normSaintName(s.name) === norm) || null; } function _withSaintLibraryMeta(saint) { if (!saint) return null; const match = _findSaintLibraryMatch(saint.name); // Only borrow library fields the daily entry doesn't already carry, so the // saint's own (more specific) years/region always win. return match ? Object.assign({}, saint, { years: saint.years || match.years, century: saint.century || match.century, region: saint.region || match.region, category: saint.category || match.category, church: saint.church || match.church, canonizedBy: saint.canonizedBy || match.canonizedBy, canonizedYear: saint.canonizedYear || match.canonizedYear }) : saint; } function _applySaintCard(data) { const massTitle = data && data.mass && data.mass.title; // Weekday pattern: "Monday of..." or liturgical pattern: "Nth Sunday in Ordinary Time" const isWeekday = massTitle && (/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b/i.test(massTitle) || /(?:Sunday|Week)\s+(?:in|of)\s+/i.test(massTitle)); const isFeast = massTitle && !isWeekday; // Never downgrade: if the card already shows a feast saint and this call // has no feast title (API returned null or a weekday label on background // refresh), keep the existing feast saint rather than replacing with the // generic daily saint. if (_activeHomeSaint && _activeHomeSaint._fromFeast && !isFeast) return; const eyebrowEl = document.getElementById('saint-eyebrow'); const nameEl = document.getElementById('saint-name'); const descEl = document.getElementById('saint-short-desc'); if (isFeast) { // Feast / memorial / solemnity — the feast saint IS the Saint of the Day const eyebrow = massTitle.includes('Solemnity') ? 'Solemnity' : massTitle.includes('Feast') ? 'Feast Day' : massTitle.includes('Memorial') ? 'Memorial' : 'Feast Day'; if (eyebrowEl) eyebrowEl.textContent = eyebrow; // Look up this feast saint in our local data for the tagline const localMatch = _findSaintByTitle(massTitle); if (localMatch) { const enriched = _withSaintLibraryMeta(localMatch); const meta = _saintMeta(enriched); if (nameEl) { nameEl.textContent = enriched.name; _clearSkeleton(nameEl); } if (descEl) { descEl.textContent = [meta, enriched.tagline].filter(Boolean).join(' · '); _clearSkeleton(descEl); } _activeHomeSaint = Object.assign({}, enriched, { _fromFeast: true }); } else { // No local entry — use cleaned mass title as display name const cleanName = _cleanFeastName(massTitle); if (nameEl) { nameEl.textContent = cleanName; _clearSkeleton(nameEl); } if (descEl) { descEl.textContent = ''; _clearSkeleton(descEl); } _activeHomeSaint = { name: cleanName, _fromFeast: true, wikiSlug: cleanName.replace(/^(sts?\.|saints?\s+)/i, '').replace(/\s+/g, '_') }; } } else { // Weekday or no mass title — use our local saint for today const saint = _getSaintToday(); if (!saint) return; const enriched = _withSaintLibraryMeta(saint); const meta = _saintMeta(enriched); if (nameEl) { nameEl.textContent = enriched.name; _clearSkeleton(nameEl); } if (descEl) { descEl.textContent = [meta, enriched.tagline].filter(Boolean).join(' · '); _clearSkeleton(descEl); } if (eyebrowEl) eyebrowEl.textContent = 'Saint of the Day'; _activeHomeSaint = Object.assign({}, enriched, { _fromFeast: false }); } } // Load saints on startup then patch the already-loaded content if any. // Only patch tagline/desc — do NOT re-call _applySaintCard which could // overwrite the correct feast saint with a weekday saint if cached data // has a null or weekday mass.title at this point. function _patchActiveSaintCardMeta() { if (!_activeHomeSaint) { // renderDailyContent hasn't run yet (no cache hit) — full apply is safe const cached = cacheGet(_isoDate()); if (cached) _applySaintCard(cached); return; } // Saints data now loaded — patch just the tagline on the active saint card const localMatch = _withSaintLibraryMeta(_findSaintByTitle(_activeHomeSaint.name) || _activeHomeSaint); if (localMatch) { const descEl = document.getElementById('saint-short-desc'); const meta = _saintMeta(localMatch); if (descEl) descEl.textContent = [meta, localMatch.tagline].filter(Boolean).join(' · '); _activeHomeSaint = Object.assign({}, localMatch, { _fromFeast: _activeHomeSaint._fromFeast }); } } _loadSaints().then(_patchActiveSaintCardMeta); _loadSaintsLibrary().then(_patchActiveSaintCardMeta);