// 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'; // Every chunk starts blank; all note fields exist from the start. export function blankChunk(start, end, spilloverEndVerse = null) { return { id: makeId(), startVerse: start, endVerse: end, spilloverEndVerse, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], }; } const joinText = (a, b) => [a, b].filter((s) => (s ?? '').trim()).join('\n\n'); // Merges chunk `b` (the one after `a`) into `a`, keeping a's id and combining notes, // words, cross-references and tags so joining never loses anything. export function mergeChunks(a, b) { const uniq = (list) => [...new Set(list)]; return { ...a, endVerse: b.endVerse, spilloverEndVerse: b.spilloverEndVerse ?? null, observation: joinText(a.observation, b.observation), interpretation: joinText(a.interpretation, b.interpretation), application: joinText(a.application, b.application), generalNotes: joinText(a.generalNotes, b.generalNotes), finalScript: joinText(a.finalScript, b.finalScript), crossReferences: uniq([...(a.crossReferences ?? []), ...(b.crossReferences ?? [])]), greekWords: [...(a.greekWords ?? []), ...(b.greekWords ?? [])], tags: uniq([...(a.tags ?? []), ...(b.tags ?? [])]), episodeNumber: a.episodeNumber || b.episodeNumber || '', episodeTitle: a.episodeTitle || b.episodeTitle || '', }; } // Verse numbers where a section heading starts, from a HelloAO chapter payload. export function headingStarts(chapterData) { const starts = []; let pending = false; for (const item of chapterData?.chapter?.content ?? []) { if (item.type === 'heading') pending = true; else if (item.type === 'verse') { if (pending) starts.push(item.number); pending = false; } } return starts; } // Uncovered runs of verses inside [from, to], given the chunks already there. export function uncoveredRuns(chunks, from, to) { const covered = new Set(); chunks.forEach((c) => { for (let v = c.startVerse; v <= c.endVerse; v += 1) covered.add(v); }); const runs = []; let start = null; for (let v = from; v <= to + 1; v += 1) { const free = v <= to && !covered.has(v); if (free && start === null) start = v; if (!free && start !== null) { runs.push([start, v - 1]); start = null; } } return runs; } // 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 = blankChunk(start, end, spilloverEndVerse); 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), })), }; }); }; // Splits the chunk containing verse `n` into [start..n] and [n+1..end] (the first keeps // its notes). If `n` isn't in any chunk yet, makes one from the last covered verse to `n`. const splitAfterVerse = (n) => { if (!activeChapter) return; updateProject((current) => { let selected = current.selectedChunkId; const chapters = current.chapters.map((ch, idx) => { if (idx !== activeChapterIndex) return ch; const chunks = [...ch.chunks]; const i = chunks.findIndex((c) => c.startVerse <= n && n < c.endVerse && !c.spilloverEndVerse); if (i >= 0) { const c = chunks[i]; chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null }; chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null)); return { ...ch, chunks }; } if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1; const created = blankChunk(start, n); selected = created.id; const at = chunks.findIndex((c) => c.startVerse > n); chunks.splice(at < 0 ? chunks.length : at, 0, created); return { ...ch, chunks }; }); return { ...current, chapters, selectedChunkId: selected }; }); }; // Joins a chunk with the one right after it (adjacent verses), combining their notes. const joinWithNext = (chunkId) => { updateProject((current) => { let selected = current.selectedChunkId; const chapters = current.chapters.map((ch) => { const i = ch.chunks.findIndex((c) => c.id === chunkId); if (i < 0 || i >= ch.chunks.length - 1) return ch; const a = ch.chunks[i]; const b = ch.chunks[i + 1]; if (a.endVerse + 1 !== b.startVerse) return ch; if (selected === b.id) selected = a.id; const chunks = [...ch.chunks]; chunks.splice(i, 2, mergeChunks(a, b)); return { ...ch, chunks }; }); return { ...current, chapters, selectedChunkId: selected }; }); }; // Fills verses that aren't in a chunk yet: one chunk per section heading (or every 8 // verses when the text has no headings). Chunks you already made are left alone. const [suggesting, setSuggesting] = useState(false); const suggestChunks = async () => { if (!activeChapter) return; const notify = (message, ms = 2400) => { setStatusMessage(message); window.setTimeout(() => setStatusMessage(''), ms); }; const last = activeChapter.verses.at(-1)?.number ?? 0; setSuggesting(true); try { let starts = []; try { const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`); if (res.ok) starts = headingStarts(await res.json()); } catch { /* headings are a nicety — fall back to even chunks */ } const usedHeadings = starts.length > 0; const first = activeChapter.verses[0]?.number ?? 1; if (!usedHeadings) for (let v = first; v <= last; v += 8) starts.push(v); const bounds = [...new Set([first, ...starts])].sort((a, b) => a - b); const ranges = []; bounds.forEach((from, k) => { const to = (bounds[k + 1] ?? last + 1) - 1; uncoveredRuns(activeChapter.chunks, from, to).forEach(([s, e]) => ranges.push({ start: s, end: e })); }); if (ranges.length === 0) { notify('Every verse is already in a chunk.'); return; } const { addedCount } = addChunkRanges(ranges); notify(`Added ${addedCount} chunk${addedCount === 1 ? '' : 's'} ${usedHeadings ? 'from section headings' : 'of about 8 verses'}.`); } finally { setSuggesting(false); } }; 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, splitAfterVerse, joinWithNext, suggestChunks, suggesting, }; }