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 && (
{authUser.email} {authUser.isAdmin && ( )}
); // --------------------------------------------------------------------------- // Shared header // --------------------------------------------------------------------------- const headerButtons = (
{currentPage !== 'home' && ( )} {project && currentPage === 'study' && ( )} {project && (
{exportMenuOpen && (
)}
{sharePanelOpen && (

Read-only share link

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 ? (
e.target.select()} className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600" />
) : ( )}
)}
)}
{loadingChapter ? ( Loading… ) : saveStatus ? ( {saveStatus} ) : (   )} {syncStatus === 'syncing' &&
Syncing…
} {syncStatus === 'synced' &&
Synced ✓
} {syncStatus === 'error' && (
⚠ Sync failed
)}
{authStatus}
); // --------------------------------------------------------------------------- // Context value — every state, handler, and computed value pages need. // --------------------------------------------------------------------------- const contextValue = { // Navigation currentPage, setCurrentPage, goHome, authServerDown, // Shared view sharedViewToken, sharedProject, sharedError, // Auth (login/2FA/settings form state lives in AuthPage and SettingsPage) authUser, setAuthUser, authStatus, signIn, // Home projectIndex, autoRestoredCount, staleLocalProjects, homeSearch, setHomeSearch, homeSort, setHomeSort, homeTagFilter, setHomeTagFilter, homeFullTextResults, readingPlan, createReadingPlan, markChapterRead, clearReadingPlan, renamingId, setRenamingId, renameValue, setRenameValue, openBibleReader, openImportProject, openNewProject, pullLatestFromServer, resumeProject, renameProjectInStorage, deleteProject, // Audio (book player on Home + chapter player in the Reader) ...audio, // Import importBookAbbrev, setImportBookAbbrev, importTranslation, setImportTranslation, importTitle, setImportTitle, importFile, importPreview, importBusy, importError, handleImportFileChange, runEpisodeImport, // Reader ...reader, copyVerse, // Setup availableTranslations, project, setProject, 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, allChunks, handleSetupField, handleLoadChapter, beginStudying, handleVerseClick, addTypedChunk, addBulkTypedChunks, addClickSpanChunk, moveChunk, deleteChunk, updateProject, // Study selectedChunk, selectedChunkChapterIndex, selectedChunkChapter, selectedChunkVerses, selectedChunkGlobalIndex, studyLayout, setStudyLayout, drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize, activeStudyTab, setActiveStudyTab, mobileStudyTab, setMobileStudyTab, collapsedSections, setCollapsedSections, interlinearData, interlinearLoading, interlinearError, commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError, crossRefInput, setCrossRefInput, suggestingCrossRefs, crossRefSuggestions, tagInput, setTagInput, suggestModal, setSuggestModal, suggestSelection, setSuggestSelection, suggestingGreekForChunkId, suggestingHebrewForChunkId, goToPreviousChunk, goToNextChunk, updateChunk, addCrossRef, removeCrossRef, suggestCrossRefsForChunk, addSuggestedCrossRef, addTag, removeTag, addGreekWord, removeGreekWord, updateChunkWord, lookupWord, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, confirmSuggestWords, speakOriginalWord, loadVerseText, importFinalScriptDocx, // Functions defined inside App (cannot be module-level exports) formatCrossRef, buildGreekDefinitionHtml, externalLookupLinks, // Computed JSX authStatus, headerButtons, }; return ( }> ); }; function PageLoading() { return (

Loading…

); } function AppRouter() { const { currentPage, authUser, authServerDown, sharedViewToken, sharedProject, sharedError, } = useApp(); if (sharedViewToken) { if (sharedError) { return (

Bible Study Project

{sharedError}

); } if (!sharedProject) { return (

Loading shared study…

); } return (
🔗 Read-only shared view —{' '} go to Bible Study Project