// 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, }; }