Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Studies now move through a stage bar in the reader's themes. Outline splits chunks by clicking between verses (join merges notes) and suggests chunks from section headings. Study is three panes: outline, passage with an Observe / Interpret / Apply accordion, and commentary / word study / cross-refs / people & places for the chunk; original words are added by clicking them in the interlinear. Script drafts an outline from notes; Export holds files and the share link. Home, Import and the Pronunciation guide are restyled. Study projects are BSB only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
664 lines
23 KiB
JavaScript
664 lines
23 KiB
JavaScript
// 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,
|
|
};
|
|
}
|