import { useEffect, useRef, useState } from 'react'; import { Document, HeadingLevel, Packer, Paragraph, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx'; const bookOptions = [ { 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' }, ]; // --------------------------------------------------------------------------- // 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()}`; // --------------------------------------------------------------------------- // Migration helpers (exported for testing) // --------------------------------------------------------------------------- export function migrateChunk(chunk) { if (chunk.observation !== undefined) return chunk; // already new format return { ...chunk, observation: chunk.notes ?? '', interpretation: '', application: '', crossReferences: [], notes: undefined, }; } export function migrateProject(raw) { if (!raw) return null; // Already new format if (Array.isArray(raw.chapters)) { return { ...raw, chapters: raw.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map(migrateChunk), })), }; } // Old flat format → wrap in chapters array const id = raw.id ?? makeId(); return { id, title: raw.title ?? '', translation: raw.translation ?? 'BSB', lastEdited: raw.lastEdited ?? Date.now(), selectedChunkId: raw.selectedChunkId ?? null, chapters: [ { book: raw.book ?? '', bookAbbrev: raw.bookAbbrev ?? '', chapter: raw.chapter ?? '1', verses: raw.verses ?? [], chunks: (raw.chunks ?? []).map(migrateChunk), }, ], }; } // --------------------------------------------------------------------------- // Storage helpers // --------------------------------------------------------------------------- const INDEX_KEY = 'bible-study-index'; const projectKey = (id) => `bible-study-project-${id}`; function loadProjectIndex() { try { const raw = window.localStorage.getItem(INDEX_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } function saveProjectToStorage(project) { const updated = { ...project, lastEdited: Date.now() }; window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated)); const index = loadProjectIndex(); const existing = index.findIndex((e) => e.id === updated.id); const summary = { id: updated.id, title: updated.title, lastEdited: updated.lastEdited, chapterSummary: buildChapterSummary(updated), }; if (existing >= 0) { index[existing] = summary; } else { index.push(summary); } window.localStorage.setItem(INDEX_KEY, JSON.stringify(index)); } function deleteProjectFromStorage(id) { window.localStorage.removeItem(projectKey(id)); const index = loadProjectIndex().filter((e) => e.id !== id); window.localStorage.setItem(INDEX_KEY, JSON.stringify(index)); } function loadProjectById(id) { try { const raw = window.localStorage.getItem(projectKey(id)); return raw ? migrateProject(JSON.parse(raw)) : null; } catch { return null; } } function migrateOldStorageKeys() { const keys = Object.keys(window.localStorage); keys.forEach((key) => { if (!key.startsWith('bible-study-') || key === INDEX_KEY || key.startsWith('bible-study-project-')) return; try { const raw = JSON.parse(window.localStorage.getItem(key)); if (!raw || !raw.book) return; const migrated = migrateProject(raw); if (!migrated) return; saveProjectToStorage(migrated); window.localStorage.removeItem(key); } catch { // skip corrupt entries } }); } function buildChapterSummary(project) { if (!Array.isArray(project.chapters)) return ''; return project.chapters .map((ch) => `${ch.book} ${ch.chapter}`) .join(', '); } 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 / prompt builders (exported for testing) // --------------------------------------------------------------------------- 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; } .definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; } `; const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chunksHtml = chapters.map((ch) => { const chapterHeader = `

${ch.book} ${ch.chapter}

`; const chunkSections = ch.chunks.map((chunk) => { const scripture = chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}-${chunk.endVerse}`; const versesText = ch.verses .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) .map((verse) => `

${verse.number} ${verse.text}

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

${word.strongNumber} — ${word.lexeme || ''}

${word.definitionHtml}
`) .join(''); return `
${scripture}
${versesText}
OBSERVATION:

${observation}

INTERPRETATION:

${interpretation}

APPLICATION:

${application}

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

No Greek word notes.

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

${project.title}

${project.translation}

${chunksHtml}
`; } export function wordTableHtml(rows) { return ` ${rows}
Strong's Greek Transliteration Part of Speech Short 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 createParagraphsFromText(text) { return text .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .map((line) => new Paragraph({ children: [new TextRun({ text: line })] })); } 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); } return ''; } return ''; } export function buildClaudePrompt(project) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide. Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that: - Synthesises my notes into coherent teaching points - Naturally integrates the Greek word insights - Includes 2–3 reflection questions per chunk - Preserves the passage-by-passage structure --- PROJECT: ${project.title} TRANSLATION: ${project.translation} PASSAGE: ${chapterLabel} `; let chunkIndex = 0; const chunks = chapters.map((ch) => { return ch.chunks.map((chunk) => { chunkIndex += 1; const ref = chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`; const verses = ch.verses .filter((v) => v.number >= chunk.startVerse && v.number <= chunk.endVerse) .map((v) => `${v.number} ${v.text}`) .join('\n'); const observation = (chunk.observation ?? '').trim() || 'No observation.'; const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.'; const application = (chunk.application ?? '').trim() || 'No application.'; const crossRefs = (chunk.crossReferences ?? []).length > 0 ? chunk.crossReferences.join(', ') : 'None.'; const greekWords = chunk.greekWords.length === 0 ? 'None.' : chunk.greekWords.map((word) => { const summary = [ word.strongNumber, word.lexeme, word.transliteration && `(${word.transliteration})`, word.partOfSpeech, word.shortDefinition, ].filter(Boolean).join(' | '); const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : ''; return `• ${summary}${definition}`; }).join('\n'); return `=== CHUNK ${chunkIndex} — ${ref} Scripture: ${verses} Observation: ${observation} Interpretation: ${interpretation} Application: ${application} Cross-References: ${crossRefs} Greek Words: ${greekWords} `; }).join('\n'); }).join('\n'); return header + chunks; } 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(), })); } // --------------------------------------------------------------------------- // App component // --------------------------------------------------------------------------- const App = () => { const [availableTranslations, setAvailableTranslations] = useState(['BSB']); const [setup, setSetup] = useState({ translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study', }); const [titleEdited, setTitleEdited] = useState(false); const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' const [currentPage, setCurrentPage] = useState('home'); const [projectIndex, setProjectIndex] = useState([]); const [activeChapterIndex, setActiveChapterIndex] = useState(0); const [showAddChapterForm, setShowAddChapterForm] = useState(false); const [crossRefInput, setCrossRefInput] = useState(''); const [saveStatus, setSaveStatus] = useState(''); const [rangeStart, setRangeStart] = useState(null); const [rangeEnd, setRangeEnd] = useState(null); const [loadingChapter, setLoadingChapter] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const saveTimerRef = useRef(null); // --------------------------------------------------------------------------- // Startup: migrate old keys and load index // --------------------------------------------------------------------------- useEffect(() => { migrateOldStorageKeys(); setProjectIndex(loadProjectIndex()); }, []); useEffect(() => { fetch('https://bible.helloao.org/api/available_translations.json') .then((res) => res.json()) .then((data) => { if (Array.isArray(data) && data.length > 0) { setAvailableTranslations(data); } }) .catch(() => {}); }, []); useEffect(() => { if (!titleEdited) { setSetup((current) => ({ ...current, title: `${current.book} ${current.chapter} Study`, })); } }, [setup.book, setup.chapter, titleEdited]); // Reset cross-ref input when selected chunk changes const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; const activeChapter = project?.chapters[activeChapterIndex] ?? null; const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; const selectedChunkChapter = selectedChunk ? project.chapters.find((ch) => ch.chunks.some((c) => c.id === selectedChunk.id)) : null; const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId); useEffect(() => { setCrossRefInput(''); }, [project?.selectedChunkId]); // Autosave useEffect(() => { if (!project) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(() => { saveProjectToStorage(project); setProjectIndex(loadProjectIndex()); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); }, 1000); return () => { if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); }; }, [project]); // --------------------------------------------------------------------------- // Setup helpers // --------------------------------------------------------------------------- const handleSetupField = (field, value) => { if (field === 'book') { const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0]; setSetup((current) => ({ ...current, book: selected.name, bookAbbrev: selected.abbrev, })); } else { setSetup((current) => ({ ...current, [field]: value })); } }; const handleLoadChapter = async () => { if (!setup.bookAbbrev || !setup.chapter) { setErrorMessage('Please choose a book and chapter.'); return; } setErrorMessage(''); setLoadingChapter(true); try { const response = await fetch( `https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`, ); if (!response.ok) throw new Error('Unable to load chapter.'); const data = await response.json(); const verses = parseBibleChapter(data); if (!data || !Array.isArray(verses) || verses.length === 0) { throw new Error('Invalid Bible data returned.'); } if (project) { // Adding a chapter to an existing project const alreadyExists = project.chapters.some( (ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter, ); if (alreadyExists) { setErrorMessage('That chapter is already in this project.'); setLoadingChapter(false); return; } const newChapter = { book: setup.book, bookAbbrev: setup.bookAbbrev, chapter: setup.chapter, verses, chunks: [], }; updateProject((current) => { const updatedChapters = [...current.chapters, newChapter]; return { ...current, chapters: updatedChapters }; }); setActiveChapterIndex(project.chapters.length); setShowAddChapterForm(false); setErrorMessage(''); } else { // Creating a brand-new project const newProject = { id: makeId(), title: setup.title, translation: setup.translation, lastEdited: Date.now(), selectedChunkId: null, chapters: [ { book: setup.book, bookAbbrev: setup.bookAbbrev, chapter: setup.chapter, verses, chunks: [], }, ], }; setProject(newProject); setActiveChapterIndex(0); setCurrentPage('setup'); } } catch (error) { setErrorMessage(error.message || 'Failed to load chapter.'); } finally { setLoadingChapter(false); } }; const updateProject = (updater) => { setProject((current) => (current ? updater(current) : current)); }; const beginStudying = () => { if (!project || allChunks.length === 0) return; updateProject((current) => ({ ...current, selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null, })); setCurrentPage('study'); }; // --------------------------------------------------------------------------- // Chunk navigation // --------------------------------------------------------------------------- const goToPreviousChunk = () => { if (selectedChunkGlobalIndex <= 0) return; updateProject((current) => ({ ...current, selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id, })); }; const goToNextChunk = () => { if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return; updateProject((current) => ({ ...current, selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id, })); }; // --------------------------------------------------------------------------- // Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study) // --------------------------------------------------------------------------- const addChunkToChapter = (chapterIndex, start, end) => { updateProject((current) => { const chapters = current.chapters.map((ch, idx) => { if (idx !== chapterIndex) return ch; const overlap = ch.chunks.find((c) => c.startVerse === start && c.endVerse === end); if (overlap) return ch; const newChunk = { id: makeId(), startVerse: start, endVerse: end, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], }; return { ...ch, chunks: [...ch.chunks, newChunk] }; }); // Find the newly added chunk id const newChunk = chapters[chapterIndex].chunks.at(-1); return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId }; }); }; const addChunk = (start, end) => { if (!project) return; const chapter = activeChapter; if (!chapter) return; const overlap = chapter.chunks.find((c) => c.startVerse === start && c.endVerse === end); if (overlap) { setStatusMessage('That chunk already exists.'); window.setTimeout(() => setStatusMessage(''), 1800); return; } addChunkToChapter(activeChapterIndex, start, end); }; const handleVerseClick = (verseNumber, event) => { if (event.shiftKey && rangeStart !== null) { const start = Math.min(rangeStart, verseNumber); const end = Math.max(rangeStart, verseNumber); addChunk(start, end); setRangeStart(null); setRangeEnd(null); return; } setRangeStart(verseNumber); setRangeEnd(verseNumber); }; const updateChunk = (chunkId, patch) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)), })), })); }; const moveChunk = (chunkId, direction) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => { const index = ch.chunks.findIndex((c) => c.id === chunkId); if (index < 0) return ch; const nextIndex = index + direction; if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch; const updated = [...ch.chunks]; const [removed] = updated.splice(index, 1); updated.splice(nextIndex, 0, removed); return { ...ch, chunks: updated }; }), })); }; const deleteChunk = (chunkId) => { updateProject((current) => { const newAllChunks = current.chapters .flatMap((ch) => ch.chunks) .filter((c) => c.id !== chunkId); const nextSelected = current.selectedChunkId === chunkId ? newAllChunks[0]?.id ?? null : current.selectedChunkId; return { ...current, selectedChunkId: nextSelected, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.filter((c) => c.id !== chunkId), })), }; }); }; // --------------------------------------------------------------------------- // Cross-references // --------------------------------------------------------------------------- const addCrossRef = (chunkId) => { const ref = crossRefInput.trim(); if (!ref) return; updateChunk(chunkId, { crossReferences: [...(selectedChunk?.crossReferences ?? []), ref], }); setCrossRefInput(''); }; const removeCrossRef = (chunkId, ref) => { updateChunk(chunkId, { crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref), }); }; // --------------------------------------------------------------------------- // Greek word helpers // --------------------------------------------------------------------------- const updateChunkWord = (chunkId, wordId, patch) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => { if (chunk.id !== chunkId) return chunk; return { ...chunk, greekWords: chunk.greekWords.map((word) => word.id === wordId ? { ...word, ...patch } : word, ), }; }), })), })); }; const addGreekWord = (chunkId) => { const newWord = { id: makeId(), query: '', strongNumber: '', lexeme: '', transliteration: '', partOfSpeech: '', shortDefinition: '', definitionHtml: '', loading: false, }; updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: [...chunk.greekWords, newWord] } : chunk, ), })), })); }; const removeGreekWord = (chunkId, wordId) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: chunk.greekWords.filter((w) => w.id !== wordId) } : chunk, ), })), })); }; const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim()); const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim()); const fetchBollsDefinition = async (query) => { const isGreek = isGreekStrongNumber(query); const isHebrew = isHebrewStrongNumber(query); if (isGreek || isHebrew) { // The bolls.life API uses bare numbers in the URL (no G/H prefix). // We filter returned results by topic prefix to get the right language. const bare = query.trim().replace(/^[GgHh]/, ''); const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encodeURIComponent(bare)}/`); if (!response.ok) return null; let defs; try { defs = await response.json(); } catch { return null; } if (!Array.isArray(defs) || defs.length === 0) return null; const prefix = isGreek ? 'G' : 'H'; const filtered = defs.filter((d) => String(d.topic ?? '').toUpperCase().startsWith(prefix)); return filtered.length > 0 ? filtered : defs; } // English word — full-text search, filter to Greek entries only const encoded = encodeURIComponent(query.trim()); try { const searchResponse = await fetch(`https://bolls.life/search-dictionaries/BDBT/${encoded}/`); if (searchResponse.ok) { const results = await searchResponse.json(); const greekResults = Array.isArray(results) ? results.filter((r) => String(r.topic ?? '').startsWith('G')) : []; if (greekResults.length > 0) return [greekResults[0]]; } } catch { /* fall through */ } return null; }; const lookupGreekWord = async (chunkId, wordId) => { const chunk = allChunks.find((c) => c.id === chunkId); const word = chunk?.greekWords.find((w) => w.id === wordId); if (!word || !word.query.trim()) return; updateChunkWord(chunkId, wordId, { loading: true }); try { // Normalize bare numbers to G prefix: "4102" → "G4102" (Greek, not Hebrew H4102). const raw = word.query.trim(); const normalized = /^\d+$/.test(raw) ? `G${raw}` : /^[gGhH]\d+$/.test(raw) ? raw.toUpperCase() : raw; const definitions = await fetchBollsDefinition(normalized); if (!definitions || definitions.length === 0) { updateChunkWord(chunkId, wordId, { strongNumber: '', shortDefinition: 'No definition found.', definitionHtml: '', }); return; } const first = definitions[0]; const extractedPartOfSpeech = extractPartOfSpeech(first.definition || ''); updateChunkWord(chunkId, wordId, { strongNumber: first.topic || normalized, lexeme: first.lexeme || '', transliteration: first.transliteration || '', partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '', shortDefinition: first.short_definition || '', definitionHtml: first.definition || '', }); } catch { updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' }); } finally { updateChunkWord(chunkId, wordId, { loading: false }); } }; // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- const exportChapter = () => { if (!project) return; const html = buildExportHtml(project); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.html`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const exportChapterDocx = async () => { if (!project) return; const children = [ new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }), new Paragraph({ text: `${project.translation} — ${buildChapterSummary(project)}`, spacing: { after: 300 }, }), ]; project.chapters.forEach((ch) => { children.push(new Paragraph({ text: `${ch.book} ${ch.chapter}`, heading: HeadingLevel.HEADING_1 })); ch.chunks.forEach((chunk) => { const scriptureHeading = chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}-${chunk.endVerse}`; children.push(new Paragraph({ text: scriptureHeading, heading: HeadingLevel.HEADING_2 })); ch.verses .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) .forEach((verse) => { children.push(new Paragraph({ children: [ new TextRun({ text: `${verse.number}. `, bold: true }), new TextRun({ text: verse.text }), ], })); }); children.push(new Paragraph({ text: 'OBSERVATION:', spacing: { before: 240, after: 120 }, bold: true })); children.push(...createParagraphsFromText(chunk.observation || 'No observation.')); children.push(new Paragraph({ text: 'INTERPRETATION:', spacing: { before: 240, after: 120 }, bold: true })); children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.')); children.push(new Paragraph({ text: 'APPLICATION:', spacing: { before: 240, after: 120 }, bold: true })); children.push(...createParagraphsFromText(chunk.application || 'No application.')); if ((chunk.crossReferences ?? []).length > 0) { children.push(new Paragraph({ text: 'CROSS-REFERENCES:', spacing: { before: 240, after: 120 }, bold: true })); children.push(new Paragraph({ text: chunk.crossReferences.join(', ') })); } children.push(new Paragraph({ text: 'GREEK WORDS:', spacing: { before: 240, after: 120 }, bold: true })); if (chunk.greekWords.length > 0) { const tableRows = [ new TableRow({ tableHeader: true, children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map((label) => new TableCell({ width: { size: 20, type: WidthType.PERCENTAGE }, children: [new Paragraph({ text: label, bold: true })], }), ), }), ...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({ text: `${word.strongNumber} — ${word.lexeme || ''}`, spacing: { before: 180, after: 120 }, bold: true })); children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml))); } }); } else { children.push(new Paragraph('No Greek word notes.')); } children.push(new Paragraph({ text: '', spacing: { after: 300 } })); }); }); const doc = new Document({ sections: [{ children }] }); const blob = await Packer.toBlob(doc); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.docx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const copyForClaude = () => { if (!project) return; const prompt = buildClaudePrompt(project); navigator.clipboard.writeText(prompt).then(() => { setSaveStatus('Copied for Claude!'); window.setTimeout(() => setSaveStatus(''), 2000); }); }; // --------------------------------------------------------------------------- // Home page helpers // --------------------------------------------------------------------------- const openNewProject = () => { setProject(null); setActiveChapterIndex(0); setShowAddChapterForm(false); setRangeStart(null); setRangeEnd(null); setErrorMessage(''); setStatusMessage(''); setTitleEdited(false); setCurrentPage('setup'); }; const resumeProject = (id) => { const loaded = loadProjectById(id); if (!loaded) return; setProject(loaded); setActiveChapterIndex(0); setCurrentPage('setup'); }; const deleteProject = (id) => { if (!window.confirm('Delete this project? This cannot be undone.')) return; deleteProjectFromStorage(id); setProjectIndex(loadProjectIndex()); }; const goHome = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); setProject(null); setRangeStart(null); setRangeEnd(null); setErrorMessage(''); setStatusMessage(''); }; const verseLabel = (start, end) => (start === end ? `${start}` : `${start}-${end}`); // --------------------------------------------------------------------------- // Shared header // --------------------------------------------------------------------------- const headerButtons = (
{currentPage !== 'home' && ( )} {project && currentPage === 'study' && ( )} {project && (
)}
{loadingChapter ? ( Loading… ) : saveStatus ? ( {saveStatus} ) : (   )}
); // --------------------------------------------------------------------------- // HOME PAGE // --------------------------------------------------------------------------- if (currentPage === 'home') { return (

Bible Study Project

My Studies

{projectIndex.length === 0 ? (

No projects yet

Start a new Bible study to get going.

) : (
{projectIndex .slice() .sort((a, b) => (b.lastEdited ?? 0) - (a.lastEdited ?? 0)) .map((entry) => (

{entry.title}

{entry.chapterSummary && (

{entry.chapterSummary}

)}

{formatRelativeDate(entry.lastEdited)}

))}
)}
); } // --------------------------------------------------------------------------- // SETUP PAGE (chunk builder) // --------------------------------------------------------------------------- if (currentPage === 'setup') { const chapterTabs = project?.chapters ?? []; return (

Bible Study Project

{project ? project.title : 'New Study'}

{headerButtons}
{/* Project setup form — shown when no project loaded yet */} {!project && (
{ setTitleEdited(true); handleSetupField('title', val); }} onLoad={handleLoadChapter} />
)} {/* Chapter tabs + verse/chunk editors */} {project && (
{/* Chapter tabs */}
{chapterTabs.map((ch, idx) => ( ))}
{/* Add-chapter form */} {showAddChapterForm && (

Add another chapter

{ setTitleEdited(true); handleSetupField('title', val); }} onLoad={handleLoadChapter} />
)} {/* Verse + chunk panel for active chapter */} {activeChapter && (

Scripture & Chunks

{activeChapter.book} {activeChapter.chapter} ({project.translation})

{statusMessage || 'Shift-click a second verse to create a chunk.'}
Chapter verses Click a verse, then shift-click an end verse.
{activeChapter.verses.map((verse) => { const inRange = rangeStart !== null && verse.number >= Math.min(rangeStart, rangeEnd) && verse.number <= Math.max(rangeStart, rangeEnd); const inChunk = activeChapter.chunks.some( (chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse, ); return ( ); })}

Chunks

{activeChapter.chunks.length} created
{activeChapter.chunks.length === 0 ? (
No chunks yet. Select verse ranges to add sections.
) : ( activeChapter.chunks.map((chunk, index) => (
)) )}
)}
)}
); } // --------------------------------------------------------------------------- // STUDY PAGE // --------------------------------------------------------------------------- return (

Bible Study Project

{project?.title ?? ''}

{headerButtons}
{/* Sidebar */} {/* Chunk editor */}

Chunk editor

{selectedChunk ? `Section ${verseLabel(selectedChunk.startVerse, selectedChunk.endVerse)}` : 'Select a chunk'}

{selectedChunk ? `Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}` : 'Choose a chunk to study.'}
{selectedChunk ? (
{/* Scripture */}

Scripture

Read-only passage for the selected chunk.

{selectedChunkChapter?.book} {selectedChunkChapter?.chapter}:{verseLabel(selectedChunk.startVerse, selectedChunk.endVerse)}
{selectedChunkChapter?.verses .filter((v) => v.number >= selectedChunk.startVerse && v.number <= selectedChunk.endVerse) .map((verse) => (

{verse.number}. {verse.text}

))}
{/* OIA Notes */}

Study Notes (OIA)

Observation · Interpretation · Application

{[ { field: 'observation', label: 'Observation', placeholder: 'What does the text say? List facts, details, key words…' }, { field: 'interpretation', label: 'Interpretation', placeholder: 'What does it mean? Context, cross-references, theology…' }, { field: 'application', label: 'Application', placeholder: 'How does it apply? Personal response, life change…' }, ].map(({ field, label, placeholder }) => (