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 && } + {refs.length > 0 && ( +

+ {refs.map((r) => ( + + ))} +

+ )} +

{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`} /> + + +
+ {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 && }
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 }); +});