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>
297 lines
10 KiB
JavaScript
297 lines
10 KiB
JavaScript
// DOCX export — kept in its own module so the (large) `docx` library is only
|
|
// downloaded when the user actually exports, instead of on every page load.
|
|
import {
|
|
BorderStyle,
|
|
Document,
|
|
HeadingLevel,
|
|
Packer,
|
|
Paragraph,
|
|
ShadingType,
|
|
Table,
|
|
TableCell,
|
|
TableRow,
|
|
TextRun,
|
|
WidthType,
|
|
} from 'docx';
|
|
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
|
|
|
export function createParagraphsFromText(text) {
|
|
return text
|
|
.split(/\n+/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
|
|
}
|
|
|
|
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 },
|
|
children: [
|
|
new TextRun({
|
|
text: text.toUpperCase(),
|
|
bold: true,
|
|
color: DOCX_ACCENT,
|
|
font: DOCX_BODY_FONT,
|
|
size: 20,
|
|
characterSpacing: 20,
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
export async function buildStudyDocxBlob(project, chapterSummary) {
|
|
const children = [
|
|
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: `${project.translation} — ${chapterSummary}`,
|
|
italics: true,
|
|
color: '7A7060',
|
|
}),
|
|
],
|
|
spacing: { after: 300 },
|
|
}),
|
|
];
|
|
|
|
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
|
|
|
|
project.chapters.forEach((ch, chapterIndex) => {
|
|
children.push(new Paragraph({
|
|
text: `${ch.book} ${ch.chapter}`,
|
|
heading: HeadingLevel.HEADING_1,
|
|
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
|
|
spacing: { before: 360, after: 160 },
|
|
}));
|
|
ch.chunks.forEach((chunk) => {
|
|
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
|
|
|
|
const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
|
|
if (chunk.episodeNumber || chunk.episodeTitle) {
|
|
headingRuns.push(new TextRun({
|
|
text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
|
|
italics: true,
|
|
size: 22,
|
|
color: '7A7060',
|
|
}));
|
|
}
|
|
children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
|
|
|
|
// Scripture text in a left-bordered table cell for a "callout" look (no fill)
|
|
const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
|
|
children: [
|
|
new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
|
|
new TextRun({ text: verse.text, italics: true }),
|
|
],
|
|
spacing: { after: 80 },
|
|
}));
|
|
children.push(new Table({
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
rows: [
|
|
new TableRow({
|
|
children: [
|
|
new TableCell({
|
|
margins: { top: 120, bottom: 120, left: 180, right: 180 },
|
|
borders: {
|
|
...{ top: noBorder, right: noBorder, bottom: noBorder },
|
|
left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
|
|
},
|
|
children: scriptureParas,
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
}));
|
|
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
|
|
|
if (chunk.generalNotes?.trim()) {
|
|
children.push(sectionLabel('Background / General Notes'));
|
|
children.push(...createParagraphsFromText(chunk.generalNotes));
|
|
}
|
|
|
|
children.push(sectionLabel('Observation'));
|
|
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
|
|
children.push(sectionLabel('Interpretation'));
|
|
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
|
|
children.push(sectionLabel('Application'));
|
|
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
|
|
|
|
if ((chunk.tags ?? []).length > 0) {
|
|
children.push(sectionLabel('Tags'));
|
|
children.push(new Paragraph({
|
|
children: chunk.tags.map((tag, i) => new TextRun({
|
|
text: i === 0 ? `#${tag}` : ` #${tag}`,
|
|
bold: true,
|
|
color: DOCX_ACCENT,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
if ((chunk.crossReferences ?? []).length > 0) {
|
|
children.push(sectionLabel('Cross-References'));
|
|
children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
|
|
}
|
|
|
|
if (chunk.greekWords.length > 0) {
|
|
children.push(sectionLabel('Word Studies'));
|
|
const headerCellStyle = (label) => new TableCell({
|
|
width: { size: 20, type: WidthType.PERCENTAGE },
|
|
borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
|
|
children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
|
|
});
|
|
const tableRows = [
|
|
new TableRow({
|
|
tableHeader: true,
|
|
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
|
|
}),
|
|
...chunk.greekWords.map((word) => new TableRow({
|
|
children: [
|
|
new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
|
|
new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
|
|
new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
|
|
new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
|
|
new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
|
|
],
|
|
})),
|
|
];
|
|
children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
|
|
chunk.greekWords.forEach((word) => {
|
|
if (word.definitionHtml) {
|
|
children.push(new Paragraph({
|
|
spacing: { before: 180, after: 80 },
|
|
children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
|
|
}));
|
|
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
|
|
}
|
|
});
|
|
}
|
|
|
|
if (chunk.finalScript?.trim()) {
|
|
children.push(sectionLabel('Final Script'));
|
|
children.push(...createParagraphsFromText(chunk.finalScript));
|
|
}
|
|
|
|
children.push(new Paragraph({
|
|
text: '',
|
|
spacing: { after: 200 },
|
|
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
|
|
}));
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|