Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user