diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res
index d3152b3..85b131c 100644
Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ
diff --git a/index.html b/index.html
index 777f534..1e1c7a5 100644
--- a/index.html
+++ b/index.html
@@ -707,6 +707,7 @@
+
diff --git a/js/app.js b/js/app.js
index 6be184a..17424b0 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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 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: '' + overlaps.length + ' entries in this file ' +
+ 'already exist locally (same UUID).
' +
+ 'Choose Overwrite to replace the local version with the ' +
+ 'file\'s (rolls back edits made since the backup was taken).
' +
+ 'Choose Skip 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 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 {
- const r = await api('/entries/bulk-import', {
- method: 'POST',
- headers: authHeaders({ 'Content-Type': 'application/json' }),
- body: JSON.stringify({ entries: encrypted }),
- });
- toast('Imported ' + r.imported + ' entries');
+ 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 }),
+ });
+ }
+ 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 ' +
+ (snap.username + '').replace(/[<>&]/g, '') +
+ ', but you are signed in as ' +
+ (state.username + '').replace(/[<>&]/g, '') +
+ '. 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();