import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { AppContext, useApp } from './context/AppContext.js'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.js'; import { loadNtGloss, loadNtConcordance, loadHebrewDict, loadGreekDict, GREEK_FUNCTION_WORDS, shortHebrewGloss, ENGLISH_STOP_WORDS, externalLookupLinks, buildGreekDefinitionHtml, fetchBollsDefinition, formatCrossRef, speakOriginalWord, } from './utils/wordStudy.js'; import HomePage from './pages/HomePage.jsx'; import { CHAPTER_COUNTS, bookOptions, NT_BOOK_NUMBER, makeId, formatChunkReference, getChunkVerseEntries, chunkSpansNextChapter, buildExportHtml, extractPartOfSpeech, parseBibleChapter, loadProjectIndex, switchStorageUser, buildPronunciationGuide, } from './utils/bibleUtils.jsx'; import { saveProjectToStorage, deleteProjectFromStorage, loadProjectById, migrateOldStorageKeys, buildChapterSummary, } from './utils/projectStorage.js'; import { buildMarkdownExport, buildClaudePrompt, buildPodcastPrompt, } from './utils/exportBuilders.js'; import { parseCrossRefString, parseEpisodePassage, parseEpisodeListDocx, } from './utils/importParsers.js'; import { saveRemoteProject, deleteRemoteProject, listRemoteProjects, loadRemoteProject, getShareStatus, enableSharing, disableSharing, getSharedProject, } from './syncService.js'; // Less-common pages are split into their own chunks so the first load only // downloads what's needed to show the home screen. const AdminPage = lazy(() => import('./pages/AdminPage.jsx')); const AuthPage = lazy(() => import('./pages/AuthPage.jsx')); const ImportPage = lazy(() => import('./pages/ImportPage.jsx')); const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx')); const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); // --------------------------------------------------------------------------- // Stable utility exports (used by tests and other modules) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // App component // --------------------------------------------------------------------------- const App = () => { const [availableTranslations, setAvailableTranslations] = useState(['BSB']); const [setup, setSetup] = useState({ translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study', }); const [titleEdited, setTitleEdited] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); // Read-only share links const [shareToken, setShareToken] = useState(null); const [sharePanelOpen, setSharePanelOpen] = useState(false); const [shareBusy, setShareBusy] = useState(false); const [shareCopied, setShareCopied] = useState(false); const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share')); const [sharedProject, setSharedProject] = useState(null); const [sharedError, setSharedError] = useState(''); 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 [crossRefInput, setCrossRefInput] = useState(''); 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 [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null); const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null); // suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] } const [suggestModal, setSuggestModal] = useState(null); const [suggestSelection, setSuggestSelection] = useState(new Set()); const [homeSearch, setHomeSearch] = useState(''); const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage' const [homeTagFilter, setHomeTagFilter] = useState(''); const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results const fullTextTimerRef = useRef(null); const [readingPlan, setReadingPlan] = useState(() => { try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; } }); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev); const [importTranslation, setImportTranslation] = useState('BSB'); const [importTitle, setImportTitle] = useState(''); const [importFile, setImportFile] = useState(null); const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters const [importBusy, setImportBusy] = useState(false); const [importError, setImportError] = useState(''); const reader = useBibleReader(currentPage); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ onSignIn: (user) => { setProjectIndex(switchStorageUser(user.id)); // Load reader ink from the server; server wins over any stale localStorage copy fetch('/api/reader/ink') .then((r) => r.ok ? r.json() : null) .then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); }) .catch(() => {}); }, onSignOut: () => { setProjectIndex(switchStorageUser(null)); goHome(); }, }); const [studyLayout, setStudyLayout] = useState(() => { const saved = localStorage.getItem('studyLayout'); return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed }); // 'stacked' | 'split' const [drawTool, setDrawTool] = useState('pen'); const [drawColor, setDrawColor] = useState('#0f172a'); const [drawSize, setDrawSize] = useState(0.003); const [activeStudyTab, setActiveStudyTab] = useState( () => localStorage.getItem('activeStudyTab') || 'notes', ); const [mobileStudyTab, setMobileStudyTab] = useState('scripture'); useEffect(() => { localStorage.setItem('studyLayout', studyLayout); }, [studyLayout]); useEffect(() => { localStorage.setItem('activeStudyTab', activeStudyTab); }, [activeStudyTab]); // Full-text search across all project notes (debounced, synchronous localStorage read) useEffect(() => { clearTimeout(fullTextTimerRef.current); const q = homeSearch.trim().toLowerCase(); if (q.length < 3) { setHomeFullTextResults(null); return; } fullTextTimerRef.current = window.setTimeout(() => { const index = loadProjectIndex(); const results = []; for (const entry of index) { const proj = loadProjectById(entry.id); if (!proj) continue; const matches = []; for (const [chIdx, ch] of (proj.chapters ?? []).entries()) { for (const chunk of (ch.chunks ?? [])) { for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) { const text = (chunk[field] ?? '').toLowerCase(); if (!text.includes(q)) continue; const idx = text.indexOf(q); const raw = chunk[field] ?? ''; const start = Math.max(0, idx - 40); const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : ''); matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet }); break; // one match per chunk } } } if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches }); } setHomeFullTextResults(results); }, 300); }, [homeSearch]); const [verseSearch, setVerseSearch] = useState(''); const [collapsedSections, setCollapsedSections] = useState({}); const [commentarySource, setCommentarySource] = useState('matthew-henry'); const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter const [commentaryLoading, setCommentaryLoading] = useState(false); const [commentaryError, setCommentaryError] = useState(''); const _commentaryCacheRef = useRef({}); const [interlinearData, setInterlinearData] = useState(null); const [interlinearLoading, setInterlinearLoading] = useState(false); const [interlinearError, setInterlinearError] = useState(''); const _interlinearCacheRef = useRef({}); const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => { if (_interlinearCacheRef.current[bookAbbrev]) return _interlinearCacheRef.current[bookAbbrev]; const res = await fetch(`/interlinear/${bookAbbrev}.json`); if (!res.ok) throw new Error('No interlinear data for this book.'); const data = await res.json(); _interlinearCacheRef.current[bookAbbrev] = data; return data; }; const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => { const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`; if (_commentaryCacheRef.current[cacheKey]) return _commentaryCacheRef.current[cacheKey]; const res = await fetch(`https://bible.helloao.org/api/c/${commentaryId}/${bookAbbrev}/${chapterNumber}.json`); const contentType = res.headers.get('content-type') || ''; if (!res.ok || !contentType.includes('json')) throw new Error(`No ${commentaryId} commentary for this chapter.`); const data = await res.json(); _commentaryCacheRef.current[cacheKey] = data; return data; }; const openBibleReader = () => { setCurrentPage('reader'); }; const copyVerse = (bookName, chapterNum, verseNum, text) => { const citation = `${bookName} ${chapterNum}:${verseNum} BSB — ${text}`; navigator.clipboard?.writeText(citation); }; // --------------------------------------------------------------------------- // Startup: migrate old keys and load index // --------------------------------------------------------------------------- useEffect(() => { migrateOldStorageKeys(); setProjectIndex(loadProjectIndex()); }, []); // A ?share=TOKEN URL loads a read-only view of someone else's project — no // login required, so this runs independent of auth state entirely. useEffect(() => { if (!sharedViewToken) return; getSharedProject(sharedViewToken).then((result) => { if (result.ok) { setSharedProject(result.data); } else { setSharedError(result.error ?? 'This share link is invalid or has been revoked.'); } }); }, [sharedViewToken]); // Loads current share status whenever the study page's project changes. useEffect(() => { if (!project?.id || currentPage !== 'study') return; setShareToken(null); getShareStatus(project.id).then((result) => { if (result.ok) setShareToken(result.data.shareToken); }); }, [project?.id, currentPage]); // Once we know who's signed in, reconcile the local project index against the server. // Runs on every authUser change (including logout -> different login) so a previous // account's stale suggestions never linger after switching users. Projects that exist // on the server but not on this device (e.g. a brand new browser) are pulled down // automatically instead of requiring a manual "Restore" click per project. useEffect(() => { if (!authUser) { setStaleLocalProjects([]); return; } let cancelled = false; const localIndex = loadProjectIndex(); listRemoteProjects().then(async (result) => { if (!result.ok || cancelled) return; const localMap = new Map(localIndex.map((e) => [e.id, e])); const missing = result.data.filter((e) => !localMap.has(e.id)); if (missing.length > 0) { const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id))); fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data)); if (cancelled) return; const restoredCount = fetched.filter((r) => r.ok).length; if (restoredCount > 0) { setProjectIndex(loadProjectIndex()); setAutoRestoredCount(restoredCount); window.setTimeout(() => setAutoRestoredCount(null), 4000); } } const stale = result.data.filter((e) => { const local = localMap.get(e.id); return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0); }); if (!cancelled) setStaleLocalProjects(stale); }); return () => { cancelled = true; }; }, [authUser]); useEffect(() => { fetch('https://bible.helloao.org/api/available_translations.json') .then((res) => res.json()) .then((data) => { if (Array.isArray(data) && data.length > 0) { setAvailableTranslations(data); } }) .catch(() => {}); }, []); useEffect(() => { if (!titleEdited) { setSetup((current) => ({ ...current, title: `${current.book} ${current.chapter} Study`, })); } }, [setup.book, setup.chapter, titleEdited]); // Reset cross-ref input when selected chunk changes 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 selectedChunkChapterIndex = selectedChunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id)) : -1; useEffect(() => { if (collapsedSections.commentary) return; if (selectedChunkChapterIndex < 0) return; const chapter = project.chapters[selectedChunkChapterIndex]; setCommentaryLoading(true); setCommentaryError(''); loadCommentaryChapter(commentarySource, chapter.bookAbbrev, chapter.chapter) .then((data) => setCommentaryData(data)) .catch((err) => { setCommentaryData(null); setCommentaryError(err.message); }) .finally(() => setCommentaryLoading(false)); }, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]); useEffect(() => { if (collapsedSections.interlinear) return; if (selectedChunkChapterIndex < 0) return; const chapter = project.chapters[selectedChunkChapterIndex]; setInterlinearLoading(true); setInterlinearError(''); loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter) .then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null)) .catch((err) => { setInterlinearData(null); setInterlinearError(err.message); }) .finally(() => setInterlinearLoading(false)); }, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]); useEffect(() => { setCrossRefSuggestions([]); setTagInput(''); }, [selectedChunk?.id]); 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(() => { setCrossRefInput(''); }, [project?.selectedChunkId]); // Keyboard shortcuts: arrow keys navigate chunks on the study page, Esc closes modals useEffect(() => { const handleKeyDown = (event) => { const tag = event.target?.tagName; const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable; if (event.key === 'Escape') { if (suggestModal) { setSuggestModal(null); setSuggestSelection(new Set()); } return; } if (currentPage !== 'study' || isEditable || suggestModal) return; if (event.key === 'ArrowLeft') { event.preventDefault(); goToPreviousChunk(); } else if (event.key === 'ArrowRight') { event.preventDefault(); goToNextChunk(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [currentPage, suggestModal, selectedChunkGlobalIndex, allChunks]); // Autosave useEffect(() => { if (!project) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(async () => { // Stamp lastEdited once so localStorage and server receive the same value const toSave = { ...project, lastEdited: Date.now() }; // 1. Save locally saveProjectToStorage(toSave); setProjectIndex(loadProjectIndex()); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); // 2. Sync to server with the same timestamped object setSyncStatus('syncing'); const result = await saveRemoteProject(toSave); setSyncStatus(result.ok ? 'synced' : 'error'); // On success clear after 2.5s; on error keep visible until next successful sync if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500); }, 1000); return () => { if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); }; }, [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 navigation // --------------------------------------------------------------------------- const goToPreviousChunk = () => { if (selectedChunkGlobalIndex <= 0) return; updateProject((current) => ({ ...current, selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id, })); }; const goToNextChunk = () => { if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return; updateProject((current) => ({ ...current, selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id, })); }; // --------------------------------------------------------------------------- // 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, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)), })), })); }; 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), })), }; }); }; // --------------------------------------------------------------------------- // Cross-references // --------------------------------------------------------------------------- const addCrossRef = (chunkId) => { const ref = crossRefInput.trim(); if (!ref) return; updateChunk(chunkId, { crossReferences: [...(selectedChunk?.crossReferences ?? []), ref], }); setCrossRefInput(''); }; const removeCrossRef = (chunkId, ref) => { updateChunk(chunkId, { crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref), }); }; const [tagInput, setTagInput] = useState(''); const addTag = (chunkId) => { const tag = tagInput.trim(); if (!tag) return; const existing = selectedChunk?.tags ?? []; if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) { setTagInput(''); return; } updateChunk(chunkId, { tags: [...existing, tag] }); setTagInput(''); }; const removeTag = (chunkId, tag) => { updateChunk(chunkId, { tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag), }); }; const importFinalScriptDocx = async (chunkId, file) => { if (!file) return; try { const arrayBuffer = await file.arrayBuffer(); const { default: mammoth } = await import('mammoth'); const { value } = await mammoth.extractRawText({ arrayBuffer }); updateChunk(chunkId, { finalScript: value.trim() }); setStatusMessage('Imported script from DOCX.'); window.setTimeout(() => setStatusMessage(''), 1800); } catch (err) { setStatusMessage('Could not read that DOCX file.'); window.setTimeout(() => setStatusMessage(''), 1800); } }; const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false); const [crossRefSuggestions, setCrossRefSuggestions] = useState([]); const _crossRefDatasetCacheRef = useRef({}); const suggestCrossRefsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); 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; setSuggestingCrossRefs(true); setCrossRefSuggestions([]); try { const cacheKey = `${chapter.bookAbbrev}/${chapter.chapter}`; let chapterData = _crossRefDatasetCacheRef.current[cacheKey]; if (!chapterData) { const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${chapter.bookAbbrev}/${chapter.chapter}.json`); if (!res.ok) throw new Error('No cross-reference data found for this chapter.'); chapterData = await res.json(); _crossRefDatasetCacheRef.current[cacheKey] = chapterData; } const verseEntries = (chapterData.chapter?.content ?? []).filter( (item) => item.verse >= chunk.startVerse && item.verse <= chunk.endVerse, ); const existing = new Set(chunk.crossReferences ?? []); const seen = new Set(); const refs = []; for (const v of verseEntries) { for (const ref of v.references ?? []) { const formatted = formatCrossRef(ref); if (existing.has(formatted) || seen.has(formatted)) continue; seen.add(formatted); refs.push({ formatted, score: ref.score ?? 0 }); } } refs.sort((a, b) => b.score - a.score); if (refs.length === 0) { setStatusMessage('No new cross-reference suggestions found for this passage.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setCrossRefSuggestions(refs.slice(0, 12).map((r) => r.formatted)); } catch (err) { setStatusMessage(`Cross-reference suggest failed: ${err.message}`); window.setTimeout(() => setStatusMessage(''), 3000); } finally { setSuggestingCrossRefs(false); } }; const _verseChapterCacheRef = useRef({}); const loadVerseText = async (refString) => { const parsed = parseCrossRefString(refString); if (!parsed) throw new Error('Could not parse reference.'); const translation = project?.translation ?? 'BSB'; const cacheKey = `${translation}/${parsed.bookAbbrev}/${parsed.chapter}`; let verses = _verseChapterCacheRef.current[cacheKey]; if (!verses) { const res = await fetch(`https://bible.helloao.org/api/${translation}/${parsed.bookAbbrev}/${parsed.chapter}.json`); if (!res.ok) throw new Error('Could not load verse text.'); const data = await res.json(); verses = parseBibleChapter(data); _verseChapterCacheRef.current[cacheKey] = verses; } const text = verses .filter((v) => v.number >= parsed.verse && v.number <= parsed.endVerse) .map((v) => v.text) .join(' '); return text; }; const addSuggestedCrossRef = (chunkId, ref) => { updateChunk(chunkId, { crossReferences: [...(selectedChunk?.crossReferences ?? []), ref], }); setCrossRefSuggestions((cur) => cur.filter((r) => r !== ref)); }; // --------------------------------------------------------------------------- // Greek word helpers // --------------------------------------------------------------------------- const updateChunkWord = (chunkId, wordId, patch) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => { if (chunk.id !== chunkId) return chunk; return { ...chunk, greekWords: chunk.greekWords.map((word) => word.id === wordId ? { ...word, ...patch } : word, ), }; }), })), })); }; const addGreekWord = (chunkId) => { const newWord = { id: makeId(), query: '', strongNumber: '', englishGloss: '', lexeme: '', transliteration: '', partOfSpeech: '', shortDefinition: '', definitionHtml: '', loading: false, }; updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: [...chunk.greekWords, newWord] } : chunk, ), })), })); }; const removeGreekWord = (chunkId, wordId) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: chunk.greekWords.filter((w) => w.id !== wordId) } : chunk, ), })), })); }; const suggestGreekWordsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); 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.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setSuggestingGreekForChunkId(chunkId); try { 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."); window.setTimeout(() => setStatusMessage(''), 3000); return; } const existingNumbers = new Set( chunk.greekWords.map((w) => w.strongNumber.toUpperCase()).filter(Boolean) ); const modalWords = []; for (const strongKey of strongsInRange) { if (existingNumbers.has(strongKey)) continue; const entry = dict[strongKey]; modalWords.push({ strongKey, lexeme: entry?.lemma ?? '', translit: entry?.translit ?? '', def: gloss[strongKey] ?? entry?.kjv_def ?? 'No definition found.', entry, }); } if (modalWords.length === 0) { setStatusMessage('All words from this passage are already added.'); window.setTimeout(() => setStatusMessage(''), 2000); return; } // Pre-select content words; pre-uncheck known function words const preSelected = new Set( modalWords .filter((w) => !GREEK_FUNCTION_WORDS.has(w.strongKey)) .map((w) => w.strongKey) ); setSuggestModal({ chunkId, language: 'greek', helperText: "Content words are pre-checked. Uncheck any you don't need.", words: modalWords, }); setSuggestSelection(preSelected); } catch (err) { setStatusMessage(`Suggest failed: ${err.message}`); window.setTimeout(() => setStatusMessage(''), 3000); } finally { setSuggestingGreekForChunkId(null); } }; const suggestHebrewWordsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); 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.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setSuggestingHebrewForChunkId(chunkId); try { const dict = await loadHebrewDict(); const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk) .map((v) => v.text) .join(' ') .toLowerCase(); const candidateWords = Array.from(new Set( versesInChunk .replace(/[^a-z\s]/g, ' ') .split(/\s+/) .map((w) => w.trim()) .filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)), )); if (candidateWords.length === 0) { setStatusMessage('No Hebrew suggestion candidates found in this passage.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } const existingNumbers = new Set( chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean) ); const seen = new Set(); const modalWords = []; const entries = Object.entries(dict); for (const token of candidateWords) { const match = entries.find(([, entry]) => { const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase(); return defs.split(/[,;()\s]+/).includes(token); }); if (!match) continue; const [strongKey, entry] = match; if (!/^H\d+$/i.test(strongKey)) continue; const normalized = strongKey.toUpperCase(); if (existingNumbers.has(normalized) || seen.has(normalized)) continue; seen.add(normalized); modalWords.push({ strongKey: normalized, lexeme: entry?.lemma ?? '', translit: entry?.xlit ?? '', def: shortHebrewGloss(entry?.kjv_def) || 'No definition found.', fullDef: entry?.kjv_def ?? '', entry, }); if (modalWords.length >= 20) break; } if (modalWords.length === 0) { setStatusMessage('No Hebrew suggestions found from this passage text.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setSuggestModal({ chunkId, language: 'hebrew', helperText: 'Heuristic matches from passage text; uncheck anything not useful.', words: modalWords, }); setSuggestSelection(new Set(modalWords.map((w) => w.strongKey))); } catch (err) { setStatusMessage(`Hebrew suggest failed: ${err.message}`); window.setTimeout(() => setStatusMessage(''), 3000); } finally { setSuggestingHebrewForChunkId(null); } }; const confirmSuggestWords = () => { if (!suggestModal) return; const { chunkId, words } = suggestModal; const newWords = words .filter((w) => suggestSelection.has(w.strongKey)) .map((w) => ({ id: makeId(), query: w.strongKey, strongNumber: w.strongKey, englishGloss: w.def, lexeme: w.lexeme, transliteration: w.translit, partOfSpeech: '', shortDefinition: w.fullDef || w.def, definitionHtml: w.entry ? buildGreekDefinitionHtml(w.strongKey, w.entry) : '', loading: false, })); if (newWords.length > 0) { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => c.id === chunkId ? { ...c, greekWords: [...c.greekWords, ...newWords] } : c ), })), })); const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek'; setStatusMessage(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`); window.setTimeout(() => setStatusMessage(''), 2000); } setSuggestModal(null); setSuggestSelection(new Set()); }; const lookupWord = async (chunkId, wordId, language = 'greek') => { const chunk = allChunks.find((c) => c.id === chunkId); const word = chunk?.greekWords.find((w) => w.id === wordId); if (!word || !word.query.trim()) return; updateChunkWord(chunkId, wordId, { loading: true }); try { // Normalize bare numbers based on selected lookup language. const raw = word.query.trim(); const inferredPrefix = language === 'hebrew' ? 'H' : 'G'; const normalized = /^\d+$/.test(raw) ? `${inferredPrefix}${raw}` : /^[gGhH]\d+$/.test(raw) ? raw.toUpperCase() : raw; const [definitions, gloss] = await Promise.all([ fetchBollsDefinition(normalized), loadNtGloss().catch(() => ({})), ]); if (!definitions || definitions.length === 0) { updateChunkWord(chunkId, wordId, { strongNumber: '', shortDefinition: 'No definition found.', definitionHtml: '', }); return; } const first = definitions[0]; const strongKey = (first.topic || normalized).toUpperCase(); const extractedPartOfSpeech = extractPartOfSpeech(first.definition || ''); updateChunkWord(chunkId, wordId, { strongNumber: first.topic || normalized, englishGloss: gloss[strongKey] || first.short_definition || '', lexeme: first.lexeme || '', transliteration: first.transliteration || '', partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '', shortDefinition: first.short_definition || '', definitionHtml: first.definition || '', }); } catch { updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' }); } finally { updateChunkWord(chunkId, wordId, { loading: false }); } }; // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- const exportChapter = () => { if (!project) return; const html = buildExportHtml(project); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.html`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const printChapterPdf = () => { if (!project) return; const html = buildExportHtml(project); const printWindow = window.open('', '_blank'); if (!printWindow) { alert('Please allow pop-ups for this site to print or save as PDF.'); return; } printWindow.document.write(html); printWindow.document.close(); printWindow.onload = () => { printWindow.focus(); printWindow.print(); }; }; const handleEnableSharing = async () => { if (!project) return; setShareBusy(true); const result = await enableSharing(project.id); setShareBusy(false); if (result.ok) setShareToken(result.data.shareToken); }; const handleDisableSharing = async () => { if (!project) return; if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return; setShareBusy(true); const result = await disableSharing(project.id); setShareBusy(false); if (result.ok) setShareToken(null); }; const handleCopyShareLink = () => { if (!shareToken) return; const url = `${window.location.origin}${window.location.pathname}?share=${shareToken}`; navigator.clipboard.writeText(url).then(() => { setShareCopied(true); window.setTimeout(() => setShareCopied(false), 2000); }); }; const exportChapterMarkdown = () => { if (!project) return; const markdown = buildMarkdownExport(project); const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.md`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const exportChapterDocx = async () => { if (!project) return; const { buildStudyDocxBlob } = await import('./utils/docxExport.js'); const blob = await buildStudyDocxBlob(project, buildChapterSummary(project)); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.docx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const copyForClaude = () => { if (!project) return; const prompt = buildClaudePrompt(project); navigator.clipboard.writeText(prompt).then(() => { setSaveStatus('Copied! Opening Claude…'); window.setTimeout(() => setSaveStatus(''), 3000); window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer'); }); }; const copyForPodcast = () => { if (!project) return; const prompt = buildPodcastPrompt(project, authUser?.podcastName); navigator.clipboard.writeText(prompt).then(() => { setSaveStatus('Copied! Opening Claude…'); window.setTimeout(() => setSaveStatus(''), 3000); window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer'); }); }; const createReadingPlan = (bookAbbrev, bookName, weeks) => { const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1; const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] }; setReadingPlan(plan); try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {} }; const markChapterRead = (chapter) => { setReadingPlan((prev) => { if (!prev) return prev; const chaptersRead = prev.chaptersRead.includes(chapter) ? prev.chaptersRead.filter((c) => c !== chapter) : [...prev.chaptersRead, chapter].sort((a, b) => a - b); const next = { ...prev, chaptersRead }; try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {} return next; }); }; const clearReadingPlan = () => { setReadingPlan(null); try { localStorage.removeItem('readingPlan'); } catch {} }; const copyPronunciationGuide = () => { if (!project) return; const guide = buildPronunciationGuide(project); navigator.clipboard.writeText(guide).then(() => { setSaveStatus('Copied pronunciation guide!'); window.setTimeout(() => setSaveStatus(''), 2000); }); }; // --------------------------------------------------------------------------- // Home page helpers // --------------------------------------------------------------------------- const openNewProject = () => { setProject(null); setActiveChapterIndex(0); setShowAddChapterForm(false); setRangeStart(null); setRangeEnd(null); setErrorMessage(''); setStatusMessage(''); setTitleEdited(false); setCurrentPage('setup'); }; const openImportProject = () => { setProject(null); setImportBookAbbrev(bookOptions[0].abbrev); setImportTranslation('BSB'); setImportTitle(''); setImportFile(null); setImportPreview(null); setImportError(''); setCurrentPage('import'); }; const handleImportFileChange = async (file) => { setImportFile(file); setImportPreview(null); setImportError(''); if (!file) return; setImportBusy(true); try { const episodes = await parseEpisodeListDocx(file); if (episodes.length === 0) { throw new Error('No "Session # / Title / Passage" table found in that document.'); } const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) })); setImportPreview(specs); } catch (error) { setImportError(error.message || 'Failed to read that document.'); } finally { setImportBusy(false); } }; const runEpisodeImport = async () => { if (!importPreview || importPreview.length === 0) { setImportError('Choose a .docx file first.'); return; } setImportBusy(true); setImportError(''); try { const selectedBook = bookOptions.find((b) => b.abbrev === importBookAbbrev) ?? bookOptions[0]; const chapterNumbersNeeded = new Set(); importPreview.forEach((spec) => { if (spec.parsed && spec.parsed !== 'invalid') { chapterNumbersNeeded.add(spec.parsed.startChapter); chapterNumbersNeeded.add(spec.parsed.endChapter); } }); const sortedChapters = Array.from(chapterNumbersNeeded).sort((a, b) => a - b); if (sortedChapters.length === 0) { throw new Error('No verse passages found to import.'); } const versesByChapter = {}; for (const chNum of sortedChapters) { const response = await fetch( `https://bible.helloao.org/api/${importTranslation}/${selectedBook.abbrev}/${chNum}.json`, ); if (!response.ok) throw new Error(`Unable to load ${selectedBook.name} ${chNum}.`); const data = await response.json(); const verses = parseBibleChapter(data); if (!verses.length) throw new Error(`No verses returned for ${selectedBook.name} ${chNum}.`); versesByChapter[chNum] = verses; } const chapterIndexByNum = new Map(sortedChapters.map((n, idx) => [n, idx])); const chapters = sortedChapters.map((chNum) => ({ book: selectedBook.name, bookAbbrev: selectedBook.abbrev, chapter: chNum, verses: versesByChapter[chNum], chunks: [], })); // Markers (rows with no passage) attach to the nearest upcoming chapter with a // real passage, or fall back to the previous one for trailing markers. const anchorChapterFor = importPreview.map((spec, i) => { if (spec.parsed && spec.parsed !== 'invalid') return spec.parsed.startChapter; for (let j = i + 1; j < importPreview.length; j += 1) { const next = importPreview[j].parsed; if (next && next !== 'invalid') return next.startChapter; } for (let j = i - 1; j >= 0; j -= 1) { const prev = importPreview[j].parsed; if (prev && prev !== 'invalid') return prev.startChapter; } return sortedChapters[0]; }); importPreview.forEach((spec, i) => { if (spec.parsed === 'invalid') return; // skip unparseable rows entirely const chIdx = chapterIndexByNum.get(anchorChapterFor[i]); if (chIdx == null) return; const target = chapters[chIdx]; const baseChunk = { id: makeId(), observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: spec.episodeNumber, episodeTitle: spec.title, finalScript: '', tags: [], }; if (!spec.parsed) { // No passage (e.g. a part intro/review) -> zero-length marker chunk. target.chunks.push({ ...baseChunk, startVerse: 0, endVerse: 0, spilloverEndVerse: null }); return; } const { startChapter, startVerse, endChapter, endVerse } = spec.parsed; if (endChapter === startChapter) { target.chunks.push({ ...baseChunk, startVerse, endVerse, spilloverEndVerse: null }); } else { const lastVerseOfStartChapter = versesByChapter[startChapter]?.at(-1)?.number ?? endVerse; target.chunks.push({ ...baseChunk, startVerse, endVerse: lastVerseOfStartChapter, spilloverEndVerse: endVerse, }); } }); const newProject = { id: makeId(), title: importTitle.trim() || `${selectedBook.name} Sessions`, translation: importTranslation, lastEdited: Date.now(), selectedChunkId: null, chapters, }; setProject(newProject); setActiveChapterIndex(0); setCurrentPage('setup'); } catch (error) { setImportError(error.message || 'Import failed.'); } finally { setImportBusy(false); } }; const resumeProject = (id) => { 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); setCurrentPage(hasSelectedChunk ? 'study' : 'setup'); }; const renameProjectInStorage = (id, title) => { const loaded = loadProjectById(id); if (!loaded) return; const updated = { ...loaded, title, lastEdited: Date.now() }; saveProjectToStorage(updated); setProjectIndex(loadProjectIndex()); saveRemoteProject(updated); if (project?.id === id) { setProject((current) => (current ? { ...current, title } : current)); } }; const deleteProject = (id) => { if (!window.confirm('Delete this project? This cannot be undone.')) return; deleteProjectFromStorage(id); setProjectIndex(loadProjectIndex()); deleteRemoteProject(id); // fire-and-forget }; const pullLatestFromServer = async (id) => { const result = await loadRemoteProject(id); if (!result.ok) { alert('Could not pull latest version from server.'); return; } saveProjectToStorage(result.data); setProjectIndex(loadProjectIndex()); setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id)); }; const goHome = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); setProject(null); setRangeStart(null); setRangeEnd(null); setErrorMessage(''); setStatusMessage(''); }; const authStatus = authUser && (
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher or a listener who wants to follow along.
{shareToken ? (Loading…
Bible Study Project
{sharedError}
Loading shared study…
Loading…