Highlight translation differences, first-draft snapshot, verse notes, paste check
- Compare translations can highlight the words that differ from the first translation (toggle remembered). - A copy of your notes is kept the first time commentary is shown; "First draft, before commentary" under More shows what changed since. Saving it doesn't count as editing the chunk. - Notes on single verses: a pencil next to each verse in Study, and a note line under each verse in Compare. They merge per verse across devices. - Pasting 15+ words into a note asks "Is this from AI?"; "I wrote it" counts the words as yours again. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a28ffa51a
commit
648c3206df
14 files changed
+225
-20
No files matched your search
+3
-2
@@ -356,12 +356,13 @@ const AppInner = () => {
|
|||||||
setProject((current) => (current ? updater(current) : current));
|
setProject((current) => (current ? updater(current) : current));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateChunk = (chunkId, patch) => {
|
// `touch: false` saves a change without counting it as an edit (so it doesn't reset "last edited" for recalls and summaries).
|
||||||
|
const updateChunk = (chunkId, patch, { touch = true } = {}) => {
|
||||||
updateProject((current) => ({
|
updateProject((current) => ({
|
||||||
...current,
|
...current,
|
||||||
chapters: current.chapters.map((ch) => ({
|
chapters: current.chapters.map((ch) => ({
|
||||||
...ch,
|
...ch,
|
||||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)),
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, ...(touch ? { editedAt: Date.now() } : {}) } : c)),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { useApp } from '../../context/AppContext.js';
|
|||||||
import { loadProjectById } from '../../utils/projectStorage.js';
|
import { loadProjectById } from '../../utils/projectStorage.js';
|
||||||
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
||||||
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
|
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
|
||||||
import { alignVerses, translationSpans } from '../../utils/compareTranslations.js';
|
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
|
||||||
import { ownShare, pastedWords, withPaste } from '../../utils/ownWork.js';
|
import { ownShare, pastedWords } from '../../utils/ownWork.js';
|
||||||
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
|
|
||||||
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ const OIA = [
|
|||||||
// They are the same notes as on the Study page, and long pastes are tagged the same way.
|
// They are the same notes as on the Study page, and long pastes are tagged the same way.
|
||||||
function OiaBoxes({ chunk, updateChunk }) {
|
function OiaBoxes({ chunk, updateChunk }) {
|
||||||
const share = ownShare(chunk);
|
const share = ownShare(chunk);
|
||||||
|
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||||
return (
|
return (
|
||||||
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
|
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
|
||||||
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
|
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
|
||||||
@@ -58,9 +60,10 @@ function OiaBoxes({ chunk, updateChunk }) {
|
|||||||
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
|
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
|
||||||
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
|
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
|
||||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
|
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
|
||||||
onPaste={(e) => { const next = withPaste(chunk, field, e.clipboardData?.getData('text')); if (next) updateChunk(chunk.id, { aiPastes: next }); }}
|
onPaste={pasteCheck.onPaste(field)}
|
||||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
|
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
|
||||||
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
{pasteCheck.prompt(field)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -71,7 +74,9 @@ function OiaBoxes({ chunk, updateChunk }) {
|
|||||||
|
|
||||||
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
|
// 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.
|
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
|
||||||
function TranslationsView({ project, chunkEntry }) {
|
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 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 spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
|
||||||
const [picked, setPicked] = useState(() => {
|
const [picked, setPicked] = useState(() => {
|
||||||
@@ -116,6 +121,9 @@ function TranslationsView({ project, chunkEntry }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{shown.length > 1 && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={highlight} onChange={(e) => setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}</label>
|
||||||
|
)}
|
||||||
{picked.map((id) => loaded[id]?.status === 'error' && (
|
{picked.map((id) => loaded[id]?.status === 'error' && (
|
||||||
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
|
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
|
||||||
))}
|
))}
|
||||||
@@ -126,17 +134,27 @@ function TranslationsView({ project, chunkEntry }) {
|
|||||||
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
|
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
|
||||||
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
|
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
|
||||||
</div>
|
</div>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => {
|
||||||
<div key={row.key} className="grid gap-x-4 gap-y-1 border-t border-[var(--r-line)] pt-3 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
|
||||||
|
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
|
||||||
|
return (
|
||||||
|
<div key={row.key} className="border-t border-[var(--r-line)] pt-3">
|
||||||
|
<div className="grid gap-x-4 gap-y-1 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
||||||
{shown.map((id) => (
|
{shown.map((id) => (
|
||||||
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
||||||
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
||||||
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
||||||
{row.texts[id] ?? <span className="text-[var(--r-sub)]">—</span>}
|
{row.texts[id] == null ? <span className="text-[var(--r-sub)]">—</span>
|
||||||
|
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{part.text}</mark> : <span key={i}>{part.text}</span>))}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<input aria-label={`Note on verse ${row.label}`} value={note} placeholder={`Note on verse ${row.label}`}
|
||||||
|
onChange={(e) => 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" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
|
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
|
||||||
{shown.map((id) => {
|
{shown.map((id) => {
|
||||||
const t = READER_TRANSLATIONS.find((x) => x.id === id);
|
const t = READER_TRANSLATIONS.find((x) => x.id === id);
|
||||||
@@ -187,7 +205,7 @@ export default function CompareSheet({ currentChunkId, onClose }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
|
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
|
||||||
<TranslationsView project={project} chunkEntry={mine} />
|
<TranslationsView project={project} chunkEntry={mine} updateChunk={updateChunk} />
|
||||||
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
|
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// "What changed since my first draft": your Observe / Interpret / Apply notes now, with the words added or
|
||||||
|
// reworded since the copy kept when you first opened commentary. It shows whether commentary changed your thinking.
|
||||||
|
import { diffVerses } from '../../utils/textDiff.js';
|
||||||
|
import { markRanges } from '../../utils/compareTranslations.js';
|
||||||
|
|
||||||
|
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
const count = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export default function FirstDraft({ chunk }) {
|
||||||
|
const draft = chunk.firstDraft;
|
||||||
|
if (!draft) return null;
|
||||||
|
const rows = FIELDS.map(([f, label]) => {
|
||||||
|
const then = draft[f] ?? '';
|
||||||
|
const now = chunk[f] ?? '';
|
||||||
|
return { f, label, then, now, same: then.trim() === now.trim(), parts: markRanges(now, diffVerses(then, now).b) };
|
||||||
|
}).filter((r) => r.then.trim() || r.now.trim());
|
||||||
|
const changed = rows.filter((r) => !r.same).length;
|
||||||
|
return (
|
||||||
|
<details className="rounded-xl border border-[var(--r-line)] p-3 text-xs text-[var(--r-sub)]">
|
||||||
|
<summary className="cursor-pointer">First draft, before commentary <span className="opacity-70">· {new Date(draft.at).toLocaleDateString()} · {changed === 0 ? 'nothing changed since' : `${changed} of ${rows.length} notes changed since`}</span></summary>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{rows.length === 0 && <p>You opened commentary before writing anything.</p>}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.f}>
|
||||||
|
<p className="font-medium text-[var(--r-text)]">{r.label} <span className="font-normal text-[var(--r-sub)]">· {count(r.then)} words then, {count(r.now)} now</span></p>
|
||||||
|
{r.same
|
||||||
|
? <p className="mt-0.5">Unchanged.</p>
|
||||||
|
: <p className="mt-0.5 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-text)]">{r.parts.map((p, i) => (p.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{p.text}</mark> : <span key={i}>{p.text}</span>))}</p>}
|
||||||
|
{!r.same && r.then.trim() && <details className="mt-1"><summary className="cursor-pointer">Show the first draft</summary><p className="mt-1 whitespace-pre-line font-serif text-[13px] leading-relaxed">{r.then}</p></details>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="opacity-70">Highlighted words are new or reworded since the draft.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
// area for background notes and tags so the page stays quiet.
|
// area for background notes and tags so the page stays quiet.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import SessionNotes from './SessionNotes.jsx';
|
import SessionNotes from './SessionNotes.jsx';
|
||||||
|
import FirstDraft from './FirstDraft.jsx';
|
||||||
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
|
||||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
@@ -39,10 +41,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
||||||
|
|
||||||
const share = ownShare(chunk);
|
const share = ownShare(chunk);
|
||||||
const keepPaste = (field) => (e) => {
|
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||||
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
|
|
||||||
if (next) updateChunk(chunk.id, { aiPastes: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Study notes" className="space-y-2">
|
<section aria-label="Study notes" className="space-y-2">
|
||||||
@@ -74,7 +73,8 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||||
onPaste={keepPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
|
{pasteCheck.prompt(field)}
|
||||||
{pasted > 0 && (
|
{pasted > 0 && (
|
||||||
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
||||||
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
||||||
@@ -110,6 +110,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
||||||
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
||||||
</label>
|
</label>
|
||||||
|
<FirstDraft chunk={chunk} />
|
||||||
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
||||||
{saveTemplate && (
|
{saveTemplate && (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// When a long block of text is pasted into a note, ask where it came from. Pastes are remembered as "not typed by
|
||||||
|
// me" straight away (so forgetting to answer never counts as your own work); saying you wrote it takes that back.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { withPaste, withoutPaste, PASTE_MIN_WORDS } from '../../utils/ownWork.js';
|
||||||
|
|
||||||
|
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
|
||||||
|
const wordsIn = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export function usePasteCheck(chunk, updateChunk) {
|
||||||
|
const [pending, setPending] = useState(null); // { field, text }
|
||||||
|
useEffect(() => { setPending(null); }, [chunk.id]);
|
||||||
|
|
||||||
|
const onPaste = (field) => (e) => {
|
||||||
|
const text = e.clipboardData?.getData('text') ?? '';
|
||||||
|
const next = withPaste(chunk, field, text);
|
||||||
|
if (!next) return;
|
||||||
|
updateChunk(chunk.id, { aiPastes: next });
|
||||||
|
setPending({ field, text: text.trim() });
|
||||||
|
};
|
||||||
|
const wroteIt = () => {
|
||||||
|
if (pending) updateChunk(chunk.id, { aiPastes: withoutPaste(chunk, pending.field, pending.text) });
|
||||||
|
setPending(null);
|
||||||
|
};
|
||||||
|
const prompt = (field) => (pending && pending.field === field ? <PastePrompt field={field} words={wordsIn(pending.text)} onKeep={() => setPending(null)} onWroteIt={wroteIt} /> : null);
|
||||||
|
return { onPaste, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PastePrompt({ field, words, onKeep, onWroteIt }) {
|
||||||
|
const btn = 'rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5';
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label="Where did this paste come from?" className="mt-1.5 rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-xs">
|
||||||
|
<p className="font-medium text-[var(--r-text)]">You pasted {words} words into {LABELS[field] ?? 'this note'}. Is this from AI?</p>
|
||||||
|
<p className="mt-0.5 text-[var(--r-sub)]">Pasted text isn’t counted as your own work. Choose “I wrote it” if it came from you.</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={onKeep} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)]`}>Yes, from AI</button>
|
||||||
|
<button type="button" onClick={onKeep} className={btn}>No, a book or website</button>
|
||||||
|
<button type="button" onClick={onWroteIt} className={btn}>No, I wrote it</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Pastes under {PASTE_MIN_WORDS} words aren’t asked about.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
|||||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||||
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
|
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
|
||||||
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
|
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
|
||||||
|
// The first time commentary or study aids are on screen for a chunk, keep a copy of your notes as they were,
|
||||||
|
// so you can later see what changed after reading others (shown under "More" in the notes).
|
||||||
|
const seeing = (tab === 'commentary' || tab === 'aids') && !held;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seeing || chunk.firstDraft) return;
|
||||||
|
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||||
|
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
|
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||||
const [openVerse, setOpenVerse] = useState(null);
|
const [openVerse, setOpenVerse] = useState(null);
|
||||||
|
const [editingNote, setEditingNote] = useState(null);
|
||||||
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||||
return (
|
return (
|
||||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||||
@@ -20,6 +21,17 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||||
</button>
|
</button>
|
||||||
<span>{v.text}</span>
|
<span>{v.text}</span>
|
||||||
|
{onVerseNote && (
|
||||||
|
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||||
|
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||||
|
className={`ml-1.5 align-baseline font-sans text-[0.7em] ${verseNotes[`${v.chapter}:${v.number}`] ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)] opacity-40 hover:opacity-100'}`}>✎</button>
|
||||||
|
)}
|
||||||
|
{(verseNotes[`${v.chapter}:${v.number}`] || editingNote === `${v.chapter}:${v.number}`) && (
|
||||||
|
<textarea aria-label={`Verse ${v.number} note`} value={verseNotes[`${v.chapter}:${v.number}`] ?? ''} rows={2} autoFocus={editingNote === `${v.chapter}:${v.number}`}
|
||||||
|
onChange={(e) => onVerseNote?.(`${v.chapter}:${v.number}`, e.target.value)}
|
||||||
|
placeholder="Your note on this verse"
|
||||||
|
className="mt-1 block w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 font-sans text-[13px] leading-snug text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
)}
|
||||||
{open && words && (
|
{open && words && (
|
||||||
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
||||||
{words.map((w, i) => {
|
{words.map((w, i) => {
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export default function StudyPage() {
|
|||||||
verses={verses} chapter={chapter}
|
verses={verses} chapter={chapter}
|
||||||
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
|
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
|
||||||
addedStrongs={addedStrongs} onPickWord={pickWord}
|
addedStrongs={addedStrongs} onPickWord={pickWord}
|
||||||
|
verseNotes={selectedChunk.verseNotes ?? {}}
|
||||||
|
onVerseNote={(key, text) => updateChunk(selectedChunk.id, { verseNotes: { ...(selectedChunk.verseNotes ?? {}), [key]: text } })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -448,6 +448,26 @@ describe('Study workspace', () => {
|
|||||||
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
const observe = screen.getByRole('textbox', { name: 'Observe' });
|
||||||
|
const pasted = Array.from({ length: 20 }, (_, i) => `pasted${i}`).join(' ');
|
||||||
|
fireEvent.paste(observe, { clipboardData: { getData: () => pasted } });
|
||||||
|
fireEvent.change(observe, { target: { value: pasted } });
|
||||||
|
expect(await screen.findByText(/is this from ai\?/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/20 words here were pasted/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /i wrote it/i }));
|
||||||
|
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/were pasted/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short paste is not asked about', async () => {
|
||||||
|
await studyOneChunk();
|
||||||
|
const observe = screen.getByRole('textbox', { name: 'Observe' });
|
||||||
|
fireEvent.paste(observe, { clipboardData: { getData: () => 'just a few words' } });
|
||||||
|
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
|
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
|
||||||
const user = await studyOneChunk();
|
const user = await studyOneChunk();
|
||||||
await user.click(screen.getByRole('button', { name: /compare…/i }));
|
await user.click(screen.getByRole('button', { name: /compare…/i }));
|
||||||
@@ -549,4 +569,3 @@ describe('Reading plan with more days than chapters', () => {
|
|||||||
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number.
|
// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number.
|
||||||
import { getChunkVerseEntries } from './bibleUtils.jsx';
|
import { getChunkVerseEntries } from './bibleUtils.jsx';
|
||||||
|
import { diffVerses } from './textDiff.js';
|
||||||
|
|
||||||
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
|
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
|
||||||
export function translationSpans(project, chapterIndex, chunk) {
|
export function translationSpans(project, chapterIndex, chunk) {
|
||||||
@@ -23,8 +24,33 @@ export function alignVerses(spans, byTranslation) {
|
|||||||
for (const [id, chapters] of Object.entries(byTranslation)) {
|
for (const [id, chapters] of Object.entries(byTranslation)) {
|
||||||
texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null;
|
texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null;
|
||||||
}
|
}
|
||||||
rows.push({ key: `${span.book}${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts });
|
rows.push({ key: `${span.book}${span.chapter}:${n}`, noteKey: `${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For one verse in several translations: which words in each text the others lack, measured against the first
|
||||||
|
// translation (and the first against the second). -> { BSB: [{ start, end }], NET: [...] }
|
||||||
|
export function differencesFor(texts, ids) {
|
||||||
|
const out = Object.fromEntries(ids.map((id) => [id, []]));
|
||||||
|
const present = ids.filter((id) => texts[id]);
|
||||||
|
if (present.length < 2) return out;
|
||||||
|
const [base, ...rest] = present;
|
||||||
|
out[base] = diffVerses(texts[base], texts[rest[0]]).a;
|
||||||
|
for (const id of rest) out[id] = diffVerses(texts[base], texts[id]).b;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cuts a text at the ranges -> [{ text, mark }] so a view can wrap the marked parts.
|
||||||
|
export function markRanges(text, ranges) {
|
||||||
|
const parts = [];
|
||||||
|
let at = 0;
|
||||||
|
for (const r of [...(ranges ?? [])].sort((a, b) => a.start - b.start)) {
|
||||||
|
if (r.start > at) parts.push({ text: text.slice(at, r.start), mark: false });
|
||||||
|
parts.push({ text: text.slice(r.start, r.end), mark: true });
|
||||||
|
at = r.end;
|
||||||
|
}
|
||||||
|
if (at < text.length) parts.push({ text: text.slice(at), mark: false });
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -27,3 +27,19 @@ test('verses are lined up by number, with null where a translation lacks one', (
|
|||||||
expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' });
|
expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' });
|
||||||
expect(rows[1].texts).toEqual({ BSB: 'b', NET: null });
|
expect(rows[1].texts).toEqual({ BSB: 'b', NET: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { differencesFor, markRanges } from './compareTranslations.js';
|
||||||
|
|
||||||
|
test('differences are the words a translation has that the first one lacks', () => {
|
||||||
|
const d = differencesFor({ BSB: 'For God so loved the world', NET: 'For this is the way God loved the world', ESV: 'For God so loved the world' }, ['BSB', 'NET', 'ESV']);
|
||||||
|
expect(d.ESV).toEqual([]); // same words as the first
|
||||||
|
const text = 'For this is the way God loved the world';
|
||||||
|
expect(markRanges(text, d.NET).filter((p) => p.mark).map((p) => p.text.trim()).join(' ')).toContain('this is the way');
|
||||||
|
expect(differencesFor({ BSB: 'a b', NET: null }, ['BSB', 'NET']).BSB).toEqual([]); // nothing to compare against
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markRanges splits a text into plain and marked pieces that rejoin to the original', () => {
|
||||||
|
const parts = markRanges('one two three four', [{ start: 4, end: 7 }, { start: 14, end: 18 }]);
|
||||||
|
expect(parts.map((p) => p.text).join('')).toBe('one two three four');
|
||||||
|
expect(parts.filter((p) => p.mark).map((p) => p.text)).toEqual(['two', 'four']);
|
||||||
|
});
|
||||||
@@ -37,6 +37,10 @@ export function mergeChunk(mine, theirs) {
|
|||||||
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
||||||
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
||||||
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
||||||
|
// Verse notes are kept per verse (the newer wording wins, an older different one is appended); the earliest first draft is kept.
|
||||||
|
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
|
||||||
|
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
|
||||||
|
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
|
||||||
out.followUps = mergeById(newer.followUps, older.followUps);
|
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||||
out.recalls = union(newer.recalls, older.recalls);
|
out.recalls = union(newer.recalls, older.recalls);
|
||||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||||
|
|||||||
@@ -51,3 +51,13 @@ export function chunksNeedingWork(chunks) {
|
|||||||
// Commentary and study aids stay folded away until you've written this much of your own Observe notes.
|
// Commentary and study aids stay folded away until you've written this much of your own Observe notes.
|
||||||
export const OBSERVE_FIRST_MIN = 15;
|
export const OBSERVE_FIRST_MIN = 15;
|
||||||
export const observeFirstDone = (chunk) => ownWords(chunk, 'observation') >= OBSERVE_FIRST_MIN;
|
export const observeFirstDone = (chunk) => ownWords(chunk, 'observation') >= OBSERVE_FIRST_MIN;
|
||||||
|
|
||||||
|
// "No, I wrote it": take one remembered paste back out, so those words count as your own again.
|
||||||
|
export function withoutPaste(chunk, field, pasted) {
|
||||||
|
const text = (pasted ?? '').trim();
|
||||||
|
const all = chunk?.aiPastes ?? {};
|
||||||
|
const list = [...(all[field] ?? [])];
|
||||||
|
const at = list.lastIndexOf(text);
|
||||||
|
if (at >= 0) list.splice(at, 1);
|
||||||
|
return { ...all, [field]: list };
|
||||||
|
}
|
||||||
@@ -56,3 +56,14 @@ test('merging two copies keeps the pastes from both', () => {
|
|||||||
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
|
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
|
||||||
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
|
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { withoutPaste } from './ownWork.js';
|
||||||
|
|
||||||
|
test('"No, I wrote it" takes one paste back out so the words count as yours again', () => {
|
||||||
|
const pasted = text(30);
|
||||||
|
const chunk = { observation: pasted, aiPastes: { observation: [pasted, 'other'] } };
|
||||||
|
const after = { ...chunk, aiPastes: withoutPaste(chunk, 'observation', ` ${pasted} `) };
|
||||||
|
expect(after.aiPastes.observation).toEqual(['other']);
|
||||||
|
expect(ownWords(after, 'observation')).toBe(30);
|
||||||
|
expect(withoutPaste({}, 'observation', 'x')).toEqual({ observation: [] });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user