Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Sync and data safety - Autosave flushes on leaving a study or the tab; storage-full errors are reported - Unsynced studies, deletes and ink are queued and retried when back online - Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect - Server assigns study versions (no client-clock conflicts); "Combine both" merges a conflicted study chunk by chunk - Reading-plan progress made offline is merged, not overwritten; plans, ink and marks are cleared from the device on sign-out Security - Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and account; constant-time login; TOTP replay protection; input validation; SESSION_SECRET required in production; admin user delete fixed - Forgot password: one-time code sent to the account's ntfy topic Features - IQ Bible proxy (route allow-list, text-only caching per its terms): more commentaries, lexicons, study aids, daily verse and devotional, plan calendar export, narrations, podcast background - Study templates, reading-plan catch-up, account export, daily database snapshots Tests and tooling - ESLint config; App tests split into src/test; tests for sync queues, library hook, merge, templates, IQ Bible UI and reset form Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
70 lines
3.2 KiB
JavaScript
70 lines
3.2 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'];
|
|
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; });
|
|
}
|
|
|
|
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]); });
|
|
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, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
|
|
stats,
|
|
};
|
|
}
|