Bible dictionary lookup in the Word study tab (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fa9e825444
commit
fdb7f16d8a
5 files changed
+195
No files matched your search
@@ -0,0 +1,75 @@
|
||||
// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible).
|
||||
// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it
|
||||
// are folded below. Scripture references in an entry open in the Reader.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { pickEntries, preview, refTarget } from '../../utils/dictionary.js';
|
||||
|
||||
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
|
||||
function Entry({ entry, credit, open: startOpen }) {
|
||||
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const [open, setOpen] = useState(startOpen);
|
||||
const p = preview(entry.definition);
|
||||
const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14);
|
||||
return (
|
||||
<article className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||
<h4 className="font-medium">{entry.term}</h4>
|
||||
<p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed">{open || !p.cut ? entry.definition : p.text}</p>
|
||||
{p.cut && <button type="button" onClick={() => setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'}</button>}
|
||||
{refs.length > 0 && (
|
||||
<p className="mt-2 flex flex-wrap gap-1.5">
|
||||
{refs.map((r) => (
|
||||
<button key={r.label} type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse); openBibleReader(); }} title="Read it in the Bible"
|
||||
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5">{r.label}</button>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-[10px] text-[var(--r-sub)]">{credit}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqDictionary() {
|
||||
const [dictId, setDictId] = useState(() => { try { return localStorage.getItem('dictionary') || 'easton'; } catch { return 'easton'; } });
|
||||
const [typed, setTyped] = useState('');
|
||||
const [asked, setAsked] = useState('');
|
||||
const list = useIq('dictionaries');
|
||||
const dictionaries = list.data?.data ?? [];
|
||||
const current = dictionaries.find((d) => d.id === dictId) ?? dictionaries[0];
|
||||
const result = useIq(asked && current ? `dictionaries/${current.id}` : null, { q: asked });
|
||||
const { exact, others } = pickEntries(result.data?.data, asked);
|
||||
const credit = current ? `${current.name}, ${current.author}, ${current.year}. ${current.license}.` : '';
|
||||
const choose = (id) => { setDictId(id); try { localStorage.setItem('dictionary', id); } catch { /* storage unavailable */ } };
|
||||
const ask = () => setAsked(typed.trim().slice(0, 100));
|
||||
|
||||
return (
|
||||
<details className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="Bible dictionary">
|
||||
<summary className="cursor-pointer text-sm font-medium">Bible dictionary</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input type="text" aria-label="Look up a word" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder="A name, place or word, e.g. cubit"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }} className={`${field} min-w-0 flex-1`} />
|
||||
<select aria-label="Dictionary" value={current?.id ?? dictId} onChange={(e) => choose(e.target.value)} className={field}>
|
||||
{dictionaries.length === 0 && <option value={dictId}>{dictId}</option>}
|
||||
{dictionaries.map((d) => <option key={d.id} value={d.id}>{d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count})</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={ask} disabled={!typed.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Look up</button>
|
||||
</div>
|
||||
{result.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Looking up…</p>}
|
||||
{result.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{result.error || 'The dictionary could not be reached.'}</p>}
|
||||
{asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
|
||||
<p className="text-xs text-[var(--r-sub)]">Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.</p>
|
||||
)}
|
||||
{exact && <Entry entry={exact} credit={credit} open={false} />}
|
||||
{others.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-[var(--r-sub)]">{exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})</summary>
|
||||
<div className="mt-2 space-y-2">{others.slice(0, 8).map((e) => <Entry key={e.slug ?? e.term} entry={e} credit={credit} open={false} />)}</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../../context/AppContext.js';
|
||||
import IqDictionary from './IqDictionary.jsx';
|
||||
|
||||
const LIST = { count: 2, data: [
|
||||
{ id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 },
|
||||
{ id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 },
|
||||
] };
|
||||
const ANSWER = { count: 2, data: [
|
||||
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] },
|
||||
{ term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] },
|
||||
] };
|
||||
|
||||
const jump = vi.fn();
|
||||
const open = vi.fn();
|
||||
const asked = [];
|
||||
beforeEach(() => {
|
||||
asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear();
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
asked.push(String(url));
|
||||
const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST;
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
||||
}));
|
||||
});
|
||||
|
||||
test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||
await user.click(screen.getByText('Bible dictionary'));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}');
|
||||
const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article');
|
||||
expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument();
|
||||
expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true);
|
||||
await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' }));
|
||||
expect(jump).toHaveBeenCalledWith('MAT', 25, 15);
|
||||
expect(open).toHaveBeenCalled();
|
||||
expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it
|
||||
});
|
||||
|
||||
test('the chosen dictionary is remembered and used for the next lookup', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||
await user.click(screen.getByText('Bible dictionary'));
|
||||
await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith');
|
||||
expect(localStorage.getItem('dictionary')).toBe('smith');
|
||||
await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}');
|
||||
await screen.findByRole('heading', { name: 'Talent' });
|
||||
expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||
import GrammarPanel from '../GrammarPanel.jsx';
|
||||
import IqDictionary from '../iq/IqDictionary.jsx';
|
||||
|
||||
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
@@ -78,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
|
||||
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
||||
const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
|
||||
return (
|
||||
<div>
|
||||
{iq && <IqDictionary />}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Bible dictionary lookups (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith). The route is
|
||||
// /dictionaries/{id}?q=term and answers { count, data: [{ term, slug, definition, scripture_refs?, citations? }] },
|
||||
// matching the word anywhere in an entry, so the entry actually named for the word is put first.
|
||||
import { parsePassageRef } from './passageRef.js';
|
||||
|
||||
const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/[^a-z0-9 ]/g, '');
|
||||
|
||||
// rows -> { exact: the entry whose heading is the word (or null), others: the rest }
|
||||
export function pickEntries(rows, q) {
|
||||
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r.definition === 'string') : [];
|
||||
const want = norm(q);
|
||||
const exact = list.find((r) => norm(r.term) === want) ?? null;
|
||||
return { exact, others: list.filter((r) => r !== exact) };
|
||||
}
|
||||
|
||||
// "Exodus 4:27-30", "Psalms 106:19", "Leviticus 8" -> { book, chapter, verse } for the Reader, or null
|
||||
export function refTarget(text) {
|
||||
const cleaned = String(text ?? '').replace(/\s*[-–,].*$/, '').trim();
|
||||
const hit = parsePassageRef(cleaned)[0];
|
||||
return hit ? { book: hit.book, chapter: hit.chapter, verse: hit.verse ?? 1, label: String(text).trim() } : null;
|
||||
}
|
||||
|
||||
// A long definition is cut at a sentence end near `max` characters for the preview; the full text stays one click away.
|
||||
export function preview(text, max = 420) {
|
||||
const t = String(text ?? '').trim();
|
||||
if (t.length <= max) return { text: t, cut: false };
|
||||
const upTo = t.slice(0, max);
|
||||
const end = Math.max(upTo.lastIndexOf('. '), upTo.lastIndexOf('; '));
|
||||
return { text: `${end > max * 0.5 ? upTo.slice(0, end + 1) : upTo.replace(/\s+\S*$/, '')}…`, cut: true };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { pickEntries, preview, refTarget } from './dictionary.js';
|
||||
|
||||
const rows = [
|
||||
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone… a denarius…' },
|
||||
{ term: 'Denarius', slug: 'denarius', definition: 'A Roman silver coin.', scripture_refs: ['Matthew 20:2'] },
|
||||
{ term: 'Farthing', slug: 'farthing', definition: 'mentions the denarius' },
|
||||
];
|
||||
|
||||
test('the entry named for the word comes first, the entries that only mention it follow', () => {
|
||||
const { exact, others } = pickEntries(rows, ' denarius ');
|
||||
expect(exact.term).toBe('Denarius');
|
||||
expect(others.map((o) => o.term)).toEqual(['Alabaster', 'Farthing']);
|
||||
expect(pickEntries(rows, 'penny').exact).toBeNull();
|
||||
expect(pickEntries(rows, 'penny').others).toHaveLength(3);
|
||||
expect(pickEntries(null, 'x')).toEqual({ exact: null, others: [] });
|
||||
expect(pickEntries([{ term: 'Bad' }], 'bad').exact).toBeNull(); // an entry without a definition is ignored
|
||||
});
|
||||
|
||||
test('Scripture references in an entry are turned into places in the Reader', () => {
|
||||
expect(refTarget('Exodus 4:27-30')).toMatchObject({ book: 'EXO', chapter: 4, verse: 27, label: 'Exodus 4:27-30' });
|
||||
expect(refTarget('Psalms 106:19')).toMatchObject({ book: 'PSA', chapter: 106, verse: 19 });
|
||||
expect(refTarget('Leviticus 8')).toMatchObject({ book: 'LEV', chapter: 8, verse: 1 });
|
||||
expect(refTarget('1 Chronicles 2:10')).toMatchObject({ book: '1CH', chapter: 2, verse: 10 });
|
||||
expect(refTarget('nonsense')).toBeNull();
|
||||
});
|
||||
|
||||
test('a long definition is previewed up to a sentence end', () => {
|
||||
const long = `${'First sentence is here. '.repeat(30)}`;
|
||||
const p = preview(long, 100);
|
||||
expect(p.cut).toBe(true);
|
||||
expect(p.text.endsWith('…')).toBe(true);
|
||||
expect(p.text.length).toBeLessThan(110);
|
||||
expect(preview('Short.', 100)).toEqual({ text: 'Short.', cut: false });
|
||||
});
|
||||
Reference in new issue
Block a user