Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Studies now move through a stage bar in the reader's themes. Outline splits chunks by clicking between verses (join merges notes) and suggests chunks from section headings. Study is three panes: outline, passage with an Observe / Interpret / Apply accordion, and commentary / word study / cross-refs / people & places for the chunk; original words are added by clicking them in the interlinear. Script drafts an outline from notes; Export holds files and the share link. Home, Import and the Pronunciation guide are restyled. Study projects are BSB only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
// 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,
|
|
};
|
|
}
|