diff --git a/src/App.jsx b/src/App.jsx index 1aebd86..21b83f6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -109,17 +109,74 @@ export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.rando // --------------------------------------------------------------------------- export function migrateChunk(chunk) { - if (chunk.observation !== undefined) return chunk; // already new format + if (chunk.observation !== undefined) { + return { + ...chunk, + spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null, + }; // already new format + } return { ...chunk, observation: chunk.notes ?? '', interpretation: '', application: '', crossReferences: [], + spilloverEndVerse: null, notes: undefined, }; } +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; +} + +function formatChunkReference(project, startChapterIndex, chunk, separator = '-') { + if (!project || !chunk) return ''; + const startChapter = project.chapters?.[startChapterIndex]; + if (!startChapter) return ''; + + if (chunkSpansNextChapter(project, startChapterIndex, chunk)) { + const nextChapter = project.chapters[startChapterIndex + 1]; + return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`; + } + + if (chunk.startVerse === chunk.endVerse) { + return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`; + } + return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`; +} + +function getChunkVerseEntries(project, startChapterIndex, chunk) { + if (!project || !chunk) return []; + const startChapter = project.chapters?.[startChapterIndex]; + if (!startChapter) return []; + + const startVerses = (startChapter.verses ?? []) + .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) + .map((verse) => ({ + book: startChapter.book, + chapter: startChapter.chapter, + ...verse, + })); + + if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) { + return startVerses; + } + + const nextChapter = project.chapters[startChapterIndex + 1]; + const spilloverVerses = (nextChapter.verses ?? []) + .filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse) + .map((verse) => ({ + book: nextChapter.book, + chapter: nextChapter.chapter, + ...verse, + })); + + return [...startVerses, ...spilloverVerses]; +} + export function migrateProject(raw) { if (!raw) return null; // Already new format @@ -570,6 +627,9 @@ const App = () => { const [loadingChapter, setLoadingChapter] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); + const [typedChunkStart, setTypedChunkStart] = useState(''); + const [typedChunkEnd, setTypedChunkEnd] = useState(''); + const [typedChunkBulk, setTypedChunkBulk] = useState(''); const saveTimerRef = useRef(null); const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [remoteOnlyProjects, setRemoteOnlyProjects] = useState([]); // projects on server not in localStorage @@ -822,6 +882,155 @@ const App = () => { addChunkToChapter(activeChapterIndex, start, end); }; + 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}`)); + const newChunks = []; + + ranges.forEach(({ start, end }) => { + const key = `${start}-${end}`; + if (existing.has(key)) { + skippedCount += 1; + return; + } + const newChunk = { + id: makeId(), + startVerse: start, + endVerse: end, + observation: '', + interpretation: '', + application: '', + crossReferences: [], + greekWords: [], + }; + 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 maxVerse = activeChapter.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; + } + + const { addedCount } = addChunkRanges([{ start, end }]); + if (addedCount === 0) { + setStatusMessage('That chunk already exists.'); + window.setTimeout(() => setStatusMessage(''), 2200); + return; + } + + setTypedChunkStart(''); + setTypedChunkEnd(''); + }; + + 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 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+)$/.exec(part); + if (!range) { + invalidCount += 1; + return; + } + + const start = Number.parseInt(range[1], 10); + const end = Number.parseInt(range[2], 10); + if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) { + invalidCount += 1; + return; + } + validRanges.push({ start, end }); + }); + + 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 handleVerseClick = (verseNumber, event) => { if (event.shiftKey && rangeStart !== null) { const start = Math.min(rangeStart, verseNumber); @@ -1857,7 +2066,7 @@ const restoreRemoteProject = async (id) => {
- {statusMessage || 'Shift-click a second verse to create a chunk.'} + {statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
@@ -1898,6 +2107,65 @@ const restoreRemoteProject = async (id) => {

Chunks

{activeChapter.chunks.length} created
+
+

Type chunk range

+
+ + + +
+
+