✦ Verse of the Day
Loading today's verse…
Loading today’s devotional…
Select a book and chapter, then press Go
Powered by AI · Responses contain AI generated theological content

Select a verse above or click a verse number in the Bible reader.

A moment of your time?

We’re building this for people like you. A few words — or just a star — helps us serve you better.

'; const htmlBlob = new Blob([styledHtml], { type: 'text/html' }); const textBlob = new Blob([shareText], { type: 'text/plain' }); try { await navigator.clipboard.write([new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob })]); _showToast('Copied with formatting \u2014 paste into email or notes'); } catch { _shareOrCopyText(shareText); } } function shareQAResponse(question, answerHtml, closePanelFirst) { if (closePanelFirst) { closeSavedPanel(); } const shareText = _buildShareText(question, answerHtml); if (!_isNativeApp && navigator.clipboard?.write) { _shareWithHtmlClipboard(question, answerHtml, shareText); return; } _shareOrCopyText(shareText); } function shareQAFromKey(key) { const raw = localStorage.getItem(key); if (!raw) { return; } const qa = JSON.parse(raw); shareQAResponse(qa.question, qa.answerHtml || '', true); } // Strip markdown/HTML to plain text for the qa share token teaser (server re-sanitizes on render). function _qaPlain(s) { return String(s || '') .replace(/<[^>]+>/g, ' ') // strip HTML tags FIRST — before the > removal below .replace(/```[\s\S]*?```/g, ' ') // code fences .replace(/`([^`]+)`/g, '$1') // inline code .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> text .replace(/[*_#>~]/g, '') // md markers (this removes '>', so tags must go first) .replace(/\s+/g, ' ').trim(); } // Strip a single matched pair of OUTER quotes (straight or curly) so we don't double up when the // display wraps text in quotes AND the source text already has them (VoTD/verse "…" duplication). function _dequote(s) { s = String(s || '').trim(); const pairs = [['"', '"'], ['“', '”'], ['‘', '’'], ["'", "'"]]; for (const p of pairs) { if (s.length > 1 && s.charAt(0) === p[0] && s.charAt(s.length - 1) === p[1]) { return s.slice(1, -1).trim(); } } return s; } // Opt-in Q&A-on-verse share (Tier-1 qa adapter). Shows a preview of exactly what will be shared — // so a personal question can be reconsidered/reframed — then emits a `qa` token: verse ref + // question + a bounded plaintext answer teaser. Arrives as verse hero + answer + "ask your own". function _shareVerseQA(ctx, question, answerText) { if (!ctx) { return; } const ref = ctx.book + ' ' + ctx.chapter + ':' + ctx.verse; const q = _qaPlain(question).slice(0, 60); const aFull = _qaPlain(answerText); const a = aFull.slice(0, 90); // keep the /s/ token SHORT — the image file-share embeds the link in TEXT, which FB truncates if long const prev = document.getElementById('qaSharePreview'); if (prev) { prev.remove(); } const ov = document.createElement('div'); ov.id = 'qaSharePreview'; ov.style.cssText = 'position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;padding:16px'; const card = document.createElement('div'); card.style.cssText = 'background:var(--bg-card);border-radius:16px;max-width:480px;width:100%;max-height:85dvh;overflow-y:auto;padding:20px 22px;box-shadow:0 8px 40px rgba(0,0,0,0.5)'; card.innerHTML = '
Share this Q&A
' + '
Recipients see the verse, Bishop’s answer, and can ask their own. Only share what you’re comfortable making public.
' + '
' + esc(ref) + '
' + '
' + esc(q) + '
' + '
' + esc(a) + (aFull.length > 90 ? '…' : '') + '
' + '
' + '' + '' + '
'; ov.appendChild(card); ov.addEventListener('click', (e) => { if (e.target === ov) { ov.remove(); } }); document.body.appendChild(ov); document.getElementById('qaShareCancel').addEventListener('click', () => ov.remove()); document.getElementById('qaShareGo').addEventListener('click', () => { ov.remove(); const tok = _buildShareTok('qa', [ref, q, a]); const link = location.origin + '/s/' + tok; _closeChat(); // Route through the SAME preview flow as the devotional/verse shares — so qa gets a rendered // image + the gradient/photo choice (was just a bare link share before). The question is the // card hero (qa mode: unquoted, no NET credit). Token is short (~204) so the link survives. _shareSubjectRef = ref; _shareSubjectText = q; _shareSubjectMode = 'qa'; const pOverlay = document.getElementById('sharePreviewOverlay'); const pImg = document.getElementById('sharePreviewImg'); const pBtn = document.getElementById('sharePreviewShareBtn'); if (pImg) { pImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; pImg.style.opacity = '0.4'; } if (pBtn) { pBtn.disabled = true; } if (pOverlay) { pOverlay.style.display = 'flex'; } window._pendingShareText = 'A question about ' + ref + ' — see Bishop’s answer and ask your own:\n' + link; window._pendingShareFile = 'askbishop-qa-' + (ref || 'verse').replace(/[^a-zA-Z0-9]/g, '-').replaceAll(/-+/g, '-') + '.png'; _shareBgStyle = 'minimal'; _highlightFormatBtn(_shareFormat); _highlightBgStyleBtn('minimal'); _renderShareCanvas(_shareFormat); }); } function _copyAndToast(text) { const existing = document.getElementById('qaShareToast'); if (existing) { existing.remove(); } const toast = document.createElement('div'); toast.id = 'qaShareToast'; toast.style.cssText = 'position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;z-index:9999;max-width:320px;width:90vw;box-shadow:0 6px 24px rgba(0,0,0,0.5);text-align:center'; const copyDone = navigator.clipboard ? navigator.clipboard.writeText(text).then(() => true).catch(() => false) : Promise.resolve(false); copyDone.then(copied => { toast.innerHTML = '
' + (copied ? '\u2705 Answer copied to clipboard.' : '\u26a0 Copy not available — use your browser share.') + '
' + '
' + '' + '
'; }); document.body.appendChild(toast); setTimeout(() => { if (toast.parentNode) { toast.remove(); } }, 6000); } function renderVerseCanvas(canvas, format = 'landscape', useBg = false) { const fmt = _SHARE_FORMATS[format] || _SHARE_FORMATS.landscape; const W = fmt.w; const H = fmt.h; canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); if (useBg && window._votdCanvasBg) { const bg = window._votdCanvasBg; const bgW = bg.naturalWidth ?? bg.width; const bgH = bg.naturalHeight ?? bg.height; const scale = Math.max(W / bgW, H / bgH); const drawW = bgW * scale; const drawH = bgH * scale; ctx.drawImage(bg, (W - drawW) / 2, (H - drawH) / 2, drawW, drawH); const overlay = ctx.createLinearGradient(0, 0, W, H); overlay.addColorStop(0, 'rgba(10, 18, 36, 0.72)'); overlay.addColorStop(0.5, 'rgba(24, 10, 40, 0.65)'); overlay.addColorStop(1, 'rgba(10, 20, 16, 0.76)'); ctx.fillStyle = overlay; ctx.fillRect(0, 0, W, H); } else { const grad = ctx.createLinearGradient(0, 0, W, H); grad.addColorStop(0, '#0d1a2e'); grad.addColorStop(0.5, '#1a0d2e'); grad.addColorStop(1, '#0d1a1a'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); } // Decorative elements \u2014 scale positions to canvas size const glow = ctx.createRadialGradient(W * 0.15, H * 0.19, 0, W * 0.15, H * 0.19, W * 0.4); glow.addColorStop(0, 'rgba(210,153,34,0.10)'); glow.addColorStop(1, 'transparent'); ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#d29922'; ctx.fillRect(0, 0, W, 4); const cx = W * 0.91, cy = H * 0.09; ctx.strokeStyle = 'rgba(210,153,34,0.15)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy - 50); ctx.lineTo(cx, cy + 50); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx - 40, cy); ctx.lineTo(cx + 40, cy); ctx.stroke(); // Verse text \u2014 size and top offset scale by format const padding = 80; const fontSizes = { portrait: 44, story: 44, square: 42 }; const fontSize = fontSizes[format] ?? 36; const textY = Math.round(H * (format === 'landscape' ? 0.13 : 0.22)); const lineH = Math.round(fontSize * 1.45); const _isQaCard = _shareSubjectMode === 'qa'; const _canvasText = _shareSubjectText || _votdText; // reader-select share overrides the VOTD const verseText = _isQaCard ? (_canvasText || 'A question for Bishop') // qa: the question itself, unquoted (not scripture) : (_canvasText ? '"' + _canvasText + '"' : '"For God so loved the world that he gave his one and only Son."'); ctx.fillStyle = '#f0e6d0'; ctx.font = `italic ${fontSize}px Georgia, serif`; ctx.textBaseline = 'top'; const wrappedH = canvasWrapText(ctx, verseText, padding, textY, W - padding * 2, lineH); const refFontSize = Math.round(fontSize * 0.8); ctx.fillStyle = '#d29922'; ctx.font = `bold ${refFontSize}px Arial, sans-serif`; ctx.fillText('\u2014 ' + (_shareSubjectRef || _votdRef || 'John 3:16'), padding, Math.min(textY + wrappedH + 20, H - 120)); // Footer. Image-first: the shared image must carry the brand + where-to-go on its own, so it // still drives people back even when a platform strips the caption link. Faint source credit // (left) + a PROMINENT amber destination CTA (right). ctx.textBaseline = 'bottom'; ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '18px Arial, sans-serif'; const leftCredit = _votdBgData?.author ? `Photo: ${_votdBgData.author}` : (_isQaCard ? '✦ Asked Bishop' : 'NET Bible, bible.org'); ctx.fillText(leftCredit, padding, H - 34); ctx.textAlign = 'right'; ctx.fillStyle = '#e5b94c'; ctx.font = `bold ${Math.round(fontSize * 0.52)}px Arial, sans-serif`; ctx.fillText('✦ Ask Bishop · askbishop.app', W - padding, H - 32); ctx.textAlign = 'left'; ctx.textBaseline = 'top'; } function canvasWrapText(ctx, text, x, y, maxW, lineH) { const words = text.split(' '); let line = '', cy = y; words.forEach(word => { const test = line + (line ? ' ' : '') + word; if (ctx.measureText(test).width > maxW && line) { ctx.fillText(line, x, cy); cy += lineH; line = word; } else { line = test; } }); if (line) ctx.fillText(line, x, cy); return cy + lineH - y; } // ── Commentary ─────────────────────────────────────────────────────────── // Shared fetch helper — 3-layer cache: memory → localStorage → HTTP const _commentaryFetching = new Map(); // key → in-flight Promise function _fetchCommentary(book, chapter, verse) { const key = `commentary_${_commentaryId}_${book}_${chapter}_${verse}`; // 1. Memory cache if (_refCache.has(key)) return Promise.resolve(_refCache.get(key)); // 2. localStorage cache try { const stored = localStorage.getItem('fc3_' + key); if (stored) { const data = JSON.parse(stored); _refCache.set(key, data); return Promise.resolve(data); } } catch (_) {} // 3. Deduplicate in-flight requests if (_commentaryFetching.has(key)) return _commentaryFetching.get(key); const p = fetch(`commentary_proxy.php?commentary=${encodeURIComponent(_commentaryId)}&book=${encodeURIComponent(book)}&chapter=${chapter}&verse=${verse}`) .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(data => { if (data.error) throw new Error(data.error); _refCache.set(key, data); try { localStorage.setItem('fc3_' + key, JSON.stringify(data)); } catch (_) {} return data; }) .finally(() => _commentaryFetching.delete(key)); _commentaryFetching.set(key, p); return p; } // Hover prefetch — fire and forget function prefetchCommentary(book, chapter, verse) { _fetchCommentary(book, chapter, verse); } // Chapter-level prefetch — fetch all verses at once so clicks are instant async function prefetchChapterCommentary(book, chapter) { try { const r = await fetch(`commentary_proxy.php?action=chapter&commentary=${encodeURIComponent(_commentaryId)}&book=${encodeURIComponent(book)}&chapter=${chapter}`); if (!r.ok) return; const data = await r.json(); if (!data.verses) return; const coveredNums = new Set(); for (const [vStr, vData] of Object.entries(data.verses)) { const vNum = parseInt(vStr); const key = `commentary_${_commentaryId}_${book}_${chapter}_${vNum}`; if (!_refCache.has(key)) { _refCache.set(key, vData); try { localStorage.setItem('fc3_' + key, JSON.stringify(vData)); } catch (_) {} } coveredNums.add(vNum); } applyVerseCoverage(book, chapter, coveredNums); } catch (_) {} } // After prefetch: amber dot on covered verses, open-circle indicator on uncovered ones function applyVerseCoverage(book, chapter, coveredNums) { // Only apply if the currently rendered chapter matches if (_readerBook !== book || _readerChapter !== chapter) return; document.querySelectorAll('#chapterContent .verse-num-link').forEach(el => { const row = el.closest('.verse-row'); if (!row) return; const vNum = parseInt(row.id.replace('v-', '')); if (!coveredNums.has(vNum)) { // Strip click — no commentary for this verse el.classList.remove('verse-num-link'); el.classList.add('verse-num-no-cover'); el.removeAttribute('onclick'); el.removeAttribute('onmouseenter'); el.removeAttribute('ontouchstart'); el.removeAttribute('title'); } }); } // Load commentary registry into Settings picker async function loadCommentaryRegistry() { const picker = document.getElementById('commentaryPicker'); if (!picker) return; try { const r = await fetch('commentary_proxy.php?action=list'); if (!r.ok) throw new Error('HTTP ' + r.status); const data = await r.json(); const list = (data.commentaries || []).filter(c => c.tier?.toLowerCase() === 'free'); picker.innerHTML = list.map(c => { const isActive = c.id === _commentaryId; return ``; }).join('') || `
No commentaries available.
`; // Update the card label with the active commentary name const active = list.find(c => c.id === _commentaryId); if (active) { const nameEl = document.getElementById('commentaryNameLabel'); if (nameEl) nameEl.textContent = active.name; localStorage.setItem('faith_commentaryName', active.name); // for the home tile } } catch (e) { picker.innerHTML = `
Could not load commentary list.
`; } } function selectCommentary(btn) { _commentaryId = btn.dataset.id; document.querySelectorAll('.commentary-picker-btn').forEach(b => b.classList.toggle('active', b.dataset.id === _commentaryId)); const nameEl = document.getElementById('commentaryNameLabel'); const nameNode = btn.querySelector('.commentary-picker-name'); if (nameNode) { if (nameEl) nameEl.textContent = nameNode.textContent; localStorage.setItem('faith_commentaryName', nameNode.textContent); // for the home tile } // Clear memory cache for commentary entries (localStorage entries stay but get ignored) for (const k of [..._refCache.keys()]) { if (k.startsWith('commentary_')) _refCache.delete(k); } // Clear rendered panel so loadCommentaryChapter doesn't hit the early-return cache check const panelBody = document.getElementById('commentaryPanelBody'); if (panelBody) { panelBody.removeAttribute('data-book'); panelBody.removeAttribute('data-chapter'); panelBody.removeAttribute('data-commentary-id'); } saveSettings(); // Re-render chapter to update coverage indicators for new commentary if (document.querySelector('#chapterContent .verse-row')) loadChapter(); // If commentary panel is open, reload it with the new commentary const bSel = document.getElementById('commentaryBookSel'); const cSel = document.getElementById('commentaryChapterSel'); if (panelBody && bSel && cSel) { loadCommentaryChapter(bSel.value, parseInt(cSel.value), null); } } async function openCommentaryPopup(book, chapter, verse, event) { event.stopPropagation(); _commentaryTarget = { book, chapter, verse }; // In split-view on wide screens, skip the modal and load directly into the panel if (document.body.classList.contains('faith-split-active') && window.innerWidth >= 1100) { loadCommentaryPanel(book, chapter, verse); return; } // Grab verse text from the rendered chapter if available const verseEl = document.querySelector(`#v-${verse} .verse-text`); const verseText = verseEl ? verseEl.textContent.trim() : ''; const overlay = document.getElementById('commentaryOverlay'); document.getElementById('commentaryModalTitle').textContent = `${book} ${chapter}:${verse}`; document.getElementById('commentaryModalBody').innerHTML = '
'; overlay.classList.add('open'); document.body.style.overflow = 'hidden'; try { const data = await _fetchCommentary(book, chapter, verse); renderCommentaryModal(data, verseText); } catch (err) { document.getElementById('commentaryModalBody').innerHTML = `
\u26a0 ${esc(err.message)}
`; } } function renderCommentaryModal(d, verseText) { const quote = verseText ? `
\u201c${esc(verseText)}\u201d
` : ''; document.getElementById('commentaryModalBody').innerHTML = ` ${quote}
${linkifyRefs(safeHtml(d.commentary_html))}
${esc(d.source_attribution || '')}
`; } function closeCommentaryModal() { const overlay = document.getElementById('commentaryOverlay'); overlay.classList.remove('open'); overlay.style.zIndex = ''; // restore default z-index document.body.style.overflow = ''; } function openCommentaryTab() { closeCommentaryModal(); if (_commentaryTarget) { loadCommentaryPanel(_commentaryTarget.book, _commentaryTarget.chapter, _commentaryTarget.verse); } showFaithTab('commentary'); } function loadCommentaryPanel(book, chapter, verse) { // Sync nav selects and load the full chapter (scrolls to verse if already loaded) syncCommentaryNav(book, chapter, verse); } // ── Commentary Browser (independent book/chapter navigation) ───────────── function buildCommentaryBookSelect() { const sel = document.getElementById('commentaryBookSel'); if (!sel || sel.options.length > 0) return; BOOKS.forEach(([name]) => { const opt = document.createElement('option'); opt.value = opt.textContent = name; if (name === 'John') opt.selected = true; sel.appendChild(opt); }); buildCommentaryChapterSelect(); // Sync nav state to select defaults _commentaryNavBook = sel.value || 'John'; _commentaryNavChapter = 1; _commentaryLocBtnUpdate(); } function buildCommentaryChapterSelect() { const bSel = document.getElementById('commentaryBookSel'); const cSel = document.getElementById('commentaryChapterSel'); if (!bSel || !cSel) return; const entry = BOOKS.find(([n]) => n === bSel.value); const chapters = entry ? entry[1] : 1; cSel.innerHTML = ''; for (let i = 1; i <= chapters; i++) { const opt = document.createElement('option'); opt.value = opt.textContent = i; cSel.appendChild(opt); } } function commentaryNavBookChanged() { buildCommentaryChapterSelect(); const bSel = document.getElementById('commentaryBookSel'); const cSel = document.getElementById('commentaryChapterSel'); if (bSel && cSel) loadCommentaryChapter(bSel.value, parseInt(cSel.value), null); } function commentaryNavChapterChanged() { const bSel = document.getElementById('commentaryBookSel'); const cSel = document.getElementById('commentaryChapterSel'); if (bSel && cSel) loadCommentaryChapter(bSel.value, parseInt(cSel.value), null); } // ── Commentary Nav Sheet ────────────────────────────────────────────────── let _commentaryNavBook = 'John'; let _commentaryNavChapter = 1; let _commentaryNavVerse = null; let _commentaryNavTab = 'book'; function _commentaryLocBtnUpdate() { const btn = document.getElementById('commentaryLocBtn'); if (btn) btn.textContent = _bookAbbrev(_commentaryNavBook) + ' · ' + _commentaryNavChapter; const pill = document.getElementById('headerNavPill'); if (pill && document.querySelector('.faith-tab.active')?.dataset.tab === 'commentary') { pill.textContent = _bookAbbrev(_commentaryNavBook) + ' · ' + _commentaryNavChapter; } } function _commentaryNavOpenSheet() { const sheet = document.getElementById('commentaryNavSheet'); if (!sheet) return; _commentaryNavVerse = null; _commentaryNavTab = 'book'; _commentaryNavUpdateLocBar(); _commentaryNavSetTab('book'); sheet.style.display = 'flex'; } function _commentaryNavClose() { const sheet = document.getElementById('commentaryNavSheet'); if (sheet) sheet.style.display = 'none'; } function _commentaryNavUpdateLocBar() { const el = document.getElementById('commentaryNavLocText'); if (!el) return; const v = _commentaryNavVerse != null ? ':' + _commentaryNavVerse : ''; el.textContent = _commentaryNavBook + ' ' + _commentaryNavChapter + v; } function _commentaryNavSetTab(tab, verseCount) { _commentaryNavTab = tab; document.getElementById('commentaryNavTabBook')?.classList.toggle('bible-nav-tab-active', tab === 'book'); document.getElementById('commentaryNavTabChapter')?.classList.toggle('bible-nav-tab-active', tab === 'chapter'); document.getElementById('commentaryNavTabVerse')?.classList.toggle('bible-nav-tab-active', tab === 'verse'); const verseTabBtn = document.getElementById('commentaryNavTabVerse'); if (verseTabBtn) verseTabBtn.style.display = (tab !== 'book') ? '' : 'none'; const grid = document.getElementById('commentaryNavGrid'); if (!grid) return; if (tab === 'book') { _commentaryNavRenderBookGrid(grid); } else if (tab === 'chapter') { _commentaryNavRenderChapterGrid(grid); } else { _commentaryNavRenderVerseGrid(grid, verseCount || 50); } } function _commentaryNavRenderBookGrid(grid) { const parts = ['
']; for (const section of _BIBLE_GRID) { if (section.divider) { parts.push('
'); continue; } for (const name of section.books) { const abbr = _BOOK_ABBREV[name] || name.slice(0, 3).toUpperCase(); const active = name === _commentaryNavBook ? ' bnp-active' : ''; parts.push(``); } } parts.push('
'); grid.innerHTML = parts.join(''); grid.querySelector('.bnp-active')?.scrollIntoView({ block: 'nearest' }); } function _commentaryNavRenderChapterGrid(grid) { const entry = BOOKS.find(([n]) => n === _commentaryNavBook); const maxCh = entry ? entry[1] : 1; const parts = ['
']; for (let i = 1; i <= maxCh; i++) { const active = i === _commentaryNavChapter ? ' bnp-active' : ''; parts.push(``); } parts.push('
'); grid.innerHTML = parts.join(''); grid.querySelector('.bnp-active')?.scrollIntoView({ block: 'nearest' }); } function _commentaryNavRenderVerseGrid(grid, count) { const parts = ['
']; for (let i = 1; i <= count; i++) { const active = i === _commentaryNavVerse ? ' bnp-active' : ''; parts.push(``); } parts.push('
'); grid.innerHTML = parts.join(''); if (_commentaryNavVerse) { grid.querySelector('.bnp-active')?.scrollIntoView({ block: 'nearest' }); } } function _commentaryNavSelectBook(name) { _commentaryNavBook = name; _commentaryNavChapter = 1; _commentaryNavVerse = null; _commentaryNavUpdateLocBar(); _commentaryNavSetTab('chapter'); } function _commentaryNavSelectChapter(ch) { _commentaryNavChapter = ch; _commentaryNavVerse = null; _commentaryNavUpdateLocBar(); // If this chapter is already loaded, check for verse entries and show verse tab const body = document.getElementById('commentaryPanelBody'); if (body && body.dataset.book === _commentaryNavBook && parseInt(body.dataset.chapter) === ch) { const entries = [...body.querySelectorAll('.commentary-verse-entry[id^="cv-"]')]; if (entries.length > 1) { const maxV = parseInt(entries[entries.length - 1].id.slice(3)) || entries.length; _commentaryNavSetTab('verse', maxV); return; } } _commentaryNavGo(); } function _commentaryNavShowVerse() { const body = document.getElementById('commentaryPanelBody'); let maxV = 50; if (body && body.dataset.book === _commentaryNavBook) { const entries = [...body.querySelectorAll('.commentary-verse-entry[id^="cv-"]')]; if (entries.length > 0) { maxV = parseInt(entries[entries.length - 1].id.slice(3)) || entries.length; } } _commentaryNavSetTab('verse', maxV); } function _commentaryNavSelectVerse(v) { _commentaryNavVerse = v; _commentaryNavGo(); } function _commentaryNavGo() { const bk = _commentaryNavBook; const ch = _commentaryNavChapter; const vs = _commentaryNavVerse; _commentaryNavClose(); _commentaryLocBtnUpdate(); syncCommentaryNav(bk, ch, vs); if (document.body.classList.contains('faith-split-active')) { _readerBook = bk; _readerChapter = ch; _syncReaderSelects(); loadChapter(); } } function _commentaryNavPrev() { if (_commentaryNavChapter > 1) { _commentaryNavChapter--; } else { const idx = BOOKS.findIndex(([n]) => n === _commentaryNavBook); if (idx > 0) { _commentaryNavBook = BOOKS[idx - 1][0]; _commentaryNavChapter = BOOKS[idx - 1][1]; } } _commentaryLocBtnUpdate(); syncCommentaryNav(_commentaryNavBook, _commentaryNavChapter, null); if (document.body.classList.contains('faith-split-active')) { _readerBook = _commentaryNavBook; _readerChapter = _commentaryNavChapter; _syncReaderSelects(); loadChapter('bwd'); } } function _commentaryNavNext() { const entry = BOOKS.find(([n]) => n === _commentaryNavBook); const maxCh = entry ? entry[1] : 1; if (_commentaryNavChapter < maxCh) { _commentaryNavChapter++; } else { const idx = BOOKS.findIndex(([n]) => n === _commentaryNavBook); if (idx < BOOKS.length - 1) { _commentaryNavBook = BOOKS[idx + 1][0]; _commentaryNavChapter = 1; } } _commentaryLocBtnUpdate(); syncCommentaryNav(_commentaryNavBook, _commentaryNavChapter, null); if (document.body.classList.contains('faith-split-active')) { _readerBook = _commentaryNavBook; _readerChapter = _commentaryNavChapter; _syncReaderSelects(); loadChapter('fwd'); } } async function loadCommentaryChapter(book, chapter, scrollToVerse) { const body = document.getElementById('commentaryPanelBody'); const ref = document.getElementById('commentaryPanelRef'); if (!body) return; // Same chapter + same commentary already rendered — just scroll if (body.dataset.book === book && parseInt(body.dataset.chapter) === chapter && body.dataset.commentaryId === _commentaryId) { if (scrollToVerse) scrollToCommentaryVerse(scrollToVerse); return; } body.innerHTML = '
'; body.removeAttribute('data-book'); body.removeAttribute('data-chapter'); if (ref) ref.textContent = `${_bookAbbrev(book)} ${chapter}`; try { const r = await fetch(`commentary_proxy.php?action=chapter&commentary=${encodeURIComponent(_commentaryId)}&book=${encodeURIComponent(book)}&chapter=${chapter}`); if (!r.ok) throw new Error('HTTP ' + r.status); const data = await r.json(); if (data.error) throw new Error(data.error); const verses = data.verses || {}; const coveredNums = Object.keys(verses).map(Number).sort((a, b) => a - b); // Store all verses in cache for instant Bible-reader verse clicks for (const [vStr, vData] of Object.entries(verses)) { const key = `commentary_${_commentaryId}_${book}_${chapter}_${vStr}`; if (!_refCache.has(key)) { _refCache.set(key, vData); try { localStorage.setItem('fc3_' + key, JSON.stringify(vData)); } catch (_) {} } } if (coveredNums.length === 0) { body.innerHTML = '

No commentary available for this chapter.

'; } else { // One attribution from the first verse that has one const attribution = coveredNums.map(v => (verses[v] || verses[String(v)] || {}).source_attribution).find(Boolean) || ''; body.innerHTML = coveredNums.map(v => { const vData = verses[v] || verses[String(v)] || {}; return `
${v}
${linkifyRefs(safeHtml(vData.commentary_html || ''))}
`; }).join('') + (attribution ? `
${esc(attribution)}
` : ''); } body.dataset.book = book; body.dataset.chapter = chapter; body.dataset.commentaryId = _commentaryId; applyVerseCoverage(book, chapter, new Set(coveredNums)); if (scrollToVerse) scrollToCommentaryVerse(scrollToVerse); } catch (e) { body.innerHTML = `

\u26a0 ${esc(e.message)}

`; } } function scrollToCommentaryVerse(vNum) { const target = document.getElementById(`cv-${vNum}`); if (!target) return; target.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Apply highlight after scroll starts; force reflow to restart if re-navigating same verse requestAnimationFrame(() => { target.classList.remove('cv-highlight', 'cv-highlight-fade'); void target.offsetWidth; // force reflow so animation restarts target.classList.add('cv-highlight'); setTimeout(() => { target.classList.add('cv-highlight-fade'); setTimeout(() => target.classList.remove('cv-highlight', 'cv-highlight-fade'), 1500); }, 800); }); } // Sync commentary browser nav to match a given book/chapter (e.g. from Bible reader click) function syncCommentaryNav(book, chapter, verse) { _commentaryNavBook = book; _commentaryNavChapter = chapter; _commentaryNavVerse = verse || null; _commentaryLocBtnUpdate(); const bSel = document.getElementById('commentaryBookSel'); const cSel = document.getElementById('commentaryChapterSel'); if (!bSel || !cSel) { loadCommentaryChapter(book, chapter, verse); return; } // Build selects if called before Commentary tab was ever opened (e.g. split-view sync) if (bSel.options.length === 0) buildCommentaryBookSelect(); const bookChanged = bSel.value !== book; if (bookChanged) { bSel.value = book; buildCommentaryChapterSelect(); } if (bookChanged || parseInt(cSel.value) !== chapter) { cSel.value = chapter; } loadCommentaryChapter(book, chapter, verse); } // ── Prayer List ────────────────────────────────────────────────────────── let _prayers = []; // Session tombstones for deleted prayers — the Firestore pull merges by union of id, // so without this a delete that races an in-flight pull (or precedes the remote delete // landing) gets resurrected. Cleared on reload; the remote doc is also deleted on delete. // Persistent, cross-collection delete tombstones (coll|id -> deletedAt). Stop the pull-merge from // resurrecting an item the user deleted — across reloads and offline gaps. (The in-memory Set this // replaces was cleared on reload, so a pull after relaunch resurrected the delete; that surfaced // more after the P0 non-destructive-Refresh change kept deleted bodies in IDB.) Pruned after a // window; cleared when an item with the same id is re-created. const _TOMBSTONE_TTL = 90 * 86400000; // 90 days let _fbTombstones = {}; function _tombstonesSave() { _lsSafeSet('faith_tombstones', JSON.stringify(_fbTombstones)); } function _tombstonesLoad() { try { _fbTombstones = JSON.parse(localStorage.getItem('faith_tombstones') || '{}') || {}; } catch (_) { _fbTombstones = {}; } const now = Date.now(); let changed = false; for (const k in _fbTombstones) { if (now - _fbTombstones[k] > _TOMBSTONE_TTL) { delete _fbTombstones[k]; changed = true; } } if (changed) { _tombstonesSave(); } } function _tombstoneKey(coll, id) { return coll + '|' + String(id); } function _tombstoneAdd(coll, id) { const tk = _tombstoneKey(coll, id); _fbTombstones[tk] = Date.now(); _tombstonesSave(); // Propagate the delete to OTHER devices: surgically merge just this key into the shared cloud // tombstone map (merge:true on a nested map adds the key without clobbering others). Other // devices pull it and drop the local item — so a delete on one device removes it everywhere. if (_fbUser && _fbDb) { try { _fbDb.collection('users').doc(_fbUser.uid).collection('data').doc('tombstones') .set({ t: { [tk]: _fbTombstones[tk] } }, { merge: true }) .catch(function (e) { console.warn('tombstone push', e); }); } catch (e) { /* non-fatal */ } } } function _tombstoneHas(coll, id) { return Object.prototype.hasOwnProperty.call(_fbTombstones, _tombstoneKey(coll, id)); } function _tombstoneClear(coll, id) { const k = _tombstoneKey(coll, id); if (k in _fbTombstones) { delete _fbTombstones[k]; _tombstonesSave(); } } // Merge the shared cloud tombstone map so deletes made on OTHER devices are known here (newest wins). async function _tombstonesFsPull() { if (!_fbUser || !_fbDb) { return; } try { const snap = await _fbDb.collection('users').doc(_fbUser.uid).collection('data').doc('tombstones').get(); if (!snap.exists) { return; } const map = (snap.data() || {}).t || {}; const now = Date.now(); let changed = false; for (const k in map) { const ts = map[k]; if (typeof ts !== 'number' || now - ts > _TOMBSTONE_TTL) { continue; } if (!_fbTombstones[k] || _fbTombstones[k] < ts) { _fbTombstones[k] = ts; changed = true; } } if (changed) { _tombstonesSave(); } } catch (e) { console.warn('Tombstones pull error:', e); } } _tombstonesLoad(); function loadPrayers() { try { _prayers = JSON.parse(localStorage.getItem('faith_prayers') || '[]'); } catch (_) { _prayers = []; } renderPrayers(); } function savePrayers() { _lsSafeSet('faith_prayers', JSON.stringify(_prayers)); _fbMarkDirty('prayers'); } function addPrayer() { const inp = document.getElementById('prayerInput'); const text = inp ? inp.value.trim() : ''; if (!text) return; _prayers.unshift({ id: Date.now().toString(), text, dateAdded: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), answered: false, dateAnswered: null, prayedCount: 0, lastPrayed: null, lastModified: Date.now() }); inp.value = ''; savePrayers(); renderPrayers(); } function toggleAnswered(id, checked) { const p = _prayers.find(x => x.id === id); if (!p) return; p.answered = checked; p.dateAnswered = checked ? new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null; p.lastModified = Date.now(); savePrayers(); renderPrayers(); } function deletePrayer(id) { _prayers = _prayers.filter(x => x.id !== id); _tombstoneAdd('prayers', id); // persistent tombstone — survives reload (was in-memory only) _fbDocDelete('prayers', String(id)); // delete the remote doc so the pull-merge can't resurrect it savePrayers(); renderPrayers(); } function prayTick(id) { const p = _prayers.find(x => x.id === id); if (!p) return; p.prayedCount = (p.prayedCount || 0) + 1; p.lastPrayed = new Date().toISOString(); p.lastModified = Date.now(); savePrayers(); renderPrayers(); } function movePrayer(id, dir) { const active = _prayers.filter(x => !x.answered); const idx = active.findIndex(x => x.id === id); const swapWithLocal = idx + dir; if (swapWithLocal < 0 || swapWithLocal >= active.length) return; const swapId = active[swapWithLocal].id; const gi = _prayers.findIndex(x => x.id === id); const gs = _prayers.findIndex(x => x.id === swapId); [_prayers[gi], _prayers[gs]] = [_prayers[gs], _prayers[gi]]; savePrayers(); renderPrayers(); } function prayerItemHTML(p) { // Calm, scannable row: text + one-line status + chevron + answered checkbox. const hasPrayers = Array.isArray(p.bishopPrayers) && p.bishopPrayers.length > 0; let status; if (p.answered) { status = `\u2713 Answered ${p.dateAnswered || ''}`; } else { status = `Added ${p.dateAdded}`; if (hasPrayers) { const n = p.bishopPrayers.length; status += ` \u00b7 \u2726 ${n} ${n === 1 ? 'prayer' : 'prayers'}`; } } return `
`; } // \u2500\u2500 Prayer detail sheet (single home for per-prayer actions) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 const _PRAYER_AFFIRMATIONS = ['He hears you.', 'Carried to the throne.', 'Faithful.', 'Standing with you.', 'He is near.', 'Held in His hands.']; let _prayerAffirmIdx = 0; let _pdsCurrentId = null; function _openPrayerDetail(id) { const p = _prayers.find(x => x.id === id); const sheet = document.getElementById('prayerDetailSheet'); if (!p || !sheet) return; _pdsCurrentId = id; _renderPrayerDetail(p); sheet.style.display = 'flex'; } function _renderPrayerDetail(p) { const statusEl = document.getElementById('pdsStatus'); const textEl = document.getElementById('pdsText'); const heroBtn = document.getElementById('pdsHeroBtn'); const prayedBtn = document.getElementById('pdsPrayedBtn'); const answeredBtn = document.getElementById('pdsAnsweredBtn'); const editForm = document.getElementById('pdsEditForm'); const affEl = document.getElementById('pdsAffirm'); const accEl = document.getElementById('pdsAccordion'); if (statusEl) statusEl.textContent = p.answered ? `\u2713 Answered ${p.dateAnswered || ''}` : `Added ${p.dateAdded}`; if (textEl) textEl.textContent = p.text; if (affEl) { affEl.classList.remove('show'); affEl.textContent = ''; } if (editForm) editForm.style.display = 'none'; const _hasBishopPrayers = Array.isArray(p.bishopPrayers) && p.bishopPrayers.length > 0; if (heroBtn) { heroBtn.style.display = p.answered ? 'none' : ''; // When a Bishop prayer already exists, demote the CTA to a quiet link so the existing // prayer (auto-expanded below) is the focus; otherwise a primary (toned-down) CTA. if (_hasBishopPrayers) { heroBtn.className = 'pds-hero-slim'; heroBtn.textContent = '✦ Pray a fresh Word'; } else { heroBtn.className = 'btn btn-amber pds-hero'; heroBtn.textContent = '✦ Pray the Word over this →'; } } if (prayedBtn) prayedBtn.style.display = p.answered ? 'none' : ''; if (answeredBtn) answeredBtn.innerHTML = p.answered ? '\u21a9 Unmark' : '\u2713 Answered'; if (accEl) { accEl.innerHTML = _renderBishopPrayerAccordion(p); // Auto-expand so the existing prayer is the visible hero (it now sits above the CTA). if (_hasBishopPrayers) { accEl.querySelector('.prayer-bishop-toggle')?.classList.add('open'); accEl.querySelector('.prayer-bishop-history')?.classList.add('open'); } } } function closePrayerDetail() { const sheet = document.getElementById('prayerDetailSheet'); if (sheet) sheet.style.display = 'none'; _pdsCurrentId = null; } function _refreshDetailSheet(id) { const sheet = document.getElementById('prayerDetailSheet'); if (!sheet || sheet.style.display === 'none' || _pdsCurrentId !== id) return; const p = _prayers.find(x => x.id === id); if (p) _renderPrayerDetail(p); } function _prayFromDetail() { const id = _pdsCurrentId; if (!id) return; // Open for everyone — free users get the Lord's Prayer floor; premium formats gate at the chip. _openPrayScripture(id); } function _prayedFromDetail() { const id = _pdsCurrentId; const affEl = document.getElementById('pdsAffirm'); if (!id || !affEl) return; prayTick(id); affEl.textContent = _PRAYER_AFFIRMATIONS[_prayerAffirmIdx % _PRAYER_AFFIRMATIONS.length]; _prayerAffirmIdx++; affEl.classList.add('show'); } function _toggleAnsweredFromDetail() { const id = _pdsCurrentId; const p = _prayers.find(x => x.id === id); if (!p) return; toggleAnswered(id, !p.answered); _renderPrayerDetail(p); } function _editFromDetail() { const editForm = document.getElementById('pdsEditForm'); const ta = document.getElementById('pdsEditTa'); const p = _prayers.find(x => x.id === _pdsCurrentId); if (!editForm || !ta || !p) return; ta.value = p.text; editForm.style.display = ''; _updateDetailMoveBtns(); ta.focus(); } function _updateDetailMoveBtns() { const active = _prayers.filter(x => !x.answered); const idx = active.findIndex(x => x.id === _pdsCurrentId); const up = document.getElementById('pdsMoveUp'); const down = document.getElementById('pdsMoveDown'); if (up) up.disabled = idx <= 0; if (down) down.disabled = idx < 0 || idx >= active.length - 1; } function _moveFromDetail(dir) { if (!_pdsCurrentId) return; movePrayer(_pdsCurrentId, dir); _updateDetailMoveBtns(); } function _saveDetailEdit() { const ta = document.getElementById('pdsEditTa'); const p = _prayers.find(x => x.id === _pdsCurrentId); if (!ta || !p) return; const newText = ta.value.trim(); if (!newText) return; p.text = newText; p.lastModified = Date.now(); savePrayers(); renderPrayers(); _renderPrayerDetail(p); } function _cancelDetailEdit() { const editForm = document.getElementById('pdsEditForm'); if (editForm) editForm.style.display = 'none'; } function _deleteFromDetail() { const id = _pdsCurrentId; if (!id) return; closePrayerDetail(); deletePrayer(id); } async function _openPrayScripture(prayerId) { const p = _prayers.find(x => x.id === prayerId); if (!p) return; const overlay = document.getElementById('prayScriptureOverlay'); if (!overlay) return; document.getElementById('psoNeedText').textContent = p.text; document.getElementById('psoPerson').value = ''; document.getElementById('psoResult').style.display = 'none'; // Format chosen in the moment (defaults to the user's saved preference / Lord's Prayer). // Free users are held to the Lord's Prayer floor regardless of any saved premium preference. _psoSelectedModel = _prayerModel || 'lords_prayer'; if (!_psoIsPremium() && _psoSelectedModel !== 'lords_prayer') _psoSelectedModel = 'lords_prayer'; _renderPsoFormatChips(); document.getElementById('psoResult').innerHTML = ''; document.getElementById('psoSubmitBtn').disabled = false; document.getElementById('psoSubmitBtn').textContent = '✦ Pray Scripture Over This Need'; closePrayerDetail(); // close the prayer-detail sheet so it isn't left as a duplicate window behind overlay._prayerId = prayerId; overlay.style.display = 'flex'; } // Prayer formats — chosen in the moment via chips in the Guided Prayer overlay. const _PRAYER_FORMATS = [ { value: 'lords_prayer', label: "Lord's Prayer", desc: 'The way Jesus taught us to pray.' }, { value: 'default', label: 'Three Movements', desc: 'Adore who God is, intercede, then surrender.' }, { value: 'acts', label: 'ACTS', desc: 'Adoration, Confession, Thanksgiving, Supplication.' }, { value: 'tabernacle', label: 'Tabernacle', desc: 'Draw near: praise, intercede, commune.' }, { value: 'lament', label: 'Honest Lament', desc: 'When it hurts — cry out, and still trust.' }, { value: 'authority', label: 'Stand in Authority', desc: 'Pray from victory over fear and bondage.' }, ]; let _psoSelectedModel = 'lords_prayer'; function _psoIsPremium() { return _userTier === 'monthly' || _userTier === 'annual'; } function _renderPsoFormatChips() { const wrap = document.getElementById('psoFormatChips'); if (!wrap) return; const premium = _psoIsPremium(); wrap.innerHTML = _PRAYER_FORMATS.map(f => { const locked = !premium && f.value !== 'lords_prayer'; const cls = 'pso-chip' + (f.value === _psoSelectedModel ? ' active' : '') + (locked ? ' locked' : ''); const lock = locked ? ' 🔒' : ''; return ``; }).join(''); const note = document.getElementById('psoFloorNote'); if (note) note.style.display = premium ? 'none' : ''; _updatePsoFormatDesc(); } function _psoSelectFormat(value) { if (!_psoIsPremium() && value !== 'lords_prayer') { _showUpgradeModal('prayer'); return; } _psoSelectedModel = value; document.querySelectorAll('#psoFormatChips .pso-chip').forEach(b => b.classList.toggle('active', b.dataset.fmt === value)); _updatePsoFormatDesc(); } function _updatePsoFormatDesc() { const el = document.getElementById('psoFormatDesc'); const f = _PRAYER_FORMATS.find(x => x.value === _psoSelectedModel); if (el && f) el.textContent = f.desc; } function closePrayScriptureOverlay() { const overlay = document.getElementById('prayScriptureOverlay'); if (overlay) overlay.style.display = 'none'; } async function _submitPrayScripture() { const overlay = document.getElementById('prayScriptureOverlay'); const prayerId = overlay?._prayerId; const p = _prayers.find(x => x.id === prayerId); if (!p) return; const need = p.text; const person = document.getElementById('psoPerson').value.trim().slice(0,80); const model = _psoSelectedModel || 'lords_prayer'; // Remember the in-the-moment choice as the user's preference for next time if (model !== _prayerModel) { _prayerModel = model; saveSettings(); } const btn = document.getElementById('psoSubmitBtn'); btn.disabled = true; btn.textContent = '✦ Praying…'; const resultEl = document.getElementById('psoResult'); resultEl.innerHTML = '
✦ Bishop is searching the Scriptures…
'; resultEl.style.display = 'block'; try { const proxyBase = '/faith_proxy.php'; const idToken = _fbUser ? await _fbUser.getIdToken().catch(() => '') : ''; const r = await fetch(proxyBase + '?action=pray_scripture', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + idToken }, body: JSON.stringify({ need, person: person || undefined, model }) }); const d = await r.json(); if (r.status === 402 || d.gate) { // Premium format (or quota) — send the user to the upgrade path resultEl.style.display = 'none'; btn.disabled = false; btn.textContent = '✦ Pray Scripture Over This Need'; _showUpgradeModal('prayer'); return; } if (d.prayer) { if (_quotaCache) { _quotaCache.used = (_quotaCache.used || 0) + 1; _renderQuotaCounter(); } // Auto-save prayer to the prayer card _saveBishopPrayer(prayerId, { prayer: d.prayer, person, model, ts: Date.now() }); const prayerHtml = linkifyRefs(d.prayer.replace(/\*\*([^*]+)\*\*/g,'$1').replace(/\n/g,'
')); resultEl.innerHTML = `
✓ Saved to prayer card
${prayerHtml}
`; resultEl._rawText = d.prayer; _psoRawText = d.prayer; // module-level mirror — a DOM property dies with a re-render (see _psoPrayAloud) resultEl.style.display = 'block'; btn.textContent = '✦ Pray Again'; btn.disabled = false; } else { resultEl.style.display = 'none'; _showError(d.error || 'Could not generate prayer. Try again.'); btn.disabled = false; btn.textContent = '✦ Pray Scripture Over This Need'; } } catch(e) { resultEl.style.display = 'none'; _showError('Could not reach Bishop. Check your connection or visit askbishop.app/status.'); btn.disabled = false; btn.textContent = '✦ Pray Scripture Over This Need'; } } function _prayScriptureToJournal(prayerId) { const p = _prayers.find(x => x.id === prayerId); const resultEl = document.getElementById('psoResult'); const prayerText = resultEl?._rawText || ''; closePrayScriptureOverlay(); showFaithTab('journal'); const prayerTitle = p ? p.text.slice(0, 120) : 'Scripture Prayer'; setTimeout(() => openJournalNew('text', { title: prayerTitle, votdRef: 'Prayer: ' + (p ? p.text.slice(0,60) : ''), _rpPrompt: prayerText }), 120); } function _saveBishopPrayer(prayerId, entry) { const p = _prayers.find(x => x.id === prayerId); if (!p) return; if (!Array.isArray(p.bishopPrayers)) p.bishopPrayers = []; p.bishopPrayers.unshift(entry); // newest first savePrayers(); renderPrayers(); // update the row's "N prayers" status _refreshDetailSheet(prayerId); // refresh the sheet accordion if open } function _renderBishopPrayerAccordion(p) { if (!p.bishopPrayers?.length) return ''; const count = p.bishopPrayers.length; const label = count === 1 ? 'Bishop\'s Prayer' : `Bishop's Prayers (${count})`; const entries = p.bishopPrayers.map((e, i) => { const date = e.ts ? new Date(e.ts).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) : ''; const personNote = e.person ? ` · For ${e.person}` : ''; const html = linkifyRefs(e.prayer.replace(/\*\*([^*]+)\*\*/g,'$1').replace(/\n/g,'
')); return `
${date}${personNote}
${html}
`; }).join(''); return `
${entries}
`; } // The generated prayer text, mirrored at module scope. It used to live ONLY as a `_rawText` property on // the #psoResult element — which a re-render silently destroys, leaving "Pray this aloud" reading // undefined and doing nothing (works again after a reload, which is exactly the reported symptom). let _psoRawText = ''; function _psoPrayAloud(btn) { const el = document.getElementById('psoResult'); _ttsTrigger(_psoRawText || (el && el._rawText) || '', { label: "Bishop's Prayer" }, btn); } function _ttsBishopPrayer(prayerId, idx, btn) { const p = _prayers.find(x => x.id === prayerId); // Was `if (text) { … }` — a SILENT no-op whenever the prayer id or accordion index didn't resolve // (stale idx after a delete/re-render, or _prayers not yet hydrated). Always route through // _ttsTrigger, which now reports an empty source instead of swallowing it. const text = p?.bishopPrayers?.[idx]?.prayer || ''; _ttsTrigger(text, { label: "Bishop's Prayer" }, btn); } function _deleteBishopPrayer(prayerId, idx) { const p = _prayers.find(x => x.id === prayerId); if (!p || !p.bishopPrayers) return; p.bishopPrayers.splice(idx, 1); savePrayers(); renderPrayers(); // update the row's "N prayers" status _refreshDetailSheet(prayerId); // re-render the sheet accordion if open // Re-open the accordion so the user sees remaining entries const accordion = document.querySelector('#pdsAccordion .prayer-bishop-accordion'); if (accordion) { accordion.querySelector('.prayer-bishop-toggle')?.classList.add('open'); accordion.querySelector('.prayer-bishop-history')?.classList.add('open'); } } function _toggleBishopAccordion(btn) { btn.classList.toggle('open'); const hist = btn.nextElementSibling; if (hist) hist.classList.toggle('open'); } function _shareBishopPrayer(prayerId, index) { const p = _prayers.find(x => x.id === prayerId); const entry = p?.bishopPrayers?.[index]; if (!entry) return; const txt = entry.prayer.replace(/\*\*([^*]+)\*\*/g,'$1'); if (navigator.share) { navigator.share({ text: txt }).catch(()=>{}); } else { navigator.clipboard?.writeText(txt).then(() => _showToast('Prayer copied to clipboard')); } } function _prayScriptureShare() { const resultEl = document.getElementById('psoResult'); const txt = resultEl?._rawText?.replace(/\*\*([^*]+)\*\*/g,'$1') || ''; if (navigator.share) { navigator.share({ text: txt }).catch(()=>{}); } else { navigator.clipboard?.writeText(txt).then(() => _showToast('Prayer copied to clipboard')); } } // ── Teach Me to Pray — free 7-day on-ramp (state in its own object, never in _prayers) ── const _TTP_DAYS = [ { day: 1, title: 'Just Start Talking', format: 'default', formatLabel: 'Three Movements', teaching: "Prayer is a conversation, not a performance. You don't have to sound holy or find the perfect words. God isn't grading you — He's just glad you came. Today, simply begin.", prayer: "Father, You are near — closer than my own breath, and You already know my name. I bring You this day and everything it holds, the parts I can carry and the parts I can't. I'm not here to impress You; I'm here because You love me. So I give You this day, and I give You me. Amen." }, { day: 2, title: 'The Prayer Jesus Gave Us', format: 'lords_prayer', formatLabel: "Lord's Prayer", teaching: "When the disciples asked how to pray, Jesus didn't give a lecture — He gave them words. The Lord's Prayer isn't a script to recite; it's a frame for everything prayer is: worship, surrender, provision, forgiveness, protection.", prayer: "Our Father in heaven, holy is Your name. Let Your kingdom come in my life today, and Your will be done in me as it is in heaven. Give me what I need for today. Forgive me, and make me quick to forgive others. Keep me from the traps of the enemy, and lead me home to You. For the kingdom and the power and the glory are Yours. Amen." }, { day: 3, title: "Count What's Already His", format: 'acts', formatLabel: 'ACTS', teaching: "Gratitude re-trains your eyes. Before you ask God for anything, remember what He's already done — it changes the size of the problem in front of you. Today, give thanks before you ask.", prayer: "I adore You, God — faithful and good in every season. I confess I forget how much You've already carried me, and I trade trust for worry too easily. Thank You — for breath, for grace, for the people around me. And now I bring You my need, knowing the One who has provided before will provide again. Amen." }, { day: 4, title: 'Come Close', format: 'tabernacle', formatLabel: 'Tabernacle', teaching: "You were made to come near to God, not shout at Him from a distance. The old tabernacle had courts to pass through to reach His presence — but Jesus tore the curtain. You get to walk all the way in. Today, draw near.", prayer: "I come with thanksgiving on my lips, Lord — You are worthy of all praise. I step closer, laying down every weight and bringing the true cries of my heart. And now I come all the way in — past the noise, past the striving — just to be with You, to rest in the presence I was made for. Here I am. Amen." }, { day: 5, title: 'You Can Be Honest', format: 'lament', formatLabel: 'Honest Lament', teaching: "God can handle your real feelings. A third of the Psalms are honest complaints. You don't have to fake fine — tell Him the truth, then preach His faithfulness back to your own heart. Today, be honest.", prayer: "Lord, this is hard, and I won't pretend it isn't. I don't understand, and part of me is weary of waiting. (Here, tell Him the real thing.) And yet — You have never once let me go. You held me before, and You are holding me now. So even here, even like this, I will trust You. You are still good. Amen." }, { day: 6, title: 'Stand Your Ground', format: 'authority', formatLabel: 'Stand in Authority', teaching: "Some things you don't beg for — you stand against, in the authority Jesus already gave you. You're not fighting for victory; you're standing from it. The same Spirit that raised Christ lives in you. Today, stand firm.", prayer: "In the name of Jesus, I stand. I am a child of God, and the One who is in me is greater than the one in the world. So I declare Your Word over this fear: I have not been given a spirit of fear, but of power, love, and a sound mind. I will not bow to the lie. I take my stand on the finished work of Christ, and I will not be moved. Amen." }, { day: 7, title: "Now It's Yours", format: 'lords_prayer', formatLabel: 'Your choice', teaching: "This week you adored, you asked, you gave thanks, you got honest, you came close, and you stood firm. That wasn't a course — that was you praying. You already have everything you need. Now carry it, and come pray the Word over the real needs on your heart.", prayer: "Lord, thank You for teaching me to pray. I won't stop now — I'll keep bringing You my whole life, in my own words, knowing You never tire of hearing from me. Have Your way in me. Amen." }, ]; function _courseState() { try { return JSON.parse(localStorage.getItem('faith_course_ttp') || 'null') || {}; } catch (_) { return {}; } } function _saveCourseState(s) { localStorage.setItem('faith_course_ttp', JSON.stringify(s)); _fbDebouncedSettingsSync(); } function _courseCurrentDay(s) { const done = new Set((s.completedDays || []).map(Number)); for (let d = 1; d <= 7; d++) { if (!done.has(d)) return d; } return 7; } function _ttpFmt(t) { return t.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\n/g, '
'); } function _renderCourseBanner() { const el = document.getElementById('courseBanner'); if (!el) return; const s = _courseState(); if (s.completed) { if (s.dismissed) { el.innerHTML = ''; return; } el.innerHTML = `
You completed Teach Me to Pray Revisit any day, any time.
`; return; } if (s.enrolled) { const day = _courseCurrentDay(s); const info = _TTP_DAYS[day - 1]; const pct = Math.round(((s.completedDays || []).length / 7) * 100); el.innerHTML = ``; return; } if (s.dismissed) { el.innerHTML = ''; return; } el.innerHTML = `
`; } function _dismissCourseBanner() { const s = _courseState(); s.dismissed = true; _saveCourseState(s); _renderCourseBanner(); } function _openTtpModal(html) { const body = document.getElementById('ttpBody'); const modal = document.getElementById('ttpModal'); if (!body || !modal) return; body.innerHTML = html; modal.style.display = 'flex'; } function _closeTtpModal() { const modal = document.getElementById('ttpModal'); if (modal) modal.style.display = 'none'; } function _openCourseInvite() { _openTtpModal(`
Teach Me to Pray
Nobody is born knowing how to pray.
The disciples walked with Jesus, watched Him still storms — and the one thing they asked Him to teach them was prayer. So you're in good company. You don't need fancy words or to sound like anybody. Prayer is just you, talking to a God who already loves you.
Give me seven days — one short prayer a day. By the end of the week you won't be wondering how to pray. You'll be praying.
The course is free. When you're ready to have Bishop pray the Word over your own needs in every style, that's part of Premium.
`); } function _courseEnroll() { const s = _courseState(); s.enrolled = true; if (!Array.isArray(s.completedDays)) s.completedDays = []; _saveCourseState(s); _renderCourseBanner(); _openCourseDay(_courseCurrentDay(s)); } let _ttpPrayerText = ''; function _openCourseDay(day) { const info = _TTP_DAYS[day - 1]; if (!info) return; _ttpPrayerText = info.prayer; const ttsBtn = document.body.classList.contains('tts-enabled') ? `` : ''; const ctaLabel = day === 7 ? 'Finish the course' : 'Mark complete'; _openTtpModal(`
Day ${day} of 7
${info.title}
Style: ${info.formatLabel}
${info.teaching}
${_ttpFmt(info.prayer)}
${ttsBtn}
`); } function _completeCourseDay(day) { const s = _courseState(); if (!Array.isArray(s.completedDays)) s.completedDays = []; if (!s.completedDays.map(Number).includes(day)) s.completedDays.push(day); s.enrolled = true; if (day >= 7) { s.completed = true; } _saveCourseState(s); _renderCourseBanner(); if (day >= 7) { _courseComplete(); return; } const next = day + 1; _openTtpModal(`
Day ${day} complete ✓
Well prayed.
Day ${next} is ready whenever you are — no rush, no streak to keep. Come back when your heart is ready.
`); } function _courseComplete() { const isPremium = _userTier === 'monthly' || _userTier === 'annual'; const upsell = isPremium ? '' : `
The Lord's Prayer is yours, free, forever — pray it over any need, any time. And when you're ready to have Bishop pray the Word over your needs in every style you just learned, Premium opens it all.
`; const premiumActions = isPremium ? `
` : ''; _openTtpModal(`
Teach Me to Pray ✓
Look at what just happened.
Seven days ago you weren't sure you knew how to pray. Today you do. That wasn't something I gave you — it was already in you. I just walked alongside while you found it.
Walk on, friend. I'm proud of you. — Bishop
${upsell}${premiumActions}`); } // ── Today's Stand — live home-screen prayer card ───────────────────────── // In-app only. If a push notification is ever attached to Today's Stand it MUST be // opt-in and frequency-capped, MUST NOT reference elapsed time or "missed" days, and // MUST NOT name a tender prayer on a lock screen (use a generic "A prayer is waiting" // and reveal the name only in-app). No elapsed-time guilt copy anywhere here. function _isTodayMs(ms) { if (!ms) return false; const d = new Date(ms), n = new Date(); return d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate(); } function _todaysStandPick() { const s = _courseState(); if (s.enrolled && !s.completed) return { type: 'course', day: _courseCurrentDay(s) }; // Gratitude beat: a prayer answered today (celebrate before moving on) const answeredToday = _prayers.filter(p => p.answered && _isTodayMs(p.lastModified)) .sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0))[0]; if (answeredToday) return { type: 'answered', prayer: answeredToday }; const active = _prayers.filter(p => !p.answered); if (!active.length) return { type: 'empty' }; // Longest-neglected: oldest last-prayed (never-prayed = most neglected). Never ranked by "weight". const pick = active.slice().sort((a, b) => { const la = a.lastPrayed ? Date.parse(a.lastPrayed) : 0; const lb = b.lastPrayed ? Date.parse(b.lastPrayed) : 0; return la - lb; })[0]; if (pick.lastPrayed && _isTodayMs(Date.parse(pick.lastPrayed))) return { type: 'prayed', prayer: pick }; return { type: 'pray', prayer: pick }; } function _standOpen(id) { _openPrayerDetail(id); } function _todaysStandTile() { const pick = _todaysStandPick(); const trunc = (t, n) => { const c = (t || '').trim(); return c.length > n ? esc(c.slice(0, n - 1)) + '…' : esc(c); }; let eyebrow, value, onclick, meta = ''; if (pick.type === 'course') { const info = _TTP_DAYS[pick.day - 1]; eyebrow = '✦ Teach Me to Pray'; value = 'Day ' + pick.day + ' · ' + esc(info.title); onclick = '_openCourseDay(' + pick.day + ')'; } else if (pick.type === 'answered') { eyebrow = '🙌 Today\'s Stand'; value = 'Give thanks: ' + trunc(pick.prayer.text, 18); meta = 'Answered from your prayer list'; onclick = "_standOpen('" + pick.prayer.id + "')"; } else if (pick.type === 'prayed') { eyebrow = '✦ Today\'s Stand'; value = '✓ ' + trunc(pick.prayer.text, 24); meta = 'From your prayer list'; onclick = "_standOpen('" + pick.prayer.id + "')"; } else if (pick.type === 'pray') { eyebrow = '✦ Today\'s Stand'; value = 'Stand with ' + trunc(pick.prayer.text, 22); meta = 'From your prayer list'; onclick = "_standOpen('" + pick.prayer.id + "')"; } else { eyebrow = '✦ Today\'s Stand'; value = 'Add your first prayer'; meta = 'Your prayer list'; onclick = "showFaithTab('prayer')"; } return ''; } function _prayerCoachHTML() { if (localStorage.getItem('faith_prayerCoachSeen')) return ''; return `
✦ Tap any prayer to pray Scripture over it with Bishop.
`; } function _dismissPrayerCoach() { localStorage.setItem('faith_prayerCoachSeen', '1'); document.getElementById('prayerCoach')?.remove(); } function renderPrayers() { _renderCourseBanner(); const active = _prayers.filter(p => !p.answered); const answered = _prayers.filter(p => p.answered); const badge = document.getElementById('prayerCountBadge'); if (badge) badge.textContent = active.length === 1 ? '1 active' : `${active.length} active`; const list = document.getElementById('prayerList'); if (!list) return; if (active.length === 0 && answered.length === 0) { list.innerHTML = '
Add a prayer request above —
then tap it to let Bishop pray Scripture over it.
'; } else if (active.length === 0) { list.innerHTML = '
\uD83D\uDE4C All prayers answered!
'; } else { list.innerHTML = _prayerCoachHTML() + active.map(prayerItemHTML).join(''); } const ansSection = document.getElementById('answeredSection'); const ansList = document.getElementById('answeredList'); const ansCount = document.getElementById('answeredCount'); if (!ansSection) return; if (answered.length > 0) { ansSection.style.display = ''; if (ansCount) ansCount.textContent = `(${answered.length})`; if (ansList) ansList.innerHTML = answered.map(prayerItemHTML).join(''); } else { ansSection.style.display = 'none'; } } function toggleAnsweredSection() { const list = document.getElementById('answeredList'); const chev = document.getElementById('answeredChevron'); if (!list) return; const open = list.style.display === 'none'; list.style.display = open ? '' : 'none'; if (chev) chev.textContent = open ? '▲' : '▼'; } // ── Utility ────────────────────────────────────────────────────────────── // ── Pin a Verse ─────────────────────────────────────────────────────────── function _pinKey(book, chapter, verse) { return 'faith_pin_' + [book, chapter, verse].join('_').replace(/\s+/g, '_'); } function _isPinned(book, chapter, verse) { return !!localStorage.getItem(_pinKey(book, chapter, verse)); } // ── Shared verse margin-icon HTML (pin + note indicators) ───────────────── // Used by loadChapter(), _rpLoadPassage(), and _studyBibleLoadChapter(). // noteIdx: the Set returned by _loadNoteIndex() function _verseMetaHtml(book, chapter, verse, noteIdx, hasChat = undefined) { const pinned = _isPinned(book, chapter, verse); const hasNote = noteIdx.has(_noteKey(book, chapter, verse)); const chatSlot = hasChat !== undefined ? '' : ''; return '' + '' + '' + chatSlot + ''; } function _loadPinIndex() { try { return JSON.parse(localStorage.getItem('faith_pins') || '[]'); } catch(_) { return []; } } function _addPinToIndex(key) { const idx = _loadPinIndex(); if (!idx.includes(key)) idx.push(key); _lsSafeSet('faith_pins', JSON.stringify(idx)); _fbMarkDirty('pins'); _tombstoneClear('memreview', key); // re-pinning clears any stale unpin tombstone (Phase 2) } function _removePinFromIndex(key) { _lsSafeSet('faith_pins', JSON.stringify(_loadPinIndex().filter(k => k !== key))); _fbDocDelete('pins', key); _fbMarkDirty('pins'); // The memory-verse review state is keyed by the pin key — remove it too so an orphaned SRS // record can't linger or resurrect on the next pull (tombstone blocks re-add). (Phase 2) try { localStorage.removeItem('faith_srs_' + key); } catch (_e) { /* non-fatal */ } try { const sidx = JSON.parse(localStorage.getItem('faith_srs_index') || '[]'); if (Array.isArray(sidx) && sidx.includes(key)) { _lsSafeSet('faith_srs_index', JSON.stringify(sidx.filter(k => k !== key))); } } catch (_e) { /* non-fatal */ } _fbDocDelete('memreview', key); _tombstoneAdd('memreview', key); } function _fbDocDelete(coll, key) { if (!_fbUser || !_fbDb) return; _fbDb.collection('users').doc(_fbUser.uid).collection(coll).doc(key) .delete().catch(e => console.warn('Firestore delete', coll, key, e)); } function _animatePinBtn(book, chapter, verse) { const row = _findVerseRow(verse); const btn = row ? row.querySelector('.verse-pin-btn') : null; if (!btn) return; // Determine direction: if currently pinned, we're about to unpin and vice versa const pinning = !localStorage.getItem(_pinKey(book, chapter, verse)); const addCls = pinning ? 'pin-clicking-in' : 'pin-clicking-out'; const remCls = pinning ? 'pin-clicking-out' : 'pin-clicking-in'; btn.classList.remove('pin-clicking-in', 'pin-clicking-out'); void btn.offsetWidth; // restart animation if re-clicked quickly btn.classList.remove(remCls); btn.classList.add(addCls); btn.addEventListener('animationend', () => btn.classList.remove(addCls), { once: true }); } // ── Verse tap-select action sheet ───────────────────────────────────────── const _verseSelected = new Set(); // "book|ch|verse" keys function _verseToggle(e, book, chapter, verse) { const key = `${book}|${chapter}|${verse}`; const row = e?.target?.closest('.verse-row') || _findVerseRow(verse); if (_verseSelected.has(key)) { _verseSelected.delete(key); if (row) row.classList.remove('verse-sel'); } else { _verseSelected.add(key); if (row) row.classList.add('verse-sel'); } _vasUpdate(); } function _vasUpdate() { const sheet = document.getElementById('verseActionSheet'); if (!sheet) return; if (_verseSelected.size === 0) { sheet.classList.remove('open'); return; } const n = _verseSelected.size; document.getElementById('vasCount').textContent = n === 1 ? '1 verse selected' : `${n} verses selected`; // Reflect pin + note state on buttons (only meaningful for single selection) const pinBtn = document.getElementById('vasPinBtn'); const noteBtn = document.getElementById('vasNoteBtn'); const pinLbl = document.getElementById('vasPinLabel'); if (pinBtn && noteBtn) { if (n === 1) { const parts = [..._verseSelected][0].split('|'); const bk = parts[0], ch = parts[1], v = parts[2]; const pinKey = 'faith_pin_' + [bk, ch, v].join('_').replace(/\s+/g, '_'); const noteKey = 'faith_note_' + [bk, ch, v].join('_').replace(/\s+/g, '_'); const pinned = !!localStorage.getItem(pinKey); const hasNote = !!localStorage.getItem(noteKey); pinBtn.classList.toggle('vas-active', pinned); if (pinLbl) pinLbl.textContent = pinned ? 'Unpin' : 'Pin'; noteBtn.classList.toggle('vas-noted', hasNote); } else { pinBtn.classList.remove('vas-active'); if (pinLbl) pinLbl.textContent = 'Pin'; noteBtn.classList.remove('vas-noted'); } } // Push to history when a single verse is selected (with verse text for the card preview) if (n === 1) { const _hParts = [..._verseSelected][0].split('|'); const _hRow = _findVerseRow(_hParts[2]); const _hText = _hRow ? (_hRow.querySelector('.verse-text')?.textContent.trim() || '') : ''; _bibleHistoryPush(_hParts[0], parseInt(_hParts[1]), parseInt(_hParts[2]), _hText); } const _vasWasOpen = sheet.classList.contains('open'); sheet.classList.add('open'); // Show "Insert into Notes" only during a Find-in-Bible hand-off from sermon/journal notes. const vasInsertRow = document.getElementById('vasInsertRow'); if (vasInsertRow) { vasInsertRow.style.display = _bibleInsertTarget ? '' : 'none'; } // Show "Open in Reader" only in the reading-plan reading view (the main reader IS the reader). const vasOpenRow = document.getElementById('vasOpenInReaderRow'); if (vasOpenRow) { const rpOpen = document.getElementById('rpReadingOverlay')?.classList.contains('open'); vasOpenRow.style.display = rpOpen ? '' : 'none'; } // Pad the scroll container so the last verses aren't hidden under the VAS sheet, // then scroll the selected verse to be fully visible above it. requestAnimationFrame(() => { const vasH = sheet.offsetHeight; const panel = document.querySelector('.faith-panel.active'); if (panel) { panel.style.paddingBottom = vasH + 'px'; } const sbPanel = document.getElementById('studyBiblePanel'); const sbContent = document.getElementById('sbContent'); if (sbPanel && sbPanel.style.display !== 'none' && sbContent) { sbContent.style.paddingBottom = vasH + 'px'; } // Only on the OPEN transition: shift once to keep the first selection visible // above the sheet. On later taps (sheet already open) NEVER move scroll — the user // stays where they tapped, so multi-verse selection across a screen isn't tedious. if (!_vasWasOpen && _verseSelected.size > 0) { const v = [..._verseSelected][0].split('|')[2]; const el = _findVerseRow(v); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }); } function _vasGoToVerse() { if (!_verseSelected.size) { return; } const parts = [..._verseSelected][0].split('|'); const bk = parts[0]; const ch = Number.parseInt(parts[1], 10); const vn = Number.parseInt(parts[2], 10); const rpOverlay = document.getElementById('rpReadingOverlay'); const sbPanel = document.getElementById('studyBiblePanel'); if (rpOverlay && rpOverlay.classList.contains('open')) { rpCloseReadingModal(); } if (sbPanel && sbPanel.style.display !== 'none') { _studyBibleClose(); } _vasDismiss(); openRefInReader(bk, ch, vn); } function _vasDismiss() { _verseSelected.forEach(key => { const v = key.split('|')[2]; const el = _findVerseRow(v); if (el) el.classList.remove('verse-sel'); }); _verseSelected.clear(); const sheet = document.getElementById('verseActionSheet'); if (sheet) sheet.classList.remove('open'); const panel = document.querySelector('.faith-panel.active'); if (panel) { panel.style.paddingBottom = ''; } const sbContent = document.getElementById('sbContent'); if (sbContent) { sbContent.style.paddingBottom = ''; } const pinBtn = document.getElementById('vasPinBtn'); if (pinBtn) pinBtn.classList.remove('vas-active'); const pinLbl = document.getElementById('vasPinLabel'); if (pinLbl) pinLbl.textContent = 'Pin'; const noteBtn = document.getElementById('vasNoteBtn'); if (noteBtn) noteBtn.classList.remove('vas-noted'); } function _vasNote() { if (!_verseSelected.size) return; const [bk, ch, v] = [..._verseSelected][0].split('|'); _vasDismiss(); openNoteSheet(bk, parseInt(ch), parseInt(v)); } function _vasPin() { [..._verseSelected].forEach(key => { const [bk, ch, v] = key.split('|'); pinVerseFromReader(bk, parseInt(ch), parseInt(v)); }); _vasDismiss(); } // Collapse selected "book|ch|verse" keys into a single consolidated reference, // e.g. "Matthew 4:1-5", "Matthew 4:1-3, 5", cross-chapter "Matthew 4:23-25, 5:1-2", // multi-book "Matthew 4:1-2; John 3:16". Consecutive verses within a chapter merge // into a range; any gap (or chapter change) starts a new comma-separated segment. function _consolidateVerseRefs(keys) { const bookOrder = []; const items = keys.map(k => { const p = k.split('|'); if (bookOrder.indexOf(p[0]) === -1) { bookOrder.push(p[0]); } return { bk: p[0], ch: parseInt(p[1], 10), v: parseInt(p[2], 10) }; }); items.sort((a, b) => { if (a.bk !== b.bk) { return bookOrder.indexOf(a.bk) - bookOrder.indexOf(b.bk); } if (a.ch !== b.ch) { return a.ch - b.ch; } return a.v - b.v; }); const bookParts = []; bookOrder.forEach(bk => { const verses = items.filter(it => it.bk === bk); const runs = []; let cur = null; verses.forEach(it => { if (cur && it.ch === cur.endCh && it.v === cur.endV + 1) { cur.endV = it.v; } else { cur = { startCh: it.ch, startV: it.v, endCh: it.ch, endV: it.v }; runs.push(cur); } }); const segs = runs.map(r => r.startV === r.endV ? `${r.startCh}:${r.startV}` : `${r.startCh}:${r.startV}-${r.endV}`); bookParts.push(`${bk} ${segs.join(', ')}`); }); return bookParts.join('; '); } function _vasCopy() { const sorted = [..._verseSelected].sort((a, b) => { const pa = a.split('|'), pb = b.split('|'); const ca = parseInt(pa[1], 10), cb = parseInt(pb[1], 10); if (ca !== cb) { return ca - cb; } return parseInt(pa[2], 10) - parseInt(pb[2], 10); }); const texts = sorted.map(key => { const v = key.split('|')[2]; const row = _findVerseRow(v); return row ? (row.querySelector('.verse-text')?.textContent.trim() || '') : ''; }).filter(Boolean); const reference = _consolidateVerseRefs([..._verseSelected]); navigator.clipboard?.writeText(`${texts.join(' ')}\n\n\u2014 ${reference}`); _showToast(_verseSelected.size === 1 ? 'Verse copied to clipboard' : 'Verses copied to clipboard'); _vasDismiss(); } // \u2500\u2500 Bible-reader consolidation: in-context "Find in Bible" + insert into the active entry \u2500\u2500 // One reader for everyone. From sermon/journal notes, minimize the editor, set the insert target, // and open the single real reader; its action sheet gains "Insert into notes" which drops the // selected verse(s) + reference into that entry and returns. Replaces the duplicate study bible. let _bibleInsertTarget = null; // { kind:'sermon'|'journal', id } \u2014 one minimized editor at a time function _findInBible(kind) { const id = (kind === 'sermon') ? (_sermonState && _sermonState.id) : (_journalEditing && _journalEditing.id); _bibleInsertTarget = { kind: kind, id: id || null }; if (kind === 'sermon') { _smMinimize(); } else { _jeMinimize(); } showFaithTab('bible'); } function _vasInsertToNotes() { if (!_bibleInsertTarget || _verseSelected.size === 0) { return; } const sorted = [..._verseSelected].sort((a, b) => { const pa = a.split('|'), pb = b.split('|'); const ca = parseInt(pa[1], 10), cb = parseInt(pb[1], 10); if (ca !== cb) { return ca - cb; } return parseInt(pa[2], 10) - parseInt(pb[2], 10); }); const texts = sorted.map(key => { const row = _findVerseRow(key.split('|')[2]); return row ? (row.querySelector('.verse-text')?.textContent.trim() || '') : ''; }).filter(Boolean); if (!texts.length) { return; } const reference = _consolidateVerseRefs([..._verseSelected]); const block = '\n\u201c' + texts.join(' ') + '\u201d \u2014 ' + reference + '\n'; const target = _bibleInsertTarget; _bibleInsertTarget = null; _vasDismiss(); if (target.kind === 'sermon') { _sermonReopen(); const ta = document.getElementById('smNotesArea'); if (ta) { ta.value = ta.value + block; } } else { _jeReopen(); const body = document.getElementById('jeBody'); if (body) { body.focus(); const sel = window.getSelection(); if (!sel.rangeCount) { const r = document.createRange(); r.selectNodeContents(body); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); } document.execCommand('insertText', false, block); _journalDirty = true; try { _jeUpdatePrimary(); } catch(e) { /* non-fatal */ } } } _showToast(texts.length === 1 ? 'Verse added to notes' : 'Verses added to notes'); } async function _vasShare() { // Reader multi-select Share: mirror shareVerseImage's image + /s/ link-back pipeline, but for // the SELECTED passage (single / multi / cross-chapter) rather than today's VOTD. Emits the // SAME legacy 2-field verse token (ref \x1f text) so the server OG card + shared-arrival hero // resolve byte-identically to a VOTD share \u2014 no type sentinel, zero backbone change // (share-tier1-adapters Phase 1). Carries the VERSE ONLY, never any Q&A. // Declare everything first (TDZ-safe) \u2014 capture ref/text from the selection BEFORE _vasDismiss() // clears _verseSelected. const ref = _consolidateVerseRefs([..._verseSelected]); // Same joined-body assembly as _vasCopy: sort selected "book|ch|verse" keys by (chapter, verse), // pull each row's rendered .verse-text, drop empties, join with spaces. const sorted = [..._verseSelected].sort((a, b) => { const pa = a.split('|'), pb = b.split('|'); const ca = parseInt(pa[1], 10), cb = parseInt(pb[1], 10); if (ca !== cb) { return ca - cb; } return parseInt(pa[2], 10) - parseInt(pb[2], 10); }); const text = sorted.map(key => { const row = _findVerseRow(key.split('|')[2]); return row ? (row.querySelector('.verse-text')?.textContent.trim() || '') : ''; }).filter(Boolean).join(' '); if (!ref) { _vasDismiss(); return; } _vasDismiss(); // Point the shared image + caption at the selected passage (shareVerseImage resets these to null). _shareSubjectRef = ref; _shareSubjectText = text; _shareSubjectMode = 'verse'; const overlay = document.getElementById('sharePreviewOverlay'); const img = document.getElementById('sharePreviewImg'); const shareBtn = document.getElementById('sharePreviewShareBtn'); // Transparent 1x1 placeholder while rendering \u2014 same broken-image guard as shareVerseImage. if (img) { img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; img.style.opacity = '0.4'; } if (shareBtn) { shareBtn.disabled = true; } if (overlay) { overlay.style.display = 'flex'; } // Verse token \u2014 via _buildShareTok('verse', \u2026), same as shareVerseImage (base64url of ref \x1f // text, no type sentinel: this IS the legacy verse type). /s/ is native-matchable (.htaccess). // Short, truncation-safe token + link-led caption via _verseShareCaption (same as shareVerseImage). window._pendingShareText = _verseShareCaption(ref, text, 'Scripture'); window._pendingShareFile = 'verse-' + (ref || 'passage') .replace(/[^a-zA-Z0-9]/g, '-').replaceAll(/-+/g, '-') + '.png'; _shareBgStyle = 'minimal'; _highlightFormatBtn(_shareFormat); _highlightBgStyleBtn('minimal'); await _renderShareCanvas(_shareFormat); } let _vasMarkType = 'h'; // 'h' = highlight, 'u' = underline // Context-aware verse row lookup — handles duplicate id="v-N" in main viewer + reading plan DOM function _findVerseRow(verse) { const rpOpen = document.getElementById('rpReadingOverlay')?.classList.contains('open'); if (rpOpen) return document.querySelector(`#rpReadingContent [id="v-${verse}"]`); const sbPanel = document.getElementById('studyBiblePanel'); if (sbPanel && sbPanel.style.display !== 'none') return document.querySelector(`#sbContent [id="v-${verse}"]`); return document.getElementById(`v-${verse}`); } // ── Markup Firestore sync ───────────────────────────────────────────────── function _markupDocId(bk, ch) { return bk.replace(/\s+/g,'_') + '_' + ch; } async function _markupFsLoad(bk, ch) { if (!_fbUser || !_fbDb) return; if (_userTier !== 'monthly' && _userTier !== 'annual') return; try { const doc = await _fbDb.collection('users').doc(_fbUser.uid) .collection('markup').doc(_markupDocId(bk, ch)).get(); if (!doc.exists) return; const data = doc.data() || {}; Object.entries(data).forEach(([field, val]) => { if (!field.startsWith('v') || !val) return; const verse = field.slice(1); const storageKey = 'faith_mk_' + [bk, ch, verse].join('_').replace(/\s+/g,'_'); // Only fill in marks missing locally — don't overwrite local state if (!localStorage.getItem(storageKey)) { localStorage.setItem(storageKey, JSON.stringify(val)); } }); } catch(_) {} } function _markupFsWrite(bk, ch, verse, data) { if (!_fbUser || !_fbDb) return; if (_userTier !== 'monthly' && _userTier !== 'annual') return; const ref = _fbDb.collection('users').doc(_fbUser.uid).collection('markup').doc(_markupDocId(bk, ch)); ref.set({ ['v' + verse]: data }, { merge: true }).catch(() => {}); } function _markupFsDelete(bk, ch, verse) { if (!_fbUser || !_fbDb) return; if (_userTier !== 'monthly' && _userTier !== 'annual') return; const ref = _fbDb.collection('users').doc(_fbUser.uid).collection('markup').doc(_markupDocId(bk, ch)); ref.update({ ['v' + verse]: firebase.firestore.FieldValue.delete() }).catch(() => {}); } function _vasHighlight() { _vasMarkType = 'h'; document.getElementById('vasHighlightBtn')?.classList.add('vas-mk-on'); document.getElementById('vasUnderlineBtn')?.classList.remove('vas-mk-on'); const cp = document.getElementById('vasColorPicker'); if (cp) cp.style.display = cp.style.display === 'none' ? 'block' : 'none'; } function _vasUnderline() { _vasMarkType = 'u'; document.getElementById('vasUnderlineBtn')?.classList.add('vas-mk-on'); document.getElementById('vasHighlightBtn')?.classList.remove('vas-mk-on'); const cp = document.getElementById('vasColorPicker'); if (cp) cp.style.display = cp.style.display === 'none' ? 'block' : 'none'; } function _vasPickColor(name, hex) { [..._verseSelected].forEach(key => { const [bk, ch, v] = key.split('|'); const storageKey = 'faith_mk_' + [bk, ch, v].join('_').replace(/\s+/g,'_'); const row = _findVerseRow(v); if (name === 'none') { localStorage.removeItem(storageKey); _markupFsDelete(bk, parseInt(ch), v); if (row) { row.removeAttribute('data-mk-t'); row.style.background = ''; const ts = row.querySelector('.verse-text'); if(ts){ts.style.textDecorationLine='';ts.style.textDecorationColor='';} } } else { const mkData = { t: _vasMarkType, c: hex }; localStorage.setItem(storageKey, JSON.stringify(mkData)); _markupFsWrite(bk, parseInt(ch), v, mkData); if (row) _applyMarkToRow(row, _vasMarkType, hex); } }); const cp = document.getElementById('vasColorPicker'); if (cp) cp.style.display = 'none'; document.getElementById('vasHighlightBtn')?.classList.remove('vas-mk-on'); document.getElementById('vasUnderlineBtn')?.classList.remove('vas-mk-on'); _vasDismiss(); } function _applyMarkToRow(row, type, color) { row.setAttribute('data-mk-t', type); if (type === 'h') { row.style.background = color + '26'; // ~15% opacity const ts = row.querySelector('.verse-text'); if (ts) { ts.style.textDecoration = ''; ts.style.textDecorationColor = ''; } } else { row.style.background = ''; const ts = row.querySelector('.verse-text'); if (ts) { ts.style.textDecorationLine = 'underline'; ts.style.textDecorationColor = color; ts.style.textDecorationThickness = '2px'; ts.style.textUnderlineOffset = '3px'; } } } function _applyVerseMarkup(container, book, chapter) { if (!container) return; const bk = book || _rpCurrentBook || _sbBk || _readerBook || ''; const ch = chapter || _rpCurrentChapter || _sbCh || _readerChapter || 0; if (!bk || !ch) return; // Apply localStorage marks immediately — no Firestore wait const applyFromStorage = () => { container.querySelectorAll('.verse-row[id^="v-"]').forEach(row => { const vNum = row.id.replace('v-',''); const storageKey = 'faith_mk_' + [bk, ch, vNum].join('_').replace(/\s+/g,'_'); const stored = localStorage.getItem(storageKey); if (stored) { try { const { t, c } = JSON.parse(stored); _applyMarkToRow(row, t, c); } catch(_) {} } }); }; applyFromStorage(); // Merge Firestore marks in background, then re-apply to pick up cross-device changes _markupFsLoad(bk, ch).then(applyFromStorage).catch(() => {}); } function _vasAskBishop() { if (!_verseSelected.size) return; const [bk, ch, v] = [..._verseSelected][0].split('|'); const row = _findVerseRow(v); const verseText = row ? (row.querySelector('.verse-text')?.textContent.trim() || '') : ''; const ref = `${bk} ${ch}:${v}`; _vasDismiss(); const verseKey = `${bk}_${ch}_${v}`; const hasHistory = _chatLoadHistory(verseKey)?.turnCount > 0; openChat('', '', verseKey, { book: bk, chapter: ch, verse: v }); if (!hasHistory) { setTimeout(() => { const inp = document.getElementById('chatInput'); if (inp) { inp.value = `What does ${ref} mean? "${verseText}"`; inp.style.height = 'auto'; inp.style.height = Math.min(inp.scrollHeight, 140) + 'px'; } }, 50); } } function _vasCommentary() { if (!_verseSelected.size) return; const [bk, ch, v] = [..._verseSelected][0].split('|'); const chNum = parseInt(ch), vNum = parseInt(v); _vasDismiss(); _commentaryTarget = { book: bk, chapter: chNum, verse: vNum }; const row = _findVerseRow(vNum); const verseText = row ? (row.querySelector('.verse-text')?.textContent.trim() || '') : ''; document.getElementById('commentaryModalTitle').textContent = `${bk} ${chNum}:${vNum}`; document.getElementById('commentaryModalBody').innerHTML = '
'; const overlay = document.getElementById('commentaryOverlay'); // Elevate above reading plan overlay (z-index 6000) when open const rpOpen = document.getElementById('rpReadingOverlay')?.classList.contains('open'); if (rpOpen) overlay.style.zIndex = '6200'; overlay.classList.add('open'); document.body.style.overflow = 'hidden'; _fetchCommentary(bk, chNum, vNum) .then(data => renderCommentaryModal(data, verseText)) .catch(err => { document.getElementById('commentaryModalBody').innerHTML = `
\u26a0 ${esc(err.message)}
`; }); } function _vasWordStudy() { if (!_verseSelected.size) return; // Gate: anonymous and free-tier users see upgrade prompt before entering word study if (_userTier !== 'monthly' && _userTier !== 'annual') { _vasDismiss(); _showUpgradeModal('search'); return; } const [bk, ch, v] = [..._verseSelected][0].split('|'); const chNum = parseInt(ch), vNum = parseInt(v); _vasDismiss(); // Use context-aware lookup so reading plan / study Bible verses resolve correctly const row = _findVerseRow(vNum); const verseText = row ? (row.querySelector('.verse-text')?.textContent.trim() || '') : ''; _openWordStudyModal(bk, chNum, vNum, verseText); } function _openWordStudyModal(book, ch, verse, verseText) { _wordStudyTarget = { book, chapter: ch, verse }; document.getElementById('wordStudyModalTitle').textContent = `Word Study \u2014 ${book} ${ch}:${verse}`; const chips = document.getElementById('wordStudyChips'); chips.dataset.book = book; chips.dataset.ch = ch; chips.dataset.verse = verse; chips.dataset.versetext = verseText; chips.innerHTML = _wsRenderChips(verseText); document.getElementById('wordStudyPanel').innerHTML = '

Tap a word above to begin.

'; const overlay = document.getElementById('wordStudyOverlay'); const rpOpen = document.getElementById('rpReadingOverlay')?.classList.contains('open'); if (rpOpen) overlay.style.zIndex = '6200'; overlay.classList.add('open'); document.body.style.overflow = 'hidden'; } function _wsRenderChips(verseText) { const minorWords = new Set([ 'the','a','an','of','and','in','to','with','for','but','that','he','she', 'it','his','her','its','by','at','on','from','as','or','not','is','was', 'are','were','be','been','this','those','these','them','they','we','you', 'i','my','your','our','their','us','me','him','who','which','what','had', 'has','have','will','shall','unto','thee','thou','thy','thine','ye','hath' ]); if (!verseText) return ''; const tokens = verseText.split(/\s+/); const parts = tokens.map(tok => { const clean = tok.replace(/[^\w']/g, '').toLowerCase(); if (!clean || minorWords.has(clean)) { return `${esc(tok)}`; } return ``; }); return `
${parts.join(' ')}
`; } function _wsStudyWord(btn) { const chips = document.getElementById('wordStudyChips'); chips.querySelectorAll('.ws-chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); document.getElementById('wordStudyPanel').innerHTML = '
'; const book = chips.dataset.book; const ch = parseInt(chips.dataset.ch); const verse = parseInt(chips.dataset.verse); const verseText = chips.dataset.versetext; const word = btn.dataset.word; _fetchWordStudy(book, ch, verse, verseText, word) .then(data => _renderWordStudyPanel(data)) .catch(err => { document.getElementById('wordStudyPanel').innerHTML = `
\u26a0 ${esc(err.message)}
`; }); } function _fetchWordStudy(book, ch, verse, verseText, word) { const cacheKey = `ws3_${book}_${ch}_${verse}_${word}_${_translation}`; // 1. Memory cache if (_refCache.has(cacheKey)) return Promise.resolve(_refCache.get(cacheKey)); // 2. localStorage cache (30 days) try { const stored = localStorage.getItem(cacheKey); if (stored) { const parsed = JSON.parse(stored); if (parsed && parsed.ts && (Date.now() - parsed.ts) < 30 * 24 * 3600 * 1000) { _refCache.set(cacheKey, parsed.d); return Promise.resolve(parsed.d); } } } catch (_) {} // 3. Deduplicate in-flight if (_wordStudyFetching.has(cacheKey)) return _wordStudyFetching.get(cacheKey); const p = (async () => { const headers = { 'Content-Type': 'application/json' }; if (_fbUser) { try { headers['Authorization'] = 'Bearer ' + await _fbUser.getIdToken(); } catch (_e) {} } const r = await fetch('faith_proxy.php?action=word_study', { method: 'POST', headers, body: JSON.stringify({ book, chapter: ch, verse, verse_text: verseText, word, translation: _translation }), }); if (r.status === 402) { const gd = await r.json().catch(() => ({})); _showUpgradeGate(gd.gate || 'anon', gd.used ?? 0, gd.limit ?? 10); throw new Error(gd.error || 'Sign in or upgrade required'); } if (r.status === 429) throw new Error('Rate limit reached \u2014 please wait a moment.'); const data = await r.json(); if (data.error) throw new Error(data.error); _refCache.set(cacheKey, data); try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), d: data })); } catch (_e) {} return data; })().finally(() => _wordStudyFetching.delete(cacheKey)); _wordStudyFetching.set(cacheKey, p); return p; } function _renderWordStudyPanel(data) { const conns = (data.connections || []).map(c => `
  • ${linkifyRefs(esc(c))}
  • `).join(''); const html = `
    ${esc(data.original)} ${esc(data.strongs)}
    ${esc(data.language)} · ${esc(data.transliteration)} · ${esc(data.gloss)}
    Meaning

    ${linkifyRefs(esc(data.range))}

    In This Verse

    ${linkifyRefs(esc(data.in_context))}

    Theological Significance

    ${linkifyRefs(esc(data.theology))}

    Related Scriptures
    `; document.getElementById('wordStudyPanel').innerHTML = html; } function closeWordStudyModal() { hideScriptureTooltip(); const overlay = document.getElementById('wordStudyOverlay'); overlay.classList.remove('open'); overlay.style.zIndex = ''; document.body.style.overflow = ''; } // ── Paragraph Annotations ───────────────────────────────────────────────── // Long-press any

    in Ask Bishop answers or journal entries to highlight // or underline the whole paragraph. Storage: faith_annot_${entityId}. let _annotLpTimer = null; const _ANNOT_COLORS = { 'Yellow highlight': '#f5e642', 'Blue highlight': '#4a9eff', 'Pink highlight': '#ff6b9d', 'Amber highlight': '#d29922' }; function _annotLoad(entityId) { try { return JSON.parse(localStorage.getItem('faith_annot_' + entityId) || '{}'); } catch(e) { return {}; } } function _annotSave(entityId, pIdx, type, color) { const map = _annotLoad(entityId); const k = 'p' + pIdx; if (!type) { delete map[k]; } else { map[k] = { type, color: color || null }; } try { localStorage.setItem('faith_annot_' + entityId, JSON.stringify(map)); } catch(_e) {} } function _annotApply(container, entityId) { const map = _annotLoad(entityId); container.querySelectorAll('p[data-p-idx]').forEach(p => { const ann = map['p' + p.dataset.pIdx]; p.classList.remove('p-hl', 'p-ul'); p.style.background = ''; if (ann?.type === 'h' && ann.color) { p.classList.add('p-hl'); p.style.background = ann.color + '33'; } else if (ann?.type === 'u') { p.classList.add('p-ul'); } }); } function _annotAttachListeners(container, entityId) { if (!container || !entityId) return; const cancel = () => { clearTimeout(_annotLpTimer); _annotLpTimer = null; }; const trigger = p => { _annotLpTimer = null; p.classList.add('annot-long-press-indicator'); _annotPickerShow(p, entityId); }; container.querySelectorAll('p').forEach((p, i) => { p.dataset.pIdx = i; // Touch long-press (mobile) p.addEventListener('touchstart', () => { clearTimeout(_annotLpTimer); _annotLpTimer = setTimeout(() => trigger(p), 500); }, { passive: true }); p.addEventListener('touchend', cancel); p.addEventListener('touchmove', cancel); p.addEventListener('touchcancel', cancel); // Mouse long-press (desktop / non-touch) p.addEventListener('mousedown', e => { if (e.button !== 0) return; clearTimeout(_annotLpTimer); _annotLpTimer = setTimeout(() => trigger(p), 500); }); p.addEventListener('mouseup', cancel); p.addEventListener('mousemove', cancel); }); _annotApply(container, entityId); } function _annotPickerShow(pEl, entityId) { const picker = document.getElementById('annotPicker'); if (!picker) return; // Clone swatch row to clear prior event listeners const row = picker.querySelector('.vas-color-row'); const newRow = row.cloneNode(true); row.parentNode.replaceChild(newRow, row); newRow.querySelectorAll('.vas-swatch').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); pEl.classList.remove('annot-long-press-indicator'); picker.style.display = 'none'; const pIdx = parseInt(pEl.dataset.pIdx, 10); if (btn.classList.contains('vas-swatch-clear')) { _annotSave(entityId, pIdx, null, null); pEl.classList.remove('p-hl', 'p-ul'); pEl.style.background = ''; } else if (btn.title === 'Underline') { _annotSave(entityId, pIdx, 'u', null); pEl.classList.remove('p-hl'); pEl.style.background = ''; pEl.classList.add('p-ul'); } else { const hex = _ANNOT_COLORS[btn.title]; if (!hex) return; _annotSave(entityId, pIdx, 'h', hex); pEl.classList.remove('p-ul'); pEl.classList.add('p-hl'); pEl.style.background = hex + '33'; } }); }); // Position above element; fall below if near top of viewport const rect = pEl.getBoundingClientRect(); let top = rect.top - 82; if (top < 8) { top = rect.bottom + 8; } picker.style.top = Math.max(8, top) + 'px'; picker.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - 230)) + 'px'; picker.style.display = 'block'; // Dismiss on outside interaction — delay 350ms to skip iOS synthetic click const dismiss = ev => { if (!picker.contains(ev.target)) { pEl.classList.remove('annot-long-press-indicator'); picker.style.display = 'none'; document.removeEventListener('touchstart', dismiss); document.removeEventListener('click', dismiss); } }; setTimeout(() => { document.addEventListener('touchstart', dismiss); document.addEventListener('click', dismiss); }, 350); } function pinVerse(book, chapter, verse, text) { chapter = parseInt(chapter); verse = parseInt(verse); _animatePinBtn(book, chapter, verse); const key = _pinKey(book, chapter, verse); const existing = localStorage.getItem(key); if (existing) { localStorage.removeItem(key); _removePinFromIndex(key); _updatePinUI(book, chapter, verse, false); showPinToast('Verse unpinned'); _updateSavedBadge(); return; } // Enforce 100-item cap — evict oldest const idx = _loadPinIndex(); if (idx.length >= 100) { const all = idx.map(k => { try { return { key: k, d: JSON.parse(localStorage.getItem(k) || '{}').datePinned || '' }; } catch(_) { return { key: k, d: '' }; } }).sort((a, b) => a.d.localeCompare(b.d)); localStorage.removeItem(all[0].key); _removePinFromIndex(all[0].key); } try { localStorage.setItem(key, JSON.stringify({ id: [book, chapter, verse].join('_').replace(/\s+/g, '_'), book, chapter, verse, ref: book + ' ' + chapter + ':' + verse, text: text || '', translation: _translation.toUpperCase(), datePinned: new Date().toISOString() })); _addPinToIndex(key); _updatePinUI(book, chapter, verse, true); showPinToast('Pinned to Memory Verses \u2013 tap \uD83D\uDD16 to view'); _updateSavedBadge(); } catch(e) { showPinToast('Storage full \u2013 remove some pinned verses first'); } } function _updatePinUI(book, chapter, verse, pinned) { // Update pin indicator in every Bible view that currently shows this chapter if (_readerBook === book && _readerChapter === chapter) { const row = document.querySelector('#chapterContent [id="v-' + verse + '"]'); if (row) { const d = row.querySelectorAll('.verse-meta .vi'); if (d[0]) d[0].classList.toggle('vi-pin', pinned); } } if (_rpCurrentBook === book && _rpCurrentChapter === chapter) { const row = document.querySelector('#rpReadingContent [id="v-' + verse + '"]'); if (row) { const d = row.querySelectorAll('.verse-meta .vi'); if (d[0]) d[0].classList.toggle('vi-pin', pinned); } } if (_sbBk === book && _sbCh === chapter) { const row = document.querySelector('#sbContent [id="v-' + verse + '"]'); if (row) { const d = row.querySelectorAll('.verse-meta .vi'); if (d[0]) d[0].classList.toggle('vi-pin', pinned); } } // Update tooltip pin btn if it's the same verse const pinBtn = document.getElementById('tooltipPinBtn'); if (pinBtn && _tooltipTarget && _tooltipTarget.book === book && _tooltipTarget.chapter === chapter && _tooltipTarget.verse === verse) { pinBtn.classList.toggle('is-pinned', pinned); } } function pinVerseFromReader(book, chapter, verse) { const el = _findVerseRow(verse); const text = el ? el.querySelector('.verse-text')?.textContent.trim() : ''; pinVerse(book, chapter, verse, text); } function pinVerseFromTooltip() { if (!_tooltipTarget) return; const { book, chapter, verse } = _tooltipTarget; const bodyEl = document.querySelector('#scTooltip .sc-tooltip-body'); const raw = bodyEl ? bodyEl.textContent.replace(/^\u201c|\u201d$/g, '').trim() : ''; pinVerse(book, chapter, verse, raw); } // _scModalIsSingleVerse and _scModalVerseData — set by openScriptureModal let _scModalIsSingleVerse = false; let _scModalVerseData = null; function pinVerseFromScModal() { if (!_scModalTarget || !_scModalIsSingleVerse) return; const { book, chapter, verse } = _scModalTarget; const text = _scModalVerseData ? _scModalVerseData.text || '' : ''; pinVerse(book, chapter, verse, text); const btn = document.getElementById('scModalPinBtn'); if (btn) btn.classList.toggle('is-pinned', _isPinned(book, chapter, verse)); } function pinVerseFromCommentaryModal() { if (!_commentaryTarget) return; const { book, chapter, verse } = _commentaryTarget; // Try to get verse text from the reader if it's loaded, or leave blank const el = document.querySelector(`#v-${verse} .verse-text`); const text = el ? el.textContent.trim() : ''; pinVerse(book, chapter, verse, text); } function showPinToast(msg) { let toast = document.getElementById('pinToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'pinToast'; toast.className = 'pin-toast'; document.body.appendChild(toast); } toast.textContent = msg; toast.classList.add('visible'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('visible'), 2200); } // ── Feedback ────────────────────────────────────────────────────────────── function onFeedbackStarClick(btn) { _feedbackRating = parseInt(btn.dataset.value, 10); document.querySelectorAll('.feedback-star').forEach(s => { s.classList.toggle('active', parseInt(s.dataset.value, 10) <= _feedbackRating); }); } async function submitFeedback() { const textEl = document.getElementById('feedbackText'); const statusEl = document.getElementById('feedbackStatus'); const sendBtn = document.querySelector('#settingsOverlay .btn-amber[onclick="submitFeedback()"]'); const text = textEl ? textEl.value.trim() : ''; if (!text && !_feedbackRating) { if (statusEl) { statusEl.textContent = 'Please add a rating or a note first.'; } return; } if (statusEl) { statusEl.textContent = 'Sending\u2026'; } if (sendBtn) { sendBtn.disabled = true; } const payload = { text: text, rating: _feedbackRating || null, uid: _fbUser ? _fbUser.uid : null, tier: _userTier, timestamp: new Date().toISOString(), userAgent: navigator.userAgent.slice(0, 200), appVersion: '1.0' }; try { await _writeFeedback(payload); if (statusEl) { statusEl.textContent = 'Thank you \u2014 your words matter to us.'; } if (textEl) { textEl.value = ''; } _feedbackRating = 0; document.querySelectorAll('.feedback-star').forEach(s => s.classList.remove('active')); setTimeout(() => { if (statusEl) { statusEl.textContent = ''; } }, 4000); } catch(e) { if (statusEl) { statusEl.textContent = 'Couldn\u2019t send \u2014 try again.'; } } finally { if (sendBtn) { sendBtn.disabled = false; } } } async function _writeFeedback(payload) { // Always use the PHP proxy — client SDK cannot write to /feedback/ (security rules). const headers = { 'Content-Type': 'application/json' }; if (_fbUser) { try { headers['Authorization'] = 'Bearer ' + await _fbUser.getIdToken(); } catch(e) {} } const r = await fetch('faith_proxy.php?action=submit_feedback', { method: 'POST', headers, body: JSON.stringify(payload) }); const fbData = await r.json().catch(() => ({})); if (!r.ok) { throw new Error(fbData.error || 'HTTP ' + r.status); } } function openFeedbackFromNag() { document.getElementById('feedbackNagOverlay').classList.remove('open'); openSettingsModal(); setTimeout(() => { const el = document.getElementById('feedbackText'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.focus(); } }, 350); } function dismissFeedbackNag(permanent) { const overlay = document.getElementById('feedbackNagOverlay'); if (overlay) { overlay.classList.remove('open'); } const checked = document.getElementById('feedbackNagDismissCheck'); const isPermanent = permanent || (checked && checked.checked); if (isPermanent && _fbUser && _fbDb) { _fbDb.collection('users').doc(_fbUser.uid).set( { feedbackNagDismissedPermanently: true, feedbackNagDismissedAt: firebase.firestore.FieldValue.serverTimestamp() }, { merge: true } ).then(() => { _feedbackNagPermanentlyDismissed = true; }).catch(() => {}); } else { localStorage.setItem('faith_feedback_nag_dismissed', Date.now().toString()); } localStorage.setItem('faith_ask_count', '0'); } function _checkFeedbackNag() { const count = parseInt(localStorage.getItem('faith_ask_count') || '0') + 1; localStorage.setItem('faith_ask_count', String(count)); if (count % 5 !== 0) { return; } if (_feedbackNagPermanentlyDismissed) { return; } const dismissed = parseInt(localStorage.getItem('faith_feedback_nag_dismissed') || '0'); const fourteenDays = 14 * 24 * 60 * 60 * 1000; if (dismissed && (Date.now() - dismissed) < fourteenDays) { return; } // Update dismiss label based on whether user can permanently dismiss const dismissText = document.getElementById('feedbackNagDismissText'); if (dismissText) { dismissText.textContent = (_fbUser && _userTier !== 'anonymous' && _userTier !== 'registered_free') ? "Don\u2019t ask me again" : "I\u2019d rather not be asked for a while"; } setTimeout(() => { openSettingsModal(); const fbSec = document.getElementById('sa-feedback'); if (fbSec) { const fbBody = fbSec.querySelector('.sa-body'); const fbHeader = fbSec.querySelector('.sa-header'); if (fbBody && fbBody.hidden) { fbBody.hidden = false; fbHeader.setAttribute('aria-expanded', 'true'); fbSec.classList.add('sa-open'); } } setTimeout(() => { const el = document.getElementById('feedbackStarRow'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 350); }, 1200); } // ── Saved Panel ─────────────────────────────────────────────────────────── function openSavedPanel(initialTab) { document.getElementById('savedOverlay').classList.add('open'); document.body.style.overflow = 'hidden'; switchSavedTab(initialTab || 'verses'); _updateSavedBadge(); } function closeSavedPanel() { document.getElementById('savedOverlay').classList.remove('open'); document.body.style.overflow = ''; } function switchSavedTab(tab) { // The Saved panel now has ONLY the Memory Verses tab (the Q&A tab + #savedQAPanel were // removed). The stale getElementById('savedQAPanel').style deref threw on EVERY open, // aborting before renderPinnedVersesList/_updateSavedBadge — guard + drop the dead branch. document.querySelectorAll('.saved-tab').forEach(b => b.classList.toggle('active', b.dataset.panel === tab)); const vp = document.getElementById('savedVersesPanel'); if (vp) { vp.style.display = tab === 'verses' ? '' : 'none'; } if (tab === 'verses') renderPinnedVersesList(); } // Parse a faith_note_* key back into { book, chapter, verse, ref } function _parseNoteKey(key) { const suffix = key.replace('faith_note_', ''); const parts = suffix.split('_'); if (parts.length < 3) return null; const verse = parseInt(parts[parts.length - 1]); const chapter = parseInt(parts[parts.length - 2]); if (isNaN(verse) || isNaN(chapter)) return null; const book = parts.slice(0, parts.length - 2).join(' '); return { book, chapter, verse, ref: `${book} ${chapter}:${verse}` }; } function renderNotesList() { const noteIdx = _loadNoteIndex(); const list = document.getElementById('savedNotesList'); const cnt = document.getElementById('notesCount'); if (cnt) cnt.textContent = noteIdx.size; if (!list) return; if (noteIdx.size === 0) { list.innerHTML = `

    No notes yet.
    Tap ✎ on any verse in the Bible reader to write a note.
    `; return; } const items = [...noteIdx].map(key => { try { const loc = _parseNoteKey(key); if (!loc) return null; const data = JSON.parse(localStorage.getItem(key) || 'null'); if (!data) return null; return { key, ...loc, text: data.text, html: data.html || '', updated: data.updated || data.created || '' }; } catch(_) { return null; } }).filter(Boolean).sort((a, b) => b.updated.localeCompare(a.updated)); list.innerHTML = items.map(v => { const nk = esc(v.key); return `
    ${esc(v.ref)} ${esc(v.text.slice(0,40))}${v.text.length>40?'…':''}
    `; }).join(''); } function _toggleNoteAcc(key) { const body = document.getElementById('nab-' + key); const toggle = document.querySelector('#nac-' + key + ' .note-acc-toggle'); if (!body) return; const open = body.style.display === 'none'; body.style.display = open ? 'block' : 'none'; if (toggle) toggle.style.transform = open ? 'rotate(90deg)' : ''; } // ── Note viewer ──────────────────────────────────────────────────────────── function openNoteViewer(noteKey) { const loc = _parseNoteKey(noteKey); let data; try { data = JSON.parse(localStorage.getItem(noteKey)); } catch(_) {} if (!loc || !data) return; const body = document.getElementById('noteViewerBody'); body.innerHTML = `
    ✎ ${esc(loc.ref)}
    ${esc(data.text)}
    `; document.getElementById('noteViewerTitle').textContent = loc.ref; const openBtn = document.getElementById('noteViewerOpenBtn'); openBtn.style.display = ''; openBtn.onclick = () => { closeNoteViewer(); closeSavedPanel(); openRefInReader(loc.book, loc.chapter, loc.verse); }; const _nvOverlay = document.getElementById('noteViewerOverlay'); const _nvDlg = _nvOverlay.querySelector('dialog'); if (_nvDlg) { _nvDlg.style.width = '520px'; } _nvOverlay.classList.add('open'); document.body.style.overflow = 'hidden'; } function closeNoteViewer() { document.getElementById('noteViewerOverlay').classList.remove('open'); document.body.style.overflow = ''; } function renderPinnedVersesList() { const idx = _loadPinIndex(); const list = document.getElementById('pinnedVersesList'); const cnt = document.getElementById('versesCount'); if (cnt) cnt.textContent = idx.length; if (!list) return; // Defer body render until the IDB entry cache is hydrated (pin bodies live in it). See renderJournalList. if (window._entryCacheReady && window._entryCacheHydrated !== true) { window._entryCacheReady.then(function(){ try { renderPinnedVersesList(); } catch(e){} }); return; } if (idx.length === 0) { list.innerHTML = `
    No pinned verses yet.
    Tap 📌 on any verse to save it here.
    `; return; } const items = idx.map(k => { try { return JSON.parse(localStorage.getItem(k)); } catch(_) { return null; } }).filter(Boolean).sort((a, b) => b.datePinned.localeCompare(a.datePinned)); const useAccordion = items.length > 1; list.innerHTML = items.map((v, i) => { const pk = esc(_pinKey(v.book, v.chapter, v.verse)); const d = new Date(v.datePinned).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }); const aid = 'pv-' + pk; const _due = !!(v.text && String(v.text).trim()) && _memVerseDue(_pinKey(v.book, v.chapter, v.verse)); const _dot = _due ? '' : ''; const _revBtn = (v.text && String(v.text).trim()) ? `` : ''; if (!useAccordion) { return `
    📌 ${esc(v.ref)}${_dot} ${esc(v.translation || '')}
    ${v.text ? `
    \u201c${esc(v.text)}\u201d
    ` : ''}
    Pinned ${d}
    ${_revBtn}
    `; } return `
    `; }).join(''); try { _memRefreshReviewBar(); } catch (_e) { /* bar may not be mounted */ } } function _togglePvAcc(id) { const body = document.getElementById(id + '-body'); const arrow = document.getElementById(id + '-arrow'); if (!body) return; const open = body.style.display === 'none'; body.style.display = open ? 'block' : 'none'; if (arrow) arrow.style.transform = open ? 'rotate(90deg)' : ''; } // ───────────────────────────────────────────────────────────────────────── // Memory Verses — adaptive memorization (Phase 1: SM-2 engine + Calm skin) // Engine (logic) and skin (rendering) are kept separate so a later skin plugs // in without engine changes. Review state lives in its OWN namespace // (faith_srs_ + faith_srs_index) — NEVER in the pin body (the pins // pull-merge restores bodies but never field-merges → would clobber progress). // Phase 1 is localStorage-only: no endpoint, no cloud sync. // ───────────────────────────────────────────────────────────────────────── const _MEM_DAILY_CAP = 4; // soft batch size; "keep going" reveals the next 4 const _MEM_STOPWORDS = new Set(['a','an','the','and','or','but','of','to','in','for','on','at','by','with','as','is','was','are','were','be','been','am','i','he','she','it','they','we','you','him','her','them','us','my','his','their','our','your','its','that','this','these','those','from','up','out','so','if','not','no','nor','o','unto','shall','hath','thy','thee','ye','thou']); // ── Engine: per-verse SM-2 state ─────────────────────────────────────────── function _memStateKey(pinKey) { return 'faith_srs_' + pinKey; } function _memNewState() { return { ease: 2.5, intervalDays: 0, reps: 0, lapses: 0, dueDate: _localDate(), lastGrade: 0 }; } function _memLoadState(pinKey) { let raw = null; try { raw = localStorage.getItem(_memStateKey(pinKey)); } catch (_e) { raw = null; } if (!raw) { return null; } try { return JSON.parse(raw); } catch (_e) { return null; } } function _memSaveState(pinKey, st) { st.updatedAt = Date.now(); // LWW timestamp for cloud sync (Phase 2) try { _lsSafeSet(_memStateKey(pinKey), JSON.stringify(st)); } catch (_e) { /* non-fatal */ } // Maintain a lightweight index of enrolled (reviewed) verses. let idx = []; try { idx = JSON.parse(localStorage.getItem('faith_srs_index') || '[]'); } catch (_e) { idx = []; } if (!Array.isArray(idx)) { idx = []; } if (!idx.includes(pinKey)) { idx.push(pinKey); _lsSafeSet('faith_srs_index', JSON.stringify(idx)); } _fbMarkDirty('memreview'); // sync review progress to the cloud (Phase 2) } // SM-2 grade: q<3 = lapse (reset), q>=3 = success (lengthen). Returns new state. function _srsGrade(state, q) { const st = Object.assign(_memNewState(), state || {}); const today = _localDate(); if (q < 3) { st.reps = 0; st.lapses = (st.lapses || 0) + 1; st.intervalDays = 1; } else { st.reps = (st.reps || 0) + 1; if (st.reps === 1) { st.intervalDays = 1; } else if (st.reps === 2) { st.intervalDays = 6; } else { st.intervalDays = Math.round((st.intervalDays || 1) * (st.ease || 2.5)); } let ease = (st.ease || 2.5) + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)); st.ease = Math.max(1.3, ease); } st.lastGrade = q; st.dueDate = _memAddDays(today, Math.max(1, st.intervalDays)); return st; } function _memAddDays(ymd, n) { const parts = String(ymd).split('-'); const dt = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); dt.setDate(dt.getDate() + n); const mm = String(dt.getMonth() + 1).padStart(2, '0'); const dd = String(dt.getDate()).padStart(2, '0'); return dt.getFullYear() + '-' + mm + '-' + dd; } // ── Engine: due-queue over pinned verses (the corpus) ────────────────────── function _memEligiblePins() { const idx = _loadPinIndex(); const today = _localDate(); const out = []; for (const key of idx) { let pin = null; try { pin = JSON.parse(localStorage.getItem(key)); } catch (_e) { pin = null; } if (!pin || !pin.text || !String(pin.text).trim()) { continue; } // skip empty-text pins const st = _memLoadState(key) || _memNewState(); out.push({ key: key, pin: pin, st: st, due: st.dueDate <= today }); } return out; } function _memBuildQueue() { // Due first (oldest dueDate first), brand-new verses (never reviewed) included as due today. return _memEligiblePins() .filter(function (e) { return e.due; }) .sort(function (a, b) { return (a.st.dueDate || '').localeCompare(b.st.dueDate || ''); }); } function _memDueCount() { return _memBuildQueue().length; } function _memVerseDue(pinKey) { const st = _memLoadState(pinKey); if (!st) { return true; } // never reviewed → due return st.dueDate <= _localDate(); } // ── Engine: information-ranked cloze generator ───────────────────────────── function _memTokenize(text) { // Alternating word / non-word tokens, preserving punctuation & spacing. const re = /([A-Za-zÀ-ɏ]+(?:['’\-][A-Za-zÀ-ɏ]+)*)|([^A-Za-zÀ-ɏ]+)/g; const toks = []; let m; while ((m = re.exec(text)) !== null) { if (m[1]) { toks.push({ t: m[1], isWord: true }); } else { toks.push({ t: m[2], isWord: false }); } } return toks; } function _memClozeScore(word) { // Lower score = blanked first. Stopwords lowest; long/capitalized/numeric highest. const lc = word.toLowerCase(); if (_MEM_STOPWORDS.has(lc)) { return 0; } let s = word.length; if (/^[A-ZÀ-ſ]/.test(word)) { s += 3; } if (/[0-9]/.test(word)) { s += 5; } return s; } // Reps-based maturity escalation: recall gets harder as ACTUAL successful recalls grow (driven by SM-2 // `reps`, not time). The ladder is RECOGNITION → cued recall → free recall → production: // word bank → cloze → first-letter scaffold → whole verse from memory. // The word-bank rung (reps 0) is the bottom step that used to be MISSING: a first encounter with a verse // went straight to free recall (45% of the words deleted, no hints, nothing to grab), which is a cold, // unwinnable way to meet a verse. Recognition first — pick the words back out of a bank — builds the // familiarity the harder rungs then test. Everything from reps 1 up is UNCHANGED, so nobody's existing // progression shifts. function _memDrillLevel(reps) { const r = reps || 0; if (r === 0) { return { frac: 0.35, firstLetter: false, bank: true, hint: 'tap the missing words' }; } // recognition if (r <= 1) { return { frac: 0.45, firstLetter: false, bank: false, hint: '' }; } // cloze — full underscores if (r <= 3) { return { frac: 0.72, firstLetter: true, bank: false, hint: 'first letters' }; } // first-letter scaffold return { frac: 1, firstLetter: false, bank: false, hint: 'from memory' }; // whole verse, NO scaffold } // Stable, non-obvious ordering for the bank. NOT Math.random (S2245, and a PRNG would reshuffle the same // verse on every render); a content hash gives a fixed scramble that never reveals the answer order. function _memWordHash(w) { let h = 0; for (let i = 0; i < w.length; i++) { h = (h * 31 + w.charCodeAt(i)) % 9973; } return h; } // Build the recognition drill: blank a few words, hand them back as tappable chips (plus up to 2 // distractors drawn from the verse's own unblanked words, so the bank can't be solved by elimination). // Same information-ranked blank selection as the cloze — deterministic, so a verse always drills the same. function _memBankBuild(text, frac) { const toks = _memTokenize(text); const wordIdx = []; toks.forEach(function (tok, i) { if (tok.isWord) { wordIdx.push(i); } }); // Cap the blanks: a long verse with 12 holes is a chore, not a drill. const count = Math.max(1, Math.min(6, Math.round((frac || 0.35) * wordIdx.length))); const ranked = wordIdx.slice().sort(function (a, b) { const d = _memClozeScore(toks[a].t) - _memClozeScore(toks[b].t); return d !== 0 ? d : a - b; }); const blanked = ranked.slice(0, count).sort(function (a, b) { return a - b; }); // reading order const slotOf = new Map(); blanked.forEach(function (tokIdx, slot) { slotOf.set(tokIdx, slot); }); const answers = blanked.map(function (i) { return toks[i].t; }); // Distractors: real words from this verse that were NOT blanked (plausible, never a giveaway). const spare = ranked.slice(count) .map(function (i) { return toks[i].t; }) .filter(function (w) { return answers.indexOf(w) === -1; }); const distractors = spare.slice(0, Math.min(2, spare.length)); const bank = answers.concat(distractors).sort(function (a, b) { const ha = _memWordHash(a), hb = _memWordHash(b); return ha !== hb ? ha - hb : a.localeCompare(b); }); let html = ''; toks.forEach(function (tok, i) { if (slotOf.has(i)) { const slot = slotOf.get(i); html += ''; } else { html += esc(tok.t); } }); return { html: html, answers: answers, bank: bank }; } // Deterministic for (text, frac): always blanks the same lowest-information words. `opts.firstLetter` // shows each blanked word's leading letter as a recall scaffold (harder rungs). function _memClozeBuild(text, frac, opts) { const firstLetter = !!(opts && opts.firstLetter); const toks = _memTokenize(text); const wordIdx = []; toks.forEach(function (tok, i) { if (tok.isWord) { wordIdx.push(i); } }); const count = Math.max(1, Math.round((frac || 0.45) * wordIdx.length)); const ranked = wordIdx.slice().sort(function (a, b) { const d = _memClozeScore(toks[a].t) - _memClozeScore(toks[b].t); return d !== 0 ? d : a - b; }); const blanks = new Set(ranked.slice(0, count)); let masked = ''; let full = ''; toks.forEach(function (tok, i) { full += esc(tok.t); if (tok.isWord && blanks.has(i)) { const n = Math.min(tok.t.length, 14); const inner = firstLetter ? (esc(tok.t.charAt(0)) + '_'.repeat(Math.max(0, n - 1))) : '_'.repeat(n); masked += ''; } else { masked += esc(tok.t); } }); return { masked: masked, full: full, blankCount: blanks.size }; } // ── Skin (Calm): review session over the due queue ───────────────────────── const _MEM_BISHOP_LINES = [ 'Let it rest in you a moment.', 'The Word is taking root.', 'Well done — hide it in your heart.', 'Return to it again, and it will return to you.', 'Not by might, but by His Spirit.', ]; let _memSession = null; // { queue, pos, batchEnd, revealed } function _memStartReview() { const queue = _memBuildQueue(); _memSession = { queue: queue, pos: 0, batchEnd: Math.min(_MEM_DAILY_CAP, queue.length), revealed: false }; const ov = document.getElementById('memReviewOverlay'); if (!ov) { return; } ov.classList.add('open'); // .modal-overlay handles display; Saved panel keeps body scroll-locked behind us _memRenderCard(); } // Revisit ONE chosen verse now (from its list card), regardless of due status — so a long list // no longer forces you through the whole due queue to reach the verse you want. function _memStartReviewOne(pinKey) { let pin = null; try { pin = JSON.parse(localStorage.getItem(pinKey)); } catch (_e) { pin = null; } if (!pin || !pin.text || !String(pin.text).trim()) { return; } // nothing to review const st = _memLoadState(pinKey) || _memNewState(); _memSession = { queue: [{ key: pinKey, pin: pin, st: st, due: true }], pos: 0, batchEnd: 1, revealed: false }; const ov = document.getElementById('memReviewOverlay'); if (!ov) { return; } ov.classList.add('open'); _memRenderCard(); } function _memCloseReview() { const ov = document.getElementById('memReviewOverlay'); if (ov) { ov.classList.remove('open'); } // Saved panel remains open behind, keeps its own scroll-lock _memSession = null; try { renderPinnedVersesList(); _memRefreshReviewBar(); } catch (_e) { /* list may be unmounted */ } } function _memRenderCard() { const s = _memSession; const body = document.getElementById('memReviewBody'); if (!s || !body) { return; } // Session/batch complete? if (s.pos >= s.batchEnd) { const remaining = s.queue.length - s.batchEnd; if (remaining > 0) { body.innerHTML = '
    ' + '
    That’s ' + (s.batchEnd) + ' settled.
    ' + '
    ' + remaining + ' more ' + (remaining === 1 ? 'verse is' : 'verses are') + ' ready when you are.
    ' + '' + '' + '
    '; const kg = document.getElementById('memKeepGoingBtn'); if (kg) { kg.addEventListener('click', _memKeepGoing); } const rb = document.getElementById('memRestBtn'); if (rb) { rb.addEventListener('click', _memCloseReview); } } else { body.innerHTML = '
    ' + '
    All your verses are resting.
    ' + '
    Come back when you’re ready.
    ' + '' + '
    '; const rb = document.getElementById('memRestBtn'); if (rb) { rb.addEventListener('click', _memCloseReview); } } return; } const item = s.queue[s.pos]; const lvl = _memDrillLevel(item.st.reps); if (lvl.bank) { _memRenderBankCard(body, item, lvl); return; } const cloze = _memClozeBuild(item.pin.text, lvl.frac, { firstLetter: lvl.firstLetter }); const isPremium = (_userTier === 'monthly' || _userTier === 'annual'); s.revealed = false; body.innerHTML = '' + '
    ' + (s.pos + 1) + ' of ' + s.batchEnd + '
    ' + '
    ' + esc(item.pin.ref) + ' ' + esc(item.pin.translation || '') + '' + (lvl.hint ? ' · ' + lvl.hint + '' : '') + '
    ' + '
    ' + cloze.masked + '
    ' + '' + '
    ' + '' + '' + '
    '; const verseEl = document.getElementById('memVerseText'); if (verseEl) { verseEl.addEventListener('click', _memReveal); } const revealBtn = document.getElementById('memRevealBtn'); if (revealBtn) { revealBtn.addEventListener('click', _memReveal); } const hearBtn = document.getElementById('memHearBtn'); if (hearBtn) { hearBtn.addEventListener('click', function () { _memHearIt(item.pin.text, hearBtn); }); } } // ── Word-bank drill (the recognition rung) ──────────────────────────────── // Tap a chip → it drops into the next empty slot. Tap a filled slot → the word goes back to the bank. // Fill them all, then Check: each slot turns green/red against the real verse and the full text is // revealed, so the grade the user gives themselves is an INFORMED one rather than a guess. let _memBank = null; // { answers:[], fills:[], el } function _memRenderBankCard(body, item, lvl) { const s = _memSession; const built = _memBankBuild(item.pin.text, lvl.frac); s.revealed = false; _memBank = { answers: built.answers, fills: built.answers.map(function () { return null; }) }; const isPremium = (_userTier === 'monthly' || _userTier === 'annual'); body.innerHTML = '' + '
    ' + (s.pos + 1) + ' of ' + s.batchEnd + '
    ' + '
    ' + esc(item.pin.ref) + ' ' + esc(item.pin.translation || '') + '' + ' · ' + esc(lvl.hint) + '
    ' + '
    ' + built.html + '
    ' + '
    ' + built.bank.map(function (w, i) { return ''; }).join('') + '
    ' + '' + '' + '
    ' + '' + '' + '
    '; body.querySelectorAll('.mem-chip').forEach(function (chip) { chip.addEventListener('click', function () { _memBankPlace(chip); }); }); body.querySelectorAll('.mem-slot').forEach(function (slot) { slot.addEventListener('click', function () { _memBankClear(slot); }); }); const hearBtn = document.getElementById('memHearBtn'); if (hearBtn) { hearBtn.addEventListener('click', function () { _memHearIt(item.pin.text, hearBtn); }); } const checkBtn = document.getElementById('memCheckBtn'); if (checkBtn) { checkBtn.addEventListener('click', _memBankCheck); } } function _memBankSyncCheckBtn() { const btn = document.getElementById('memCheckBtn'); if (btn) { btn.disabled = _memBank.fills.some(function (f) { return f === null; }); } } function _memBankPlace(chip) { if (!_memBank || _memSession?.revealed) { return; } const next = _memBank.fills.indexOf(null); if (next === -1) { return; } // all slots full — tap a slot to free one const slot = document.querySelector('.mem-slot[data-slot="' + next + '"]'); if (!slot) { return; } _memBank.fills[next] = { word: chip.dataset.word, chip: chip.dataset.chip }; slot.textContent = chip.dataset.word; slot.classList.add('filled'); chip.classList.add('used'); _memBankSyncCheckBtn(); } function _memBankClear(slot) { if (!_memBank || _memSession?.revealed) { return; } const idx = parseInt(slot.dataset.slot, 10); const fill = _memBank.fills[idx]; if (!fill) { return; } const chip = document.querySelector('.mem-chip[data-chip="' + fill.chip + '"]'); if (chip) { chip.classList.remove('used'); } _memBank.fills[idx] = null; slot.textContent = ''; slot.classList.remove('filled'); _memBankSyncCheckBtn(); } function _memBankCheck() { const s = _memSession; if (!s || s.revealed || !_memBank) { return; } s.revealed = true; let right = 0; _memBank.answers.forEach(function (answer, i) { const slot = document.querySelector('.mem-slot[data-slot="' + i + '"]'); const got = _memBank.fills[i] ? _memBank.fills[i].word : ''; const ok = got.toLowerCase() === answer.toLowerCase(); if (ok) { right += 1; } if (slot) { slot.classList.remove('filled'); slot.classList.add(ok ? 'ok' : 'bad'); if (!ok) { slot.textContent = answer; } // show the truth, not their miss } }); const total = _memBank.answers.length; const score = document.getElementById('memScore'); if (score) { // Say plainly that the highlighted words are the CORRECT ones. Without this the red slots are // ambiguous — the reader can't tell if they're seeing their mistake or the correction. score.textContent = right === total ? 'All ' + total + ' right — the Word is taking root.' : right + ' of ' + total + ' right. The correct words are shown above.'; score.style.display = ''; } const bankRow = document.getElementById('memBankRow'); if (bankRow) { bankRow.style.display = 'none'; } const verseEl = document.getElementById('memVerseText'); if (verseEl) { verseEl.classList.add('mem-revealed'); } // Offer another swing. On a RECOGNITION drill, "2 of 6" with only Not-yet / That-settled is a dead // end — the point of this rung is to get it right and let the pattern land, not to be scored once. _memShowGradeButtons(s.queue[s.pos], _memBankRetry); } // Re-run the SAME verse from scratch: fresh slots, full bank, nothing graded, no advance through the queue. function _memBankRetry() { const s = _memSession; const body = document.getElementById('memReviewBody'); if (!s || !body) { return; } const item = s.queue[s.pos]; _memRenderBankCard(body, item, _memDrillLevel(item.st.reps)); } // Shared by both drills: reveal → self-grade into SM-2. Pulled out so the word-bank rung grades exactly // like every other rung (one code path, one SM-2 entry point). `onRetry` is optional — only the word-bank // rung passes it, so the cloze/from-memory rungs are untouched. function _memShowGradeButtons(item, onRetry) { const line = document.getElementById('memBishopLine'); if (line) { const pick = ((item.st.reps || 0) + _memSession.pos) % _MEM_BISHOP_LINES.length; line.textContent = _MEM_BISHOP_LINES[pick]; line.style.display = ''; } const controls = document.getElementById('memControls'); if (!controls) { return; } controls.innerHTML = '' + (onRetry ? '' : '') + '' + ''; const rb = document.getElementById('memRetryBtn'); if (rb && onRetry) { rb.addEventListener('click', onRetry); } const ny = document.getElementById('memNotYetBtn'); if (ny) { ny.addEventListener('click', function () { _memGrade(2); }); } const se = document.getElementById('memSettledBtn'); if (se) { se.addEventListener('click', function () { _memGrade(4); }); } } function _memReveal() { const s = _memSession; if (!s || s.revealed) { return; } s.revealed = true; const item = s.queue[s.pos]; const verseEl = document.getElementById('memVerseText'); if (verseEl) { verseEl.innerHTML = esc(item.pin.text); verseEl.classList.add('mem-revealed'); } // A gentle Bishop line + the two grade buttons — shared with the word-bank rung so both drills // land in SM-2 through exactly one path. _memShowGradeButtons(item); } function _memGrade(q) { const s = _memSession; if (!s) { return; } const item = s.queue[s.pos]; const next = _srsGrade(item.st, q); _memSaveState(item.key, next); item.st = next; s.pos += 1; _memRenderCard(); } function _memKeepGoing() { const s = _memSession; if (!s) { return; } s.batchEnd = Math.min(s.batchEnd + _MEM_DAILY_CAP, s.queue.length); _memRenderCard(); } function _memHearIt(text, btn) { try { _ttsTrigger(text, { label: 'Memory Verse' }, btn); } catch (_e) { /* gating handled by _ttsTrigger */ } } // ── Skin: entry point in the Saved "Memory Verses" panel ─────────────────── function _memRefreshReviewBar() { const bar = document.getElementById('memReviewBar'); if (!bar) { return; } const due = _memDueCount(); if (due > 0) { bar.style.display = 'flex'; const lbl = document.getElementById('memReviewBarLabel'); if (lbl) { lbl.textContent = due + ' ' + (due === 1 ? 'verse' : 'verses') + ' ready to revisit'; } } else { bar.style.display = 'none'; } } function unpinVerseFromPanel(key, book, chapter, verse) { localStorage.removeItem(key); _removePinFromIndex(key); _updatePinUI(book, chapter, verse, false); _updateSavedBadge(); renderPinnedVersesList(); } function toggleNoteEditor(pinKey, btn) { const form = document.getElementById('nef-' + pinKey); if (!form) return; const open = form.classList.toggle('open'); if (open) { form.querySelector('textarea')?.focus(); if (btn) btn.style.display = 'none'; } } function cancelNoteEdit(pinKey) { const form = document.getElementById('nef-' + pinKey); if (form) form.classList.remove('open'); // Restore the toggle button const card = form?.closest('.pinned-verse-card'); const btn = card?.querySelector('.note-edit-toggle'); if (btn) btn.style.display = ''; } function saveVerseNote(pinKey, saveBtn) { const form = document.getElementById('nef-' + pinKey); const ta = form?.querySelector('textarea'); if (!ta) return; const noteText = ta.value.trim(); let v; try { v = JSON.parse(localStorage.getItem(pinKey)); } catch(_) { return; } if (!v) return; v.note = noteText; localStorage.setItem(pinKey, JSON.stringify(v)); renderPinnedVersesList(); // re-render to reflect saved note + reset toggle } function _updateSavedBadge() { const pinCount = _loadPinIndex().length; const noteCount = _loadNoteIndex().size; const total = pinCount + noteCount + _loadQAIndex().length; const badge = document.getElementById('savedBadge'); if (badge) { badge.style.display = total > 0 ? 'block' : 'none'; } const nc = document.getElementById('notesCount'); if (nc) nc.textContent = noteCount; } // ── Saved Q&A ───────────────────────────────────────────────────────────── function _loadQAIndex() { try { return JSON.parse(localStorage.getItem('faith_qa_index') || '[]'); } catch(_) { return []; } } function _addQAToIndex(key) { const idx = _loadQAIndex(); idx.unshift(key); _lsSafeSet('faith_qa_index', JSON.stringify(idx)); } function _removeQAFromIndex(key) { _lsSafeSet('faith_qa_index', JSON.stringify(_loadQAIndex().filter(k => k !== key))); } function _autoSaveQA(question, answerHtml) { const id = Date.now().toString(36) + '_' + Array.from(crypto.getRandomValues(new Uint8Array(3))) .map(b => b.toString(16).padStart(2,'0')).join(''); const key = 'faith_qa_' + id; const idx = _loadQAIndex(); // Enforce 50-item cap — evict oldest non-pinned if (idx.length >= 50) { const all = idx.map(k => { try { const d = JSON.parse(localStorage.getItem(k) || '{}'); return { key: k, pinned: !!d.pinned, dateSaved: d.dateSaved || '' }; } catch(_) { return { key: k, pinned: false, dateSaved: '' }; } }).sort((a, b) => a.dateSaved.localeCompare(b.dateSaved)); const evict = all.find(x => !x.pinned) || all[0]; localStorage.removeItem(evict.key); _removeQAFromIndex(evict.key); } try { localStorage.setItem(key, JSON.stringify({ id, question, answerHtml, dateSaved: new Date().toISOString(), pinned: false })); _addQAToIndex(key); _updateSavedBadge(); _fbMarkDirty('qa'); // Show save confirm strip const confirm = document.getElementById('qaSaveConfirm'); if (confirm) { confirm.style.display = 'flex'; confirm.classList.add('visible'); clearTimeout(confirm._timer); confirm._timer = setTimeout(() => confirm.classList.remove('visible'), 3500); } } catch(e) { /* quota */ } // If History panel is open, refresh it so the new item appears immediately const histPanel = document.getElementById('bishopHistoryPanel'); if (histPanel && histPanel.style.display !== 'none') _renderBishopHistory(); return key; } // When the IndexedDB entry cache finishes hydrating, redraw any list that rendered before the // bodies were available — otherwise Q&A history / journal / pinned verses look empty on a cold // load because those bodies live in IDB and weren't loaded yet at first paint. (P0a race fix) window._onEntryCacheReady = function() { try { renderJournalList(); } catch(e) { /* non-fatal */ } try { renderSavedQAList(); } catch(e) { /* non-fatal */ } try { renderPinnedVersesList(); } catch(e) { /* non-fatal */ } }; // The IIFE's one-shot call fires when the cache hydrates; on a mobile WebView that can happen // BEFORE this far-down handler is defined, so the one-shot is skipped. Run it now if hydration // already completed — makes the handoff order-independent rather than fire-once. if (window._entryCacheHydrated) { try { window._onEntryCacheReady(); } catch(e) { /* non-fatal */ } } function renderSavedQAList(query) { const searchVal = query !== undefined ? query : (document.getElementById('qaSearchInput')?.value || ''); const idx = _loadQAIndex(); const cnt = document.getElementById('qaCount'); if (cnt) cnt.textContent = idx.length; const list = document.getElementById('savedQAList'); if (!list) return; // Defer body render until the IDB entry cache is hydrated (Q&A bodies live in it). See renderJournalList. if (window._entryCacheReady && window._entryCacheHydrated !== true) { window._entryCacheReady.then(function(){ try { renderSavedQAList(query); } catch(e){} }); return; } let items = idx.map(k => { try { return JSON.parse(localStorage.getItem(k)); } catch(_) { return null; } }).filter(Boolean).filter(qa => !qa.archived); if (searchVal.trim()) { const q = searchVal.trim().toLowerCase(); items = items.filter(qa => qa.question.toLowerCase().includes(q)); } // Pinned first, then newest items.sort((a, b) => { if (a.pinned !== b.pinned) return (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0); return b.dateSaved.localeCompare(a.dateSaved); }); if (items.length === 0) { list.innerHTML = `
    ${searchVal.trim() ? 'No matching questions.' : 'No saved Q&As yet.
    Ask Bishop a question to begin.'}
    `; return; } list.innerHTML = items.map(qa => { const key = 'faith_qa_' + qa.id; const d = new Date(qa.dateSaved).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }); return `
    ${d}
    ${qa.answerHtml || ''}
    `; }).join(''); } function toggleQAExpand(id) { const el = document.getElementById('qai-' + id); if (el) el.classList.toggle('expanded'); } function toggleQAPin(key) { const raw = localStorage.getItem(key); if (!raw) return; const qa = JSON.parse(raw); qa.pinned = !qa.pinned; localStorage.setItem(key, JSON.stringify(qa)); renderSavedQAList(); } function _toggleQAPinInHistory(key, btn) { const raw = localStorage.getItem(key); if (!raw) return; const qa = JSON.parse(raw); qa.pinned = !qa.pinned; localStorage.setItem(key, JSON.stringify(qa)); renderSavedQAList(); // Update button in-place btn.textContent = qa.pinned ? '📌 Unpin' : '📌 Pin'; // Move item within history panel without full re-render const panel = document.getElementById('bishopHistoryPanel'); const item = btn.closest('[data-bhi-key]'); if (!panel || !item) return; item.dataset.bhiPinned = qa.pinned ? '1' : '0'; if (qa.pinned) { panel.insertBefore(item, panel.firstChild); } else { // Move below last pinned item const pinned = [...panel.querySelectorAll('[data-bhi-pinned="1"]')]; const lastPinned = pinned[pinned.length - 1]; if (lastPinned && lastPinned !== item) { lastPinned.insertAdjacentElement('afterend', item); } } } function deleteQA(key) { localStorage.removeItem(key); _removeQAFromIndex(key); _tombstoneAdd('savedQAs', key); // persist a tombstone so the pull-merge can't resurrect it _fbDocDelete('savedQAs', key); // and delete the cloud doc (was never deleted -> resurrected) _updateSavedBadge(); renderSavedQAList(); } function archiveQA(key) { const raw = localStorage.getItem(key); if (!raw) return; try { const item = JSON.parse(raw); item.archived = true; localStorage.setItem(key, JSON.stringify(item)); renderSavedQAList(); _renderBishopHistory(); _fbMarkDirty('qa'); } catch(e) {} } function _toggleBishopHistory() { const panel = document.getElementById('bishopHistoryPanel'); const btn = document.getElementById('bishopHistoryBtn'); if (!panel) return; const open = panel.style.display !== 'none'; if (open) { panel.style.display = 'none'; if (btn) btn.textContent = '\u{1F550} History'; } else { panel.style.display = 'block'; if (btn) btn.textContent = '\u2715 History'; _renderBishopHistory(); } _updateBishopIcon(); } function _renderBishopHistory() { const panel = document.getElementById('bishopHistoryPanel'); if (!panel || panel.style.display === 'none') return; const idx = JSON.parse(localStorage.getItem('faith_qa_index') || '[]'); let items = idx.map(k => { try { const q = JSON.parse(localStorage.getItem(k)); return q ? { key: k, qa: q } : null; } catch(_) { return null; } }).filter(Boolean).filter(o => !o.qa.archived); items.sort((a, b) => { if (a.qa.pinned !== b.qa.pinned) return (b.qa.pinned ? 1 : 0) - (a.qa.pinned ? 1 : 0); return b.qa.dateSaved.localeCompare(a.qa.dateSaved); }); if (items.length === 0) { panel.innerHTML = '
    No saved Q&As yet.
    '; return; } panel.innerHTML = items.map(({ key, qa }) => { const d = new Date(qa.dateSaved).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }); const id = 'bhi-' + esc(qa.id); return `
    `; }).join(''); } function _histGoDeeper(key) { try { const qa = JSON.parse(localStorage.getItem(key) || 'null'); if (!qa) { return; } const plainAnswer = (qa.answerHtml || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); openChat(qa.question, plainAnswer, key, null, qa.answerHtml || ''); } catch(e) {} } function _saveQAToJournal(key, fromModal) { try { const qa = JSON.parse(localStorage.getItem(key) || 'null'); if (!qa) { return; } if (fromModal) { closeSavedPanel(); } const jBody = `

    ${esc(qa.question)}

    ` + (qa.answerHtml || ''); openJournalNew('daily', { body: jBody, folder: 'bishop' }, _newJid()); } catch(e) {} } function _toggleBhItem(id) { const body = document.getElementById('bhi-' + id + '-body'); const arrow = document.getElementById('bhi-' + id + '-arrow'); if (!body) return; const open = body.style.display === 'none'; body.style.display = open ? 'block' : 'none'; if (arrow) arrow.style.transform = open ? 'rotate(90deg)' : ''; } function onQASearch(val) { renderSavedQAList(val); } function reaskQuestion(text) { const inp = document.getElementById('researchInput'); if (inp) inp.value = text; showFaithTab('bishop'); requestAnimationFrame(() => inp && inp.focus()); } // ── Devotional Calendar ─────────────────────────────────────────────────── // Returns today's date as "YYYY-MM-DD" in the user's LOCAL timezone. // toISOString() returns UTC, which can be a different calendar date in // the evening (e.g. 8 PM Central = next day UTC) — causing the devotional // to store yesterday's content under today's localStorage key. function _localDate(d) { const dt = d || new Date(); return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`; } function esc(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Format Bishop's guided discovery response into rich HTML function _formatBishopPrompt(raw) { if (!raw) return ''; const lines = raw.split('\n'); let html = ''; let inReflect = false; let qCount = 0; let bodyLines = []; function flushBody() { if (!bodyLines.length) return; const bodyText = bodyLines.join('\n').trim(); if (!bodyText) { bodyLines = []; return; } // linkify verse refs const linked = bodyText.replace(/\n/g,'
    ').replace( _REF_RE, (m, book, ch, vs) => { const verseNum = vs ? parseInt(vs.replace(':','')) : 1; return `${m}`; } ); if (inReflect) { html += `
    ${linked}
    `; } else { html += `
    ${linked}
    `; } bodyLines = []; } for (const line of lines) { const sectionMatch = line.match(/^\*\*([^*]+)\*\*\s*$/); if (sectionMatch) { flushBody(); const title = sectionMatch[1].trim(); inReflect = /reflect/i.test(title); qCount = 0; html += `
    ${esc(title)}
    `; continue; } // numbered question line const qMatch = line.match(/^(\d+)\.\s+(.+)/); if (inReflect && qMatch) { flushBody(); qCount++; const qText = qMatch[2].replace(_REF_RE, (m,book,ch,vs) => { const vn = vs ? parseInt(vs.replace(':','')) : 1; return `${m}`; }); html += `
    ${qCount}. ${qText}
    `; continue; } bodyLines.push(line); } flushBody(); // close any open section if (html.includes('je-ps-section') && !html.endsWith('
    ')) html += ''; return html; } // safeHtml — strip-attribute HTML sanitizer for API-sourced markup. // Allows only the listed tags with NO attributes; everything else is // unwrapped to plain text. Uses