Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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>
This commit is contained in:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
@@ -0,0 +1,69 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user