diff --git a/src/components/iq/IqChunkAids.jsx b/src/components/iq/IqChunkAids.jsx
new file mode 100644
index 0000000..94f299c
--- /dev/null
+++ b/src/components/iq/IqChunkAids.jsx
@@ -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 (
+
+ {verses.length > 1 && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/iq/IqCommentary.jsx b/src/components/iq/IqCommentary.jsx
index bee3634..0d88435 100644
--- a/src/components/iq/IqCommentary.jsx
+++ b/src/components/iq/IqCommentary.jsx
@@ -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' && Loading…
}
{notes.status === 'error' && {notes.error}
}
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
- {source?.author_name} has no notes at or before verse {verse} in this chapter.
+ {source?.author_name} has no notes at or before {endVerse > verse ? `verses ${verse}–${endVerse}` : `verse ${verse}`} in this chapter.
)}
{entries.map((e, i) => (
- {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}` : ''}
{e.text}
+ {onQuote &&
}
))}
diff --git a/src/components/iq/IqOutlineAttach.jsx b/src/components/iq/IqOutlineAttach.jsx
new file mode 100644
index 0000000..9ea21cb
--- /dev/null
+++ b/src/components/iq/IqOutlineAttach.jsx
@@ -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 ;
+ }
+ 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 (
+
+ {loading &&
Loading from IQ Bible…
}
+ {!loading && options.length === 0 &&
Nothing more to add for this passage.
}
+ {options.length > 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/iq/IqStudyAids.jsx b/src/components/iq/IqStudyAids.jsx
index 808c773..431c8b1 100644
--- a/src/components/iq/IqStudyAids.jsx
+++ b/src/components/iq/IqStudyAids.jsx
@@ -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 (
@@ -116,7 +116,9 @@ export default function IqStudyAids({ book, chapter, verse }) {
{(d) => (
{listOf(d).slice(0, 40).map((t, i) => (
- {t.name}
+ onTag
+ ?
+ : {t.name}
))}
)}
diff --git a/src/components/iq/iq.test.jsx b/src/components/iq/iq.test.jsx
index 161af14..9442038 100644
--- a/src/components/iq/iq.test.jsx
+++ b/src/components/iq/iq.test.jsx
@@ -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();
+ 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( {}} 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();
+ 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
+ });
+});
diff --git a/src/components/study/CommentaryChunkTab.jsx b/src/components/study/CommentaryChunkTab.jsx
index 0e779ef..0b2c192 100644
--- a/src/components/study/CommentaryChunkTab.jsx
+++ b/src/components/study/CommentaryChunkTab.jsx
@@ -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 (
+
+
+
+
+ );
+ }
+ return : null} />;
+}
+
+function ModeSwitch({ more, setMode, chip }) {
+ return (
+
+
+
+
+ );
+}
+
+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 (
+ {header}
)}
diff --git a/src/utils/iqbible.js b/src/utils/iqbible.js
index deac0cf..76c9f4b 100644
--- a/src/utils/iqbible.js
+++ b/src/utils/iqbible.js
@@ -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));
diff --git a/src/utils/iqbible.test.js b/src/utils/iqbible.test.js
index 5f337de..f178cf4 100644
--- a/src/utils/iqbible.test.js
+++ b/src/utils/iqbible.test.js
@@ -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
+});