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
+232
-27
No files matched your search
@@ -1,5 +1,6 @@
|
||||
// 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 { diffVerses } from './textDiff.js';
|
||||
|
||||
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
|
||||
export function translationSpans(project, chapterIndex, chunk) {
|
||||
@@ -23,8 +24,33 @@ export function alignVerses(spans, byTranslation) {
|
||||
for (const [id, chapters] of Object.entries(byTranslation)) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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[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]); });
|
||||
// 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])]));
|
||||
// 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.recalls = union(newer.recalls, older.recalls);
|
||||
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.
|
||||
export const OBSERVE_FIRST_MIN = 15;
|
||||
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'] } };
|
||||
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