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 ?? [];