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:
r-zakarya
2026-07-01 20:24:35 +01:00
parent fbfc24a4d5
commit 0a3372c151
3 changed files with 200 additions and 25 deletions
Binary file not shown.
+1
View File
@@ -707,6 +707,7 @@
<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-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>
</div>
</div>
+195 -21
View File
@@ -8165,7 +8165,16 @@ function parseEntriesFromJSON(text) {
// Notes legitimately have no `site` — their "content" lives in
// password (the note body). Logins still need both site + pwd.
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 tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
@@ -8230,6 +8239,13 @@ async function encryptImportEntry(plain) {
cfIv = c.iv;
} 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 {
uuid: plain.uuid || '',
site: plain.site,
@@ -8238,7 +8254,7 @@ async function encryptImportEntry(plain) {
encrypted_password: pw.encrypted,
iv: pw.iv,
folder: plain.folder || 'All',
tags: plain.tags || '',
tags: tagsStr,
totp_secret: totpEnc,
totp_iv: totpIv,
kind: plain.kind === 'note' ? 'note' : 'login',
@@ -8332,8 +8348,9 @@ async function doImport() {
});
if (!confirmed) return;
const parsedAtt = parsed.entries.reduce(
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
// parsedAtt is computed AFTER dedup — see below where
// dedupedEntries is defined.
let parsedAtt = 0;
// Apply folder customisation (color, icon) from the payload —
// additive only: existing local folders are left untouched so the
@@ -8394,20 +8411,84 @@ async function doImport() {
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 = [];
for (const e of parsed.entries) {
for (const e of fresh) {
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 {
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',
headers: authHeaders({ 'Content-Type': 'application/json' }),
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
// 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');
}
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 atts = parsed.entries[i].attachments;
const atts = dedupedEntries[i].attachments;
if (typeof newId !== 'number' || newId <= 0) continue;
if (!Array.isArray(atts) || atts.length === 0) continue;
for (const a of atts) {
@@ -9486,8 +9567,17 @@ async function buildSyncSnapshot() {
entries: [],
tombstones: [],
};
for (const e of state.entries) {
if (!e.uuid) continue; // legacy row that missed the backfill — skip
// Progress feedback: buildSyncSnapshot dominates runtime (per-entry
// 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);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
@@ -9544,22 +9634,26 @@ async function buildSyncSnapshot() {
}
async function applyRemoteSnapshot(remote) {
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0 };
let 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, failed = 0;
// Push remote tombstones first — server will hard-delete any matching
// 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) {
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
if (uuids.length > 0) {
const preLocal = new Set();
for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid);
try {
await api('/entries/tombstones', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ uuids }),
});
deleted = uuids.length;
deleted = uuids.filter(u => preLocal.has(u)).length;
} catch (_) {}
}
}
@@ -9569,6 +9663,18 @@ async function applyRemoteSnapshot(remote) {
const byUuid = new Map();
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 are left untouched (user's local customisation wins).
if (Array.isArray(remote.folders)) {
@@ -9591,6 +9697,9 @@ async function applyRemoteSnapshot(remote) {
// Per-entry merge.
for (const r of remote.entries) {
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);
if (!local) {
// New entry on remote — encrypt locally + POST keeping the uuid.
@@ -9622,7 +9731,7 @@ async function applyRemoteSnapshot(remote) {
} catch (_) {}
}
}
} catch (_) {}
} catch (_) { failed++; }
} else {
// Both sides have it — keep the newer one (lexical ISO sort).
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
@@ -9638,10 +9747,29 @@ async function applyRemoteSnapshot(remote) {
body: JSON.stringify(enc),
});
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() {
@@ -9657,6 +9785,7 @@ async function runSyncNow() {
const ab = await loadAutoBackupConfig();
const dir = (ab && ab.dir) ? ab.dir : null;
if (dir) {
syncStatus('Local backup…');
const snap = await buildSyncSnapshot();
const container = await encryptExportPayload(snap, cfg.encPwd);
const ts = new Date().toISOString()
@@ -9668,19 +9797,48 @@ async function runSyncNow() {
}
toast('Syncing…');
syncStatus('Pulling…');
let merged = { added: 0, updated: 0, deleted: 0 };
let pulled = false;
try {
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
if (r.status >= 200 && r.status < 300 && r.payload) {
let snap;
try {
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
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);
pulled = true;
} 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) {
// 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');
}
// 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.
try {
await loadEntries(); // pull latest after applying remote changes
const snap = await buildSyncSnapshot();
syncStatus('Encrypting…');
const container = await encryptExportPayload(snap, cfg.encPwd);
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
syncStatus('Pushing…');
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
bytesToBase64(bodyBytes));
if (!(r.status >= 200 && r.status < 300)) {
syncStatus('');
return toast('Push failed: HTTP ' + r.status, 'error');
}
} catch (e) {
syncStatus('');
return toast('Sync push failed: ' + (e && e.message || e), 'error');
}
syncStatus('');
const now = new Date().toISOString();
Bridge.setPref(SYNC_PREFS.last, now);
render();