feat: sync guards + import restore mode + inline progress + uncategorised polish
Sync engine
- Cross-account guard: refuse to merge a remote snapshot whose
username differs from the currently-signed-in one (confirm dialog,
Cancel by default) so a shared WebDAV URL / same sync password
between accounts stops silently mixing vaults.
- Local tombstones veto: skip any remote entry whose uuid is already
in the local entry_tombstones table — otherwise a perm-delete on
this device was getting undone on the next pull.
- "deleted" counter fixed: report only tombstones that actually
removed a live local entry this round, not the accumulated history
the toast used to inflate ("73 deleted" for 48 real deletes).
- Push failure surfaces syncStatus reset + toast so state doesn't
get stuck on a stale phase label.
Sync progress feedback
- Inline #syncStatus label next to the Sync button reports each
phase: Local backup… → Pulling… → Merging… → Preparing… N/total
(per-entry counter during the slow buildSyncSnapshot decrypt loop)
→ Encrypting… → Pushing…, then clears.
- Sync now / Test connection buttons are disabled while any run is
in flight so double-clicks can't kick off a concurrent sync.
Import (JSON only — CSV out of scope)
- Uuid-aware dedup: split parsed rows into fresh (new uuid) vs
overlaps (uuid already present locally).
- Overlaps prompt: confirm dialog offers Overwrite (restore/roll
back) or Skip. Overwrite PUTs the file's payload over each match;
Skip drops them and only imports fresh. Prevents the "re-import
doubles everything" regression while still allowing restore.
- Bulk-import call is skipped entirely when there's nothing fresh to
send (avoids a POST with an empty entries array).
- Template notes with empty body but populated custom_fields
(credit-card, ssh-key, etc.) no longer skipped as "note body
required" — kept as long as at least one custom field has a value.
Sidebar / uncategorised view
- "(no folder)" pseudo-entry stays visible whenever the vault has
any real folder, so it always works as a drag target for
uncategorising — and doesn't vanish mid-action when the user is
currently viewing it.
- Header title reads "(no folder)" for state.view === 'folder:All'
instead of the ambiguous "All".
- Dedicated empty-state copy for the uncategorised view.
Rebuild assets required.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -707,6 +707,7 @@
|
|||||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px">
|
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px">
|
||||||
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
|
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
|
||||||
<button class="btn btn-primary btn-sm" id="syncNowBtn">Sync now</button>
|
<button class="btn btn-primary btn-sm" id="syncNowBtn">Sync now</button>
|
||||||
|
<span id="syncStatus" style="font-size:11px;color:var(--accent);display:none"></span>
|
||||||
<span id="syncLast" style="font-size:11px;color:var(--text-faint)"></span>
|
<span id="syncLast" style="font-size:11px;color:var(--text-faint)"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8165,7 +8165,16 @@ function parseEntriesFromJSON(text) {
|
|||||||
// Notes legitimately have no `site` — their "content" lives in
|
// Notes legitimately have no `site` — their "content" lives in
|
||||||
// password (the note body). Logins still need both site + pwd.
|
// password (the note body). Logins still need both site + pwd.
|
||||||
if (kind === 'login' && (!site || !pwd)) { skipped++; continue; }
|
if (kind === 'login' && (!site || !pwd)) { skipped++; continue; }
|
||||||
if (kind === 'note' && !pwd) { skipped++; continue; }
|
// Notes with a template (credit-card, ssh-key, etc.) carry data
|
||||||
|
// in custom_fields — an empty body is legitimate as long as at
|
||||||
|
// least one custom field has content. Only skip a note if BOTH
|
||||||
|
// the body AND every custom field are empty.
|
||||||
|
if (kind === 'note' && !pwd) {
|
||||||
|
const cfList = Array.isArray(e.custom_fields) ? e.custom_fields : [];
|
||||||
|
const anyFieldFilled = cfList.some(f =>
|
||||||
|
f && (String(f.value || '').trim() !== ''));
|
||||||
|
if (!anyFieldFilled) { skipped++; continue; }
|
||||||
|
}
|
||||||
|
|
||||||
const tagsVal = e.tags;
|
const tagsVal = e.tags;
|
||||||
const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
|
const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
|
||||||
@@ -8230,6 +8239,13 @@ async function encryptImportEntry(plain) {
|
|||||||
cfIv = c.iv;
|
cfIv = c.iv;
|
||||||
} catch (e) { /* drop silently */ }
|
} catch (e) { /* drop silently */ }
|
||||||
}
|
}
|
||||||
|
// tags may arrive as a comma-separated string (CSV / our own JSON
|
||||||
|
// export) or as a real array (buildSyncSnapshot uses parseTags → []).
|
||||||
|
// The server's HandleCreateEntry does GetValue<string> which throws
|
||||||
|
// "TJSONArray → string non supporté" on an array — normalise here.
|
||||||
|
const tagsStr = Array.isArray(plain.tags)
|
||||||
|
? plain.tags.filter(Boolean).join(',')
|
||||||
|
: (plain.tags || '');
|
||||||
return {
|
return {
|
||||||
uuid: plain.uuid || '',
|
uuid: plain.uuid || '',
|
||||||
site: plain.site,
|
site: plain.site,
|
||||||
@@ -8238,7 +8254,7 @@ async function encryptImportEntry(plain) {
|
|||||||
encrypted_password: pw.encrypted,
|
encrypted_password: pw.encrypted,
|
||||||
iv: pw.iv,
|
iv: pw.iv,
|
||||||
folder: plain.folder || 'All',
|
folder: plain.folder || 'All',
|
||||||
tags: plain.tags || '',
|
tags: tagsStr,
|
||||||
totp_secret: totpEnc,
|
totp_secret: totpEnc,
|
||||||
totp_iv: totpIv,
|
totp_iv: totpIv,
|
||||||
kind: plain.kind === 'note' ? 'note' : 'login',
|
kind: plain.kind === 'note' ? 'note' : 'login',
|
||||||
@@ -8332,8 +8348,9 @@ async function doImport() {
|
|||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
const parsedAtt = parsed.entries.reduce(
|
// parsedAtt is computed AFTER dedup — see below where
|
||||||
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
|
// dedupedEntries is defined.
|
||||||
|
let parsedAtt = 0;
|
||||||
|
|
||||||
// Apply folder customisation (color, icon) from the payload —
|
// Apply folder customisation (color, icon) from the payload —
|
||||||
// additive only: existing local folders are left untouched so the
|
// additive only: existing local folders are left untouched so the
|
||||||
@@ -8394,20 +8411,84 @@ async function doImport() {
|
|||||||
if (createdMissing > 0) await loadFolders();
|
if (createdMissing > 0) await loadFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
toast('Encrypting ' + parsed.entries.length + ' entries…');
|
// Dedupe by uuid: split parsed rows into "fresh" (uuid absent
|
||||||
|
// locally, safe to bulk-insert) and "overlapping" (uuid already
|
||||||
|
// exists — the user is either re-importing a backup or rolling
|
||||||
|
// back to an earlier version). Ask what to do with overlapping
|
||||||
|
// entries so a restore isn't silently blocked by the dedup.
|
||||||
|
const localByUuid = new Map();
|
||||||
|
for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e);
|
||||||
|
const fresh = [];
|
||||||
|
const overlaps = [];
|
||||||
|
for (const e of parsed.entries) {
|
||||||
|
if (e.uuid && localByUuid.has(e.uuid)) overlaps.push(e);
|
||||||
|
else fresh.push(e);
|
||||||
|
}
|
||||||
|
let overwriteOverlaps = false;
|
||||||
|
if (overlaps.length > 0) {
|
||||||
|
overwriteOverlaps = await confirmDialog({
|
||||||
|
title: overlaps.length + ' entries already in vault',
|
||||||
|
message: '<b>' + overlaps.length + '</b> entries in this file ' +
|
||||||
|
'already exist locally (same UUID).<br><br>' +
|
||||||
|
'Choose <b>Overwrite</b> to replace the local version with the ' +
|
||||||
|
'file\'s (rolls back edits made since the backup was taken).<br><br>' +
|
||||||
|
'Choose <b>Skip</b> to keep the current local version and only ' +
|
||||||
|
'import genuinely new entries.',
|
||||||
|
okText: 'Overwrite',
|
||||||
|
cancelText: 'Skip',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fresh.length === 0 && !overwriteOverlaps) {
|
||||||
|
return toast('Nothing new to import', 'warning');
|
||||||
|
}
|
||||||
|
parsedAtt = fresh.reduce(
|
||||||
|
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
|
||||||
|
|
||||||
|
toast('Encrypting ' + fresh.length + ' entries…');
|
||||||
|
|
||||||
const encrypted = [];
|
const encrypted = [];
|
||||||
for (const e of parsed.entries) {
|
for (const e of fresh) {
|
||||||
encrypted.push(await encryptImportEntry(e));
|
encrypted.push(await encryptImportEntry(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overwriting overlaps: PUT each existing entry with the file's
|
||||||
|
// content. Attachments on the local entry stay in place — the
|
||||||
|
// user typically wants to roll back credentials, not lose
|
||||||
|
// manually-uploaded files. Adjust if that assumption changes.
|
||||||
|
let overwritten = 0;
|
||||||
|
if (overwriteOverlaps) {
|
||||||
|
for (const src of overlaps) {
|
||||||
try {
|
try {
|
||||||
const r = await api('/entries/bulk-import', {
|
const local = localByUuid.get(src.uuid);
|
||||||
|
if (!local) continue;
|
||||||
|
const enc = await encryptImportEntry(src);
|
||||||
|
await api('/entries/' + local.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(enc),
|
||||||
|
});
|
||||||
|
overwritten++;
|
||||||
|
} catch (_) { /* skip the single row on failure */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use dedupedEntries as an alias for fresh so downstream code
|
||||||
|
// (attachments loop) keeps working without a second rename.
|
||||||
|
const dedupedEntries = fresh;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r = { imported: 0, ids: [] };
|
||||||
|
if (encrypted.length > 0) {
|
||||||
|
r = await api('/entries/bulk-import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ entries: encrypted }),
|
body: JSON.stringify({ entries: encrypted }),
|
||||||
});
|
});
|
||||||
toast('Imported ' + r.imported + ' entries');
|
}
|
||||||
|
const tailMsg = overwritten > 0
|
||||||
|
? ' · ' + overwritten + ' overwritten'
|
||||||
|
: '';
|
||||||
|
toast('Imported ' + r.imported + ' entries' + tailMsg);
|
||||||
|
|
||||||
// Restore attachments. ids[] is parallel-indexed with the
|
// Restore attachments. ids[] is parallel-indexed with the
|
||||||
// input (-1 = server skipped this row), so we can map back
|
// input (-1 = server skipped this row), so we can map back
|
||||||
@@ -8417,9 +8498,9 @@ async function doImport() {
|
|||||||
toast(parsedAtt + ' attachment(s) skipped — server missing /ids response', 'warning');
|
toast(parsedAtt + ' attachment(s) skipped — server missing /ids response', 'warning');
|
||||||
}
|
}
|
||||||
let attachCount = 0;
|
let attachCount = 0;
|
||||||
for (let i = 0; i < parsed.entries.length; i++) {
|
for (let i = 0; i < dedupedEntries.length; i++) {
|
||||||
const newId = ids[i];
|
const newId = ids[i];
|
||||||
const atts = parsed.entries[i].attachments;
|
const atts = dedupedEntries[i].attachments;
|
||||||
if (typeof newId !== 'number' || newId <= 0) continue;
|
if (typeof newId !== 'number' || newId <= 0) continue;
|
||||||
if (!Array.isArray(atts) || atts.length === 0) continue;
|
if (!Array.isArray(atts) || atts.length === 0) continue;
|
||||||
for (const a of atts) {
|
for (const a of atts) {
|
||||||
@@ -9486,8 +9567,17 @@ async function buildSyncSnapshot() {
|
|||||||
entries: [],
|
entries: [],
|
||||||
tombstones: [],
|
tombstones: [],
|
||||||
};
|
};
|
||||||
for (const e of state.entries) {
|
// Progress feedback: buildSyncSnapshot dominates runtime (per-entry
|
||||||
if (!e.uuid) continue; // legacy row that missed the backfill — skip
|
// decrypt + attachment fetch). Report N/total as we go so the user
|
||||||
|
// doesn't think the app froze on large vaults.
|
||||||
|
const eligible = state.entries.filter(e => e && e.uuid);
|
||||||
|
const total = eligible.length;
|
||||||
|
let done = 0;
|
||||||
|
for (const e of eligible) {
|
||||||
|
done++;
|
||||||
|
if (total > 5 && (done === 1 || done % 5 === 0 || done === total)) {
|
||||||
|
syncStatus('Preparing… ' + done + '/' + total);
|
||||||
|
}
|
||||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||||
let plainTotp = '';
|
let plainTotp = '';
|
||||||
if (e.totp_secret && e.totp_iv) {
|
if (e.totp_secret && e.totp_iv) {
|
||||||
@@ -9544,22 +9634,26 @@ async function buildSyncSnapshot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyRemoteSnapshot(remote) {
|
async function applyRemoteSnapshot(remote) {
|
||||||
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0 };
|
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0 };
|
||||||
let added = 0, updated = 0, deleted = 0;
|
let added = 0, updated = 0, deleted = 0, failed = 0;
|
||||||
|
|
||||||
// Push remote tombstones first — server will hard-delete any matching
|
// Push remote tombstones first — server will hard-delete any matching
|
||||||
// local entries AND remember them so they don't reappear from a future
|
// local entries AND remember them so they don't reappear from a future
|
||||||
// local push.
|
// local push. Count only tombstones that actually deleted a live
|
||||||
|
// local entry this round (not the accumulated history that both
|
||||||
|
// sides already know about) so the toast reflects real user impact.
|
||||||
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
||||||
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
|
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
|
||||||
if (uuids.length > 0) {
|
if (uuids.length > 0) {
|
||||||
|
const preLocal = new Set();
|
||||||
|
for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid);
|
||||||
try {
|
try {
|
||||||
await api('/entries/tombstones', {
|
await api('/entries/tombstones', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ uuids }),
|
body: JSON.stringify({ uuids }),
|
||||||
});
|
});
|
||||||
deleted = uuids.length;
|
deleted = uuids.filter(u => preLocal.has(u)).length;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9569,6 +9663,18 @@ async function applyRemoteSnapshot(remote) {
|
|||||||
const byUuid = new Map();
|
const byUuid = new Map();
|
||||||
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
|
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
|
||||||
|
|
||||||
|
// Local tombstones must veto ANY remote entry with a matching uuid —
|
||||||
|
// otherwise a perm-delete on this device gets undone by the next
|
||||||
|
// pull ("48 added" after wiping the vault, because the remote
|
||||||
|
// snapshot still carries the pre-delete state). The tombstones are
|
||||||
|
// pushed back to the remote at the next push, propagating the
|
||||||
|
// delete cleanly.
|
||||||
|
const localTombstones = new Set();
|
||||||
|
try {
|
||||||
|
const ts = await api('/entries/tombstones', { headers: authHeaders() });
|
||||||
|
(Array.isArray(ts) ? ts : []).forEach(t => t.uuid && localTombstones.add(t.uuid));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Folders — add missing ones with the remote's color/icon. Existing
|
// Folders — add missing ones with the remote's color/icon. Existing
|
||||||
// folders are left untouched (user's local customisation wins).
|
// folders are left untouched (user's local customisation wins).
|
||||||
if (Array.isArray(remote.folders)) {
|
if (Array.isArray(remote.folders)) {
|
||||||
@@ -9591,6 +9697,9 @@ async function applyRemoteSnapshot(remote) {
|
|||||||
// Per-entry merge.
|
// Per-entry merge.
|
||||||
for (const r of remote.entries) {
|
for (const r of remote.entries) {
|
||||||
if (!r.uuid) continue;
|
if (!r.uuid) continue;
|
||||||
|
// Skip entries this device has already tombstoned — never
|
||||||
|
// resurrect a deleted entry.
|
||||||
|
if (localTombstones.has(r.uuid)) continue;
|
||||||
const local = byUuid.get(r.uuid);
|
const local = byUuid.get(r.uuid);
|
||||||
if (!local) {
|
if (!local) {
|
||||||
// New entry on remote — encrypt locally + POST keeping the uuid.
|
// New entry on remote — encrypt locally + POST keeping the uuid.
|
||||||
@@ -9622,7 +9731,7 @@ async function applyRemoteSnapshot(remote) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) { failed++; }
|
||||||
} else {
|
} else {
|
||||||
// Both sides have it — keep the newer one (lexical ISO sort).
|
// Both sides have it — keep the newer one (lexical ISO sort).
|
||||||
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
|
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
|
||||||
@@ -9638,10 +9747,29 @@ async function applyRemoteSnapshot(remote) {
|
|||||||
body: JSON.stringify(enc),
|
body: JSON.stringify(enc),
|
||||||
});
|
});
|
||||||
updated++;
|
updated++;
|
||||||
} catch (_) {}
|
} catch (_) { failed++; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { added, updated, deleted };
|
return { added, updated, deleted, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the inline sync-status label next to the button. Empty string
|
||||||
|
// hides it (end of sync). Called from every phase so the user sees
|
||||||
|
// where the runtime is spent (buildSyncSnapshot is by far the slowest,
|
||||||
|
// hence the per-entry counter).
|
||||||
|
function syncStatus(text) {
|
||||||
|
const el = document.getElementById('syncStatus');
|
||||||
|
if (el) {
|
||||||
|
if (text) { el.textContent = text; el.style.display = ''; }
|
||||||
|
else { el.textContent = ''; el.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
// Lock the Sync/Test buttons while a run is in flight so the user
|
||||||
|
// can't double-click a second concurrent sync.
|
||||||
|
const active = !!text;
|
||||||
|
['syncNowBtn', 'syncTestBtn'].forEach(id => {
|
||||||
|
const b = document.getElementById(id);
|
||||||
|
if (b) b.disabled = active;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSyncNow() {
|
async function runSyncNow() {
|
||||||
@@ -9657,6 +9785,7 @@ async function runSyncNow() {
|
|||||||
const ab = await loadAutoBackupConfig();
|
const ab = await loadAutoBackupConfig();
|
||||||
const dir = (ab && ab.dir) ? ab.dir : null;
|
const dir = (ab && ab.dir) ? ab.dir : null;
|
||||||
if (dir) {
|
if (dir) {
|
||||||
|
syncStatus('Local backup…');
|
||||||
const snap = await buildSyncSnapshot();
|
const snap = await buildSyncSnapshot();
|
||||||
const container = await encryptExportPayload(snap, cfg.encPwd);
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||||
const ts = new Date().toISOString()
|
const ts = new Date().toISOString()
|
||||||
@@ -9668,19 +9797,48 @@ async function runSyncNow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast('Syncing…');
|
toast('Syncing…');
|
||||||
|
syncStatus('Pulling…');
|
||||||
let merged = { added: 0, updated: 0, deleted: 0 };
|
let merged = { added: 0, updated: 0, deleted: 0 };
|
||||||
let pulled = false;
|
let pulled = false;
|
||||||
try {
|
try {
|
||||||
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
|
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
|
||||||
if (r.status >= 200 && r.status < 300 && r.payload) {
|
if (r.status >= 200 && r.status < 300 && r.payload) {
|
||||||
|
let snap;
|
||||||
try {
|
try {
|
||||||
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
|
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
|
||||||
const container = JSON.parse(jsonText);
|
const container = JSON.parse(jsonText);
|
||||||
const snap = await decryptExportContainer(container, cfg.encPwd);
|
snap = await decryptExportContainer(container, cfg.encPwd);
|
||||||
|
} catch (e) {
|
||||||
|
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
||||||
|
}
|
||||||
|
// Cross-account guard: if the remote snapshot belongs to a
|
||||||
|
// different account than the one currently signed in, refuse
|
||||||
|
// to merge — otherwise a shared URL / same sync password
|
||||||
|
// between accounts silently pulls foreign entries into the
|
||||||
|
// current vault and pushes the polluted state back out.
|
||||||
|
if (snap && snap.username && state.username &&
|
||||||
|
snap.username !== state.username) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: 'Different account on remote',
|
||||||
|
message: 'The remote snapshot belongs to <b>' +
|
||||||
|
(snap.username + '').replace(/[<>&]/g, '') +
|
||||||
|
'</b>, but you are signed in as <b>' +
|
||||||
|
(state.username + '').replace(/[<>&]/g, '') +
|
||||||
|
'</b>. Merging would mix the two vaults. ' +
|
||||||
|
'Use a distinct sync URL per account.',
|
||||||
|
okText: 'Merge anyway',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return toast('Sync cancelled — account mismatch', 'warning');
|
||||||
|
}
|
||||||
|
syncStatus('Merging…');
|
||||||
|
try {
|
||||||
merged = await applyRemoteSnapshot(snap);
|
merged = await applyRemoteSnapshot(snap);
|
||||||
pulled = true;
|
pulled = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
syncStatus('');
|
||||||
|
return toast('Apply failed: ' + (e && e.message || e), 'error');
|
||||||
}
|
}
|
||||||
} else if (r.status === 404) {
|
} else if (r.status === 404) {
|
||||||
// First sync — no remote yet, we'll just upload our state.
|
// First sync — no remote yet, we'll just upload our state.
|
||||||
@@ -9694,21 +9852,37 @@ async function runSyncNow() {
|
|||||||
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
|
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard against data loss: if we failed to import ONE or more remote
|
||||||
|
// entries locally (server rejected them, encryption failed, etc.),
|
||||||
|
// pushing the current local snapshot would silently overwrite the
|
||||||
|
// remote file with a shrunken dataset. Abort the push and surface a
|
||||||
|
// clear error so the user can investigate + retry.
|
||||||
|
if (merged.failed && merged.failed > 0) {
|
||||||
|
return toast('Sync aborted — ' + merged.failed +
|
||||||
|
' remote entry(ies) failed to import locally. Push skipped to avoid overwriting remote data.',
|
||||||
|
'error');
|
||||||
|
}
|
||||||
|
|
||||||
// Push merged state back to the remote.
|
// Push merged state back to the remote.
|
||||||
try {
|
try {
|
||||||
await loadEntries(); // pull latest after applying remote changes
|
await loadEntries(); // pull latest after applying remote changes
|
||||||
const snap = await buildSyncSnapshot();
|
const snap = await buildSyncSnapshot();
|
||||||
|
syncStatus('Encrypting…');
|
||||||
const container = await encryptExportPayload(snap, cfg.encPwd);
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||||
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||||
|
syncStatus('Pushing…');
|
||||||
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
||||||
bytesToBase64(bodyBytes));
|
bytesToBase64(bodyBytes));
|
||||||
if (!(r.status >= 200 && r.status < 300)) {
|
if (!(r.status >= 200 && r.status < 300)) {
|
||||||
|
syncStatus('');
|
||||||
return toast('Push failed: HTTP ' + r.status, 'error');
|
return toast('Push failed: HTTP ' + r.status, 'error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
syncStatus('');
|
||||||
return toast('Sync push failed: ' + (e && e.message || e), 'error');
|
return toast('Sync push failed: ' + (e && e.message || e), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncStatus('');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
Bridge.setPref(SYNC_PREFS.last, now);
|
Bridge.setPref(SYNC_PREFS.last, now);
|
||||||
render();
|
render();
|
||||||
|
|||||||
Reference in New Issue
Block a user