From 787edcbe73562302d8ed721770ebcda28975c457 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:41:40 -0400 Subject: [PATCH] Connect reading plans to study/audio, cache offline audio, add journal export Reading plan days now offer "Study this" (seeds a new chunk-builder project with the day's full passage and jumps straight into the Study editor) and "Listen" (starts book-mode audio at the right chapter via a new handlePlayBookAudioFrom, avoiding a stale-closure bug the reader-state approach would hit). Fixed the "Listen to BSB Audio" player only rendering when a study project existed, leaving Listen with nowhere to show up. The service worker now caches audio.bible.helloao.org (the actual host serving narrator audio, not just the text/cross-ref API) and no longer tries to cache 206 Partial Content responses, which the Cache API rejects and was throwing unhandled rejections for every range-requested audio file. Added a reading journal export (Settings > My reading journal): gathers bookmarks, text highlights, and completed plans, fetches their verse text, and offers a .docx download or a print-to-PDF page. Co-Authored-By: Claude Sonnet 5 --- public/sw.js | 11 +- src/App.jsx | 57 ++++++++- src/hooks/useAudio.js | 15 +++ src/pages/HomePage.jsx | 138 +++++++++++++--------- src/pages/ReadingPlansPage.jsx | 85 +++++++++++--- src/pages/SettingsPage.jsx | 57 +++++++++ src/utils/docxExport.js | 139 +++++++++++++++++----- src/utils/journalExport.js | 207 +++++++++++++++++++++++++++++++++ 8 files changed, 604 insertions(+), 105 deletions(-) create mode 100644 src/utils/journalExport.js diff --git a/public/sw.js b/public/sw.js index c8798a5..d4a6125 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,9 @@ const SHELL = 'bible-shell-v1'; const API = 'bible-api-v1'; -const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life']; +// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from +// these origins — cache them so the reader, reading plans, and audio all keep +// working offline once you've visited a passage before. +const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org']; self.addEventListener('install', (e) => { e.waitUntil( @@ -27,12 +30,14 @@ self.addEventListener('fetch', (e) => { // Never intercept API routes — let Express handle them if (new URL(url).pathname.startsWith('/api/')) return; - // External Bible API: network-first, fall back to cache + // External Bible API (text, cross-refs, audio): network-first, fall back to cache. + // Audio elements often issue Range requests, which come back as 206 Partial + // Content — the Cache API throws on those, so only cache full (200) responses. if (BIBLE_ORIGINS.some(o => url.startsWith(o))) { e.respondWith( fetch(request) .then(res => { - if (res.ok) caches.open(API).then(c => c.put(request, res.clone())); + if (res.status === 200) caches.open(API).then(c => c.put(request, res.clone())); return res; }) .catch(() => diff --git a/src/App.jsx b/src/App.jsx index 43f369a..ef959de 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,7 +8,9 @@ import { useReadingPlans } from './hooks/useReadingPlans.js'; import { useSharedView } from './hooks/useSharedView.js'; import HomePage from './pages/HomePage.jsx'; import { getRemoteReadingProgress, saveRemoteProject } from './syncService.js'; -import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx'; +import { + bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, +} from './utils/bibleUtils.jsx'; import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js'; // Less-common pages are split into their own chunks so the first load only @@ -35,6 +37,7 @@ const App = () => { const [saveStatus, setSaveStatus] = useState(''); const saveTimerRef = useRef(null); const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' + const [studyBuildBusy, setStudyBuildBusy] = useState(false); const reader = useBibleReader(currentPage); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); const readingPlans = useReadingPlans(); @@ -123,6 +126,55 @@ const App = () => { setCurrentPage('reader'); }; + // Starts book-mode audio playback at a specific plan day's chapter, then + // shows the Home audio player. Uses handlePlayBookAudioFrom (not the reader + // player) so this works even without opening the reader first. + const listenReadingPlanDay = (bookAbbrev, chapter) => { + audio.handlePlayBookAudioFrom(bookAbbrev, chapter); + goHome(); + }; + + // Builds a new chunk-builder project seeded with a reading-plan day's + // passage — one whole-chapter chunk per chapter in the day — and jumps + // straight into the Study page, skipping the manual chapter/chunk setup. + const studyReadingPlanDay = async (dayRefs, title) => { + setStudyBuildBusy(true); + try { + const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => { + const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`); + if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`); + const data = await res.json(); + const verses = parseBibleChapter(data); + const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book; + const chunk = { + id: makeId(), + startVerse: verses[0]?.number ?? 1, + endVerse: verses.at(-1)?.number ?? 1, + spilloverEndVerse: null, + observation: '', interpretation: '', application: '', + crossReferences: [], greekWords: [], generalNotes: '', + episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], + }; + return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] }; + })); + const newProject = { + id: makeId(), + title: title || 'Reading Plan Study', + translation: 'BSB', + lastEdited: Date.now(), + selectedChunkId: chapters[0]?.chunks[0]?.id ?? null, + chapters, + }; + setProject(newProject); + setCurrentPage('study'); + } catch (err) { + console.error('studyReadingPlanDay error:', err); + window.alert("Couldn't load today's passage. Please try again."); + } finally { + setStudyBuildBusy(false); + } + }; + const openNewProject = () => { setProject(null); setCurrentPage('setup'); @@ -172,6 +224,9 @@ const App = () => { openBibleReader, openReadingPlans, readReadingPlanDay, + listenReadingPlanDay, + studyReadingPlanDay, + studyBuildBusy, openImportProject, openNewProject, // Audio (book player on Home + chapter player in the Reader) diff --git a/src/hooks/useAudio.js b/src/hooks/useAudio.js index 018e885..acbf08b 100644 --- a/src/hooks/useAudio.js +++ b/src/hooks/useAudio.js @@ -67,6 +67,20 @@ export function useAudio(readerBookAbbrev, readerChapter) { } }; + // Starts book-mode playback at an arbitrary chapter (e.g. a reading plan's + // "today" chapter) instead of always chapter 1. Explicit args avoid relying + // on reader state that may not have re-rendered yet if called right after + // navigating there in the same event handler. + const handlePlayBookAudioFrom = async (abbrev, startChapter) => { + audioModeRef.current = 'book'; + setAudioBook(abbrev); + try { + await playAudioChapter(abbrev, startChapter, audioNarrator); + } catch { + setAudioState({ status: 'error', chapter: 0, total: 0 }); + } + }; + const handleStopBookAudio = () => { if (audioRef.current) { audioRef.current.pause(); @@ -161,6 +175,7 @@ export function useAudio(readerBookAbbrev, readerChapter) { audioState, audioProgress, handlePlayBookAudio, + handlePlayBookAudioFrom, handleStopBookAudio, handleToggleBookAudioPause, handleSkipBookChapter, diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 4625a9e..98a971d 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -21,6 +21,9 @@ export default function HomePage() { toggleDay, openReadingPlans, readReadingPlanDay, + listenReadingPlanDay, + studyReadingPlanDay, + studyBuildBusy, audioBook, setAudioBook, audioNarrator, setAudioNarrator, audioState, @@ -161,9 +164,61 @@ export default function HomePage() { readingActivity={readingActivity} openReadingPlans={openReadingPlans} readReadingPlanDay={readReadingPlanDay} + listenReadingPlanDay={listenReadingPlanDay} + studyReadingPlanDay={studyReadingPlanDay} + studyBuildBusy={studyBuildBusy} toggleDay={toggleDay} /> + {/* BSB audio player — shown regardless of whether there are any study projects yet, + so "Listen" from a reading plan always has somewhere visible to land. */} +
+ b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}` + } + errorMessage="Couldn't load audio for this book/narrator." + status={audioState.status} + currentTime={audioProgress.currentTime} + duration={audioProgress.duration} + onPlay={handlePlayBookAudio} + onTogglePause={handleToggleBookAudioPause} + onSeekBack={() => handleSeekAudio(-10)} + onSeekForward={() => handleSeekAudio(10)} + onSeekTo={handleSeekAudioTo} + onNext={handleSkipBookChapter} + nextDisabled={audioState.chapter >= audioState.total} + onStop={handleStopBookAudio} + controls={( + <> + + + + )} + /> +
+ {projectIndex.length === 0 ? (

No projects yet

@@ -252,52 +307,6 @@ export default function HomePage() {
)} -
- b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}` - } - errorMessage="Couldn't load audio for this book/narrator." - status={audioState.status} - currentTime={audioProgress.currentTime} - duration={audioProgress.duration} - onPlay={handlePlayBookAudio} - onTogglePause={handleToggleBookAudioPause} - onSeekBack={() => handleSeekAudio(-10)} - onSeekForward={() => handleSeekAudio(10)} - onSeekTo={handleSeekAudioTo} - onNext={handleSkipBookChapter} - nextDisabled={audioState.chapter >= audioState.total} - onStop={handleStopBookAudio} - controls={( - <> - - - - )} - /> -
{projectIndex .slice() @@ -410,7 +419,9 @@ export default function HomePage() { ); } -function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPlans, readReadingPlanDay, toggleDay }) { +function ReadingPlansSummaryCard({ + planProgress, readingActivity, openReadingPlans, readReadingPlanDay, listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, toggleDay, +}) { const active = planProgress.filter((p) => !p.completedAt); const streak = computeStreak(readingActivity); @@ -486,13 +497,32 @@ function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPla

{refs && ( - +
+ + + +
)}
diff --git a/src/pages/ReadingPlansPage.jsx b/src/pages/ReadingPlansPage.jsx index ba266f0..0ca8fd1 100644 --- a/src/pages/ReadingPlansPage.jsx +++ b/src/pages/ReadingPlansPage.jsx @@ -36,6 +36,9 @@ export default function ReadingPlansPage() { removeCustomPlan, updateCustomPlan, readReadingPlanDay, + listenReadingPlanDay, + studyReadingPlanDay, + studyBuildBusy, } = useApp(); const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit @@ -78,7 +81,14 @@ export default function ReadingPlansPage() {
{activeProgress.length > 0 && ( - + )} {activeProgress.length > 0 && ( @@ -90,6 +100,9 @@ export default function ReadingPlansPage() { key={progress.id} progress={progress} onRead={readReadingPlanDay} + onListen={listenReadingPlanDay} + onStudy={studyReadingPlanDay} + studyBuildBusy={studyBuildBusy} onToggleDay={toggleDay} onRestart={restartPlan} onRemove={removePlanProgress} @@ -220,7 +233,7 @@ export default function ReadingPlansPage() { // A single combined "what's left to read today, across every active plan" list — // the fast-glance view; ActivePlanCard below still has full detail/expand/restart. -function TodayPanel({ activeProgress, onRead, onToggleDay }) { +function TodayPanel({ activeProgress, onRead, onListen, onStudy, studyBuildBusy, onToggleDay }) { const rows = activeProgress .map((progress) => { const plan = getPlan(progress.planId); @@ -252,13 +265,32 @@ function TodayPanel({ activeProgress, onRead, onToggleDay }) {

{plan.title}

{formatDayReadings(refs)}

- +
+ + + +
))} @@ -286,7 +318,7 @@ function PlanCover({ plan }) { ); } -function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, onRecalibrate }) { +function ActivePlanCard({ progress, onRead, onListen, onStudy, studyBuildBusy, onToggleDay, onRestart, onRemove, onRecalibrate }) { const [expanded, setExpanded] = useState(false); const plan = getPlan(progress.planId); if (!plan) return null; @@ -314,13 +346,32 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, on
{nextFirstRef && ( - + <> + + + + )} + +
+ {journalError &&

{journalError}

} + +

Reading reminders

diff --git a/src/utils/docxExport.js b/src/utils/docxExport.js index 0d8f823..63809f1 100644 --- a/src/utils/docxExport.js +++ b/src/utils/docxExport.js @@ -6,6 +6,7 @@ import { HeadingLevel, Packer, Paragraph, + ShadingType, Table, TableCell, TableRow, @@ -26,6 +27,35 @@ const DOCX_ACCENT = 'C9A84C'; // antique gold const DOCX_HEADING_FONT = 'Cormorant Garamond'; const DOCX_BODY_FONT = 'Lora'; +const DOCX_STYLES = { + default: { + document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } }, + }, + paragraphStyles: [ + { + id: 'Title', + name: 'Title', + basedOn: 'Normal', + next: 'Normal', + run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' }, + }, + { + id: 'Heading1', + name: 'Heading 1', + basedOn: 'Normal', + next: 'Normal', + run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' }, + }, + { + id: 'Heading2', + name: 'Heading 2', + basedOn: 'Normal', + next: 'Normal', + run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' }, + }, + ], +}; + export function sectionLabel(text) { return new Paragraph({ spacing: { before: 240, after: 80 }, @@ -182,36 +212,85 @@ export async function buildStudyDocxBlob(project, chapterSummary) { }); }); - const doc = new Document({ - styles: { - default: { - document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } }, - }, - paragraphStyles: [ - { - id: 'Title', - name: 'Title', - basedOn: 'Normal', - next: 'Normal', - run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' }, - }, - { - id: 'Heading1', - name: 'Heading 1', - basedOn: 'Normal', - next: 'Normal', - run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' }, - }, - { - id: 'Heading2', - name: 'Heading 2', - basedOn: 'Normal', - next: 'Normal', - run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' }, - }, - ], - }, - sections: [{ children }], + const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] }); + return Packer.toBlob(doc); +} + +// A journal of bookmarks, text highlights, and completed reading plans — see +// utils/journalExport.js for how this data is gathered. +export async function buildJournalDocxBlob({ bookmarks, highlights, completedPlans }) { + const children = [ + new Paragraph({ text: 'My Reading Journal', heading: HeadingLevel.TITLE }), + new Paragraph({ + children: [new TextRun({ text: `Generated ${new Date().toLocaleDateString()}`, italics: true, color: '7A7060' })], + spacing: { after: 300 }, + }), + ]; + + const heading = (text) => new Paragraph({ + text, + heading: HeadingLevel.HEADING_1, + border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } }, + spacing: { before: 360, after: 160 }, }); + + if (completedPlans.length > 0) { + children.push(heading('Reading Plans Completed')); + completedPlans.forEach(({ plan, completedAt }) => { + children.push(new Paragraph({ + children: [ + new TextRun({ text: `${plan.icon ?? '✓'} ${plan.title}`, bold: true, color: DOCX_ACCENT }), + new TextRun({ text: ` Completed ${new Date(completedAt).toLocaleDateString()} · ${plan.days} days`, italics: true, color: '7A7060' }), + ], + spacing: { after: 80 }, + })); + }); + } + + if (bookmarks.length > 0) { + children.push(heading('Bookmarked Verses')); + bookmarks.forEach((b) => { + children.push(new Paragraph({ + children: [new TextRun({ text: `${b.bookName} ${b.chapter}:${b.verse}`, bold: true, color: DOCX_ACCENT })], + spacing: { before: 160 }, + })); + if (b.text) { + children.push(new Paragraph({ children: [new TextRun({ text: b.text, italics: true })], spacing: { after: 40 } })); + } + }); + } + + if (highlights.length > 0) { + children.push(heading('Highlighted Passages')); + highlights.forEach((h) => { + children.push(new Paragraph({ + children: [new TextRun({ text: `${h.bookName} ${h.chapter}:${h.verse}`, bold: true, color: DOCX_ACCENT })], + spacing: { before: 160 }, + })); + if (h.fullText) { + children.push(new Paragraph({ + children: [ + new TextRun({ text: h.fullText.slice(0, h.startOffset), italics: true }), + new TextRun({ + text: h.excerpt ?? '', + italics: true, + bold: true, + shading: { type: ShadingType.CLEAR, fill: (h.color || '#FFF176').replace('#', '') }, + }), + new TextRun({ text: h.fullText.slice(h.endOffset), italics: true }), + ], + spacing: { after: 40 }, + })); + } + }); + } + + if (completedPlans.length + bookmarks.length + highlights.length === 0) { + children.push(new Paragraph({ + text: "Nothing saved yet — bookmark a verse, highlight some text, or finish a reading plan to build your journal.", + })); + } + + const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] }); return Packer.toBlob(doc); } diff --git a/src/utils/journalExport.js b/src/utils/journalExport.js new file mode 100644 index 0000000..dcc93b6 --- /dev/null +++ b/src/utils/journalExport.js @@ -0,0 +1,207 @@ +// "My Reading Journal" export — gathers bookmarks, text highlights, and +// completed reading plans into one document. These first two are local-only +// (see useBibleReader.js: 'reader-bookmarks' and 'readerTextHighlights' in +// localStorage, never synced to the server), so this only ever reflects the +// current device. Offered as a .docx (matching the study-project exporter) +// and a print-to-PDF page (matching printProjectPdf in projectExport.js). +import { bookOptions, parseBibleChapter } from './bibleUtils.jsx'; +import { getPlan } from './readingPlans.js'; + +const BOOK_NAME_BY_ABBREV = Object.fromEntries(bookOptions.map((b) => [b.abbrev, b.name])); +const BOOK_INDEX_BY_ABBREV = Object.fromEntries(bookOptions.map((b, i) => [b.abbrev, i])); + +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function loadReadingProgress() { + try { + const raw = JSON.parse(localStorage.getItem('readingPlanProgress') || 'null'); + if (Array.isArray(raw)) return { progress: raw, customPlans: [] }; + return { progress: raw?.progress ?? [], customPlans: raw?.customPlans ?? [] }; + } catch { + return { progress: [], customPlans: [] }; + } +} + +// One fetch per distinct chapter, however many bookmarks/highlights reference it. +async function fetchVersesByChapter(refs) { + const cache = new Map(); + await Promise.all(refs.map(async ({ book, chapter }) => { + const key = `${book}_${chapter}`; + if (cache.has(key)) return; + cache.set(key, null); // claim the slot so concurrent dupes don't double-fetch + try { + const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`); + if (!res.ok) return; + const data = await res.json(); + cache.set(key, parseBibleChapter(data)); + } catch { + // leave as null — caller falls back to showing just the reference + } + })); + return cache; +} + +/** + * Reads bookmarks/highlights/reading-plan history from local storage and + * fetches the verse text they need, ready to hand to buildJournalDocxBlob or + * buildJournalHtml. + */ +export async function gatherJournalData() { + const bookmarksRaw = JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); + const highlightsRaw = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]'); + + const bookmarkEntries = Object.entries(bookmarksRaw).map(([key, color]) => { + const [book, chapterStr, verseStr] = key.split('-'); + return { book, chapter: Number(chapterStr), verse: Number(verseStr), color }; + }); + + const chapterRefs = [ + ...bookmarkEntries.map((b) => ({ book: b.book, chapter: b.chapter })), + ...highlightsRaw.map((h) => ({ book: h.book, chapter: h.chapter })), + ]; + const versesByChapter = await fetchVersesByChapter(chapterRefs); + + const sortByPassage = (a, b) => ( + (BOOK_INDEX_BY_ABBREV[a.book] ?? 999) - (BOOK_INDEX_BY_ABBREV[b.book] ?? 999) + || a.chapter - b.chapter + || (a.verse ?? 0) - (b.verse ?? 0) + ); + + const bookmarks = bookmarkEntries + .map((b) => { + const verse = versesByChapter.get(`${b.book}_${b.chapter}`)?.find((v) => v.number === b.verse); + return { + ...b, + bookName: BOOK_NAME_BY_ABBREV[b.book] ?? b.book, + text: verse?.text ?? null, + }; + }) + .sort(sortByPassage); + + const highlights = highlightsRaw + .map((h) => { + const verse = versesByChapter.get(`${h.book}_${h.chapter}`)?.find((v) => v.number === h.verse); + const fullText = verse?.text ?? null; + const excerpt = fullText ? fullText.slice(h.startOffset, h.endOffset) : null; + return { + ...h, + bookName: BOOK_NAME_BY_ABBREV[h.book] ?? h.book, + fullText, + excerpt, + }; + }) + .sort(sortByPassage); + + const { progress, customPlans } = loadReadingProgress(); + const completedPlans = progress + .filter((p) => p.completedAt) + .map((p) => ({ ...p, plan: getPlan(p.planId) })) + .filter((p) => p.plan) + .sort((a, b) => b.completedAt - a.completedAt); + + return { bookmarks, highlights, completedPlans, customPlansCount: customPlans.length }; +} + +export async function exportJournalDocx() { + const data = await gatherJournalData(); + // docx is large, so it's only downloaded when someone actually exports. + const { buildJournalDocxBlob } = await import('./docxExport.js'); + const blob = await buildJournalDocxBlob(data); + downloadBlob(blob, `my-reading-journal-${new Date().toISOString().slice(0, 10)}.docx`); +} + +function escapeHtml(text) { + return String(text ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +function buildJournalHtml({ bookmarks, highlights, completedPlans }) { + const dateStr = new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); + const section = (title, rows) => (rows.length === 0 ? '' : ` +

${title}

+
${rows.join('')}
+ `); + + const planRows = completedPlans.map(({ plan, completedAt }) => ` +
+ ${plan.icon ?? '✓'} ${escapeHtml(plan.title)} + Completed ${new Date(completedAt).toLocaleDateString()} · ${plan.days} days +
+ `); + + const bookmarkRows = bookmarks.map((b) => ` +
+ ${escapeHtml(b.bookName)} ${b.chapter}:${b.verse} + ${b.text ? `

${escapeHtml(b.text)}

` : ''} +
+ `); + + const highlightRows = highlights.map((h) => ` +
+ ${escapeHtml(h.bookName)} ${h.chapter}:${h.verse} +

${ + h.fullText + ? escapeHtml(h.fullText.slice(0, h.startOffset)) + + `${escapeHtml(h.excerpt ?? '')}` + + escapeHtml(h.fullText.slice(h.endOffset)) + : '(verse text unavailable)' + }

+
+ `); + + return ` + + + +My Reading Journal + + + +

My Reading Journal

+

Generated ${dateStr}

+ ${section('Reading Plans Completed', planRows)} + ${section('Bookmarked Verses', bookmarkRows)} + ${section('Highlighted Passages', highlightRows)} + ${planRows.length + bookmarkRows.length + highlightRows.length === 0 + ? '

Nothing saved yet — bookmark a verse, highlight some text, or finish a reading plan to build your journal.

' + : ''} + +`; +} + +export async function printJournalPdf() { + const data = await gatherJournalData(); + const html = buildJournalHtml(data); + const printWindow = window.open('', '_blank'); + if (!printWindow) { + alert('Please allow pop-ups for this site to print or save as PDF.'); + return; + } + printWindow.document.write(html); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.focus(); + printWindow.print(); + }; +}