Files
study-bible-project/src/utils/bibleUtils.jsx
T
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00

567 lines
23 KiB
React

import { labelFor as outlineLabel, kindLabel as outlineKindLabel } from './outlineLabels.js';
// ---------------------------------------------------------------------------
// Study constants
// ---------------------------------------------------------------------------
export const CHAPTER_COUNTS = {
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
'3JN':1,JUD:1,REV:22,
};
export const bookOptions = [
{ name: 'Genesis', abbrev: 'GEN' },
{ name: 'Exodus', abbrev: 'EXO' },
{ name: 'Leviticus', abbrev: 'LEV' },
{ name: 'Numbers', abbrev: 'NUM' },
{ name: 'Deuteronomy', abbrev: 'DEU' },
{ name: 'Joshua', abbrev: 'JOS' },
{ name: 'Judges', abbrev: 'JDG' },
{ name: 'Ruth', abbrev: 'RUT' },
{ name: '1 Samuel', abbrev: '1SA' },
{ name: '2 Samuel', abbrev: '2SA' },
{ name: '1 Kings', abbrev: '1KI' },
{ name: '2 Kings', abbrev: '2KI' },
{ name: '1 Chronicles', abbrev: '1CH' },
{ name: '2 Chronicles', abbrev: '2CH' },
{ name: 'Ezra', abbrev: 'EZR' },
{ name: 'Nehemiah', abbrev: 'NEH' },
{ name: 'Esther', abbrev: 'EST' },
{ name: 'Job', abbrev: 'JOB' },
{ name: 'Psalms', abbrev: 'PSA' },
{ name: 'Proverbs', abbrev: 'PRO' },
{ name: 'Ecclesiastes', abbrev: 'ECC' },
{ name: 'Song', abbrev: 'SNG' },
{ name: 'Isaiah', abbrev: 'ISA' },
{ name: 'Jeremiah', abbrev: 'JER' },
{ name: 'Lamentations', abbrev: 'LAM' },
{ name: 'Ezekiel', abbrev: 'EZK' },
{ name: 'Daniel', abbrev: 'DAN' },
{ name: 'Hosea', abbrev: 'HOS' },
{ name: 'Joel', abbrev: 'JOL' },
{ name: 'Amos', abbrev: 'AMO' },
{ name: 'Obadiah', abbrev: 'OBA' },
{ name: 'Jonah', abbrev: 'JON' },
{ name: 'Micah', abbrev: 'MIC' },
{ name: 'Nahum', abbrev: 'NAM' },
{ name: 'Habakkuk', abbrev: 'HAB' },
{ name: 'Zephaniah', abbrev: 'ZEP' },
{ name: 'Haggai', abbrev: 'HAG' },
{ name: 'Zechariah', abbrev: 'ZEC' },
{ name: 'Malachi', abbrev: 'MAL' },
{ name: 'Matthew', abbrev: 'MAT' },
{ name: 'Mark', abbrev: 'MRK' },
{ name: 'Luke', abbrev: 'LUK' },
{ name: 'John', abbrev: 'JHN' },
{ name: 'Acts', abbrev: 'ACT' },
{ name: 'Romans', abbrev: 'ROM' },
{ name: '1 Corinthians', abbrev: '1CO' },
{ name: '2 Corinthians', abbrev: '2CO' },
{ name: 'Galatians', abbrev: 'GAL' },
{ name: 'Ephesians', abbrev: 'EPH' },
{ name: 'Philippians', abbrev: 'PHP' },
{ name: 'Colossians', abbrev: 'COL' },
{ name: '1 Thessalonians', abbrev: '1TH' },
{ name: '2 Thessalonians', abbrev: '2TH' },
{ name: '1 Timothy', abbrev: '1TI' },
{ name: '2 Timothy', abbrev: '2TI' },
{ name: 'Titus', abbrev: 'TIT' },
{ name: 'Philemon', abbrev: 'PHM' },
{ name: 'Hebrews', abbrev: 'HEB' },
{ name: 'James', abbrev: 'JAS' },
{ name: '1 Peter', abbrev: '1PE' },
{ name: '2 Peter', abbrev: '2PE' },
{ name: '1 John', abbrev: '1JN' },
{ name: '2 John', abbrev: '2JN' },
{ name: '3 John', abbrev: '3JN' },
{ name: 'Jude', abbrev: 'JUD' },
{ name: 'Revelation', abbrev: 'REV' },
];
export const NT_BOOK_NUMBER = {
MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
'2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59,
'1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64,
JUD: 65, REV: 66,
};
export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
// ---------------------------------------------------------------------------
// Format helpers
// ---------------------------------------------------------------------------
export function formatRelativeDate(ts) {
if (!ts) return '';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function chunkSpansNextChapter(project, startChapterIndex, chunk) {
if (!project || !Array.isArray(project.chapters)) return false;
if (!Number.isInteger(chunk?.spilloverEndVerse)) return false;
if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false;
const startChapter = project.chapters[startChapterIndex];
const nextChapter = project.chapters[startChapterIndex + 1];
return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
}
export function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
if (!project || !chunk) return '';
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return '';
if (chunkSpansNextChapter(project, startChapterIndex, chunk)) {
const nextChapter = project.chapters[startChapterIndex + 1];
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`;
}
if (chunk.startVerse === chunk.endVerse) {
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`;
}
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`;
}
export function getChunkVerseEntries(project, startChapterIndex, chunk) {
if (!project || !chunk) return [];
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return [];
const startVerses = (startChapter.verses ?? [])
.filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
.map((verse) => ({
book: startChapter.book,
chapter: startChapter.chapter,
...verse,
}));
if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) {
return startVerses;
}
const nextChapter = project.chapters[startChapterIndex + 1];
const spilloverVerses = (nextChapter.verses ?? [])
.filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse)
.map((verse) => ({
book: nextChapter.book,
chapter: nextChapter.chapter,
...verse,
}));
return [...startVerses, ...spilloverVerses];
}
// ---------------------------------------------------------------------------
// HTML export utilities
// ---------------------------------------------------------------------------
const escHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export function buildExportHtml(project) {
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
.page { max-width: 900px; margin: auto; }
h1, h2 { font-family: Georgia, serif; }
h1 { margin-bottom: 0.5rem; }
.chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; }
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
.oia, .cross-refs, .greek { margin-top: 1rem; }
.oia-section { margin-bottom: 0.75rem; }
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
.greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.5rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.3rem 0; line-height: 1.5; }
.outline .lbl { display: inline-block; min-width: 2rem; font-weight: 700; }
.outline .kind { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; }
.outline .o-note { margin: 0.2rem 0 0 2rem; color: #334155; font-size: 0.95em; }
.outline .o-quote { margin: 0.3rem 0 0.3rem 2rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; }
.outline .o-attach { margin-left: 2rem; font-size: 0.85em; color: #64748b; }
.outline .ref { color: #64748b; font-size: 0.9em; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print {
body { padding: 0; }
.chunk { break-inside: avoid; border-color: #cbd5e1; }
.chapter-heading { break-after: avoid; }
}
`;
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
const outlineHtml = (project.outline ?? []).length > 0
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineTimeHtml(project.outline)}${outlineListHtml(project.outline, 0)}</section>`
: '';
const chunksHtml = chapters.map((ch, chapterIndex) => {
const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
const chunkSections = ch.chunks.map((chunk) => {
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
.map((verse) => `<p class="verse"><strong>${escHtml(verse.chapter)}:${escHtml(verse.number)}</strong> ${escHtml(verse.text)}</p>`)
.join('');
const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No observation.</em>';
const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No interpretation.</em>';
const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '<br />') || '<em>No application.</em>';
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
? `<div class="cross-refs"><strong>CROSS-REFERENCES:</strong> ${chunk.crossReferences.map(escHtml).join(', ')}</div>`
: '';
const greekRows = chunk.greekWords.map((word) => `
<tr>
<td><strong>${escHtml(word.strongNumber)}</strong></td>
<td>${escHtml(word.lexeme)}</td>
<td>${escHtml(word.transliteration)}</td>
<td>${escHtml(word.partOfSpeech)}</td>
<td>${escHtml(word.shortDefinition)}</td>
</tr>
`).join('');
const greekTable = wordTableHtml(greekRows);
const extendedDefinitions = chunk.greekWords
.map((word) => {
if (word.definitionHtml) {
return `
<div class="definition-block">
<p><strong>${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}</strong></p>
<div class="definition">${word.definitionHtml}</div>
</div>
`;
}
if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
const raw = word.query.trim();
const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
const isStrongs = /^G\d+$/.test(normalized);
const num = isStrongs ? normalized.slice(1) : null;
const links = isStrongs && num
? `<a href="https://biblehub.com/greek/${num}.htm">BibleHub</a> · <a href="https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/">Blue Letter Bible</a> · <a href="https://www.studylight.org/lexicons/eng/greek/${num}.html">StudyLight</a>`
: `<a href="https://biblehub.com/search.php?q=${encodeURIComponent(raw)}">BibleHub</a> · <a href="https://www.blueletterbible.org/search/search.cfm?Criteria=${encodeURIComponent(raw)}&t=KJV#s=s_lexiconc">Blue Letter Bible</a>`;
return `
<div class="definition-block" style="border-color:#fcd34d;background:#fffbeb;">
<p><strong>${escHtml(raw)}</strong> — definition not found in auto-lookup</p>
<p style="margin-top:0.5rem;font-size:0.9rem;">Look it up manually: ${links}</p>
</div>
`;
}
return '';
})
.join('');
return `
<section class="chunk">
<div class="scripture-ref">${escHtml(scripture)}</div>
${versesText}
${(chunk.outline ?? []).length > 0 ? `<div class="outline"><strong>OUTLINE:</strong>${outlineListHtml(chunk.outline, 0)}</div>` : ''}
<div class="oia">
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
<div class="oia-section"><strong>APPLICATION:</strong><p>${application}</p></div>
</div>
${crossRefsHtml}
<div class="greek"><strong>GREEK WORDS:</strong>
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
${extendedDefinitions}
</div>
</section>
`;
}).join('');
return chapterHeader + chunkSections;
}).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escHtml(project.title)}</title>
<style>${style}</style>
</head>
<body>
<div class="page">
<h1>${escHtml(project.title)}</h1>
<p>${escHtml(project.translation)}</p>
${outlineHtml}
${chunksHtml}
</div>
</body>
</html>`;
}
// <ul> of outline points, each with its label (I., A., 1. …) and passage
function outlineTimeHtml(tree) {
const total = flattenMinutes(tree);
return total > 0 ? `<p class="ref">About ${total} minute${total === 1 ? '' : 's'}</p>` : '';
}
function flattenMinutes(nodes) {
return (nodes ?? []).reduce((sum, n) => sum + (Number.isFinite(n.minutes) ? n.minutes : 0) + flattenMinutes(n.children), 0);
}
function outlineListHtml(nodes, depth) {
const one = (n, i) => {
const kind = n.kind ? `<span class="kind">${escHtml(outlineKindLabel(n.kind))}</span> ` : '';
const mins = Number.isFinite(n.minutes) && n.minutes > 0 ? ` <span class="ref">· ${n.minutes} min</span>` : '';
const note = (n.note ?? '').trim() ? `<div class="o-note">${escHtml(n.note.trim()).replace(/\n/g, '<br />')}</div>` : '';
const quote = n.scripture?.text ? `<blockquote class="o-quote">“${escHtml(n.scripture.text)}” — ${escHtml(n.scripture.ref)}</blockquote>` : '';
const attached = (n.attachments ?? []).length > 0
? `<div class="o-attach">${n.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${escHtml(a.label)}${a.detail ? ` — ${escHtml(a.detail)}` : ''}`).join(' · ')}</div>` : '';
return `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${kind}${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${mins}${note}${quote}${attached}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`;
};
return `<ul>${nodes.map(one).join('')}</ul>`;
}
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
export function buildOutlineHtml({ title, subtitle = '', tree }) {
const total = flattenMinutes(tree);
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
.page { max-width: 760px; margin: auto; }
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.sub { color: #64748b; margin: 0 0 1.5rem; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.45rem 0; line-height: 1.5; }
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; }
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
@media print { body { padding: 0; } li { break-inside: avoid; } }
`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escHtml(title)} — Outline</title>
<style>${style}</style>
</head>
<body>
<div class="page">
<h1>${escHtml(title)}</h1>
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
<div class="outline">${outlineListHtml(tree, 0)}</div>
</div>
</body>
</html>`;
}
export function wordTableHtml(rows) {
return `
<table>
<thead>
<tr>
<th>Strong's</th>
<th>Greek</th>
<th>Transliteration</th>
<th>Part of Speech</th>
<th>Short Definition</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
export function extractPartOfSpeech(definitionHtml) {
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
if (match && match[1]) {
return match[1].trim();
}
return '';
}
export function htmlToPlainText(html = '') {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const lines = [];
doc.body.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
lines.push(node.textContent || '');
return;
}
if (node.nodeName === 'BR' || node.nodeName === 'P') {
lines.push(node.textContent || '');
lines.push('\n');
return;
}
lines.push(node.textContent || '');
});
return lines.join('').replace(/\n{2,}/g, '\n').trim();
}
export function renderVerseContent(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
// A footnote marker ({ noteId }) often sits where the source had a space ("light,” [note] and there
// was light"), which would run the words together. Put the space back when nothing else supplies one.
let out = '';
let afterNote = false;
for (const part of content) {
const piece = renderVerseContent(part);
if (piece === '' && part && typeof part === 'object' && 'noteId' in part) { afterNote = true; continue; }
if (afterNote && out && piece && !/\s$/.test(out) && /^[\p{L}\p{N}“‘"'(]/u.test(piece)) out += ' ';
afterNote = false;
out += piece;
}
return out;
}
if (content && typeof content === 'object') {
if (content.lineBreak) {
return '\n';
}
if (content.type === 'line_break') {
return '\n';
}
if (Array.isArray(content.content)) {
return renderVerseContent(content.content);
}
if (typeof content.text === 'string') {
return content.text;
}
return '';
}
return '';
}
export function parseBibleChapter(data) {
if (data?.verses && Array.isArray(data.verses)) {
return data.verses.map((verse) => ({
number: verse.number,
text: verse.text,
}));
}
const chapterContents = data?.chapter?.content;
if (!Array.isArray(chapterContents)) {
return [];
}
return chapterContents
.filter((item) => item?.type === 'verse')
.map((item) => ({
number: item.number,
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
}));
}
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
const INDEX_KEY_LEGACY = 'bible-study-index';
const projectKeyLegacy = (id) => `bible-study-project-${id}`;
let activeStorageUserId = null;
export function indexKey() {
return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY;
}
export function projectKey(id) {
return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id);
}
export function loadProjectIndex() {
try {
const raw = window.localStorage.getItem(indexKey());
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function migrateLegacyLocalDataToUser(userId) {
if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return;
const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY);
if (!legacyRaw) return;
try {
const legacyIndex = JSON.parse(legacyRaw) ?? [];
legacyIndex.forEach((entry) => {
const raw = window.localStorage.getItem(projectKeyLegacy(entry.id));
if (raw != null) {
window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw);
window.localStorage.removeItem(projectKeyLegacy(entry.id));
}
});
window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw);
window.localStorage.removeItem(INDEX_KEY_LEGACY);
} catch {
// Leave legacy data in place if anything goes wrong
}
}
export function switchStorageUser(userId) {
activeStorageUserId = userId ?? null;
if (userId) migrateLegacyLocalDataToUser(userId);
return loadProjectIndex();
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
export function BookmarkIcon({ filled }) {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6">
<path d="M5 3.5C5 2.67 5.67 2 6.5 2h7c.83 0 1.5.67 1.5 1.5v14l-5-3.5-5 3.5v-14z" strokeLinejoin="round" />
</svg>
);
}
export function CopyIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
<rect x="7" y="7" width="10" height="11" rx="1.5" />
<path d="M4.5 13.5h-1A1.5 1.5 0 0 1 2 12V3.5A1.5 1.5 0 0 1 3.5 2H12a1.5 1.5 0 0 1 1.5 1.5v1" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function buildPronunciationGuide(project) {
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
const lines = [];
chapters.forEach((ch) => {
ch.chunks.forEach((chunk) => {
(chunk.greekWords ?? []).forEach((word) => {
const gloss = word.shortDefinition || word.englishGloss || '';
const lexeme = word.lexeme || '';
const translit = word.transliteration || '';
if (!lexeme && !translit) return;
const label = gloss ? `${gloss} — ` : '';
lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`);
});
});
});
if (lines.length === 0) return 'No Greek/Hebrew words collected yet.';
return lines.join('\n');
}