IQ Bible on the Study side: commentaries, lexicons, study aids, outline
Build and Push Docker Image / build-and-push (push) Failing after 19s

- Commentary tab: "More sources" switch with notes for the whole chunk and
  "Add to notes" quoting
- Word study: Lexicons button for words with a Strong's number
- New "Study aids" tab (any verse of the chunk; topics add as tags)
- Outline points: "Add from IQ Bible" attaches topics, Gospel parallels,
  manuscript notes and places

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:30:56 -04:00
1 parent 8939a7f49f
commit f70f1baf15
12 files changed
+281 -11

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
// The Study page's "Study aids" tab: IQ Bible's aids for a verse of the chunk being studied (the first by
// default). Topics can be added to the chunk as tags with one tap.
import { useState } from 'react';
import IqStudyAids from './IqStudyAids.jsx';
export default function IqChunkAids({ chunk, chapter, updateChunk, notify }) {
const [picked, setPicked] = useState(null);
const last = Math.min(chunk.endVerse, chunk.startVerse + 59);
const verse = picked != null && picked >= chunk.startVerse && picked <= last ? picked : chunk.startVerse;
const verses = Array.from({ length: last - chunk.startVerse + 1 }, (_, i) => chunk.startVerse + i);
const addTag = (name) => {
const tags = chunk.tags ?? [];
if (tags.some((t) => t.toLowerCase() === name.toLowerCase())) { notify(`“${name}” is already a tag.`, 1600); return; }
updateChunk(chunk.id, { tags: [...tags, name] });
notify(`Added the tag “${name}”.`, 1600);
};
return (
<div>
{verses.length > 1 && (
<label className="mb-3 block text-xs text-[var(--r-sub)]">Aids for verse{' '}
<select value={verse} onChange={(e) => setPicked(Number(e.target.value))} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-xs text-[var(--r-text)]">
{verses.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</label>
)}
<IqStudyAids key={`${chapter.bookAbbrev}${chapter.chapter}${verse}`} book={chapter.bookAbbrev} chapter={Number(chapter.chapter)} verse={verse} onTag={addTag} />
</div>
);
}
+5 -4
View File
@@ -7,13 +7,13 @@ import { listOf, pickIqEntries } from '../../utils/iqbible.js';
const KEY = 'iq-commentary';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
export default function IqCommentary({ book, chapter, verse }) {
export default function IqCommentary({ book, chapter, verse, endVerse = verse, onQuote }) {
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } });
const sources = useIq(`books/${book}/commentaries`, { chapter });
const list = listOf(sources.data);
const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0];
const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null);
const entries = pickIqEntries(notes.data?.entries, chapter, verse);
const entries = pickIqEntries(notes.data?.entries, chapter, verse, endVerse);
const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } };
const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {});
@@ -38,15 +38,16 @@ export default function IqCommentary({ book, chapter, verse }) {
{notes.status === 'loading' && <p className={muted}>Loading…</p>}
{notes.status === 'error' && <p className={muted}>{notes.error}</p>}
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
<p className={muted}>{source?.author_name} has no notes at or before verse {verse} in this chapter.</p>
<p className={muted}>{source?.author_name} has no notes at or before {endVerse > verse ? `verses ${verse}–${endVerse}` : `verse ${verse}`} in this chapter.</p>
)}
{entries.map((e, i) => (
<div key={i}>
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">
{e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
{e.verse !== verse || e.verse_end || endVerse > verse ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
{e.source_title ? ` · ${e.source_title}` : ''}
</p>
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">{e.text}</div>
{onQuote && <button type="button" onClick={() => onQuote(e.text, e.verse, source?.author_name)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>}
</div>
))}
</div>
+55
View File
@@ -0,0 +1,55 @@
// "Add from IQ Bible" for an outline point: pick a topic, Gospel parallel, manuscript note or place for the
// point's passage and it is attached to the point like a cross-reference or word. Nothing is fetched until
// the picker is opened.
import { useState } from 'react';
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import { listOf } from '../../utils/iqbible.js';
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
// -> [{ id, label, detail, group }] from the four responses (each may be absent)
export function aidOptions({ topics, parallels, variants, places }) {
const out = [];
listOf(topics).forEach((t) => t?.name && out.push({ id: `iq:topic:${t.name}`, group: 'Topics', label: t.name, detail: t.label || undefined }));
listOf(parallels).forEach((s) => s?.number && out.push({
id: `iq:harmony:${s.number}`, group: 'Gospel parallels',
label: (s.references ?? []).map((r) => r.citation ?? r).join('; ') || `Harmony §${s.number}`,
detail: s.title ? String(s.title).toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : undefined,
}));
listOf(variants).forEach((v) => v?.title && out.push({ id: `iq:variant:${v.slug ?? v.title}`, group: 'Manuscript notes', label: v.title, detail: v.explanation ? String(v.explanation).slice(0, 240) : undefined }));
listOf(places).forEach((p) => p?.name && out.push({ id: `iq:place:${p.id ?? p.name}`, group: 'Places', label: p.name, detail: [p.place_type, p.modern_name && p.modern_name !== p.name ? `today: ${p.modern_name}` : ''].filter(Boolean).join(' · ') || undefined }));
return out;
}
export default function IqOutlineAttach({ book, chapter, verse, attachedIds, onAttach, selectClass = '' }) {
const on = useIqAvailable();
const [open, setOpen] = useState(false);
const q = (path) => (open ? path : null);
const topics = useIq(q(`topics/${book}/${chapter}/${verse}`));
const parallels = useIq(q(GOSPELS.includes(book) ? 'harmony' : null), { book, chapter });
const variants = useIq(q(`textual-variants/${book}/${chapter}/${verse}`));
const places = useIq(q(`geo/${book}/${chapter}/${verse}`));
if (!on) return null;
if (!open) {
return <button type="button" onClick={() => setOpen(true)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Add from IQ Bible…</button>;
}
const loading = [topics, parallels, variants, places].some((r) => r.status === 'loading');
const options = aidOptions({ topics: topics.data, parallels: parallels.data, variants: variants.data, places: places.data }).filter((o) => !attachedIds.has(o.id));
const groups = options.reduce((acc, o) => { (acc[o.group] ??= []).push(o); return acc; }, {});
return (
<div className="mt-2">
{loading && <p className="text-xs text-[var(--r-sub)]">Loading from IQ Bible…</p>}
{!loading && options.length === 0 && <p className="text-xs text-[var(--r-sub)]">Nothing more to add for this passage.</p>}
{options.length > 0 && (
<select value="" aria-label="Attach from IQ Bible" className={selectClass}
onChange={(e) => { const pick = options.find((o) => o.id === e.target.value); if (pick) onAttach({ id: pick.id, type: 'see', label: pick.label, detail: pick.detail }); }}>
<option value="">Choose a topic, parallel, note or place…</option>
{Object.entries(groups).map(([group, items]) => (
<optgroup key={group} label={group}>{items.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}</optgroup>
))}
</select>
)}
<button type="button" onClick={() => setOpen(false)} className="mt-1 text-[11px] text-[var(--r-sub)] hover:underline">Close</button>
</div>
);
}
+4 -2
View File
@@ -101,7 +101,7 @@ function Harmony({ data, book, chapter }) {
);
}
export default function IqStudyAids({ book, chapter, verse }) {
export default function IqStudyAids({ book, chapter, verse, onTag }) {
return (
<div className="space-y-1.5">
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
@@ -116,7 +116,9 @@ export default function IqStudyAids({ book, chapter, verse }) {
{(d) => (
<div className="flex flex-wrap gap-1.5">
{listOf(d).slice(0, 40).map((t, i) => (
<span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
onTag
? <button key={i} type="button" onClick={() => onTag(t.name)} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs hover:bg-black/5" title={`Add “${t.name}” as a tag on this chunk`}>+ {t.name}</button>
: <span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
))}
</div>
)}
+56
View File
@@ -159,3 +159,59 @@ describe('IqDailyCard', () => {
expect(screen.getByText(/Couldn’t load NLT just now/)).toBeInTheDocument();
});
});
describe('Study side', () => {
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
test('topics from the aids tab can be added to the chunk as tags (once)', async () => {
const { default: IqChunkAids } = await import('./IqChunkAids.jsx');
routes['/api/iq/topics/JHN/3/14'] = { data: [{ name: 'faith' }, { name: 'love' }] };
const updateChunk = vi.fn();
const notify = vi.fn();
render(<IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />);
const user = userEvent.setup();
await user.click(screen.getByText('Topics on this verse'));
await user.click(await screen.findByRole('button', { name: /\+ faith/ }));
expect(updateChunk).toHaveBeenCalledWith('c1', { tags: ['love', 'faith'] });
await user.click(screen.getByRole('button', { name: /\+ love/ }));
expect(updateChunk).toHaveBeenCalledTimes(1); // already a tag
expect(notify).toHaveBeenLastCalledWith('“love” is already a tag.', 1600);
});
test('the aids can be read for any verse of the chunk', async () => {
const { default: IqChunkAids } = await import('./IqChunkAids.jsx');
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'eternal life' }] };
render(<IqChunkAids chunk={chunk} chapter={chapter} updateChunk={() => {}} notify={() => {}} />);
const user = userEvent.setup();
await user.selectOptions(screen.getByRole('combobox'), '16');
await user.click(screen.getByText('Topics on this verse'));
expect(await screen.findByText('+ eternal life')).toBeInTheDocument();
});
test('outline attachments are offered from topics, parallels and notes, minus those already attached', async () => {
const { aidOptions } = await import('./IqOutlineAttach.jsx');
const options = aidOptions({
topics: { data: [{ name: 'faith' }, { name: 'grace', label: 'Of God' }] },
parallels: { data: [{ number: '72', title: 'FEEDING OF THE FIVE THOUSAND', references: [{ citation: 'Mark 6:30-44' }, { citation: 'John 6:1-13' }] }] },
variants: { data: [{ slug: 'mark-16', title: 'The Longer Ending of Mark', explanation: 'Long.' }] },
places: { data: [{ id: 5, name: 'Capernaum', place_type: 'settlement' }] },
});
expect(options.map((o) => o.group)).toEqual(['Topics', 'Topics', 'Gospel parallels', 'Manuscript notes', 'Places']);
expect(options[2]).toMatchObject({ id: 'iq:harmony:72', label: 'Mark 6:30-44; John 6:1-13', detail: 'Feeding Of The Five Thousand' });
});
test('the outline picker fetches nothing until it is opened, then attaches the choice', async () => {
const { default: IqOutlineAttach } = await import('./IqOutlineAttach.jsx');
routes['/api/iq/topics/JHN/3/14'] = { data: [{ name: 'faith' }, { name: 'grace' }] };
const onAttach = vi.fn();
render(<IqOutlineAttach book="JHN" chapter={3} verse={14} attachedIds={new Set(['iq:topic:grace'])} onAttach={onAttach} />);
const user = userEvent.setup();
const button = await screen.findByRole('button', { name: /add from iq bible/i });
expect(asked.some((u) => u.includes('/topics/'))).toBe(false);
await user.click(button);
await user.selectOptions(await screen.findByLabelText('Attach from IQ Bible'), 'iq:topic:faith');
expect(onAttach).toHaveBeenCalledWith({ id: 'iq:topic:faith', type: 'see', label: 'faith', detail: undefined });
expect(screen.queryByRole('option', { name: 'grace' })).toBeNull(); // already attached
});
});
@@ -4,8 +4,44 @@
// the chunk's background notes.
import { COMMENTARIES } from '../../hooks/useVerseStudy.js';
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
import { useState } from 'react';
import { useIqAvailable } from '../../hooks/useIq.js';
import IqCommentary from '../iq/IqCommentary.jsx';
export default function CommentaryChunkTab({ resources, chunk, project, chapterIndex, updateChunk, notify }) {
const iq = useIqAvailable();
const [more, setMore] = useState(() => { try { return localStorage.getItem('iq-commentary-mode') === '1'; } catch { return false; } });
const setMode = (on) => { setMore(on); try { localStorage.setItem('iq-commentary-mode', on ? '1' : '0'); } catch { /* storage unavailable */ } };
const chip = (on) => `rounded-full border px-2.5 py-0.5 text-[11px] ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)]'}`;
const chapter = project.chapters[chapterIndex];
if (iq && more) {
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const quote = (text, verse, author) => {
const citation = `${text} (${author}, ${ref.split(':')[0]}:${verse})`;
const existing = chunk.generalNotes ?? '';
updateChunk(chunk.id, { generalNotes: existing ? `${existing}\n\n${citation}` : citation });
notify('Added to background notes.', 1600);
};
return (
<div>
<ModeSwitch more={more} setMode={setMode} chip={chip} />
<IqCommentary book={chapter.bookAbbrev} chapter={Number(chapter.chapter)} verse={chunk.startVerse} endVerse={chunk.endVerse} onQuote={quote} />
</div>
);
}
return <ClassicChunkCommentary {...{ resources, chunk, project, chapterIndex, updateChunk, notify }} header={iq ? <ModeSwitch more={more} setMode={setMode} chip={chip} /> : null} />;
}
function ModeSwitch({ more, setMode, chip }) {
return (
<div className="mb-2 flex gap-1.5" role="group" aria-label="Commentary library">
<button type="button" onClick={() => setMode(false)} aria-pressed={!more} className={chip(!more)}>Classic</button>
<button type="button" onClick={() => setMode(true)} aria-pressed={more} className={chip(more)}>More sources</button>
</div>
);
}
function ClassicChunkCommentary({ resources, chunk, project, chapterIndex, updateChunk, notify, header }) {
const { commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError } = resources;
const source = COMMENTARIES.find((c) => c.id === commentarySource) ?? COMMENTARIES[0];
const items = (commentaryData?.chapter?.content ?? []).filter((i) => i.type === 'verse').sort((a, b) => a.number - b.number);
@@ -26,6 +62,7 @@ export default function CommentaryChunkTab({ resources, chunk, project, chapterI
return (
<div>
{header}
<select aria-label="Commentary source" value={source.id} onChange={(e) => setCommentarySource(e.target.value)}
className="mb-3 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
+8 -1
View File
@@ -5,20 +5,26 @@ import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx';
import IqChunkAids from '../iq/IqChunkAids.jsx';
import { useIqAvailable } from '../../hooks/useIq.js';
export const RESOURCE_TABS = [
{ id: 'commentary', label: 'Commentary' },
{ id: 'words', label: 'Word study' },
{ id: 'refs', label: 'Cross-refs' },
{ id: 'people', label: 'People & places' },
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
];
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
if (tab === 'aids' && !iq) tab = 'commentary';
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
return (
<div className="flex min-h-0 flex-1 flex-col">
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
{RESOURCE_TABS.map((t) => (
{tabs.map((t) => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.label}{counts[t.id] ? ` · ${counts[t.id]}` : ''}
@@ -30,6 +36,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
{tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
</div>
</div>
);
@@ -0,0 +1,54 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import ResourcePane from './ResourcePane.jsx';
import { resetIq } from '../../utils/iqbible.js';
let available;
beforeEach(() => {
resetIq();
available = true;
vi.stubGlobal('fetch', vi.fn((url) => {
const path = String(url);
const body = path === '/api/iq/status' ? { available }
: path === '/api/iq/lexicon/greek/strongs/G4102' ? { data: [{ glossary: 'pistis: faith', occurences: 243 }] }
: path === '/api/iq/lexicon/crosswalk/G4102' ? { data: [] } : { error: 'nf' };
const ok = path.startsWith('/api/iq/status') || path.includes('G4102');
return Promise.resolve({ ok, status: ok ? 200 : 404, json: () => Promise.resolve(body) });
}));
localStorage.clear();
});
afterEach(() => vi.unstubAllGlobals());
const chunk = { id: 'c1', startVerse: 1, endVerse: 3, tags: [], crossReferences: [], greekWords: [{ id: 'w1', query: 'G4102', strongNumber: 'G4102', lexeme: 'πίστις', shortDefinition: 'faith' }] };
const wordStudy = new Proxy({}, { get: () => vi.fn() });
const project = { id: 'p', title: 'T', chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [], chunks: [chunk] }] };
const props = (tab) => ({
tab, setTab: vi.fn(), chunk, chapter: project.chapters[0], chapterIndex: 0, project, updateChunk: vi.fn(), notify: vi.fn(),
resources: {}, wordStudy, crossRefs: {}, openWordId: 'w1', setOpenWordId: vi.fn(),
});
test('the Study aids tab appears only when the server has an IQ Bible key', async () => {
const { unmount } = render(<ResourcePane {...props('words')} />);
expect(await screen.findByRole('tab', { name: /study aids/i })).toBeInTheDocument();
unmount();
available = false;
resetIq();
render(<ResourcePane {...props('words')} />);
await waitFor(() => expect(fetch).toHaveBeenCalledWith('/api/iq/status', expect.anything()));
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByRole('tab', { name: /study aids/i })).toBeNull();
});
test('a saved "Study aids" tab falls back to commentary when the key is gone', async () => {
available = false;
render(<ResourcePane {...props('aids')} />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText('About this chapter')).toBeNull();
});
test('a word with a Strong\'s number can be looked up in the lexicons from word study', async () => {
render(<ResourcePane {...props('words')} />);
await userEvent.setup().click(await screen.findByRole('button', { name: 'Lexicons' }));
expect(await screen.findByText(/pistis: faith/)).toBeInTheDocument();
});
+8
View File
@@ -3,12 +3,18 @@
import { useState } from 'react';
import DOMPurify from 'dompurify';
import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import { normalizeStrongs } from '../../utils/iqbible.js';
import IqLexicon from '../iq/IqLexicon.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';
function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
const iq = useIqAvailable();
const [lexicon, setLexicon] = useState(false);
const strongs = normalizeStrongs(word.strongNumber || word.query);
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
const field = (label, key, placeholder) => (
<label className="block text-xs text-[var(--r-sub)]">{label}
@@ -25,6 +31,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
</button>
{open && (
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
{lexicon && iq && strongs && <IqLexicon strongs={strongs} onClose={() => setLexicon(false)} />}
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
@@ -33,6 +40,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
</div>
+9
View File
@@ -10,6 +10,7 @@ import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
import {
@@ -301,6 +302,14 @@ export default function OutlinePage() {
{available.map((a) => <option key={a.key} value={a.key}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</option>)}
</select>
)}
{attachSource && (() => {
const chap = project.chapters.find((c) => c.chunks.some((k) => k.id === attachSource.id));
return chap ? (
<IqOutlineAttach book={chap.bookAbbrev} chapter={Number(chap.chapter)} verse={attachSource.startVerse}
attachedIds={attached} selectClass={field}
onAttach={(att) => patch(node.id, { attachments: [...(node.attachments ?? []), att] })} />
) : null;
})()}
</div>
</div>
)}
+6 -4
View File
@@ -77,11 +77,13 @@ export function normalizeStrongs(s) {
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
}
// The commentary entries that speak to `verse`: those covering it by range, else the closest earlier ones.
export function pickIqEntries(entries, chapter, verse) {
// The commentary entries that speak to verses `verse`..`endVerse`: those starting inside the range or covering
// its start by their own range; if there are none, the closest earlier ones.
export function pickIqEntries(entries, chapter, verse, endVerse = verse) {
const here = (entries ?? []).filter((e) => (e.chapter ?? chapter) === chapter && (e.verse ?? 0) > 0 && e.text);
const covers = here.filter((e) => (e.verse ?? 0) <= verse && verse <= ((e.chapter_end ?? chapter) === chapter ? (e.verse_end ?? e.verse) : Infinity));
if (covers.length) return covers;
const entryEnd = (e) => ((e.chapter_end ?? chapter) === chapter ? (e.verse_end ?? e.verse) : Infinity);
const inside = here.filter((e) => (e.verse >= verse && e.verse <= endVerse) || (e.verse <= verse && verse <= entryEnd(e)));
if (inside.length) return inside.sort((x, y) => x.verse - y.verse);
const earlier = here.filter((e) => e.verse <= verse);
if (!earlier.length) return [];
const start = Math.max(...earlier.map((e) => e.verse));
+10
View File
@@ -27,3 +27,13 @@ test('loosely shaped records are summarised and lists found', () => {
expect(listOf({ topics: [3] })).toEqual([3]);
expect(iqUrl('bibles/eng_kjv/reading-plan', { days: 30, books: 'MAT,MRK', format: 'ical', skip: '' })).toBe('/api/iq/bibles/eng_kjv/reading-plan?days=30&books=MAT%2CMRK&format=ical');
});
test('a chunk\'s verse range picks every entry starting inside it, plus one covering its start', () => {
const entries = [
{ chapter: 3, verse: 1, verse_end: 4, text: 'covers the start' },
{ chapter: 3, verse: 6, text: 'inside' },
{ chapter: 3, verse: 12, text: 'after' },
];
expect(pickIqEntries(entries, 3, 3, 8).map((e) => e.text)).toEqual(['covers the start', 'inside']);
expect(pickIqEntries(entries, 3, 9, 11).map((e) => e.text)).toEqual(['inside']); // nothing inside: closest earlier
});