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} 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 }) {
+ {iq && strongs && } {word.lexeme && }
diff --git a/src/pages/OutlinePage.jsx b/src/pages/OutlinePage.jsx index 8cdcaec..f545bb8 100644 --- a/src/pages/OutlinePage.jsx +++ b/src/pages/OutlinePage.jsx @@ -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) => )} )} + {attachSource && (() => { + const chap = project.chapters.find((c) => c.chunks.some((k) => k.id === attachSource.id)); + return chap ? ( + patch(node.id, { attachments: [...(node.attachments ?? []), att] })} /> + ) : null; + })()}
)} 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 +});