// 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, ), }; }), })), })); }; // Adds a blank word entry and returns its id (so the UI can open it for editing). 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, ), })), })); return newWord.id; }; // Adds a fully filled-in entry for an original word picked from the interlinear // (Strong's number, lexeme, transliteration and gloss come with the click). const addWordFromInterlinear = async (chunkId, w) => { const chunk = allChunks.find((c) => c.id === chunkId); const key = String(w?.s ?? '').toUpperCase(); if (!chunk || !key) return null; if (chunk.greekWords.some((x) => (x.strongNumber || '').toUpperCase() === key)) { notify('That word is already in this chunk.', 1800); return chunk.greekWords.find((x) => (x.strongNumber || '').toUpperCase() === key).id; } let entry = null; try { entry = (key.startsWith('H') ? await loadHebrewDict() : await loadGreekDict())[key] ?? null; } catch { /* dictionary unavailable — keep what the click gave us */ } const newWord = { id: makeId(), query: key, strongNumber: key, englishGloss: w.g || '', lexeme: w.o || entry?.lemma || '', transliteration: w.t || entry?.translit || entry?.xlit || '', partOfSpeech: '', shortDefinition: entry?.kjv_def || w.g || '', definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '', loading: false, }; updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, greekWords: [...c.greekWords, newWord] } : c)), })), })); notify(`Added ${newWord.lexeme || key} to word study.`, 1800); return newWord.id; }; 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, addWordFromInterlinear, removeGreekWord, lookupWord, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, confirmSuggestWords, }; }