diff --git a/src/App.jsx b/src/App.jsx index 21b83f6..ae51afa 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -129,7 +129,10 @@ export function migrateChunk(chunk) { function chunkSpansNextChapter(project, startChapterIndex, chunk) { if (!project || !Array.isArray(project.chapters)) return false; if (!Number.isInteger(chunk?.spilloverEndVerse)) return false; - return startChapterIndex >= 0 && startChapterIndex < project.chapters.length - 1; + if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false; + const startChapter = project.chapters[startChapterIndex]; + const nextChapter = project.chapters[startChapterIndex + 1]; + return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev; } function formatChunkReference(project, startChapterIndex, chunk, separator = '-') { @@ -323,15 +326,12 @@ export function buildExportHtml(project) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; - const chunksHtml = chapters.map((ch) => { + const chunksHtml = chapters.map((ch, chapterIndex) => { const chapterHeader = `

${ch.book} ${ch.chapter}

`; const chunkSections = ch.chunks.map((chunk) => { - const scripture = chunk.startVerse === chunk.endVerse - ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` - : `${ch.book} ${ch.chapter}:${chunk.startVerse}-${chunk.endVerse}`; - const versesText = ch.verses - .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) - .map((verse) => `

${verse.number} ${verse.text}

`) + const scripture = formatChunkReference(project, chapterIndex, chunk, '-'); + const versesText = getChunkVerseEntries(project, chapterIndex, chunk) + .map((verse) => `

${verse.chapter}:${verse.number} ${verse.text}

`) .join(''); const observation = (chunk.observation ?? '').trim().replace(/\n/g, '
') || 'No observation.'; @@ -516,16 +516,13 @@ PASSAGE: ${chapterLabel} `; let chunkIndex = 0; - const chunks = chapters.map((ch) => { + const chunks = chapters.map((ch, chapterIndex) => { return ch.chunks.map((chunk) => { chunkIndex += 1; - const ref = chunk.startVerse === chunk.endVerse - ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` - : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`; + const ref = formatChunkReference(project, chapterIndex, chunk, '–'); - const verses = ch.verses - .filter((v) => v.number >= chunk.startVerse && v.number <= chunk.endVerse) - .map((v) => `${v.number} ${v.text}`) + const verses = getChunkVerseEntries(project, chapterIndex, chunk) + .map((v) => `${v.chapter}:${v.number} ${v.text}`) .join('\n'); const observation = (chunk.observation ?? '').trim() || 'No observation.'; @@ -629,6 +626,8 @@ const App = () => { 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' @@ -685,9 +684,15 @@ const App = () => { const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; const activeChapter = project?.chapters[activeChapterIndex] ?? null; const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; - const selectedChunkChapter = selectedChunk - ? project.chapters.find((ch) => ch.chunks.some((c) => c.id === selectedChunk.id)) + const selectedChunkChapterIndex = selectedChunk + ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id)) + : -1; + const selectedChunkChapter = selectedChunkChapterIndex >= 0 + ? project.chapters[selectedChunkChapterIndex] : null; + const selectedChunkVerses = selectedChunk + ? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk) + : []; const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId); useEffect(() => { @@ -845,16 +850,21 @@ const App = () => { // Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study) // --------------------------------------------------------------------------- - const addChunkToChapter = (chapterIndex, start, end) => { + 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); + 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: '', @@ -869,17 +879,21 @@ const App = () => { }); }; - const addChunk = (start, end) => { + 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); + 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); + addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse); }; const addChunkRanges = (ranges) => { @@ -894,11 +908,13 @@ const App = () => { 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}`)); + const existing = new Set( + ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`), + ); const newChunks = []; - ranges.forEach(({ start, end }) => { - const key = `${start}-${end}`; + ranges.forEach(({ start, end, spilloverEndVerse = null }) => { + const key = `${start}-${end}-${spilloverEndVerse ?? ''}`; if (existing.has(key)) { skippedCount += 1; return; @@ -907,6 +923,7 @@ const App = () => { id: makeId(), startVerse: start, endVerse: end, + spilloverEndVerse, observation: '', interpretation: '', application: '', @@ -938,7 +955,11 @@ const App = () => { 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.'); @@ -956,7 +977,30 @@ const App = () => { return; } - const { addedCount } = addChunkRanges([{ start, end }]); + 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); @@ -965,6 +1009,7 @@ const App = () => { setTypedChunkStart(''); setTypedChunkEnd(''); + setTypedChunkNextEnd(''); }; const addBulkTypedChunks = () => { @@ -977,6 +1022,8 @@ const App = () => { } 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()) @@ -997,7 +1044,7 @@ const App = () => { return; } - const range = /^(\d+)\s*-\s*(\d+)$/.exec(part); + const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part); if (!range) { invalidCount += 1; return; @@ -1005,11 +1052,22 @@ const App = () => { 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; } - validRanges.push({ start, end }); + 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) { @@ -1031,6 +1089,49 @@ const App = () => { } }; + 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); @@ -1243,9 +1344,10 @@ const App = () => { const suggestGreekWordsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); - const chapter = project?.chapters.find((ch) => + const chapterIndex = project?.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunkId) - ); + ) ?? -1; + const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null; if (!chunk || !chapter) return; if (!NT_BOOK_NUMBER[chapter.bookAbbrev]) { setStatusMessage('Auto-suggest only works for New Testament books.'); @@ -1257,11 +1359,19 @@ const App = () => { const [concordance, dict, gloss] = await Promise.all([loadNtConcordance(), loadGreekDict(), loadNtGloss()]); const bookData = concordance[chapter.bookAbbrev] ?? {}; const chapterData = bookData[chapter.chapter] ?? {}; + const nextChapterData = chunkSpansNextChapter(project, chapterIndex, chunk) + ? bookData[project.chapters[chapterIndex + 1].chapter] ?? {} + : {}; const strongsInRange = new Set(); for (let v = chunk.startVerse; v <= chunk.endVerse; v++) { for (const s of chapterData[String(v)] ?? []) strongsInRange.add(s); } + if (chunkSpansNextChapter(project, chapterIndex, chunk)) { + for (let v = 1; v <= chunk.spilloverEndVerse; v++) { + for (const s of nextChapterData[String(v)] ?? []) strongsInRange.add(s); + } + } if (strongsInRange.size === 0) { setStatusMessage("No Strong's data found for this passage."); @@ -1323,9 +1433,10 @@ const App = () => { const suggestHebrewWordsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); - const chapter = project?.chapters.find((ch) => + const chapterIndex = project?.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunkId) - ); + ) ?? -1; + const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null; if (!chunk || !chapter) return; if (NT_BOOK_NUMBER[chapter.bookAbbrev]) { setStatusMessage('Hebrew suggestions are for Old Testament passages.'); @@ -1336,8 +1447,7 @@ const App = () => { setSuggestingHebrewForChunkId(chunkId); try { const dict = await loadHebrewDict(); - const versesInChunk = (chapter.verses ?? []) - .filter((v) => v.number >= chunk.startVerse && v.number <= chunk.endVerse) + const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk) .map((v) => v.text) .join(' ') .toLowerCase(); @@ -1622,20 +1732,16 @@ const App = () => { }), ]; - project.chapters.forEach((ch) => { + project.chapters.forEach((ch, chapterIndex) => { children.push(new Paragraph({ text: `${ch.book} ${ch.chapter}`, heading: HeadingLevel.HEADING_1 })); ch.chunks.forEach((chunk) => { - const scriptureHeading = chunk.startVerse === chunk.endVerse - ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` - : `${ch.book} ${ch.chapter}:${chunk.startVerse}-${chunk.endVerse}`; + const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-'); children.push(new Paragraph({ text: scriptureHeading, heading: HeadingLevel.HEADING_2 })); - ch.verses - .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) - .forEach((verse) => { + getChunkVerseEntries(project, chapterIndex, chunk).forEach((verse) => { children.push(new Paragraph({ children: [ - new TextRun({ text: `${verse.number}. `, bold: true }), + new TextRun({ text: `${verse.chapter}:${verse.number}. `, bold: true }), new TextRun({ text: verse.text }), ], })); @@ -1773,8 +1879,6 @@ const restoreRemoteProject = async (id) => { setStatusMessage(''); }; - const verseLabel = (start, end) => (start === end ? `${start}` : `${start}-${end}`); - // --------------------------------------------------------------------------- // Shared header // --------------------------------------------------------------------------- @@ -2081,9 +2185,18 @@ const restoreRemoteProject = async (id) => { rangeStart !== null && verse.number >= Math.min(rangeStart, rangeEnd) && verse.number <= Math.max(rangeStart, rangeEnd); - const inChunk = activeChapter.chunks.some( + const inOwnChapterChunk = activeChapter.chunks.some( (chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse, ); + const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null; + const inPrevChapterSpillover = prevChapter + ? prevChapter.chunks.some((chunk) => + Number.isInteger(chunk.spilloverEndVerse) + && prevChapter.bookAbbrev === activeChapter.bookAbbrev + && verse.number <= chunk.spilloverEndVerse + ) + : false; + const inChunk = inOwnChapterChunk || inPrevChapterSpillover; return (