Harden sync and security; add IQ Bible, templates, password reset, backups
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+69
View File
@@ -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,
};
}