diff --git a/src/components/iq/IqDictionary.jsx b/src/components/iq/IqDictionary.jsx
new file mode 100644
index 0000000..53e8797
--- /dev/null
+++ b/src/components/iq/IqDictionary.jsx
@@ -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 (
+
+ {entry.term}
+ {open || !p.cut ? entry.definition : p.text}
+ {p.cut && setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'} }
+ {refs.length > 0 && (
+
+ {refs.map((r) => (
+ { 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}
+ ))}
+
+ )}
+ {credit}
+
+ );
+}
+
+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 (
+
+ Bible dictionary
+
+
+ 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`} />
+ choose(e.target.value)} className={field}>
+ {dictionaries.length === 0 && {dictId} }
+ {dictionaries.map((d) => {d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count}) )}
+
+ Look up
+
+ {result.status === 'loading' &&
Looking up…
}
+ {result.status === 'error' &&
{result.error || 'The dictionary could not be reached.'}
}
+ {asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
+
Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.
+ )}
+ {exact &&
}
+ {others.length > 0 && (
+
+ {exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})
+ {others.slice(0, 8).map((e) => )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/iq/IqDictionary.test.jsx b/src/components/iq/IqDictionary.test.jsx
new file mode 100644
index 0000000..44f1dc1
--- /dev/null
+++ b/src/components/iq/IqDictionary.test.jsx
@@ -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( );
+ 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( );
+ 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);
+});
diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx
index ff669fb..69228cb 100644
--- a/src/components/study/WordStudyTab.jsx
+++ b/src/components/study/WordStudyTab.jsx
@@ -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 (
+ {iq &&
}
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'}
diff --git a/src/utils/dictionary.js b/src/utils/dictionary.js
new file mode 100644
index 0000000..0e7b07d
--- /dev/null
+++ b/src/utils/dictionary.js
@@ -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 };
+}
diff --git a/src/utils/dictionary.test.js b/src/utils/dictionary.test.js
new file mode 100644
index 0000000..773e8d8
--- /dev/null
+++ b/src/utils/dictionary.test.js
@@ -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 });
+});