Connect reading plans to study/audio, cache offline audio, add journal export
Build and Push Docker Image / build-and-push (push) Successful in 4m33s

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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 13:41:40 -04:00
1 parent 1dd6aef6a5
commit 787edcbe73
8 files changed
+604 -105

No files matched your search

+109 -30
View File
@@ -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);
}
+207
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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 ? '' : `
<h2>${title}</h2>
<div class="entries">${rows.join('')}</div>
`);
const planRows = completedPlans.map(({ plan, completedAt }) => `
<div class="entry">
<span class="ref">${plan.icon ?? '✓'} ${escapeHtml(plan.title)}</span>
<span class="meta">Completed ${new Date(completedAt).toLocaleDateString()} · ${plan.days} days</span>
</div>
`);
const bookmarkRows = bookmarks.map((b) => `
<div class="entry">
<span class="ref">${escapeHtml(b.bookName)} ${b.chapter}:${b.verse}</span>
${b.text ? `<p class="verse">${escapeHtml(b.text)}</p>` : ''}
</div>
`);
const highlightRows = highlights.map((h) => `
<div class="entry">
<span class="ref">${escapeHtml(h.bookName)} ${h.chapter}:${h.verse}</span>
<p class="verse">${
h.fullText
? escapeHtml(h.fullText.slice(0, h.startOffset))
+ `<mark style="background:${escapeHtml(h.color)}">${escapeHtml(h.excerpt ?? '')}</mark>`
+ escapeHtml(h.fullText.slice(h.endOffset))
: '(verse text unavailable)'
}</p>
</div>
`);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>My Reading Journal</title>
<style>
body { font-family: Georgia, 'Times New Roman', serif; color: #1c1917; max-width: 720px; margin: 40px auto; padding: 0 20px; line-height: 1.5; }
h1 { font-size: 28px; margin-bottom: 4px; }
.subtitle { color: #78716c; font-style: italic; margin-top: 0; margin-bottom: 32px; }
h2 { font-size: 18px; text-transform: uppercase; letter-spacing: 0.06em; color: #a16207; border-bottom: 1px solid #e7e5e4; padding-bottom: 6px; margin-top: 36px; }
.entry { padding: 10px 0; border-bottom: 1px solid #f5f5f4; }
.ref { font-weight: bold; color: #a16207; }
.meta { color: #78716c; font-size: 13px; margin-left: 8px; }
.verse { margin: 4px 0 0; }
mark { padding: 1px 2px; border-radius: 2px; }
@media print { body { margin: 0; max-width: none; } }
</style>
</head>
<body>
<h1>My Reading Journal</h1>
<p class="subtitle">Generated ${dateStr}</p>
${section('Reading Plans Completed', planRows)}
${section('Bookmarked Verses', bookmarkRows)}
${section('Highlighted Passages', highlightRows)}
${planRows.length + bookmarkRows.length + highlightRows.length === 0
? '<p>Nothing saved yet — bookmark a verse, highlight some text, or finish a reading plan to build your journal.</p>'
: ''}
</body>
</html>`;
}
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();
};
}