Move study editor state into StudyPage and hooks
- StudyPage owns layout/tabs/collapsed sections, tags, DOCX script import, chunk navigation, keyboard shortcuts, and the selected-chunk values derived from the project. - useStudyResources: commentary + interlinear for the selected chunk's chapter. These now load only on the study page (they previously also fetched while on Setup). - useCrossRefs: typed refs, dataset suggestions, verse text. - useWordStudy: word entries, Strong's lookups, suggestion picker. - Fix: study-page messages (word suggestions, DOCX import, lookups) set a status that only SetupPage displayed, so they were never seen. They now show as a toast on the study page. - copyVerse moves into ReaderPage. - Tests: toast, arrow-key navigation, cross-refs, tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b14b3fbe6
commit
bf02e071fa
8 files changed
+749
-771
No files matched your search
@@ -1,68 +0,0 @@
|
|||||||
# Study App Improvement Suggestions
|
|
||||||
|
|
||||||
_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Study Tools
|
|
||||||
- **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project, but only one renders at a time — no split/parallel view
|
|
||||||
- **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
|
|
||||||
- **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
|
|
||||||
|
|
||||||
### Export / Sharing
|
|
||||||
- **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning
|
|
||||||
- **Share link is single-use-case** — one share token per project, all-or-nothing (whole project, all chunks). A per-chunk or per-chapter share might be worth it for someone who only wants to share one episode's notes rather than the whole series
|
|
||||||
|
|
||||||
### Chunk Builder (Setup Page)
|
|
||||||
- **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection
|
|
||||||
- **Auto-chunk** — no "split by paragraph/section" button; every chunk boundary is manual or typed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UX / UI
|
|
||||||
|
|
||||||
### Study Page
|
|
||||||
|
|
||||||
### Reader
|
|
||||||
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
|
|
||||||
|
|
||||||
### Home Page
|
|
||||||
- Search/filter/sort/rename are all implemented — nothing open here currently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Architecture
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
- **`App.jsx` is now ~5,700+ lines** — still one component. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than ever given the continued size growth
|
|
||||||
|
|
||||||
### Sync / Persistence
|
|
||||||
- No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password or 2FA code; worth adding if this is ever reachable beyond a small trusted group
|
|
||||||
- **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI
|
|
||||||
- **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down
|
|
||||||
|
|
||||||
### Security (OWASP)
|
|
||||||
- **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
|
|
||||||
- **CORS** — still no CORS headers configured
|
|
||||||
- **Shared HTML view is sandboxed but not escaped** — `buildExportHtml` interpolates OIA notes into HTML without escaping `<`/`>`/`&`; the public share view mitigates this by rendering in a `sandbox="allow-popups"` iframe (no `allow-scripts`, so injected `<script>`/event handlers can't execute), but the underlying string-building still isn't defense-in-depth. Worth properly HTML-escaping user text in `buildExportHtml` itself
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
|
|
||||||
- **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly with no retry UI if the free API is briefly down; whole-Bible search now adds a third hard dependency on this API (`/complete.json`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Missing: autosave debounce behavior, DOCX session-list import, cross-ref auto-suggest, commentary loading, and no coverage yet for the newer share-link/2FA/whole-Bible-search flows (all verified manually in-browser instead)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Developer Experience
|
|
||||||
|
|
||||||
- **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
|
|
||||||
- **No TypeScript** — still true
|
|
||||||
- **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
|
|
||||||
+1
-670
@@ -6,28 +6,12 @@ import { useBibleReader } from './hooks/useBibleReader.js';
|
|||||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
import { useReadingPlan } from './hooks/useReadingPlan.js';
|
import { useReadingPlan } from './hooks/useReadingPlan.js';
|
||||||
import { useSharedView } from './hooks/useSharedView.js';
|
import { useSharedView } from './hooks/useSharedView.js';
|
||||||
import {
|
|
||||||
loadNtGloss,
|
|
||||||
loadNtConcordance,
|
|
||||||
loadHebrewDict,
|
|
||||||
loadGreekDict,
|
|
||||||
GREEK_FUNCTION_WORDS,
|
|
||||||
shortHebrewGloss,
|
|
||||||
ENGLISH_STOP_WORDS,
|
|
||||||
buildGreekDefinitionHtml,
|
|
||||||
fetchBollsDefinition,
|
|
||||||
formatCrossRef,
|
|
||||||
} from './utils/wordStudy.js';
|
|
||||||
import HomePage from './pages/HomePage.jsx';
|
import HomePage from './pages/HomePage.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bookOptions,
|
bookOptions,
|
||||||
NT_BOOK_NUMBER,
|
|
||||||
makeId,
|
makeId,
|
||||||
getChunkVerseEntries,
|
|
||||||
chunkSpansNextChapter,
|
|
||||||
buildExportHtml,
|
buildExportHtml,
|
||||||
extractPartOfSpeech,
|
|
||||||
parseBibleChapter,
|
parseBibleChapter,
|
||||||
loadProjectIndex,
|
loadProjectIndex,
|
||||||
switchStorageUser,
|
switchStorageUser,
|
||||||
@@ -36,9 +20,6 @@ import {
|
|||||||
saveProjectToStorage,
|
saveProjectToStorage,
|
||||||
migrateOldStorageKeys,
|
migrateOldStorageKeys,
|
||||||
} from './utils/projectStorage.js';
|
} from './utils/projectStorage.js';
|
||||||
import {
|
|
||||||
parseCrossRefString,
|
|
||||||
} from './utils/importParsers.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
saveRemoteProject,
|
saveRemoteProject,
|
||||||
@@ -78,7 +59,6 @@ const App = () => {
|
|||||||
const [projectIndex, setProjectIndex] = useState([]);
|
const [projectIndex, setProjectIndex] = useState([]);
|
||||||
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
|
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
|
||||||
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
|
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
|
||||||
const [crossRefInput, setCrossRefInput] = useState('');
|
|
||||||
const [saveStatus, setSaveStatus] = useState('');
|
const [saveStatus, setSaveStatus] = useState('');
|
||||||
const [rangeStart, setRangeStart] = useState(null);
|
const [rangeStart, setRangeStart] = useState(null);
|
||||||
const [rangeEnd, setRangeEnd] = useState(null);
|
const [rangeEnd, setRangeEnd] = useState(null);
|
||||||
@@ -92,11 +72,6 @@ const App = () => {
|
|||||||
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||||||
const saveTimerRef = useRef(null);
|
const saveTimerRef = useRef(null);
|
||||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||||
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 reader = useBibleReader(currentPage);
|
const reader = useBibleReader(currentPage);
|
||||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||||
const readingPlan = useReadingPlan();
|
const readingPlan = useReadingPlan();
|
||||||
@@ -122,64 +97,11 @@ const App = () => {
|
|||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
setActiveChapterIndex,
|
setActiveChapterIndex,
|
||||||
});
|
});
|
||||||
const [studyLayout, setStudyLayout] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('studyLayout');
|
|
||||||
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
|
||||||
}); // 'stacked' | 'split'
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const [verseSearch, setVerseSearch] = useState('');
|
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 = () => {
|
const openBibleReader = () => {
|
||||||
setCurrentPage('reader');
|
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
|
// Startup: migrate old keys and load index
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -197,88 +119,8 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
}, [setup.book, setup.chapter, titleEdited]);
|
}, [setup.book, setup.chapter, titleEdited]);
|
||||||
|
|
||||||
// Reset cross-ref input when selected chunk changes
|
|
||||||
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
||||||
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
|
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
|
// Autosave
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
@@ -406,26 +248,6 @@ const App = () => {
|
|||||||
setCurrentPage('study');
|
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)
|
// Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -777,455 +599,6 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Home page helpers
|
// Home page helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1295,7 +668,6 @@ const App = () => {
|
|||||||
...audio,
|
...audio,
|
||||||
// Reader
|
// Reader
|
||||||
...reader,
|
...reader,
|
||||||
copyVerse,
|
|
||||||
// Setup
|
// Setup
|
||||||
project, setProject,
|
project, setProject,
|
||||||
setup,
|
setup,
|
||||||
@@ -1325,49 +697,8 @@ const App = () => {
|
|||||||
moveChunk,
|
moveChunk,
|
||||||
deleteChunk,
|
deleteChunk,
|
||||||
updateProject,
|
updateProject,
|
||||||
// Study
|
// Study (editor state lives in StudyPage and its hooks)
|
||||||
selectedChunk,
|
|
||||||
selectedChunkChapterIndex,
|
|
||||||
selectedChunkChapter,
|
|
||||||
selectedChunkVerses,
|
|
||||||
selectedChunkGlobalIndex,
|
|
||||||
studyLayout, setStudyLayout,
|
|
||||||
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,
|
updateChunk,
|
||||||
addCrossRef,
|
|
||||||
removeCrossRef,
|
|
||||||
suggestCrossRefsForChunk,
|
|
||||||
addSuggestedCrossRef,
|
|
||||||
addTag,
|
|
||||||
removeTag,
|
|
||||||
addGreekWord,
|
|
||||||
removeGreekWord,
|
|
||||||
updateChunkWord,
|
|
||||||
lookupWord,
|
|
||||||
suggestGreekWordsForChunk,
|
|
||||||
suggestHebrewWordsForChunk,
|
|
||||||
confirmSuggestWords,
|
|
||||||
loadVerseText,
|
|
||||||
importFinalScriptDocx,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -631,3 +631,43 @@ describe('Project header and sharing', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Study editor (StudyPage local state + useCrossRefs / useWordStudy hooks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Study editor', () => {
|
||||||
|
test('study-page messages now show as a status toast', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arrow keys move between chunks', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||||
|
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowLeft' });
|
||||||
|
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(await screen.findByText('Rom 3:23')).toBeInTheDocument();
|
||||||
|
fireEvent.change(input, { target: { value: 'half typed' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
|
||||||
|
await waitFor(() => expect(input).toHaveValue(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tag can be added with Enter', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
const input = screen.getByPlaceholderText(/add a tag/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'grace' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(await screen.findByText(/#?grace/)).toBeInTheDocument();
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Cross-references for the selected chunk: typed entry, suggestions from the
|
||||||
|
// Open Cross Ref dataset, and loading a reference's verse text.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
import { parseCrossRefString } from '../utils/importParsers.js';
|
||||||
|
import { formatCrossRef } from '../utils/wordStudy.js';
|
||||||
|
|
||||||
|
const crossRefDatasetCache = {}; // "BOOK/chapter" -> Open Cross Ref chapter data
|
||||||
|
const verseChapterCache = {}; // "translation/BOOK/chapter" -> parsed verses
|
||||||
|
|
||||||
|
// notify(message, ms) shows a short status message on the study page.
|
||||||
|
export function useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify }) {
|
||||||
|
const [crossRefInput, setCrossRefInput] = useState('');
|
||||||
|
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
|
||||||
|
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrossRefSuggestions([]);
|
||||||
|
}, [selectedChunk?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrossRefInput('');
|
||||||
|
}, [project?.selectedChunkId]);
|
||||||
|
|
||||||
|
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 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 = crossRefDatasetCache[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();
|
||||||
|
crossRefDatasetCache[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) {
|
||||||
|
notify('No new cross-reference suggestions found for this passage.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCrossRefSuggestions(refs.slice(0, 12).map((r) => r.formatted));
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Cross-reference suggest failed: ${err.message}`, 3000);
|
||||||
|
} finally {
|
||||||
|
setSuggestingCrossRefs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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 = verseChapterCache[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);
|
||||||
|
verseChapterCache[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));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
crossRefInput, setCrossRefInput,
|
||||||
|
suggestingCrossRefs,
|
||||||
|
crossRefSuggestions,
|
||||||
|
addCrossRef,
|
||||||
|
removeCrossRef,
|
||||||
|
suggestCrossRefsForChunk,
|
||||||
|
addSuggestedCrossRef,
|
||||||
|
loadVerseText,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Commentary and interlinear text for the chapter of the selected chunk.
|
||||||
|
// Each loads only while its section is expanded; results are cached for the visit.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
|
||||||
|
const interlinearCache = {}; // BOOK -> whole-book interlinear data
|
||||||
|
|
||||||
|
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections }) {
|
||||||
|
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 [interlinearData, setInterlinearData] = useState(null);
|
||||||
|
const [interlinearLoading, setInterlinearLoading] = useState(false);
|
||||||
|
const [interlinearError, setInterlinearError] = useState('');
|
||||||
|
|
||||||
|
const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => {
|
||||||
|
if (interlinearCache[bookAbbrev]) return interlinearCache[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();
|
||||||
|
interlinearCache[bookAbbrev] = data;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => {
|
||||||
|
const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`;
|
||||||
|
if (commentaryCache[cacheKey]) return commentaryCache[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();
|
||||||
|
commentaryCache[cacheKey] = data;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentarySource, setCommentarySource,
|
||||||
|
commentaryData,
|
||||||
|
commentaryLoading,
|
||||||
|
commentaryError,
|
||||||
|
interlinearData,
|
||||||
|
interlinearLoading,
|
||||||
|
interlinearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// Greek/Hebrew word studies on a chunk: word entries, Strong's lookups, and the
|
||||||
|
// "suggest words from this passage" picker.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
NT_BOOK_NUMBER,
|
||||||
|
chunkSpansNextChapter,
|
||||||
|
extractPartOfSpeech,
|
||||||
|
getChunkVerseEntries,
|
||||||
|
makeId,
|
||||||
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
import {
|
||||||
|
ENGLISH_STOP_WORDS,
|
||||||
|
GREEK_FUNCTION_WORDS,
|
||||||
|
buildGreekDefinitionHtml,
|
||||||
|
fetchBollsDefinition,
|
||||||
|
loadGreekDict,
|
||||||
|
loadHebrewDict,
|
||||||
|
loadNtConcordance,
|
||||||
|
loadNtGloss,
|
||||||
|
shortHebrewGloss,
|
||||||
|
} from '../utils/wordStudy.js';
|
||||||
|
|
||||||
|
// notify(message, ms) shows a short status message on the study page.
|
||||||
|
export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
||||||
|
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 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]) {
|
||||||
|
notify('Auto-suggest only works for New Testament books.', 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) {
|
||||||
|
notify("No Strong's data found for this passage.", 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) {
|
||||||
|
notify('All words from this passage are already added.', 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) {
|
||||||
|
notify(`Suggest failed: ${err.message}`, 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]) {
|
||||||
|
notify('Hebrew suggestions are for Old Testament passages.', 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) {
|
||||||
|
notify('No Hebrew suggestion candidates found in this passage.', 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) {
|
||||||
|
notify('No Hebrew suggestions found from this passage text.', 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) {
|
||||||
|
notify(`Hebrew suggest failed: ${err.message}`, 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';
|
||||||
|
notify(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`, 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestingGreekForChunkId,
|
||||||
|
suggestingHebrewForChunkId,
|
||||||
|
suggestModal, setSuggestModal,
|
||||||
|
suggestSelection, setSuggestSelection,
|
||||||
|
updateChunkWord,
|
||||||
|
addGreekWord,
|
||||||
|
removeGreekWord,
|
||||||
|
lookupWord,
|
||||||
|
suggestGreekWordsForChunk,
|
||||||
|
suggestHebrewWordsForChunk,
|
||||||
|
confirmSuggestWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import DrawCanvas from './DrawCanvas.jsx';
|
|||||||
|
|
||||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||||
|
|
||||||
|
// Copies "Book 3:16 BSB — text" to the clipboard.
|
||||||
|
const copyVerse = (bookName, chapterNum, verseNum, text) => {
|
||||||
|
navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} BSB — ${text}`);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReaderPage() {
|
export default function ReaderPage() {
|
||||||
const {
|
const {
|
||||||
goHome,
|
goHome,
|
||||||
@@ -42,7 +47,6 @@ export default function ReaderPage() {
|
|||||||
handlePlayReaderAudio,
|
handlePlayReaderAudio,
|
||||||
handleToggleReaderAudioPause,
|
handleToggleReaderAudioPause,
|
||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
copyVerse,
|
|
||||||
setReaderCrossRefs,
|
setReaderCrossRefs,
|
||||||
readerInkByPage,
|
readerInkByPage,
|
||||||
updateReaderPageInk,
|
updateReaderPageInk,
|
||||||
|
|||||||
+157
-32
@@ -1,60 +1,180 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { useCrossRefs } from '../hooks/useCrossRefs.js';
|
||||||
|
import { useStudyResources } from '../hooks/useStudyResources.js';
|
||||||
|
import { useWordStudy } from '../hooks/useWordStudy.js';
|
||||||
import { buildGreekDefinitionHtml, externalLookupLinks, speakOriginalWord } from '../utils/wordStudy.js';
|
import { buildGreekDefinitionHtml, externalLookupLinks, speakOriginalWord } from '../utils/wordStudy.js';
|
||||||
import ProjectHeader from '../components/ProjectHeader.jsx';
|
import ProjectHeader from '../components/ProjectHeader.jsx';
|
||||||
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
|
import {
|
||||||
|
STUDY_TABS,
|
||||||
|
COMMENTARY_OPTIONS,
|
||||||
|
NT_BOOK_NUMBER,
|
||||||
|
formatChunkReference,
|
||||||
|
getChunkVerseEntries,
|
||||||
|
CrossRefChip,
|
||||||
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
|
const { project, allChunks, projectIndex, updateProject, updateChunk } = useApp();
|
||||||
|
|
||||||
|
// Short-lived status line (word suggestions, DOCX import, lookups, …)
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
const statusTimerRef = useRef(null);
|
||||||
|
const notify = (message, ms = 2000) => {
|
||||||
|
window.clearTimeout(statusTimerRef.current);
|
||||||
|
setStatusMessage(message);
|
||||||
|
statusTimerRef.current = window.setTimeout(() => setStatusMessage(''), ms);
|
||||||
|
};
|
||||||
|
useEffect(() => () => window.clearTimeout(statusTimerRef.current), []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const selectedChunkChapter = selectedChunkChapterIndex >= 0
|
||||||
|
? project.chapters[selectedChunkChapterIndex]
|
||||||
|
: null;
|
||||||
|
const selectedChunkVerses = selectedChunk
|
||||||
|
? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk)
|
||||||
|
: [];
|
||||||
|
const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId);
|
||||||
|
|
||||||
|
const [studyLayout, setStudyLayout] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('studyLayout');
|
||||||
|
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||||||
|
}); // 'stacked' | 'split'
|
||||||
|
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]);
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
project,
|
|
||||||
allChunks,
|
|
||||||
selectedChunk,
|
|
||||||
selectedChunkChapterIndex,
|
|
||||||
selectedChunkChapter,
|
|
||||||
selectedChunkVerses,
|
|
||||||
selectedChunkGlobalIndex,
|
|
||||||
studyLayout, setStudyLayout,
|
|
||||||
activeStudyTab, setActiveStudyTab,
|
|
||||||
mobileStudyTab, setMobileStudyTab,
|
|
||||||
collapsedSections, setCollapsedSections,
|
|
||||||
interlinearData,
|
|
||||||
interlinearLoading,
|
|
||||||
interlinearError,
|
|
||||||
commentarySource, setCommentarySource,
|
commentarySource, setCommentarySource,
|
||||||
commentaryData,
|
commentaryData,
|
||||||
commentaryLoading,
|
commentaryLoading,
|
||||||
commentaryError,
|
commentaryError,
|
||||||
|
interlinearData,
|
||||||
|
interlinearLoading,
|
||||||
|
interlinearError,
|
||||||
|
} = useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections });
|
||||||
|
|
||||||
|
const {
|
||||||
crossRefInput, setCrossRefInput,
|
crossRefInput, setCrossRefInput,
|
||||||
suggestingCrossRefs,
|
suggestingCrossRefs,
|
||||||
crossRefSuggestions,
|
crossRefSuggestions,
|
||||||
tagInput, setTagInput,
|
|
||||||
suggestModal, setSuggestModal,
|
|
||||||
suggestSelection, setSuggestSelection,
|
|
||||||
suggestingGreekForChunkId,
|
|
||||||
suggestingHebrewForChunkId,
|
|
||||||
projectIndex,
|
|
||||||
goToPreviousChunk,
|
|
||||||
goToNextChunk,
|
|
||||||
updateProject,
|
|
||||||
updateChunk,
|
|
||||||
addCrossRef,
|
addCrossRef,
|
||||||
removeCrossRef,
|
removeCrossRef,
|
||||||
suggestCrossRefsForChunk,
|
suggestCrossRefsForChunk,
|
||||||
addSuggestedCrossRef,
|
addSuggestedCrossRef,
|
||||||
addTag,
|
loadVerseText,
|
||||||
removeTag,
|
} = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
|
||||||
|
|
||||||
|
const {
|
||||||
|
suggestingGreekForChunkId,
|
||||||
|
suggestingHebrewForChunkId,
|
||||||
|
suggestModal, setSuggestModal,
|
||||||
|
suggestSelection, setSuggestSelection,
|
||||||
|
updateChunkWord,
|
||||||
addGreekWord,
|
addGreekWord,
|
||||||
removeGreekWord,
|
removeGreekWord,
|
||||||
updateChunkWord,
|
|
||||||
lookupWord,
|
lookupWord,
|
||||||
suggestGreekWordsForChunk,
|
suggestGreekWordsForChunk,
|
||||||
suggestHebrewWordsForChunk,
|
suggestHebrewWordsForChunk,
|
||||||
confirmSuggestWords,
|
confirmSuggestWords,
|
||||||
loadVerseText,
|
} = useWordStudy({ project, allChunks, updateProject, notify });
|
||||||
importFinalScriptDocx,
|
|
||||||
formatChunkReference: _ignored,
|
const [tagInput, setTagInput] = useState('');
|
||||||
} = useApp();
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTagInput('');
|
||||||
|
}, [selectedChunk?.id]);
|
||||||
|
|
||||||
|
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() });
|
||||||
|
notify('Imported script from DOCX.', 1800);
|
||||||
|
} catch {
|
||||||
|
notify('Could not read that DOCX file.', 1800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (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);
|
||||||
|
}, [suggestModal, selectedChunkGlobalIndex, allChunks]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
@@ -998,6 +1118,11 @@ export default function StudyPage() {
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
{statusMessage && (
|
||||||
|
<div role="status" className="fixed bottom-20 left-1/2 z-40 -translate-x-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-lg">
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user