// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { loadProjectById } from '../../utils/projectStorage.js';
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
import { ownShare, pastedWords } from '../../utils/ownWork.js';
import { usePasteCheck } from './PastePrompt.jsx';
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
// Every chunk in your studies, as a pick-list entry that remembers where it sits.
function choices(projectIndex, currentProject) {
const projects = projectIndex.map((e) => (e.id === currentProject?.id ? currentProject : loadProjectById(e.id))).filter(Boolean);
return projects.flatMap((project) => project.chapters.flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk,
label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))));
}
function Column({ entry }) {
if (!entry) return
Pick a passage to compare.
;
const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk);
return (
{entry.label}
{verses.map((v) =>
{v.number} {v.text}
)}
{FIELDS.map(([f, label]) => (
{label}
{entry.chunk[f]?.trim() || Nothing written. }
))}
);
}
const OIA = [
['observation', 'Observe', 'What does the text say?'],
['interpretation', 'Interpret', 'What does it mean?'],
['application', 'Apply', 'What do I do with it?'],
];
// The chunk's own Observe / Interpret / Apply notes, editable right here while the translations are side by side.
// They are the same notes as on the Study page, and long pastes are tagged the same way.
function OiaBoxes({ chunk, updateChunk }) {
const share = ownShare(chunk);
const pasteCheck = usePasteCheck(chunk, updateChunk);
return (
Your notes{share !== null ? ` · ${share}% your own words` : ''}
{OIA.map(([field, label, hint]) => {
const pasted = pastedWords(chunk, field);
return (
{label} {hint} {pasted > 0 && {pasted} pasted }
);
})}
);
}
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
function TranslationsView({ project, chunkEntry, updateChunk }) {
const [highlight, setHighlight] = useState(() => { try { return localStorage.getItem('compare-highlight') !== 'off'; } catch { return true; } });
const setHl = (on) => { setHighlight(on); try { localStorage.setItem('compare-highlight', on ? 'on' : 'off'); } catch { /* storage unavailable */ } };
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
const [picked, setPicked] = useState(() => {
try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ }
return [project?.translation ?? 'BSB', 'NET'];
});
const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error }
const toggle = (id) => setPicked((cur) => {
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
// The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes.
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
picked.filter((id) => !loaded[id]).forEach((id) => {
setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } }));
Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter))
.then((res) => [`${span.book}|${span.chapter}`, res])))
.then((results) => alive.current && setLoaded((cur) => ({
...cur,
[id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' },
})))
.catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); });
});
}, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps
const shown = picked.filter((id) => loaded[id]?.status === 'ok');
const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters])));
const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` };
return (
{READER_TRANSLATIONS.map((t) => {
const on = picked.includes(t.id);
return (
toggle(t.id)} title={t.name}
className={`rounded-full border px-3 py-1 text-xs ${on ? '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.id}
);
})}
{shown.length > 1 && (
setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}
)}
{picked.map((id) => loaded[id]?.status === 'error' && (
{id} isn’t available right now: {loaded[id].error}
))}
{picked.some((id) => loaded[id]?.status === 'loading') &&
Loading…
}
{shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') &&
Pick two or more translations above.
}
{shown.length > 0 && (
{shown.map((id) => {translationName(id)} )}
{rows.map((row) => {
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
return (
{shown.map((id) => (
{row.label}
{id}
{row.texts[id] == null ? —
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? {part.text} : {part.text} ))}
))}
updateChunk(chunkEntry.chunk.id, { verseNotes: { ...(chunkEntry.chunk.verseNotes ?? {}), [row.noteKey]: e.target.value } })}
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
);
})}
{shown.map((id) => {
const t = READER_TRANSLATIONS.find((x) => x.id === id);
return t?.notice || loaded[id].copyright ?
{loaded[id].copyright || t.notice}
: null;
})}
)}
);
}
export default function CompareSheet({ currentChunkId, onClose }) {
const { projectIndex, project, updateChunk } = useApp();
// Other studies are read from storage once per list change; the open study comes straight from state so typing stays quick.
const others = useMemo(() => choices(projectIndex.filter((e) => e.id !== project?.id), null), [projectIndex, project?.id]);
const here = useMemo(() => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk, label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))), [project]);
const mine = here.find((c) => c.chunk.id === currentChunkId);
const all = useMemo(() => [...here, ...others], [here, others]);
const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
const [otherKey, setOtherKey] = useState('');
const other = all.find((c) => c.key === otherKey);
const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === 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'}`;
return (
Compare
setMode('passage')} className={tab('passage')}>Another passage
setMode('translations')} className={tab('translations')}>Translations
{mode === 'passage' && (
setOtherKey(e.target.value)}
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
Choose a passage to set beside this one…
{all.filter((c) => c.key !== mine?.key).map((c) => {c.label} )}
)}
Close
{mode === 'passage' ? (
) : (
{mine?.label}
{mine && }
)}
);
}