Files
study-bible-project/src/utils/mergeStudies.js
T
nmemmertandClaude Sonnet 5.5 eccc5e509d Studying: daily five minutes, reading list, plan-day studies use your template
- Home: "Your five minutes": a rotating Observe/Interpret/Apply prompt on the
  passage whose matching note is thinnest in your own words, a 5:00 clock and a
  box that appends to that note. Pasting is blocked; sessions show in Insights.
- Study: a Reading list tab (R) for the study: books and articles to consult,
  with a note and a tick; merges across devices.
- Starting a study from a reading-plan day now uses the notes template you last
  picked when starting a study by hand (logic moved to utils/planStudy.js).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:49 -04:00

85 lines
4.5 KiB
JavaScript

// Combines two copies of one study (this device's and the other device's) chunk by chunk, so a
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
// same note, the newer one stays and the older one is appended under a marker.
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle', 'teachBack', 'attendees', 'groupNotes'];
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
export const MERGE_MARKER = '— from your other device —';
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const blank = (s) => !(s ?? '').toString().trim();
function mergeText(newer, older) {
if (same(newer, older) || blank(older)) return newer ?? '';
if (blank(newer)) return older;
if (newer.includes(older)) return newer;
if (older.includes(newer)) return older;
return `${newer.replace(/\s+$/, '')}\n\n${MERGE_MARKER}\n${older}`;
}
function union(a = [], b = []) {
const seen = new Set();
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
}
// Follow-ups are matched by id; the newer copy's wording and done-state win, nothing is dropped.
function mergeById(newer = [], older = []) {
const seen = new Set(newer.map((i) => i.id));
return [...newer, ...older.filter((i) => !seen.has(i.id))];
}
export function mergeChunk(mine, theirs) {
if (same(mine, theirs)) return { chunk: mine, changed: false };
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
const [newer, older] = mineNewer ? [mine, theirs] : [theirs, mine];
const out = { ...older, ...newer };
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(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.
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.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
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;
return { chunk: out, changed: !same(out, mine) };
}
const chapterKey = (ch) => `${ch.bookAbbrev}|${ch.chapter}`;
// -> { project, stats: { added, merged } } where `added` counts chunks that only the other copy had and
// `merged` counts chunks both copies had that differed. Settings such as the title and the open chunk stay mine.
export function mergeStudies(mine, theirs) {
const stats = { added: 0, merged: 0 };
const theirChapters = new Map((theirs.chapters ?? []).map((ch) => [chapterKey(ch), ch]));
const chapters = (mine.chapters ?? []).map((ch) => {
const other = theirChapters.get(chapterKey(ch));
theirChapters.delete(chapterKey(ch));
if (!other) return ch;
const theirChunks = new Map((other.chunks ?? []).map((c) => [c.id, c]));
const chunks = (ch.chunks ?? []).map((c) => {
const o = theirChunks.get(c.id);
theirChunks.delete(c.id);
if (!o) return c;
const { chunk, changed } = mergeChunk(c, o);
if (changed) stats.merged += 1;
return chunk;
});
const extra = [...theirChunks.values()];
stats.added += extra.length;
return { ...ch, chunks: [...chunks, ...extra] };
});
for (const ch of theirChapters.values()) {
stats.added += (ch.chunks ?? []).length;
chapters.push(ch);
}
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
return {
project: { ...mine, chapters, ...(mine.readingList || theirs.readingList ? { readingList: mergeById(mine.readingList, theirs.readingList) } : {}), selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
stats,
};
}