Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
// More commentaries from IQ Bible: Gill and Matthew Henry plus hundreds of church-history sources.
// Only sources that actually have notes on the chapter being read are offered.
import { useState } from 'react';
import { useIq } from '../../hooks/useIq.js';
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 }) {
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 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; }, {});
if (sources.status === 'loading') return <p className={muted}>Loading…</p>;
if (sources.status === 'error') return <p className={muted}>{sources.error}</p>;
if (!list.length) return <p className={muted}>No IQ Bible commentaries cover this chapter.</p>;
return (
<div>
<label className={muted}>
Source ({list.length}){' '}
<select value={source?.name ?? ''} onChange={(e) => pick(e.target.value)} className="max-w-[16rem] rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
{Object.entries(groups).map(([cat, items]) => (
<optgroup key={cat} label={cat}>
{items.map((s) => <option key={s.name} value={s.name}>{s.author_name}{s.year ? ` (${s.year})` : ''}</option>)}
</optgroup>
))}
</select>
</label>
<div className="mt-2 space-y-2">
{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>
)}
{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.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>
</div>
))}
</div>
{source && <p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.author_name}{source.year ? `, ${source.year}` : ''} · via IQ Bible{source.license ? ` · ${source.license}` : ''}</p>}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
import { useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
export default function IqDailyCard() {
const on = useIqAvailable();
if (!on) return null;
return <Card />;
}
function Card() {
const { jumpToReaderVerse, openBibleReader } = useApp();
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
const dev = useIq('devotionals/today');
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
const verse = votd.data?.data;
const reading = dev.data?.data?.[part];
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
if (!verse && !reading) return null;
return (
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
{verse && (
<div>
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day · KJV</p>
<p className="mt-1 font-serif text-[15px] leading-relaxed">{verse.text}</p>
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
className="mt-1 text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
</div>
)}
{reading && (
<details className="mt-3 border-t border-[var(--r-line)] pt-3">
<summary className="cursor-pointer text-sm font-medium">Spurgeon, {part === 'morning' ? 'Morning' : 'Evening'}: “{reading.heading_text}”</summary>
<div className="mt-2 flex gap-1.5">
{['morning', 'evening'].map((p) => (
<button key={p} type="button" onClick={() => setPart(p)} aria-pressed={part === p}
className={`rounded-full border px-2.5 py-0.5 text-[11px] ${part === p ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)]'}`}>{p === 'morning' ? 'Morning' : 'Evening'}</button>
))}
</div>
<p className="mt-1 text-xs text-[var(--r-sub)]">{reading.heading_citation}</p>
<div className="mt-2 max-h-72 overflow-y-auto whitespace-pre-line font-serif text-[15px] leading-relaxed">{reading.body}</div>
<p className="mt-2 text-[10px] text-[var(--r-sub)]">Charles Spurgeon, Morning and Evening (1866), via IQ Bible.</p>
</details>
)}
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
// A word's entry in the original-language lexicons: Strong's, then Brown-Driver-Briggs (Hebrew) or
// Liddell-Scott-Jones and Abbott-Smith (Greek), found through IQ Bible's Strong's crosswalk.
import { useState } from 'react';
import DOMPurify from 'dompurify';
import { useIq } from '../../hooks/useIq.js';
import { listOf, normalizeStrongs } from '../../utils/iqbible.js';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
const SOURCES = { bdb: 'Brown-Driver-Briggs', lsj: 'Liddell-Scott-Jones', 'abbott-smith': 'Abbott-Smith' };
const html = (s) => DOMPurify.sanitize(String(s ?? ''), { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'span', 'ul', 'ol', 'li', 'sup', 'sub'], ALLOWED_ATTR: [] });
function SourceEntry({ lang, source, nativeKey }) {
const { status, data, error } = useIq(`lexicon/${lang}/${source}/${encodeURIComponent(nativeKey)}`);
const entry = listOf(data)[0];
return (
<details className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
<summary className="cursor-pointer text-sm font-medium">{SOURCES[source] ?? source}</summary>
<div className="mt-2 text-[13px] leading-relaxed">
{status === 'loading' && <p className={muted}>Loading…</p>}
{status === 'error' && <p className={muted}>{error}</p>}
{status === 'empty' && <p className={muted}>No entry.</p>}
{entry && source === 'bdb' && (
<>
<p><span className="font-serif text-base" dir="rtl">{entry.head_word}</span> <span className={muted}>{entry.pos}</span></p>
{entry.primary_gloss && <p className="font-medium">{entry.primary_gloss}</p>}
<div className="whitespace-pre-line" dangerouslySetInnerHTML={{ __html: html(entry.description) }} />
</>
)}
{entry && source !== 'bdb' && (
<>
<p><span className="font-serif text-base">{entry.greek}</span> <span className={muted}>{entry.transliteration}{entry.morph ? ` · ${entry.morph}` : ''}</span></p>
{entry.gloss && <p className="font-medium">{entry.gloss}</p>}
<div className="max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: html(entry.meaning) }} />
</>
)}
</div>
</details>
);
}
function Occurrences({ strongs }) {
const { status, data, error } = useIq('original-language/search', { strongs, limit: 20 });
if (status === 'loading') return <p className={muted}>Loading…</p>;
if (status === 'error') return <p className={muted}>{error}</p>;
const list = data?.occurrences ?? [];
if (!list.length) return <p className={muted}>No occurrences found.</p>;
return (
<p className="text-[13px] leading-relaxed">
{list.map((o) => `${o.name_en} ${o.chapter}:${o.verse}`).join(' · ')}
{data.count >= 20 && <span className={muted}> … first 20 shown</span>}
</p>
);
}
export default function IqLexicon({ strongs: raw, onClose }) {
const strongs = normalizeStrongs(raw);
const lang = strongs?.startsWith('H') ? 'hebrew' : 'greek';
const basic = useIq(strongs ? `lexicon/${lang}/strongs/${strongs}` : null);
const cross = useIq(strongs ? `lexicon/crosswalk/${strongs}` : null);
const [showOcc, setShowOcc] = useState(false);
if (!strongs) return null;
const strong = listOf(basic.data)[0];
const sources = listOf(cross.data).filter((c) => SOURCES[c.source] && c.native_key && (lang === 'hebrew' ? c.source === 'bdb' : c.source !== 'bdb'));
const seen = new Set();
const unique = sources.filter((c) => { const k = `${c.source}/${c.native_key}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 4);
return (
<div className="mt-3 rounded-xl border border-[var(--r-line,#cbd5e1)] p-3" role="region" aria-label={`Lexicon entry for ${strongs}`}>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">{strongs}{strong?.occurences ? <span className="ml-2 text-xs font-normal text-[var(--r-sub,#64748b)]">{strong.occurences}× in the Bible</span> : null}</p>
<button type="button" onClick={onClose} aria-label="Close lexicon entry" className="rounded p-1 text-[var(--r-sub,#64748b)] hover:bg-black/5">×</button>
</div>
{basic.status === 'loading' && <p className={muted}>Loading…</p>}
{basic.status === 'error' && <p className={muted}>{basic.error}</p>}
{strong && <p className="mb-2 whitespace-pre-line text-[13px] leading-relaxed">{strong.glossary}</p>}
<div className="space-y-1.5">
{cross.status === 'loading' && <p className={muted}>Finding other lexicons…</p>}
{unique.map((c) => <SourceEntry key={`${c.source}/${c.native_key}`} lang={lang} source={c.source} nativeKey={c.native_key} />)}
</div>
<button type="button" onClick={() => setShowOcc((o) => !o)} className="mt-2 text-xs font-medium text-[var(--r-accent,#0369a1)] hover:underline">
{showOcc ? 'Hide' : 'Where this word appears'}
</button>
{showOcc && <div className="mt-1"><Occurrences strongs={strongs} /></div>}
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">Lexicons via IQ Bible (Strong's, BDB, LSJ, Abbott-Smith; see credits there).</p>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
// chapter this way uses one of the account's monthly audio streams, so the group says so.
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import { listOf } from '../../utils/iqbible.js';
export default function IqNarratorOptions() {
const on = useIqAvailable();
const { data } = useIq(on ? 'audio' : null);
const sets = listOf(data).filter((s) => s.active !== false);
if (!on || sets.length === 0) return null;
return (
<optgroup label="IQ Bible narrations (uses monthly streams)">
{sets.map((s) => <option key={s.audio_id} value={`iq:${s.audio_id}`}>{s.audio_id}{s.narrator ? ` · ${s.narrator}` : ''}</option>)}
</optgroup>
);
}
+24
View File
@@ -0,0 +1,24 @@
// Calendar (.ics) and spreadsheet (.csv) downloads of a reading plan, generated by IQ Bible so the
// readings show up as daily all-day events in any calendar app.
import { useIqAvailable } from '../../hooks/useIq.js';
import { iqUrl } from '../../utils/iqbible.js';
const link = 'text-[var(--r-sub)] hover:text-[var(--r-text)] underline-offset-2 hover:underline';
export default function IqPlanExport({ plan, mcheyne = false }) {
const on = useIqAvailable();
if (!on) return null;
const query = mcheyne
? { plan: 'mcheyne' }
: Array.isArray(plan?.books) && plan.books.length > 0 ? { days: plan.days, books: plan.books.join(',') } : null;
if (!query) return null;
const href = (format) => iqUrl('bibles/eng_kjv/reading-plan', { ...query, format });
return (
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--r-sub)]">
<span>{mcheyne ? 'M’Cheyne’s 365-day calendar' : 'Take this plan with you'}:</span>
<a href={href('ical')} download className={link}>Add to calendar (.ics)</a>
<a href={href('csv')} download className={link}>Spreadsheet (.csv)</a>
{!mcheyne && <span>Starts today, in the order of the Bible.</span>}
</p>
);
}
+142
View File
@@ -0,0 +1,142 @@
// A verse's "study aids" from IQ Bible: chapter overview, topics, places, people, textual notes, Gospel
// parallels and timeline. Each section loads only when opened, so nothing is spent on what isn't read.
import { useState } from 'react';
import { useIq } from '../../hooks/useIq.js';
import { listOf } from '../../utils/iqbible.js';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
function Aid({ title, path, query, children, empty = 'Nothing recorded here.' }) {
const [open, setOpen] = useState(false);
const { status, data, error } = useIq(open ? path : null, query);
return (
<details onToggle={(e) => setOpen(e.currentTarget.open)} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
<summary className="cursor-pointer text-sm font-medium">{title}</summary>
<div className="mt-2 text-[13px] leading-relaxed">
{status === 'loading' && <p className={muted}>Loading…</p>}
{status === 'error' && <p className={muted}>{error}</p>}
{status === 'empty' && <p className={muted}>{empty}</p>}
{status === 'ready' && children(data)}
</div>
</details>
);
}
function PersonProfile({ ustrong }) {
const { status, data } = useIq(`bible-characters/by-ustrong/${ustrong}`);
const p = data?.data;
if (status === 'loading') return <p className={muted}>Loading…</p>;
if (!p) return <p className={muted}>No profile.</p>;
return (
<div className="mt-1 space-y-1">
<p>{p.brief || p.briefest || p.description}</p>
{p.tribe_nation && <p className={muted}>{p.tribe_nation}</p>}
{p.first_appearance && <p className={muted}>First appears {p.first_appearance.book} {p.first_appearance.chapter}:{p.first_appearance.verse}</p>}
</div>
);
}
function People({ data, verse }) {
const [open, setOpen] = useState(null);
const people = listOf(data);
return (
<ul className="space-y-1">
{people.map((p) => (
<li key={p.ustrong}>
<button type="button" onClick={() => setOpen(open === p.ustrong ? null : p.ustrong)} className="text-left font-medium">
{p.name} <span className={muted}>{p.first_verse === verse ? 'this verse' : `v${p.first_verse}`} {open === p.ustrong ? '▾' : '▸'}</span>
</button>
{open === p.ustrong && <PersonProfile ustrong={p.ustrong} />}
</li>
))}
</ul>
);
}
function Places({ data }) {
const places = listOf(data);
return (
<ul className="space-y-1.5">
{places.map((p) => (
<li key={p.id} className="flex items-baseline justify-between gap-2">
<span>{p.name}<span className={muted}> {p.place_type}{p.modern_name && p.modern_name !== p.name ? ` · today: ${p.modern_name}` : ''}{p.special ? ` · ${p.special.replace(/_/g, ' ')}` : ''}</span></span>
{p.lat != null && p.lon != null && (
<a href={`https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lon}#map=8/${p.lat}/${p.lon}`} target="_blank" rel="noopener noreferrer" className="shrink-0 text-xs font-semibold text-sky-600 hover:underline">Map ↗</a>
)}
</li>
))}
</ul>
);
}
function Variants({ data }) {
const items = listOf(data);
return (
<ul className="space-y-3">
{items.map((v, i) => (
<li key={v.slug ?? i}>
<p className="font-medium">{v.title}</p>
{v.traditional_reading && <p><span className={muted}>Traditional (KJV/TR): </span>{v.traditional_reading}</p>}
{v.critical_reading && <p><span className={muted}>Critical text: </span>{v.critical_reading}</p>}
{v.explanation && <p className="mt-1">{v.explanation}</p>}
</li>
))}
</ul>
);
}
function Harmony({ data, book, chapter }) {
const items = listOf(data);
return (
<ul className="space-y-2">
{items.map((s) => (
<li key={s.number}>
<p className="font-medium">§{s.number} {String(s.title ?? '').toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase())}</p>
<p className={muted}>{(s.references ?? []).map((r) => r.citation ?? r).join(' · ')}</p>
</li>
))}
<li className={muted} aria-hidden="true">{book} {chapter}</li>
</ul>
);
}
export default function IqStudyAids({ book, chapter, verse }) {
return (
<div className="space-y-1.5">
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
{(d) => (
<>
<p className="whitespace-pre-line">{d.summary}</p>
{d.source_author && <p className={`mt-1 ${muted}`}>{d.source_author}{d.source_year ? `, ${d.source_year}` : ''}</p>}
</>
)}
</Aid>
<Aid title="Topics on this verse" path={`topics/${book}/${chapter}/${verse}`} empty="No topics are tagged on this 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>
))}
</div>
)}
</Aid>
<Aid title="Places on this verse" path={`geo/${book}/${chapter}/${verse}`} empty="No places are mentioned on this verse.">{(d) => <Places data={d} />}</Aid>
<Aid title="People in this chapter" path={`bible-people/${book}/${chapter}`} empty="No named people in this chapter.">{(d) => <People data={d} verse={verse} />}</Aid>
<Aid title="Textual notes" path={`textual-variants/${book}/${chapter}/${verse}`} empty="No known manuscript differences on this verse.">{(d) => <Variants data={d} />}</Aid>
{GOSPELS.includes(book) && (
<Aid title="Parallel accounts in the Gospels" path="harmony" query={{ book, chapter }} empty="No Gospel parallels recorded for this chapter.">{(d) => <Harmony data={d} book={book} chapter={chapter} />}</Aid>
)}
<Aid title="Where this sits in the timeline" path={`chronology/for/${book}/${chapter}`}>
{(d) => (
<>
<p>{d.placement ?? `${d.date ?? ''}`}</p>
{d.preceding?.length > 0 && <p className={muted}>Before: {d.preceding.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
{d.following?.length > 0 && <p className={muted}>After: {d.following.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
</>
)}
</Aid>
<p className="pt-1 text-[10px] text-[var(--r-sub,#64748b)]">Study aids via IQ Bible.</p>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest';
import IqCommentary from './IqCommentary.jsx';
import IqLexicon from './IqLexicon.jsx';
import IqStudyAids from './IqStudyAids.jsx';
import IqPlanExport from './IqPlanExport.jsx';
import { resetIq } from '../../utils/iqbible.js';
// Answers /api/iq/... from a table of path -> JSON (or { status }), and records what was asked.
let routes;
let asked;
const reply = (body, status = 200) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body), text: () => Promise.resolve('') });
beforeEach(() => {
resetIq();
asked = [];
routes = { '/api/iq/status': { available: true } };
vi.stubGlobal('fetch', vi.fn((url) => {
const path = String(url);
asked.push(path);
const hit = routes[path] ?? routes[path.split('?')[0]];
return hit === undefined ? reply({ error: 'not found' }, 404) : hit.status ? reply(hit.body ?? { error: 'x' }, hit.status) : reply(hit);
}));
localStorage.clear();
});
afterEach(() => vi.unstubAllGlobals());
describe('IqCommentary', () => {
const sources = { data: [
{ name: 'mhenry', author_name: 'Matthew Henry', category: 'Puritan', year: '1706' },
{ name: 'thomas-aquinas', author_name: 'Thomas Aquinas', category: 'Western & Medieval Theology', year: '1274' },
] };
test('offers the commentators on this chapter and shows their notes for the verse', async () => {
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [
{ chapter: 3, verse: 0, text: 'intro' },
{ chapter: 3, verse: 14, verse_end: 21, text: 'God so loved the world.' },
] };
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
expect(await screen.findByText('God so loved the world.')).toBeInTheDocument();
expect(screen.getByText(/Notes on verses 14–21/)).toBeInTheDocument();
const options = within(screen.getByRole('combobox')).getAllByRole('option').map((o) => o.textContent);
expect(options).toEqual(['Matthew Henry (1706)', 'Thomas Aquinas (1274)']);
});
test('choosing another commentator loads theirs and remembers the choice', async () => {
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Henry on 16' }] };
routes['/api/iq/commentaries/thomas-aquinas/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Aquinas on 16' }] };
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
await screen.findByText('Henry on 16');
await userEvent.setup().selectOptions(screen.getByRole('combobox'), 'thomas-aquinas');
expect(await screen.findByText('Aquinas on 16')).toBeInTheDocument();
expect(localStorage.getItem('iq-commentary')).toBe('thomas-aquinas');
});
test('says so when nobody wrote on the chapter', async () => {
routes['/api/iq/books/JHN/commentaries?chapter=3'] = { data: [] };
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
expect(await screen.findByText(/No IQ Bible commentaries cover this chapter/)).toBeInTheDocument();
});
});
describe('IqLexicon', () => {
test('shows Strong\'s and the Greek lexicons the crosswalk points to', async () => {
routes['/api/iq/lexicon/greek/strongs/G26'] = { data: [{ glossary: 'agape: love', occurences: 116 }] };
routes['/api/iq/lexicon/crosswalk/G26'] = { data: [
{ strongs_id: 'G26', source: 'lsj', native_key: 'G0026' },
{ strongs_id: 'G26', source: 'abbott-smith', native_key: 'G0026' },
] };
routes['/api/iq/lexicon/greek/lsj/G0026'] = { data: [{ greek: 'ἀγάπη', transliteration: 'agapē', gloss: 'love', meaning: '<b>love</b><script>alert(1)</script>' }] };
const { container } = render(<IqLexicon strongs="G0026" onClose={() => {}} />);
expect(await screen.findByText(/agape: love/)).toBeInTheDocument();
expect(screen.getByText('116× in the Bible')).toBeInTheDocument();
await userEvent.setup().click(await screen.findByText('Liddell-Scott-Jones'));
await screen.findByText('love', { selector: 'p.font-medium' });
expect(container.querySelector('script')).toBeNull(); // lexicon HTML is sanitised
expect(screen.getByText('Abbott-Smith')).toBeInTheDocument();
});
test('Hebrew words use BDB', async () => {
routes['/api/iq/lexicon/hebrew/strongs/H430'] = { data: [{ glossary: 'elohim' }] };
routes['/api/iq/lexicon/crosswalk/H430'] = { data: [{ source: 'bdb', native_key: 'BDB65' }, { source: 'lsj', native_key: 'x' }] };
render(<IqLexicon strongs="H430" onClose={() => {}} />);
expect(await screen.findByText('Brown-Driver-Briggs')).toBeInTheDocument();
expect(screen.queryByText('Liddell-Scott-Jones')).toBeNull();
});
});
describe('IqStudyAids', () => {
test('each section loads only when opened', async () => {
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'faith', edition: 'iqbible' }, { name: 'eternal life', edition: 'nave-torrey' }] };
render(<IqStudyAids book="JHN" chapter={3} verse={16} />);
expect(asked.filter((u) => u.startsWith('/api/iq/') && !u.includes('status'))).toEqual([]);
await userEvent.setup().click(screen.getByText('Topics on this verse'));
expect(await screen.findByText('faith')).toBeInTheDocument();
expect(screen.getByText('eternal life')).toBeInTheDocument();
expect(asked.some((u) => u.includes('/geo/'))).toBe(false);
});
test('a section with nothing says so, and the Gospel parallels appear only in the Gospels', async () => {
const { rerender } = render(<IqStudyAids book="ROM" chapter={8} verse={1} />);
expect(screen.queryByText('Parallel accounts in the Gospels')).toBeNull();
await userEvent.setup().click(screen.getByText('Textual notes'));
expect(await screen.findByText(/No known manuscript differences/)).toBeInTheDocument();
rerender(<IqStudyAids book="MAT" chapter={14} verse={1} />);
expect(screen.getByText('Parallel accounts in the Gospels')).toBeInTheDocument();
});
test('places link to a map when they have coordinates', async () => {
routes['/api/iq/geo/2KI/5/12'] = { data: [{ id: 1, name: 'Abana', place_type: 'river', modern_name: 'Barada River', lat: 33.5, lon: 36.3 }, { id: 2, name: 'Somewhere', place_type: 'region', special: 'unknown_place' }] };
render(<IqStudyAids book="2KI" chapter={5} verse={12} />);
await userEvent.setup().click(screen.getByText('Places on this verse'));
const link = await screen.findByRole('link', { name: /Map/ });
expect(link.href).toContain('mlat=33.5');
expect(screen.getAllByRole('link')).toHaveLength(1);
});
});
describe('IqPlanExport', () => {
test('links a plan to calendar and spreadsheet files, only when IQ Bible is on', async () => {
const plan = { id: 'p', days: 30, books: ['MAT', 'MRK'] };
const { container, rerender } = render(<IqPlanExport plan={plan} />);
await waitFor(() => expect(screen.getByRole('link', { name: /calendar/i })).toBeInTheDocument());
const href = screen.getByRole('link', { name: /calendar/i }).getAttribute('href');
expect(href).toContain('/api/iq/bibles/eng_kjv/reading-plan?');
expect(href).toContain('format=ical');
expect(href).toContain('days=30');
rerender(<IqPlanExport plan={{ id: 'q', days: 5 }} />); // a plan with no book list can't be exported
expect(container.querySelector('a')).toBeNull();
});
test('shows nothing without a key on the server', async () => {
routes['/api/iq/status'] = { available: false };
const { container } = render(<IqPlanExport plan={{ days: 30, books: ['MAT'] }} />);
await new Promise((r) => setTimeout(r, 20));
expect(container).toBeEmptyDOMElement();
});
});