From dff6ec29839c15e3845defe79afe592fcd08a1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:28:41 +0000 Subject: [PATCH] Add project dashboard, OIA notes, cross-references, and multi-chapter support - Home page (project list/dashboard): projects stored in localStorage index, Resume/Delete per project, sorted by last-edited date - OIA notes: replace single `notes` field with observation/interpretation/application textareas on each chunk following inductive study method - Cross-references: string tag array per chunk with inline add/remove UI, included in HTML and DOCX exports and Claude prompt - Multi-chapter projects: chapters array on project, chapter tabs in setup view, chapter-grouped sidebar in study view, global chunk navigation across chapters - Migration: migrateChunk/migrateProject exported helpers auto-upgrade old localStorage entries on startup - Tests updated: new baseProject fixture, 11 additional tests (99 total) --- src/App.jsx | 2070 ++++++++++++++++++++++++++++----------------- src/App.test.jsx | 27 +- src/utils.test.js | 218 +++-- 3 files changed, 1474 insertions(+), 841 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e1ae4aa..9b5434f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -41,19 +41,163 @@ const bookOptions = [ { 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; } - .notes, .greek { margin-top: 1rem; } + .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; } @@ -61,16 +205,27 @@ export function buildExportHtml(project) { .definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; } `; - const chunksHtml = project.chunks - .map((chunk) => { + 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 - ? `${project.book} ${project.chapter}:${chunk.startVerse}` - : `${project.book} ${project.chapter}:${chunk.startVerse}-${chunk.endVerse}`; - const versesText = project.verses + ? `${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 notes = chunk.notes.trim().replace(/\n/g, '
') || 'No notes.'; + + 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} @@ -90,19 +245,26 @@ export function buildExportHtml(project) { `) .join(''); + return `
${scripture}
${versesText} -
STUDY NOTES:

${notes}

+
+
OBSERVATION:

${observation}

+
INTERPRETATION:

${interpretation}

+
APPLICATION:

${application}

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

No Greek word notes.

'} ${extendedDefinitions}
`; - }) - .join(''); + }).join(''); + return chapterHeader + chunkSections; + }).join(''); return ` @@ -115,7 +277,7 @@ export function buildExportHtml(project) {

${project.title}

-

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

+

${project.translation}

${chunksHtml}
@@ -199,9 +361,12 @@ export function renderVerseContent(content) { } export function buildClaudePrompt(project) { - const header = `I've prepared a Bible study on ${project.book} ${project.chapter} (${project.translation}) and need your help turning my notes into a polished study guide. + const chapters = Array.isArray(project.chapters) ? project.chapters : []; + const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); -Below is my work organised by passage chunk, including my study notes and Greek word research. Please create a clear, structured study guide that: + 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 @@ -211,51 +376,69 @@ Below is my work organised by passage chunk, including my study notes and Greek PROJECT: ${project.title} TRANSLATION: ${project.translation} -PASSAGE: ${project.book} ${project.chapter} +PASSAGE: ${chapterLabel} `; - const chunks = project.chunks.map((chunk, index) => { - const ref = chunk.startVerse === chunk.endVerse - ? `${project.book} ${project.chapter}:${chunk.startVerse}` - : `${project.book} ${project.chapter}:${chunk.startVerse}–${chunk.endVerse}`; + 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 = project.verses - .filter((v) => v.number >= chunk.startVerse && v.number <= chunk.endVerse) - .map((v) => `${v.number} ${v.text}`) - .join('\n'); + const verses = ch.verses + .filter((v) => v.number >= chunk.startVerse && v.number <= chunk.endVerse) + .map((v) => `${v.number} ${v.text}`) + .join('\n'); - const notes = chunk.notes.trim() || 'No notes.'; + const observation = (chunk.observation ?? '').trim() || 'No observation.'; + const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.'; + const application = (chunk.application ?? '').trim() || 'No application.'; - 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'); + const crossRefs = (chunk.crossReferences ?? []).length > 0 + ? chunk.crossReferences.join(', ') + : 'None.'; - return `=== + 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'); -CHUNK ${index + 1} — ${ref} + return `=== + +CHUNK ${chunkIndex} — ${ref} Scripture: ${verses} -My Notes: -${notes} +Observation: +${observation} + +Interpretation: +${interpretation} + +Application: +${application} + +Cross-References: ${crossRefs} Greek Words: ${greekWords} `; + }).join('\n'); }).join('\n'); return header + chunks; @@ -282,6 +465,10 @@ export function parseBibleChapter(data) { })); } +// --------------------------------------------------------------------------- +// App component +// --------------------------------------------------------------------------- + const App = () => { const [availableTranslations, setAvailableTranslations] = useState(['BSB']); const [setup, setSetup] = useState({ @@ -293,7 +480,12 @@ const App = () => { }); const [titleEdited, setTitleEdited] = useState(false); const [project, setProject] = useState(null); - const [currentPage, setCurrentPage] = useState('setup'); + // '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); @@ -302,6 +494,14 @@ const App = () => { 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()) @@ -310,9 +510,7 @@ const App = () => { setAvailableTranslations(data); } }) - .catch(() => { - // Keep default translation if API fails. - }); + .catch(() => {}); }, []); useEffect(() => { @@ -324,54 +522,38 @@ const App = () => { } }, [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. - } - }, []); + // 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(() => { - if (!project) { - return; - } - if (saveTimerRef.current) { - window.clearTimeout(saveTimerRef.current); - } + setCrossRefInput(''); + }, [project?.selectedChunkId]); + + // Autosave + 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)); + saveProjectToStorage(project); + setProjectIndex(loadProjectIndex()); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); }, 1000); return () => { - if (saveTimerRef.current) { - window.clearTimeout(saveTimerRef.current); - } + 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]; @@ -381,24 +563,10 @@ const App = () => { bookAbbrev: selected.abbrev, })); } else { - setSetup((current) => ({ - ...current, - [field]: value, - })); + 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.'); @@ -407,41 +575,62 @@ const App = () => { 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 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; - } + + 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'); } - setProject(createProjectFromChapter(verses)); - setCurrentPage('setup'); } catch (error) { setErrorMessage(error.message || 'Failed to load chapter.'); } finally { @@ -454,52 +643,73 @@ const App = () => { }; const beginStudying = () => { - if (!project || project.chunks.length === 0) return; + if (!project || allChunks.length === 0) return; updateProject((current) => ({ ...current, - selectedChunkId: current.selectedChunkId || current.chunks[0]?.id || null, + selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null, })); setCurrentPage('study'); }; + // --------------------------------------------------------------------------- + // Chunk navigation + // --------------------------------------------------------------------------- + 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 })); - } + if (selectedChunkGlobalIndex <= 0) return; + updateProject((current) => ({ + ...current, + selectedChunkId: allChunks[selectedChunkGlobalIndex - 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 })); - } + 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 overlap = project.chunks.find((chunk) => chunk.startVerse === start && chunk.endVerse === 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; } - const newChunk = { - id: makeId(), - startVerse: start, - endVerse: end, - notes: '', - greekWords: [], - }; - updateProject((current) => ({ - ...current, - chunks: [...current.chunks, newChunk], - selectedChunkId: newChunk.id, - })); + addChunkToChapter(activeChapterIndex, start, end); }; const handleVerseClick = (verseNumber, event) => { @@ -515,25 +725,90 @@ const App = () => { 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)), + 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, - chunks: current.chunks.map((chunk) => { - if (chunk.id !== chunkId) return chunk; - return { - ...chunk, - greekWords: chunk.greekWords.map((word) => (word.id === wordId ? { ...word, ...patch } : word)), - }; - }), + 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, + ), + }; + }), + })), })); }; @@ -551,38 +826,40 @@ const App = () => { }; updateProject((current) => ({ ...current, - chunks: current.chunks.map((chunk) => - chunk.id === chunkId - ? { ...chunk, greekWords: [...chunk.greekWords, newWord] } - : chunk - ), + 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, - chunks: current.chunks.map((chunk) => - chunk.id === chunkId - ? { ...chunk, greekWords: chunk.greekWords.filter((word) => word.id !== wordId) } - : chunk - ), + 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 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; - } + 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 { 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.'); - } + if (!response.ok) throw new Error('Lookup failed.'); const definitions = await response.json(); if (!Array.isArray(definitions) || definitions.length === 0) { updateChunkWord(chunkId, wordId, { @@ -602,42 +879,16 @@ const App = () => { shortDefinition: first.short_definition || '', definitionHtml: first.definition || '', }); - } catch (error) { - updateChunkWord(chunkId, wordId, { - shortDefinition: 'Lookup failed.', - }); + } catch { + 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, - }; - }); - }; + // --------------------------------------------------------------------------- + // Export + // --------------------------------------------------------------------------- const exportChapter = () => { if (!project) return; @@ -646,7 +897,7 @@ const App = () => { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `${project.book}-${project.chapter}-study.html`; + link.download = `${project.title.replace(/\s+/g, '-')}-study.html`; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -656,70 +907,78 @@ const App = () => { const exportChapterDocx = async () => { if (!project) return; const children = [ + new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }), new Paragraph({ - text: project.title, - heading: HeadingLevel.TITLE, - }), - new Paragraph({ - text: `${project.translation} — ${project.book} ${project.chapter}`, + text: `${project.translation} — ${buildChapterSummary(project)}`, 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}`; + 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 })); - 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: 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: '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 })); + 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.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 })], + 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 || '')] }), + ], })), - }), - ...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 } })); + ]; + 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 }] }); @@ -727,7 +986,7 @@ const App = () => { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `${project.book}-${project.chapter}-study.docx`; + link.download = `${project.title.replace(/\s+/g, '-')}-study.docx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -743,565 +1002,800 @@ const App = () => { }); }; + // --------------------------------------------------------------------------- + // 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}`); - return ( -
-
-
-
- {project && currentPage === 'study' ? ( - - ) : null} + // --------------------------------------------------------------------------- + // Shared header + // --------------------------------------------------------------------------- + const headerButtons = ( +
+ {currentPage !== 'home' && ( + + )} + {project && currentPage === 'study' && ( + + )} + {project && ( +
+ + + +
+ )} +
+ {loadingChapter ? ( + Loading… + ) : saveStatus ? ( + {saveStatus} + ) : ( +   + )} +
+
+ ); + + // --------------------------------------------------------------------------- + // HOME PAGE + // --------------------------------------------------------------------------- + if (currentPage === 'home') { + return ( +
+
+

Bible Study Project

-

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

+

My Studies

+
-
- {project && ( +
+
+ {projectIndex.length === 0 ? ( +
+

No projects yet

+

Start a new Bible study to get going.

- )} - {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) => ( -
- -
- - - -
-
- )) +
+

{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}

- - ); - }) - )} -
- -
-
-
-

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.

-
+ {/* 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 }) => ( +
+ +