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' }, ]; export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`; export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; 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; } .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; } .notes, .greek { margin-top: 1rem; } .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 chunksHtml = project.chunks .map((chunk) => { const scripture = chunk.startVerse === chunk.endVerse ? `${project.book} ${project.chapter}:${chunk.startVerse}` : `${project.book} ${project.chapter}:${chunk.startVerse}-${chunk.endVerse}`; const versesText = project.verses .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) .map((verse) => `

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

`) .join(''); const notes = chunk.notes.trim().replace(/\n/g, '
') || 'No notes.'; 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}
STUDY NOTES:

${notes}

GREEK WORDS: ${greekRows ? greekTable : '

No Greek word notes.

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

${project.title}

${project.translation} — ${project.book} ${project.chapter}

${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 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(), })); } 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); const [currentPage, setCurrentPage] = useState('setup'); 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); 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(() => { // Keep default translation if API fails. }); }, []); useEffect(() => { if (!titleEdited) { setSetup((current) => ({ ...current, title: `${current.book} ${current.chapter} Study`, })); } }, [setup.book, setup.chapter, titleEdited]); useEffect(() => { const key = storageKey(setup.translation, setup.bookAbbrev, setup.chapter); const saved = window.localStorage.getItem(key); if (!saved) { return; } try { const parsed = JSON.parse(saved); if (parsed && parsed.bookAbbrev === setup.bookAbbrev && parsed.chapter === setup.chapter) { if (parsed.chunks?.length > 0 && !parsed.selectedChunkId) { parsed.selectedChunkId = parsed.chunks[0].id; } const resume = window.confirm('A saved study exists for this chapter. Resume studying? Press OK to continue studying, or Cancel to edit chunks.'); setProject(parsed); setSetup({ translation: parsed.translation, book: parsed.book, bookAbbrev: parsed.bookAbbrev, chapter: parsed.chapter, title: parsed.title, }); setCurrentPage(resume ? 'study' : 'setup'); } } catch { // Ignore invalid saved data. } }, []); useEffect(() => { if (!project) { return; } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { const key = storageKey(project.translation, project.bookAbbrev, project.chapter); window.localStorage.setItem(key, JSON.stringify(project)); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); }, 1000); return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); } }; }, [project]); 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 createProjectFromChapter = (verses) => ({ translation: setup.translation, book: setup.book, bookAbbrev: setup.bookAbbrev, chapter: setup.chapter, title: setup.title, verses, chunks: [], selectedChunkId: null, }); 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.'); } const key = storageKey(setup.translation, setup.bookAbbrev, setup.chapter); const saved = window.localStorage.getItem(key); if (saved) { const parsed = JSON.parse(saved); if (parsed && parsed.bookAbbrev === setup.bookAbbrev && parsed.chapter === setup.chapter) { if (parsed.chunks?.length > 0 && !parsed.selectedChunkId) { parsed.selectedChunkId = parsed.chunks[0].id; } const resume = window.confirm('A saved study exists for this chapter. Resume saved project?'); if (resume) { setProject(parsed); setSetup({ translation: parsed.translation, book: parsed.book, bookAbbrev: parsed.bookAbbrev, chapter: parsed.chapter, title: parsed.title, }); setCurrentPage('study'); setLoadingChapter(false); return; } } } setProject(createProjectFromChapter(verses)); 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 || project.chunks.length === 0) return; updateProject((current) => ({ ...current, selectedChunkId: current.selectedChunkId || current.chunks[0]?.id || null, })); setCurrentPage('study'); }; const goToPreviousChunk = () => { if (!project?.chunks.length || !project.selectedChunkId) return; const index = project.chunks.findIndex((chunk) => chunk.id === project.selectedChunkId); if (index > 0) { updateProject((current) => ({ ...current, selectedChunkId: current.chunks[index - 1].id })); } }; const goToNextChunk = () => { if (!project?.chunks.length || !project.selectedChunkId) return; const index = project.chunks.findIndex((chunk) => chunk.id === project.selectedChunkId); if (index >= 0 && index < project.chunks.length - 1) { updateProject((current) => ({ ...current, selectedChunkId: current.chunks[index + 1].id })); } }; const addChunk = (start, end) => { if (!project) { return; } const overlap = project.chunks.find((chunk) => chunk.startVerse === start && chunk.endVerse === end); if (overlap) { setStatusMessage('That chunk already exists.'); window.setTimeout(() => setStatusMessage(''), 1800); return; } const newChunk = { id: makeId(), startVerse: start, endVerse: end, notes: '', greekWords: [], }; updateProject((current) => ({ ...current, chunks: [...current.chunks, newChunk], selectedChunkId: newChunk.id, })); }; 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 selectedChunk = project?.chunks.find((chunk) => chunk.id === project.selectedChunkId) ?? project?.chunks[0] ?? null; const updateChunk = (chunkId, patch) => { updateProject((current) => ({ ...current, chunks: current.chunks.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...patch } : chunk)), })); }; const updateChunkWord = (chunkId, wordId, patch) => { updateProject((current) => ({ ...current, chunks: current.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, chunks: current.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: [...chunk.greekWords, newWord] } : chunk ), })); }; const removeGreekWord = (chunkId, wordId) => { updateProject((current) => ({ ...current, chunks: current.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: chunk.greekWords.filter((word) => word.id !== wordId) } : chunk ), })); }; const lookupGreekWord = async (chunkId, wordId) => { const chunk = project?.chunks.find((item) => item.id === chunkId); const word = chunk?.greekWords.find((item) => item.id === wordId); if (!word || !word.query.trim()) { return; } updateChunkWord(chunkId, wordId, { loading: true }); try { const query = encodeURIComponent(word.query.trim()); const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${query}/`); if (!response.ok) { throw new Error('Lookup failed.'); } const definitions = await response.json(); if (!Array.isArray(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 || word.query, lexeme: first.lexeme || '', transliteration: first.transliteration || '', partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '', shortDefinition: first.short_definition || '', definitionHtml: first.definition || '', }); } catch (error) { updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.', }); } finally { updateChunkWord(chunkId, wordId, { loading: false }); } }; const moveChunk = (chunkId, direction) => { updateProject((current) => { const index = current.chunks.findIndex((chunk) => chunk.id === chunkId); if (index < 0) return current; const nextIndex = index + direction; if (nextIndex < 0 || nextIndex >= current.chunks.length) return current; const updated = [...current.chunks]; const [removed] = updated.splice(index, 1); updated.splice(nextIndex, 0, removed); return { ...current, chunks: updated, }; }); }; const deleteChunk = (chunkId) => { updateProject((current) => { const remaining = current.chunks.filter((chunk) => chunk.id !== chunkId); const nextSelected = current.selectedChunkId === chunkId ? remaining[0]?.id ?? null : current.selectedChunkId; return { ...current, chunks: remaining, selectedChunkId: nextSelected, }; }); }; 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.book}-${project.chapter}-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} — ${project.book} ${project.chapter}`, spacing: { after: 300 }, }), ]; project.chunks.forEach((chunk) => { const scriptureHeading = chunk.startVerse === chunk.endVerse ? `${project.book} ${project.chapter}:${chunk.startVerse}` : `${project.book} ${project.chapter}:${chunk.startVerse}-${chunk.endVerse}`; children.push(new Paragraph({ text: scriptureHeading, heading: HeadingLevel.HEADING_2 })); project.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: 'STUDY NOTES:', spacing: { before: 240, after: 120 }, bold: true })); children.push(...createParagraphsFromText(chunk.notes || 'No notes.')); 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.book}-${project.chapter}-study.docx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const verseLabel = (start, end) => (start === end ? `${start}` : `${start}-${end}`); return (
{project && currentPage === 'study' ? ( ) : null}

Bible Study Project

{project ? project.title : 'Create a new chapter study'}

{project && ( )} {project && (
)}
{loadingChapter ? Loading… : saveStatus ? {saveStatus} :  }
{!project ? (

Project Setup

Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.

{errorMessage ? {errorMessage} : 'Start by loading the chapter text from HelloAO.'}
) : currentPage === 'setup' ? (

Project Setup

Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.

{errorMessage ? {errorMessage} : 'Start by loading the chapter text from HelloAO.'}

Scripture & Chunks

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

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

Chunks

{project.chunks.length} created
{project.chunks.length === 0 ? (
No chunks yet. Select verse ranges to add sections.
) : ( project.chunks.map((chunk, index) => (
)) )}
) : (

Chunk editor

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

{selectedChunk ? `Chunk ${project.chunks.findIndex((chunk) => chunk.id === selectedChunk.id) + 1} of ${project.chunks.length}` : 'Choose a chunk to study.'}
{selectedChunk ? (

Scripture

Read-only passage for the selected chunk.

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

{verse.number}. {verse.text}

))}

Study Notes

Write your observations, cross-references, and teaching notes here.