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}
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 `
| Strong's |
Greek |
Transliteration |
Part of Speech |
Short Definition |
${rows}
`;
}
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 ? (
) : currentPage === 'setup' ? (
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.
Greek Word Studies
Add lexical notes, look up Strong's entries, and keep definitions with each chunk.
{selectedChunk.greekWords.length === 0 ? (
No Greek words yet. Add one to begin a lookup.
) : (
selectedChunk.greekWords.map((word) => (
))
)}
) : (
Select a chunk from the left panel to edit its notes and Greek studies.
)}
)}
);
};
export default App;