From 0f1a9c3d5b7a3ae35f4f90d5c96d065c2b5b95bb Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:01:53 -0400 Subject: [PATCH] Move chunk builder into SetupPage via useChunkBuilder - The setup form, active chapter tab, verse range selection, typed/bulk/ cross-chapter chunk creation, reorder, and delete move out of App. - Setup now opens on the chapter holding the chunk last studied (e.g. after Back to Chunks), and the Add Chapter form suggests the chapter after the project's last one instead of repeating the current one. - openNewProject/goHome/resume/import no longer reset setup state by hand; SetupPage starts fresh each time it opens. - Tests: add-chapter default, Back to Chunks chapter. Co-Authored-By: Claude Opus 5.5 --- src/App.jsx | 511 +------------------------------- src/App.test.jsx | 29 ++ src/hooks/useChunkBuilder.js | 521 +++++++++++++++++++++++++++++++++ src/hooks/useProjectLibrary.js | 2 - src/pages/ImportPage.jsx | 3 +- src/pages/SetupPage.jsx | 7 +- 6 files changed, 555 insertions(+), 518 deletions(-) create mode 100644 src/hooks/useChunkBuilder.js diff --git a/src/App.jsx b/src/App.jsx index b5ef250..d26a4af 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,10 +9,7 @@ import { useSharedView } from './hooks/useSharedView.js'; import HomePage from './pages/HomePage.jsx'; import { - bookOptions, - makeId, buildExportHtml, - parseBibleChapter, loadProjectIndex, switchStorageUser, } from './utils/bibleUtils.jsx'; @@ -45,31 +42,11 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); // --------------------------------------------------------------------------- const App = () => { - 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' | 'settings' const [currentPage, setCurrentPage] = useState('home'); const [projectIndex, setProjectIndex] = useState([]); - const [activeChapterIndex, setActiveChapterIndex] = useState(0); - const [showAddChapterForm, setShowAddChapterForm] = useState(false); 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 [typedChunkStart, setTypedChunkStart] = useState(''); - const [typedChunkEnd, setTypedChunkEnd] = useState(''); - const [typedChunkNextEnd, setTypedChunkNextEnd] = useState(''); - const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState(''); - const [typedChunkBulk, setTypedChunkBulk] = useState(''); const saveTimerRef = useRef(null); const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const reader = useBibleReader(currentPage); @@ -95,9 +72,7 @@ const App = () => { setProject, setProjectIndex, setCurrentPage, - setActiveChapterIndex, }); - const [verseSearch, setVerseSearch] = useState(''); const openBibleReader = () => { setCurrentPage('reader'); }; @@ -110,17 +85,7 @@ const App = () => { setProjectIndex(loadProjectIndex()); }, []); - useEffect(() => { - if (!titleEdited) { - setSetup((current) => ({ - ...current, - title: `${current.book} ${current.chapter} Study`, - })); - } - }, [setup.book, setup.chapter, titleEdited]); - const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; - const activeChapter = project?.chapters[activeChapterIndex] ?? null; // Autosave useEffect(() => { if (!project) return; @@ -147,412 +112,10 @@ const App = () => { }; }, [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 CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study) - // --------------------------------------------------------------------------- - - const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => { - 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 - && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), - ); - if (overlap) return ch; - const newChunk = { - id: makeId(), - startVerse: start, - endVerse: end, - spilloverEndVerse, - observation: '', - interpretation: '', - application: '', - crossReferences: [], - greekWords: [], - generalNotes: '', - episodeNumber: '', - episodeTitle: '', - finalScript: '', - tags: [], - }; - 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, spilloverEndVerse = null) => { - if (!project) return; - const chapter = activeChapter; - if (!chapter) return; - const overlap = chapter.chunks.find( - (c) => c.startVerse === start - && c.endVerse === end - && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), - ); - if (overlap) { - setStatusMessage('That chunk already exists.'); - window.setTimeout(() => setStatusMessage(''), 1800); - return; - } - addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse); - }; - - const addChunkRanges = (ranges) => { - if (!project || !activeChapter || ranges.length === 0) { - return { addedCount: 0, skippedCount: 0 }; - } - - let addedCount = 0; - let skippedCount = 0; - let lastAddedChunkId = null; - - updateProject((current) => { - const chapters = current.chapters.map((ch, idx) => { - if (idx !== activeChapterIndex) return ch; - const existing = new Set( - ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`), - ); - const newChunks = []; - - ranges.forEach(({ start, end, spilloverEndVerse = null }) => { - const key = `${start}-${end}-${spilloverEndVerse ?? ''}`; - if (existing.has(key)) { - skippedCount += 1; - return; - } - const newChunk = { - id: makeId(), - startVerse: start, - endVerse: end, - spilloverEndVerse, - observation: '', - interpretation: '', - application: '', - crossReferences: [], - greekWords: [], - tags: [], - }; - existing.add(key); - newChunks.push(newChunk); - addedCount += 1; - lastAddedChunkId = newChunk.id; - }); - - return newChunks.length > 0 - ? { ...ch, chunks: [...ch.chunks, ...newChunks] } - : ch; - }); - - return { - ...current, - chapters, - selectedChunkId: lastAddedChunkId ?? current.selectedChunkId, - }; - }); - - return { addedCount, skippedCount }; - }; - - const addTypedChunk = () => { - if (!activeChapter) return; - const start = Number.parseInt(typedChunkStart, 10); - const end = Number.parseInt(typedChunkEnd, 10); - const nextEndRaw = typedChunkNextEnd.trim(); - const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null; - const maxVerse = activeChapter.verses.at(-1)?.number ?? 0; - const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; - const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0; - - if (!Number.isInteger(start) || !Number.isInteger(end)) { - setStatusMessage('Type a valid start and end verse.'); - window.setTimeout(() => setStatusMessage(''), 2200); - return; - } - if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) { - setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`); - window.setTimeout(() => setStatusMessage(''), 2200); - return; - } - if (start > end) { - setStatusMessage('Start verse must be less than or equal to end verse.'); - window.setTimeout(() => setStatusMessage(''), 2200); - return; - } - - if (nextEndRaw) { - if (!nextChapter) { - setStatusMessage('Add the next chapter first to span chunks across chapters.'); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) { - setStatusMessage('Chapter spanning only works into the next chapter of the same book.'); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) { - setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - if (end !== maxVerse) { - setStatusMessage('To span chapters, set End to the last verse of this chapter.'); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - } - - const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]); - if (addedCount === 0) { - setStatusMessage('That chunk already exists.'); - window.setTimeout(() => setStatusMessage(''), 2200); - return; - } - - setTypedChunkStart(''); - setTypedChunkEnd(''); - setTypedChunkNextEnd(''); - }; - - const addBulkTypedChunks = () => { - if (!activeChapter) return; - const raw = typedChunkBulk.trim(); - if (!raw) { - setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12'); - window.setTimeout(() => setStatusMessage(''), 2200); - return; - } - - const maxVerse = activeChapter.verses.at(-1)?.number ?? 0; - const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; - const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0; - const parts = raw - .split(/[\n,;]+/) - .map((part) => part.trim()) - .filter(Boolean); - - const validRanges = []; - let invalidCount = 0; - - parts.forEach((part) => { - const single = /^(\d+)$/.exec(part); - if (single) { - const v = Number.parseInt(single[1], 10); - if (v >= 1 && v <= maxVerse) { - validRanges.push({ start: v, end: v }); - } else { - invalidCount += 1; - } - return; - } - - const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part); - if (!range) { - invalidCount += 1; - return; - } - - const start = Number.parseInt(range[1], 10); - const end = Number.parseInt(range[2], 10); - const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null; - if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) { - invalidCount += 1; - return; - } - if (spilloverEndVerse !== null) { - if (!nextChapter - || nextChapter.bookAbbrev !== activeChapter.bookAbbrev - || spilloverEndVerse < 1 - || spilloverEndVerse > nextMaxVerse - || end !== maxVerse) { - invalidCount += 1; - return; - } - } - validRanges.push({ start, end, spilloverEndVerse }); - }); - - if (validRanges.length === 0) { - setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`); - window.setTimeout(() => setStatusMessage(''), 2600); - return; - } - - const { addedCount, skippedCount } = addChunkRanges(validRanges); - const notes = []; - if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`); - if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`); - if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`); - setStatusMessage(notes.join(' • ')); - window.setTimeout(() => setStatusMessage(''), 2800); - - if (addedCount > 0) { - setTypedChunkBulk(''); - } - }; - - const addClickSpanChunk = () => { - if (!activeChapter || rangeStart === null) { - setStatusMessage('Click a start verse first, then set next chapter end.'); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - - const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; - if (!nextChapter) { - setStatusMessage('Add the next chapter first to span into it.'); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) { - setStatusMessage('Click-based spanning only works into the next chapter of the same book.'); - window.setTimeout(() => setStatusMessage(''), 2600); - return; - } - - const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0; - const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10); - if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) { - setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - - const start = rangeStart; - const end = activeChapter.verses.at(-1)?.number ?? start; - const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]); - if (addedCount === 0) { - setStatusMessage('That cross-chapter chunk already exists.'); - window.setTimeout(() => setStatusMessage(''), 2400); - return; - } - - setClickedSpanNextEnd(''); - setRangeStart(null); - setRangeEnd(null); - setStatusMessage('Cross-chapter chunk added.'); - window.setTimeout(() => setStatusMessage(''), 1800); - }; - - 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, @@ -563,55 +126,12 @@ const App = () => { })); }; - 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), - })), - }; - }); - }; - // --------------------------------------------------------------------------- // Home page helpers // --------------------------------------------------------------------------- const openNewProject = () => { setProject(null); - setActiveChapterIndex(0); - setShowAddChapterForm(false); - setRangeStart(null); - setRangeEnd(null); - setErrorMessage(''); - setStatusMessage(''); - setTitleEdited(false); setCurrentPage('setup'); }; @@ -624,10 +144,6 @@ const App = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); setProject(null); - setRangeStart(null); - setRangeEnd(null); - setErrorMessage(''); - setStatusMessage(''); }; // Manual retry after a failed background sync (shown in the project header). @@ -668,34 +184,9 @@ const App = () => { ...audio, // Reader ...reader, - // Setup + // Setup (chunk-builder state lives in SetupPage via useChunkBuilder) project, setProject, - setup, - titleEdited, setTitleEdited, - activeChapterIndex, setActiveChapterIndex, - activeChapter, - showAddChapterForm, setShowAddChapterForm, - rangeStart, setRangeStart, - rangeEnd, setRangeEnd, - verseSearch, setVerseSearch, - typedChunkStart, setTypedChunkStart, - typedChunkEnd, setTypedChunkEnd, - typedChunkNextEnd, setTypedChunkNextEnd, - typedChunkBulk, setTypedChunkBulk, - clickedSpanNextEnd, setClickedSpanNextEnd, - loadingChapter, - errorMessage, - statusMessage, allChunks, - handleSetupField, - handleLoadChapter, - beginStudying, - handleVerseClick, - addTypedChunk, - addBulkTypedChunks, - addClickSpanChunk, - moveChunk, - deleteChunk, updateProject, // Study (editor state lives in StudyPage and its hooks) updateChunk, diff --git a/src/App.test.jsx b/src/App.test.jsx index 1eafa4f..4c1eeab 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -671,3 +671,32 @@ describe('Study editor', () => { expect(input).toHaveValue(''); }); }); + +// --------------------------------------------------------------------------- +// Chunk builder (useChunkBuilder, local to SetupPage) +// --------------------------------------------------------------------------- +describe('Chunk builder', () => { + async function addSecondChapter(user) { + await user.click(screen.getByRole('button', { name: /add chapter/i })); + // The add-chapter form starts on the chapter after the project's last one. + expect(screen.getByDisplayValue('2')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /load chapter/i })); + return screen.findByRole('button', { name: 'Titus 2' }); + } + + test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => { + const user = await loadChapter(); + expect(await addSecondChapter(user)).toHaveClass('bg-slate-900'); + }); + + test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => { + const user = await loadChapter(); + await addSecondChapter(user); + addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected) + await user.click(screen.getByRole('button', { name: 'Titus 1' })); + await user.click(screen.getByRole('button', { name: /begin studying/i })); + await screen.findByText(/chunk editor/i); + await user.click(screen.getByRole('button', { name: /back to chunks/i })); + expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900'); + }); +}); diff --git a/src/hooks/useChunkBuilder.js b/src/hooks/useChunkBuilder.js new file mode 100644 index 0000000..bd0c1c0 --- /dev/null +++ b/src/hooks/useChunkBuilder.js @@ -0,0 +1,521 @@ +// The Setup page's chunk builder: the new-project / add-chapter form, the +// active chapter tab, and every way of creating chunks (click, shift-click, +// typed range, bulk ranges, cross-chapter spans), plus reorder and delete. +import { useEffect, useState } from 'react'; +import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'; + +// A new project starts on Titus 1. When adding to an open project, start on +// the chapter after its last one (same book and translation). +function initialSetupForm(project) { + const last = project?.chapters?.at(-1); + if (!last) { + return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study' }; + } + const next = Math.min(Number(last.chapter) + 1, CHAPTER_COUNTS[last.bookAbbrev] ?? Number(last.chapter)); + return { + translation: project.translation ?? 'BSB', + book: last.book, + bookAbbrev: last.bookAbbrev, + chapter: String(next), + title: project.title, + }; +} + +// Open on the chapter holding the chunk last studied (e.g. after "Back to Chunks"). +function initialChapterIndex(project) { + const index = project?.chapters?.findIndex((ch) => ch.chunks.some((c) => c.id === project.selectedChunkId)) ?? -1; + return index >= 0 ? index : 0; +} + +export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) { + const [setup, setSetup] = useState(() => initialSetupForm(project)); + const [titleEdited, setTitleEdited] = useState(false); + const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project)); + const [showAddChapterForm, setShowAddChapterForm] = useState(false); + const [rangeStart, setRangeStart] = useState(null); + const [rangeEnd, setRangeEnd] = useState(null); + const [loadingChapter, setLoadingChapter] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [typedChunkStart, setTypedChunkStart] = useState(''); + const [typedChunkEnd, setTypedChunkEnd] = useState(''); + const [typedChunkNextEnd, setTypedChunkNextEnd] = useState(''); + const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState(''); + const [typedChunkBulk, setTypedChunkBulk] = useState(''); + const [verseSearch, setVerseSearch] = useState(''); + + useEffect(() => { + if (!titleEdited) { + setSetup((current) => ({ + ...current, + title: `${current.book} ${current.chapter} Study`, + })); + } + }, [setup.book, setup.chapter, titleEdited]); + + // Each time the add-chapter form opens, suggest the chapter after the last one. + useEffect(() => { + if (showAddChapterForm) setSetup(initialSetupForm(project)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showAddChapterForm]); + + const activeChapter = project?.chapters[activeChapterIndex] ?? null; + + 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 beginStudying = () => { + if (!project || allChunks.length === 0) return; + updateProject((current) => ({ + ...current, + selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null, + })); + setCurrentPage('study'); + }; + + const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => { + 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 + && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), + ); + if (overlap) return ch; + const newChunk = { + id: makeId(), + startVerse: start, + endVerse: end, + spilloverEndVerse, + observation: '', + interpretation: '', + application: '', + crossReferences: [], + greekWords: [], + generalNotes: '', + episodeNumber: '', + episodeTitle: '', + finalScript: '', + tags: [], + }; + 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, spilloverEndVerse = null) => { + if (!project) return; + const chapter = activeChapter; + if (!chapter) return; + const overlap = chapter.chunks.find( + (c) => c.startVerse === start + && c.endVerse === end + && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), + ); + if (overlap) { + setStatusMessage('That chunk already exists.'); + window.setTimeout(() => setStatusMessage(''), 1800); + return; + } + addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse); + }; + + const addChunkRanges = (ranges) => { + if (!project || !activeChapter || ranges.length === 0) { + return { addedCount: 0, skippedCount: 0 }; + } + + let addedCount = 0; + let skippedCount = 0; + let lastAddedChunkId = null; + + updateProject((current) => { + const chapters = current.chapters.map((ch, idx) => { + if (idx !== activeChapterIndex) return ch; + const existing = new Set( + ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`), + ); + const newChunks = []; + + ranges.forEach(({ start, end, spilloverEndVerse = null }) => { + const key = `${start}-${end}-${spilloverEndVerse ?? ''}`; + if (existing.has(key)) { + skippedCount += 1; + return; + } + const newChunk = { + id: makeId(), + startVerse: start, + endVerse: end, + spilloverEndVerse, + observation: '', + interpretation: '', + application: '', + crossReferences: [], + greekWords: [], + tags: [], + }; + existing.add(key); + newChunks.push(newChunk); + addedCount += 1; + lastAddedChunkId = newChunk.id; + }); + + return newChunks.length > 0 + ? { ...ch, chunks: [...ch.chunks, ...newChunks] } + : ch; + }); + + return { + ...current, + chapters, + selectedChunkId: lastAddedChunkId ?? current.selectedChunkId, + }; + }); + + return { addedCount, skippedCount }; + }; + + const addTypedChunk = () => { + if (!activeChapter) return; + const start = Number.parseInt(typedChunkStart, 10); + const end = Number.parseInt(typedChunkEnd, 10); + const nextEndRaw = typedChunkNextEnd.trim(); + const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null; + const maxVerse = activeChapter.verses.at(-1)?.number ?? 0; + const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; + const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0; + + if (!Number.isInteger(start) || !Number.isInteger(end)) { + setStatusMessage('Type a valid start and end verse.'); + window.setTimeout(() => setStatusMessage(''), 2200); + return; + } + if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) { + setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`); + window.setTimeout(() => setStatusMessage(''), 2200); + return; + } + if (start > end) { + setStatusMessage('Start verse must be less than or equal to end verse.'); + window.setTimeout(() => setStatusMessage(''), 2200); + return; + } + + if (nextEndRaw) { + if (!nextChapter) { + setStatusMessage('Add the next chapter first to span chunks across chapters.'); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) { + setStatusMessage('Chapter spanning only works into the next chapter of the same book.'); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) { + setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + if (end !== maxVerse) { + setStatusMessage('To span chapters, set End to the last verse of this chapter.'); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + } + + const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]); + if (addedCount === 0) { + setStatusMessage('That chunk already exists.'); + window.setTimeout(() => setStatusMessage(''), 2200); + return; + } + + setTypedChunkStart(''); + setTypedChunkEnd(''); + setTypedChunkNextEnd(''); + }; + + const addBulkTypedChunks = () => { + if (!activeChapter) return; + const raw = typedChunkBulk.trim(); + if (!raw) { + setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12'); + window.setTimeout(() => setStatusMessage(''), 2200); + return; + } + + const maxVerse = activeChapter.verses.at(-1)?.number ?? 0; + const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; + const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0; + const parts = raw + .split(/[\n,;]+/) + .map((part) => part.trim()) + .filter(Boolean); + + const validRanges = []; + let invalidCount = 0; + + parts.forEach((part) => { + const single = /^(\d+)$/.exec(part); + if (single) { + const v = Number.parseInt(single[1], 10); + if (v >= 1 && v <= maxVerse) { + validRanges.push({ start: v, end: v }); + } else { + invalidCount += 1; + } + return; + } + + const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part); + if (!range) { + invalidCount += 1; + return; + } + + const start = Number.parseInt(range[1], 10); + const end = Number.parseInt(range[2], 10); + const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null; + if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) { + invalidCount += 1; + return; + } + if (spilloverEndVerse !== null) { + if (!nextChapter + || nextChapter.bookAbbrev !== activeChapter.bookAbbrev + || spilloverEndVerse < 1 + || spilloverEndVerse > nextMaxVerse + || end !== maxVerse) { + invalidCount += 1; + return; + } + } + validRanges.push({ start, end, spilloverEndVerse }); + }); + + if (validRanges.length === 0) { + setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`); + window.setTimeout(() => setStatusMessage(''), 2600); + return; + } + + const { addedCount, skippedCount } = addChunkRanges(validRanges); + const notes = []; + if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`); + if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`); + if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`); + setStatusMessage(notes.join(' • ')); + window.setTimeout(() => setStatusMessage(''), 2800); + + if (addedCount > 0) { + setTypedChunkBulk(''); + } + }; + + const addClickSpanChunk = () => { + if (!activeChapter || rangeStart === null) { + setStatusMessage('Click a start verse first, then set next chapter end.'); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + + const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; + if (!nextChapter) { + setStatusMessage('Add the next chapter first to span into it.'); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) { + setStatusMessage('Click-based spanning only works into the next chapter of the same book.'); + window.setTimeout(() => setStatusMessage(''), 2600); + return; + } + + const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0; + const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10); + if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) { + setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + + const start = rangeStart; + const end = activeChapter.verses.at(-1)?.number ?? start; + const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]); + if (addedCount === 0) { + setStatusMessage('That cross-chapter chunk already exists.'); + window.setTimeout(() => setStatusMessage(''), 2400); + return; + } + + setClickedSpanNextEnd(''); + setRangeStart(null); + setRangeEnd(null); + setStatusMessage('Cross-chapter chunk added.'); + window.setTimeout(() => setStatusMessage(''), 1800); + }; + + 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 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), + })), + }; + }); + }; + + return { + setup, + titleEdited, setTitleEdited, + activeChapterIndex, setActiveChapterIndex, + activeChapter, + showAddChapterForm, setShowAddChapterForm, + rangeStart, setRangeStart, + rangeEnd, setRangeEnd, + verseSearch, setVerseSearch, + typedChunkStart, setTypedChunkStart, + typedChunkEnd, setTypedChunkEnd, + typedChunkNextEnd, setTypedChunkNextEnd, + typedChunkBulk, setTypedChunkBulk, + clickedSpanNextEnd, setClickedSpanNextEnd, + loadingChapter, + errorMessage, + statusMessage, + handleSetupField, + handleLoadChapter, + beginStudying, + handleVerseClick, + addTypedChunk, + addBulkTypedChunks, + addClickSpanChunk, + moveChunk, + deleteChunk, + }; +} diff --git a/src/hooks/useProjectLibrary.js b/src/hooks/useProjectLibrary.js index 39bf8b8..28753c3 100644 --- a/src/hooks/useProjectLibrary.js +++ b/src/hooks/useProjectLibrary.js @@ -20,7 +20,6 @@ export function useProjectLibrary({ setProject, setProjectIndex, setCurrentPage, - setActiveChapterIndex, }) { const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast @@ -67,7 +66,6 @@ export function useProjectLibrary({ const loaded = loadProjectById(id); if (!loaded) return; setProject(loaded); - setActiveChapterIndex(0); const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks); const hasSelectedChunk = loaded.selectedChunkId && loadedChunks.some((c) => c.id === loaded.selectedChunkId); diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx index 45a24c1..ecad741 100644 --- a/src/pages/ImportPage.jsx +++ b/src/pages/ImportPage.jsx @@ -6,7 +6,7 @@ import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx' import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js'; export default function ImportPage() { - const { setCurrentPage, setProject, setActiveChapterIndex } = useApp(); + const { setCurrentPage, setProject } = useApp(); const availableTranslations = useAvailableTranslations(); const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev); const [importTranslation, setImportTranslation] = useState('BSB'); @@ -141,7 +141,6 @@ export default function ImportPage() { chapters, }; setProject(newProject); - setActiveChapterIndex(0); setCurrentPage('setup'); } catch (error) { setImportError(error.message || 'Import failed.'); diff --git a/src/pages/SetupPage.jsx b/src/pages/SetupPage.jsx index 32776c0..2821d34 100644 --- a/src/pages/SetupPage.jsx +++ b/src/pages/SetupPage.jsx @@ -1,4 +1,5 @@ import { useApp } from '../context/AppContext.js'; +import { useChunkBuilder } from '../hooks/useChunkBuilder.js'; import ProjectHeader from '../components/ProjectHeader.jsx'; import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js'; import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx'; @@ -81,8 +82,8 @@ function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, } export default function SetupPage() { + const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp(); const { - project, setup, titleEdited, setTitleEdited, activeChapterIndex, setActiveChapterIndex, @@ -99,7 +100,6 @@ export default function SetupPage() { loadingChapter, errorMessage, statusMessage, - allChunks, handleSetupField, handleLoadChapter, beginStudying, @@ -109,8 +109,7 @@ export default function SetupPage() { addClickSpanChunk, moveChunk, deleteChunk, - updateProject, - } = useApp(); + } = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }); const availableTranslations = useAvailableTranslations(); const chapterTabs = project?.chapters ?? [];