diff --git a/src/App.jsx b/src/App.jsx index 76b79bb..74ed2af 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import mammoth from 'mammoth'; -import { getStroke } from 'perfect-freehand'; -import { pathFromStroke } from './utils/inkRender.js'; - import { AppContext, useApp } from './context/AppContext.js'; import AdminPage from './pages/AdminPage.jsx'; import AuthPage from './pages/AuthPage.jsx'; @@ -26,6 +23,32 @@ import { TextRun, WidthType, } from 'docx'; +import { + COMMENTARY_OPTIONS, + STUDY_TABS, + CHAPTER_COUNTS, + bookOptions, + NT_BOOK_NUMBER, + storageKey, + makeId, + formatRelativeDate, + formatChunkReference, + getChunkVerseEntries, + chunkSpansNextChapter, + buildExportHtml, + wordTableHtml, + extractPartOfSpeech, + htmlToPlainText, + renderVerseContent, + parseBibleChapter, + indexKey, + projectKey, + loadProjectIndex, + switchStorageUser, + BookmarkIcon, + CopyIcon, + CrossRefChip, +} from './utils/bibleUtils.jsx'; import { saveRemoteProject, @@ -54,119 +77,18 @@ import { adminDeleteProject, } from './syncService.js'; -export const COMMENTARY_OPTIONS = [ - { id: 'matthew-henry', name: 'Matthew Henry' }, - { id: 'john-gill', name: 'John Gill' }, - { id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' }, - { id: 'adam-clarke', name: 'Adam Clarke' }, - { id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' }, - { id: 'tyndale', name: 'Tyndale Open Study Notes' }, -]; +// COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS imported from ./utils/bibleUtils.jsx +export { COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS }; -export const STUDY_TABS = [ - { id: 'notes', label: 'Notes' }, - { id: 'crossRefs', label: 'Cross-Refs' }, - { id: 'wordStudy', label: 'Word Study' }, - { id: 'commentary', label: 'Commentary' }, - { id: 'script', label: 'Script' }, -]; - -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, -}; +// bookOptions, NT_BOOK_NUMBER imported from ./utils/bibleUtils.jsx +export { bookOptions, NT_BOOK_NUMBER }; // --------------------------------------------------------------------------- // Stable utility exports (used by tests and other modules) // --------------------------------------------------------------------------- -export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`; -export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; +// storageKey, makeId imported from ./utils/bibleUtils.jsx +export { storageKey, makeId }; // --------------------------------------------------------------------------- // Migration helpers (exported for testing) @@ -202,59 +124,9 @@ export function migrateChunk(chunk) { }; } -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; -} +// formatChunkReference imported from ./utils/bibleUtils.jsx +export { formatChunkReference }; -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}`; -} - -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]; -} export function migrateProject(raw) { if (!raw) return null; @@ -292,65 +164,8 @@ export function migrateProject(raw) { // Storage helpers // --------------------------------------------------------------------------- -// Pre-multi-user, un-namespaced keys. Kept around only so existing local data -// (from before accounts existed) can be claimed by the first user who signs in -// on a given browser — see switchStorageUser() below. -const INDEX_KEY_LEGACY = 'bible-study-index'; -const projectKeyLegacy = (id) => `bible-study-project-${id}`; - -// The signed-in user's id, or null when signed out / not yet resolved. -// Sets which localStorage namespace loadProjectIndex/saveProjectToStorage/etc. read -// and write, so two accounts sharing one browser never see each other's cached projects. -let activeStorageUserId = null; - -function indexKey() { - return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY; -} - -function projectKey(id) { - return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id); -} - -/** - * Moves any pre-multi-user local project data into this user's own namespace, - * then deletes the shared legacy keys so no other account can claim them afterward. - * No-op if this user already has their own namespaced index, or if there's nothing legacy to claim. - */ -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 — better to re-try next login than lose it. - } -} - -/** Switches the active local-storage namespace and returns the freshly loaded index for it. */ -export function switchStorageUser(userId) { - activeStorageUserId = userId ?? null; - if (userId) migrateLegacyLocalDataToUser(userId); - return loadProjectIndex(); -} - -function loadProjectIndex() { - try { - const raw = window.localStorage.getItem(indexKey()); - return raw ? JSON.parse(raw) : []; - } catch { - return []; - } -} +// indexKey, projectKey, loadProjectIndex, switchStorageUser imported from ./utils/bibleUtils.jsx +export { switchStorageUser }; function saveProjectToStorage(project) { // Honour an already-stamped lastEdited (e.g. from autosave) so localStorage @@ -421,167 +236,15 @@ function buildChapterSummary(project) { .join(', '); } -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`; -} +// formatRelativeDate imported from ./utils/bibleUtils.jsx +export { formatRelativeDate }; // --------------------------------------------------------------------------- // Export / prompt builders (exported for testing) // --------------------------------------------------------------------------- -const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - -function inkStrokesToSvgHtml(strokes) { - if (!strokes?.length) return ''; - const W = 700, H = 500; - const lines = []; - for (let y = 32; y < H; y += 32) - lines.push(``); - lines.push(``); - const paths = strokes.map((s) => { - const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]); - const outline = getStroke(pts, { - size: s.size * H, - thinning: s.tool === 'highlighter' ? 0 : 0.5, - smoothing: 0.5, - streamline: 0.4, - }); - const d = pathFromStroke(outline); - if (!d) return ''; - return ``; - }).filter(Boolean).join(''); - return `
INK NOTES:
${lines.join('')}${paths}
`; -} - -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, .ink-notes { 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; } - .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 : []; - - const chunksHtml = chapters.map((ch, chapterIndex) => { - const chapterHeader = `

${escHtml(ch.book)} ${escHtml(ch.chapter)}

`; - const chunkSections = ch.chunks.map((chunk) => { - const scripture = formatChunkReference(project, chapterIndex, chunk, '-'); - const versesText = getChunkVerseEntries(project, chapterIndex, chunk) - .map((verse) => `

${escHtml(verse.chapter)}:${escHtml(verse.number)} ${escHtml(verse.text)}

`) - .join(''); - - const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '
') || 'No observation.'; - const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '
') || 'No interpretation.'; - const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '
') || 'No application.'; - - const crossRefsHtml = (chunk.crossReferences ?? []).length > 0 - ? `
CROSS-REFERENCES: ${chunk.crossReferences.map(escHtml).join(', ')}
` - : ''; - - const greekRows = chunk.greekWords.map((word) => ` - - ${escHtml(word.strongNumber)} - ${escHtml(word.lexeme)} - ${escHtml(word.transliteration)} - ${escHtml(word.partOfSpeech)} - ${escHtml(word.shortDefinition)} - - `).join(''); - const greekTable = wordTableHtml(greekRows); - const extendedDefinitions = chunk.greekWords - .map((word) => { - if (word.definitionHtml) { - return ` -
-

${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}

-
${word.definitionHtml}
-
- `; - } - 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 - ? `BibleHub · Blue Letter Bible · StudyLight` - : `BibleHub · Blue Letter Bible`; - return ` -
-

${escHtml(raw)} — definition not found in auto-lookup

-

Look it up manually: ${links}

-
- `; - } - return ''; - }) - .join(''); - - const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes); - - return ` -
-
${escHtml(scripture)}
- ${versesText} -
-
OBSERVATION:

${observation}

-
INTERPRETATION:

${interpretation}

-
APPLICATION:

${application}

-
- ${crossRefsHtml} -
GREEK WORDS: - ${greekRows ? greekTable : '

No Greek word notes.

'} - ${extendedDefinitions} -
- ${inkHtml} -
- `; - }).join(''); - return chapterHeader + chunkSections; - }).join(''); - - return ` - - - - - ${escHtml(project.title)} - - - -
-

${escHtml(project.title)}

-

${escHtml(project.translation)}

- ${chunksHtml} -
- -`; -} +// buildExportHtml imported from ./utils/bibleUtils.jsx +export { buildExportHtml }; export function buildMarkdownExport(project) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; @@ -625,51 +288,8 @@ export function buildMarkdownExport(project) { return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`; } -export function wordTableHtml(rows) { - return ` - - - - - - - - - - - - ${rows} - -
Strong'sGreekTransliterationPart of SpeechShort Definition
- `; -} - -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(); -} +// wordTableHtml, extractPartOfSpeech, htmlToPlainText imported from ./utils/bibleUtils.jsx +export { wordTableHtml, extractPartOfSpeech, htmlToPlainText }; export function createParagraphsFromText(text) { return text @@ -699,30 +319,8 @@ export function sectionLabel(text) { }); } -export function renderVerseContent(content) { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - return content.map(renderVerseContent).join(''); - } - 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 ''; -} +// renderVerseContent imported from ./utils/bibleUtils.jsx +export { renderVerseContent }; function buildChunkBodies(project) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; @@ -904,26 +502,8 @@ PASSAGE: ${chapterLabel} return header + buildChunkBodies(project); } -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(), - })); -} +// parseBibleChapter imported from ./utils/bibleUtils.jsx +export { parseBibleChapter }; // Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23") const BOOK_NAME_ALIASES = { @@ -1024,65 +604,8 @@ async function parseEpisodeListDocx(file) { return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)); } -// Small, unambiguous icons for the reader's verse actions (replaces emoji, which -// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance). -export function BookmarkIcon({ filled }) { - return ( - - - - ); -} - -export function CopyIcon() { - return ( - - - - - ); -} - -// A cross-reference chip that fetches and shows the referenced verse text on hover -export function CrossRefChip({ label, onRemove, loadVerseText }) { - const [hovered, setHovered] = useState(false); - const [verseText, setVerseText] = useState(null); - const [error, setError] = useState(''); - - useEffect(() => { - if (!hovered || verseText || error) return; - let cancelled = false; - loadVerseText(label) - .then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); }) - .catch(() => { if (!cancelled) setError('Verse text unavailable.'); }); - return () => { cancelled = true; }; - }, [hovered]); - - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {label} - {onRemove && ( - - )} - {hovered && ( - - {error || verseText || 'Loading...'} - - )} - - ); -} +// BookmarkIcon, CopyIcon, CrossRefChip imported from ./utils/bibleUtils.jsx +export { BookmarkIcon, CopyIcon, CrossRefChip }; // --------------------------------------------------------------------------- // IndexedDB helpers for whole-Bible search cache diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index 90b290b..b86ff14 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -1,5 +1,5 @@ import { useApp } from '../context/AppContext.js'; -import { buildExportHtml } from '../App.jsx'; +import { buildExportHtml } from '../utils/bibleUtils.jsx'; import { adminDeleteUser, adminDeleteProject, diff --git a/src/pages/AuthPage.jsx b/src/pages/AuthPage.jsx index 8676132..22f7a4e 100644 --- a/src/pages/AuthPage.jsx +++ b/src/pages/AuthPage.jsx @@ -1,6 +1,6 @@ import { useApp } from '../context/AppContext.js'; import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js'; -import { switchStorageUser } from '../App.jsx'; +import { switchStorageUser } from '../utils/bibleUtils.jsx'; export default function AuthPage() { const { diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index eeaec28..1fe283e 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useApp } from '../context/AppContext.js'; -import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx'; +import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx'; export default function HomePage() { const { diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx index 01b950a..f415de4 100644 --- a/src/pages/ImportPage.jsx +++ b/src/pages/ImportPage.jsx @@ -1,5 +1,5 @@ import { useApp } from '../context/AppContext.js'; -import { bookOptions } from '../App.jsx'; +import { bookOptions } from '../utils/bibleUtils.jsx'; export default function ImportPage() { const { diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 1c2a719..cf096b5 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useApp } from '../context/AppContext.js'; -import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx'; +import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx'; import DrawCanvas from './DrawCanvas.jsx'; export default function ReaderPage() { diff --git a/src/pages/SetupPage.jsx b/src/pages/SetupPage.jsx index cc26ebc..97c383b 100644 --- a/src/pages/SetupPage.jsx +++ b/src/pages/SetupPage.jsx @@ -1,5 +1,5 @@ import { useApp } from '../context/AppContext.js'; -import { bookOptions, formatChunkReference } from '../App.jsx'; +import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx'; function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) { return ( diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index 9868f13..f69a645 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom'; import DOMPurify from 'dompurify'; import { useApp } from '../context/AppContext.js'; -import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../App.jsx'; +import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx'; import DrawCanvas from './DrawCanvas.jsx'; export default function StudyPage() { diff --git a/src/utils/bibleUtils.jsx b/src/utils/bibleUtils.jsx new file mode 100644 index 0000000..6069879 --- /dev/null +++ b/src/utils/bibleUtils.jsx @@ -0,0 +1,542 @@ +import { useState, useEffect } from 'react'; +import { getStroke } from 'perfect-freehand'; +import { pathFromStroke } from './inkRender.js'; + +// --------------------------------------------------------------------------- +// Study constants +// --------------------------------------------------------------------------- + +export const COMMENTARY_OPTIONS = [ + { id: 'matthew-henry', name: 'Matthew Henry' }, + { id: 'john-gill', name: 'John Gill' }, + { id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' }, + { id: 'adam-clarke', name: 'Adam Clarke' }, + { id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' }, + { id: 'tyndale', name: 'Tyndale Open Study Notes' }, +]; + +export const STUDY_TABS = [ + { id: 'notes', label: 'Notes' }, + { id: 'crossRefs', label: 'Cross-Refs' }, + { id: 'wordStudy', label: 'Word Study' }, + { id: 'commentary', label: 'Commentary' }, + { id: 'script', label: 'Script' }, +]; + +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, '&').replace(//g, '>').replace(/"/g, '"'); + +function inkStrokesToSvgHtml(strokes) { + if (!strokes?.length) return ''; + const W = 700, H = 500; + const lines = []; + for (let y = 32; y < H; y += 32) + lines.push(``); + lines.push(``); + const paths = strokes.map((s) => { + const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]); + const outline = getStroke(pts, { + size: s.size * H, + thinning: s.tool === 'highlighter' ? 0 : 0.5, + smoothing: 0.5, + streamline: 0.4, + }); + const d = pathFromStroke(outline); + if (!d) return ''; + return ``; + }).filter(Boolean).join(''); + return `
INK NOTES:
${lines.join('')}${paths}
`; +} + +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, .ink-notes { 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; } + .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 : []; + + const chunksHtml = chapters.map((ch, chapterIndex) => { + const chapterHeader = `

${escHtml(ch.book)} ${escHtml(ch.chapter)}

`; + const chunkSections = ch.chunks.map((chunk) => { + const scripture = formatChunkReference(project, chapterIndex, chunk, '-'); + const versesText = getChunkVerseEntries(project, chapterIndex, chunk) + .map((verse) => `

${escHtml(verse.chapter)}:${escHtml(verse.number)} ${escHtml(verse.text)}

`) + .join(''); + + const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '
') || 'No observation.'; + const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '
') || 'No interpretation.'; + const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '
') || 'No application.'; + + const crossRefsHtml = (chunk.crossReferences ?? []).length > 0 + ? `
CROSS-REFERENCES: ${chunk.crossReferences.map(escHtml).join(', ')}
` + : ''; + + const greekRows = chunk.greekWords.map((word) => ` + + ${escHtml(word.strongNumber)} + ${escHtml(word.lexeme)} + ${escHtml(word.transliteration)} + ${escHtml(word.partOfSpeech)} + ${escHtml(word.shortDefinition)} + + `).join(''); + const greekTable = wordTableHtml(greekRows); + const extendedDefinitions = chunk.greekWords + .map((word) => { + if (word.definitionHtml) { + return ` +
+

${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}

+
${word.definitionHtml}
+
+ `; + } + 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 + ? `BibleHub · Blue Letter Bible · StudyLight` + : `BibleHub · Blue Letter Bible`; + return ` +
+

${escHtml(raw)} — definition not found in auto-lookup

+

Look it up manually: ${links}

+
+ `; + } + return ''; + }) + .join(''); + + const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes); + + return ` +
+
${escHtml(scripture)}
+ ${versesText} +
+
OBSERVATION:

${observation}

+
INTERPRETATION:

${interpretation}

+
APPLICATION:

${application}

+
+ ${crossRefsHtml} +
GREEK WORDS: + ${greekRows ? greekTable : '

No Greek word notes.

'} + ${extendedDefinitions} +
+ ${inkHtml} +
+ `; + }).join(''); + return chapterHeader + chunkSections; + }).join(''); + + return ` + + + + + ${escHtml(project.title)} + + + +
+

${escHtml(project.title)}

+

${escHtml(project.translation)}

+ ${chunksHtml} +
+ +`; +} + +export function wordTableHtml(rows) { + return ` + + + + + + + + + + + + ${rows} + +
Strong'sGreekTransliterationPart of SpeechShort Definition
+ `; +} + +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)) { + return content.map(renderVerseContent).join(''); + } + 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 ( + + + + ); +} + +export function CopyIcon() { + return ( + + + + + ); +} + +export function CrossRefChip({ label, onRemove, loadVerseText }) { + const [hovered, setHovered] = useState(false); + const [verseText, setVerseText] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + if (!hovered || verseText || error) return; + let cancelled = false; + loadVerseText(label) + .then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); }) + .catch(() => { if (!cancelled) setError('Verse text unavailable.'); }); + return () => { cancelled = true; }; + }, [hovered]); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {label} + {onRemove && ( + + )} + {hovered && ( + + {error || verseText || 'Loading...'} + + )} + + ); +}