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);
}