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>
208 lines
7.7 KiB
JavaScript
208 lines
7.7 KiB
JavaScript
// "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 ? '' : `
|
|
<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();
|
|
};
|
|
}
|