feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle

- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server +
  JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with
  pull/merge/push flow, Settings UI, pre-sync backup option. Test
  connection now treats 404 as OK (snapshot not yet created) and 401/
  403 as auth failure with dedicated toast.
- Batch drag-drop: cards + table rows carry checked-set ids (CSV) when
  dragged from an active selection; folder + trash drop handlers parse
  and apply in batch via new moveEntriesToFolder helper that preserves
  TOTP / custom_fields / kind in the full PUT payload.
- Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the
  bridge message-only window; FormCloseQuery bypasses the tray-minimize
  intercept on system shutdown / restart / logoff so FireDAC closes the
  SQLite WAL cleanly instead of leaving -shm / -wal residue after a
  force-kill.
- Center-mode modal: blur+dim backdrop via body::before pseudo-element
  in editor-position=center, swallows clicks below the panel so the
  existing outside-click handlers reliably dismiss the slideover /
  settings panel.
- Batch bar state fixes: state.checked cleared before render in
  moveEntriesToFolder, emptyTrash, and per-card restoreEntry /
  permanentDelete / deleteEntry so the action bar disappears once the
  selection is fully processed.
- Save-then-discard duplicate fix: soState reset to null before
  openSlideOver re-opens the freshly saved entry, otherwise the dirty
  check fired on the soState.id=null → newId switch and a Cancel left
  the form in new-entry mode (second Save → POST duplicate).
- TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync
  with 2 real instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-30 00:32:12 +01:00
parent b00da43ab0
commit 6869b7c692
10 changed files with 1132 additions and 25 deletions
+528 -14
View File
@@ -2451,8 +2451,12 @@ function renderSidebar() {
await reorderFolderTo(draggedFolder, name, before);
return;
}
const id = e.dataTransfer.getData('text/plain');
if (id) await moveEntryToFolder(parseInt(id), name);
const raw = e.dataTransfer.getData('text/plain') || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) {
await moveEntriesToFolder(ids, name);
state.checked.clear();
}
});
const delBtn = el('button', {
@@ -2496,8 +2500,12 @@ function renderSidebar() {
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (id) await moveEntryToFolder(parseInt(id), 'All');
const raw = e.dataTransfer.getData('text/plain') || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) {
await moveEntriesToFolder(ids, 'All');
state.checked.clear();
}
});
fList.appendChild(item);
@@ -3385,7 +3393,16 @@ function renderCard(e) {
if (!inTrash) {
card.addEventListener('dragstart', ev => {
ev.dataTransfer.setData('text/plain', String(e.id));
// If the dragged card is part of an active selection, carry
// ALL checked ids so a drop on a folder / trash moves the
// whole batch in one gesture. Otherwise carry just this one.
let ids;
if (state.checked.size > 1 && state.checked.has(e.id)) {
ids = Array.from(state.checked).join(',');
} else {
ids = String(e.id);
}
ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move';
});
}
@@ -3849,8 +3866,21 @@ function renderTableRow(e) {
+ (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''),
'data-id': String(e.id),
draggable: !inTrash ? 'true' : 'false',
on: { click: ev => handleCardClick(ev, e, inTrash) },
});
if (!inTrash) {
tr.addEventListener('dragstart', ev => {
let ids;
if (state.checked.size > 1 && state.checked.has(e.id)) {
ids = Array.from(state.checked).join(',');
} else {
ids = String(e.id);
}
ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move';
});
}
// Cells are emitted in EXACTLY the same order as getTableColumns()
// returns headers, otherwise THs and TDs drift apart and clicks land
@@ -5293,6 +5323,13 @@ async function soSave() {
if (typeof healthCache !== 'undefined') healthCache = null;
const updated = state.entries.find(x => x.id === targetId);
// Clear soState BEFORE re-opening so openSlideOver doesn't mistake
// the re-open for a "switch entry while dirty" — the form still
// holds the just-saved values but soState.original is stale, which
// would falsely trigger the discard-confirm modal and (on Cancel)
// leave soState at id=null, causing a second Save to POST again
// and create a duplicate.
soState = null;
if (updated) openSlideOver(updated.id);
else closeSlideOver();
render();
@@ -5586,6 +5623,7 @@ async function restoreEntry(id) {
try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
toast('Restored');
state.checked.delete(id);
await loadEntries();
await loadTrash();
render();
@@ -5603,6 +5641,7 @@ async function permanentDelete(id) {
try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
toast('Deleted permanently');
state.checked.delete(id);
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
@@ -5620,6 +5659,7 @@ async function emptyTrash() {
try {
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
toast('Trash emptied');
state.checked.clear();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
@@ -5727,6 +5767,7 @@ async function deleteEntry(id) {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash');
closeSlideOver();
state.checked.delete(id);
await loadEntries();
state.trashedCount = (state.trashedCount || 0) + 1;
render();
@@ -5752,6 +5793,42 @@ async function togglePin(id) {
} catch (e) { toast(e.message, 'error'); }
}
async function moveEntriesToFolder(ids, folder) {
let ok = 0, skipped = 0;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e) continue;
if (e.folder === folder) { skipped++; continue; }
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.folder = folder;
ok++;
} catch (err) { /* try the rest */ }
}
state.checked.clear();
render();
if (ok === 0 && skipped > 0) return;
if (ok === 1) toast('Moved to ' + folder);
else if (ok > 1) toast('Moved ' + ok + ' entries to ' + folder);
}
async function moveEntryToFolder(id, folder) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) return;
@@ -7998,6 +8075,7 @@ function parseEntriesFromJSON(text) {
a && typeof a === 'object' && a.filename && a.content_b64);
}
entries.push({
uuid: String(e.uuid || '').trim(),
site: site,
title: String(e.title || '').trim(),
username: String(e.username || e.user || e.login || '').trim(),
@@ -8043,6 +8121,7 @@ async function encryptImportEntry(plain) {
} catch (e) { /* drop silently */ }
}
return {
uuid: plain.uuid || '',
site: plain.site,
title: plain.title || '',
username: plain.username || '',
@@ -8366,6 +8445,7 @@ async function doExport() {
} catch (_) { /* partial export beats a failed one */ }
payload.entries.push({
uuid: e.uuid || '',
site: e.site,
title: e.title || '',
username: e.username,
@@ -8845,6 +8925,12 @@ function openSettings() {
} else {
refreshAutoBackupUI(null);
}
// Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs).
const syncField = document.getElementById('syncField');
if (syncField) {
syncField.style.display = Bridge.active ? '' : 'none';
if (Bridge.active) loadSyncConfig().then(refreshSyncUI);
}
$('#settingsPanel').classList.add('is-open');
}
@@ -9027,6 +9113,399 @@ async function autoPurgeTrashIfNeeded() {
}
}
// ============================================================
// SYNC (WebDAV, auto-merge with timestamps)
// ============================================================
// Multi-device sync via a remote WebDAV server (Nextcloud, ownCloud,
// Apache mod_dav, any compatible). The remote file is an encrypted
// JSON snapshot (same crypto container as the export, separate
// password — the sync password is device-local DPAPI and the user must
// configure the same one on each device they want to sync).
//
// Strategy: auto-merge with last-write-wins on per-entry updated_at,
// tombstones for delete propagation. No conflict UI in v1 — silent
// resolution because solo personal use rarely produces simultaneous
// edits across devices. Toast reports added / updated / deleted counts.
//
// Sensitive actions (export, change master pw…) keep their own reauth
// path. Sync only touches entries + folders + tombstones.
const SYNC_PREFS = {
enabled: 'syncEnabled',
url: 'syncUrl', // e.g. https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json
user: 'syncUser',
pwd: 'syncPwd', // WebDAV password / app token
encPwd: 'syncEncPwd', // secret for the encrypted JSON container
preBackup: 'syncPreBackup', // 'on' / '' — write a local copy before each sync
last: 'syncLast', // ISO timestamp of last successful run
};
let _webdavResolvers = {};
Bridge.onWebdavResult = function(reqId, status, payload) {
const r = _webdavResolvers[reqId];
if (!r) return;
delete _webdavResolvers[reqId];
r({ status: status | 0, payload: payload || '' });
};
function _webdavCall(method, url, user, pwd, dataB64) {
return new Promise(resolve => {
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
_webdavResolvers[reqId] = resolve;
let q = 'cmd://webdav/' + method
+ '?reqId=' + encodeURIComponent(reqId)
+ '&url=' + encodeURIComponent(url)
+ '&user=' + encodeURIComponent(user || '')
+ '&pwd=' + encodeURIComponent(pwd || '');
if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
window.location.href = q;
setTimeout(() => {
if (_webdavResolvers[reqId]) {
delete _webdavResolvers[reqId];
resolve({ status: 0, payload: 'timeout' });
}
}, 60000);
});
}
function refreshSyncUI(cfg) {
if (!cfg) return;
const cb = document.getElementById('settingSyncEnabled');
const cfg2 = document.getElementById('syncConfig');
if (cb) cb.checked = !!cfg.enabled;
if (cfg2) cfg2.style.display = cfg.enabled ? '' : 'none';
const url = document.getElementById('syncUrl');
const user = document.getElementById('syncUser');
const pwd = document.getElementById('syncPwd');
const pre = document.getElementById('syncPreBackup');
if (url) url.value = cfg.url || '';
if (user) user.value = cfg.user || '';
if (pwd) pwd.value = cfg.pwd || '';
if (pre) pre.checked = !!cfg.preBackup;
const pwdStatus = document.getElementById('syncPwdStatus');
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? 'Set.' : 'Not set.';
const last = document.getElementById('syncLast');
if (last) last.textContent = cfg.last
? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16)
: '';
}
async function syncSetEncPwdFlow() {
let err = '';
let n = 0;
for (;;) {
const v = await promptDialog({
title: 'Set sync password',
message: 'Use the SAME password on every device that syncs with this remote. ' +
'Stored DPAPI-protected on this device only — never transmitted.',
placeholder: 'At least 8 characters',
password: true,
okText: 'Save',
error: err,
});
if (!v) return;
if (v.length >= 8) {
Bridge.setPref(SYNC_PREFS.encPwd, v);
const pwdStatus = document.getElementById('syncPwdStatus');
if (pwdStatus) pwdStatus.textContent = 'Set.';
toast('Sync password saved');
return;
}
n++;
if (n >= 5) return toast('Too many invalid attempts', 'error');
err = 'Use at least 8 characters (attempt ' + n + ' / 5).';
}
}
async function loadSyncConfig() {
if (!Bridge.active) return null;
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([
Bridge.getPref(SYNC_PREFS.enabled),
Bridge.getPref(SYNC_PREFS.url),
Bridge.getPref(SYNC_PREFS.user),
Bridge.getPref(SYNC_PREFS.pwd),
Bridge.getPref(SYNC_PREFS.encPwd),
Bridge.getPref(SYNC_PREFS.preBackup),
Bridge.getPref(SYNC_PREFS.last),
]);
return {
enabled: enabled === '1',
url: url || '',
user: user || '',
pwd: pwd || '',
encPwd: encPwd || '',
preBackup: pre === '1',
last: last || '',
};
}
async function syncTestConnection() {
const cfg = await loadSyncConfig();
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
toast('Testing connection…');
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
if (r.status >= 200 && r.status < 400) {
toast('Connection OK (' + r.status + ')');
} else if (r.status === 404) {
// Server reachable, snapshot file just doesn't exist yet — normal
// before the first sync. Treat as success.
toast('Connection OK · snapshot not created yet');
} else if (r.status === 401 || r.status === 403) {
toast('Auth failed (' + r.status + ') — check user/password', 'error');
} else if (r.status === 0) {
toast('Network error: ' + (r.payload || 'unreachable'), 'error');
} else {
toast('Server returned ' + r.status, 'error');
}
}
// Build the snapshot payload that gets encrypted + pushed to the remote.
// Includes entries (decrypted plaintext, then re-encrypted under the
// sync key), folders metadata, and tombstones. Mirrors doExport's shape
// so a sync snapshot is also importable via "Import vault".
async function buildSyncSnapshot() {
const payload = {
version: 1,
snapshot_at: new Date().toISOString(),
username: state.username,
folders: (state.folders || [])
.filter(f => f && f.name && f.name !== 'All')
.map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })),
entries: [],
tombstones: [],
};
for (const e of state.entries) {
if (!e.uuid) continue; // legacy row that missed the backfill — skip
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try { plainCustom = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv); }
catch (_) {}
}
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename, mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) {}
payload.entries.push({
uuid: e.uuid,
site: e.site || '',
title: e.title || '',
username: e.username || '',
password: plain === '[ERROR]' ? '' : plain,
folder: e.folder || 'All',
tags: parseTags(e.tags),
favorite: !!e.favorite,
totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments,
icon_b64: e.icon_b64 || '',
created_at: e.created_at,
updated_at: e.updated_at,
});
}
try {
const ts = await api('/entries/tombstones', { headers: authHeaders() });
payload.tombstones = (Array.isArray(ts) ? ts : []).map(t => ({
uuid: t.uuid, deleted_at: t.deleted_at,
}));
} catch (_) {}
return payload;
}
async function applyRemoteSnapshot(remote) {
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0 };
let added = 0, updated = 0, deleted = 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.
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
if (uuids.length > 0) {
try {
await api('/entries/tombstones', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ uuids }),
});
deleted = uuids.length;
} catch (_) {}
}
}
// Refresh local view AFTER tombstones so the maps reflect the cull.
await loadEntries();
const byUuid = new Map();
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
// 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)) {
const localNames = new Set((state.folders || []).map(f => f.name));
for (const f of remote.folders) {
if (!f.name || localNames.has(f.name)) continue;
try {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
name: f.name, color: f.color || '', icon: f.icon || '',
}),
});
} catch (_) {}
}
await loadFolders();
}
// Per-entry merge.
for (const r of remote.entries) {
if (!r.uuid) continue;
const local = byUuid.get(r.uuid);
if (!local) {
// New entry on remote — encrypt locally + POST keeping the uuid.
try {
const enc = await encryptImportEntry(Object.assign({}, r, { uuid: r.uuid }));
const created = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(enc),
});
added++;
// Restore attachments for this new entry.
if (Array.isArray(r.attachments) && created && created.id) {
for (const a of r.attachments) {
try {
const bytes = base64ToBytes(a.content_b64 || '');
const blob = await encryptBlobBytes(bytes);
await api('/entries/' + created.id + '/attachments', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filename: a.filename,
mime: a.mime || 'application/octet-stream',
encrypted_blob: blob.encrypted,
iv: blob.iv,
size_bytes: a.size_bytes || bytes.length,
}),
});
} catch (_) {}
}
}
} catch (_) {}
} else {
// Both sides have it — keep the newer one (lexical ISO sort).
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
if (!remoteWins) continue;
try {
const enc = await encryptImportEntry(r);
// PUT keeps the existing id but accepts the encrypted blobs.
// template + uuid not touched here (server preserves columns
// when fields aren't in body — uuid is immutable anyway).
await api('/entries/' + local.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(enc),
});
updated++;
} catch (_) {}
}
}
return { added, updated, deleted };
}
async function runSyncNow() {
const cfg = await loadSyncConfig();
if (!cfg) return toast('Bridge not available', 'error');
if (!cfg.url) return toast('Sync not configured', 'warning');
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
// Pre-sync backup — best effort, doesn't block sync on failure.
if (cfg.preBackup) {
try {
const ab = await loadAutoBackupConfig();
const dir = (ab && ab.dir) ? ab.dir : null;
if (dir) {
const snap = await buildSyncSnapshot();
const container = await encryptExportPayload(snap, cfg.encPwd);
const ts = new Date().toISOString()
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
const path = dir.replace(/[\\/]+$/, '') + '\\vault-presync-' + ts + '.json';
await Bridge.writeFile(path, JSON.stringify(container, null, 2));
}
} catch (_) { /* best-effort */ }
}
toast('Syncing…');
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) {
try {
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
const container = JSON.parse(jsonText);
const snap = await decryptExportContainer(container, cfg.encPwd);
merged = await applyRemoteSnapshot(snap);
pulled = true;
} catch (e) {
return toast('Remote decrypt failed — wrong sync password?', 'error');
}
} else if (r.status === 404) {
// First sync — no remote yet, we'll just upload our state.
pulled = true;
} else if (r.status === 0) {
return toast('Network error: ' + (r.payload || 'unreachable'), 'error');
} else {
return toast('Pull failed: HTTP ' + r.status, 'error');
}
} catch (e) {
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
}
// Push merged state back to the remote.
try {
await loadEntries(); // pull latest after applying remote changes
const snap = await buildSyncSnapshot();
const container = await encryptExportPayload(snap, cfg.encPwd);
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
bytesToBase64(bodyBytes));
if (!(r.status >= 200 && r.status < 300)) {
return toast('Push failed: HTTP ' + r.status, 'error');
}
} catch (e) {
return toast('Sync push failed: ' + (e && e.message || e), 'error');
}
const now = new Date().toISOString();
Bridge.setPref(SYNC_PREFS.last, now);
render();
const summary =
merged.added + ' added · ' +
merged.updated + ' updated · ' +
merged.deleted + ' deleted';
toast('Sync complete — ' + summary);
}
// ============================================================
// AUTO-BACKUP (silent encrypted exports on a schedule)
// ============================================================
@@ -9194,6 +9673,7 @@ async function runAutoBackupNow() {
}
} catch (_) {}
payload.entries.push({
uuid: e.uuid || '',
site: e.site, title: e.title || '', username: e.username,
password: plain, folder: e.folder, tags: parseTags(e.tags),
favorite: !!e.favorite, totp_secret: plainTotp,
@@ -9620,15 +10100,21 @@ async function init() {
trashItem.addEventListener('drop', async ev => {
ev.preventDefault();
trashItem.classList.remove('drag-over');
const id = parseInt(ev.dataTransfer.getData('text/plain'));
if (!id) return;
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash');
await loadEntries();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
const raw = ev.dataTransfer.getData('text/plain') || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (!ids.length) return;
let ok = 0;
for (const id of ids) {
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
ok++;
} catch (err) { /* keep going for the rest */ }
}
toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash');
state.checked.clear();
await loadEntries();
await loadTrash();
render();
});
}
@@ -10002,6 +10488,34 @@ async function init() {
saveServerSettings();
toast('Unlock method updated');
});
// Sync (WebDAV) listeners
const syncCb = document.getElementById('settingSyncEnabled');
const syncCfgBox = document.getElementById('syncConfig');
const syncUrlEl = document.getElementById('syncUrl');
const syncUserEl = document.getElementById('syncUser');
const syncPwdEl = document.getElementById('syncPwd');
const syncPreEl = document.getElementById('syncPreBackup');
const syncSetPwd = document.getElementById('syncSetPwdBtn');
const syncTest = document.getElementById('syncTestBtn');
const syncNow = document.getElementById('syncNowBtn');
if (syncCb) syncCb.addEventListener('change', e => {
const on = !!e.target.checked;
Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : '');
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
toast(on ? 'Sync enabled' : 'Sync disabled');
});
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
if (syncUserEl) syncUserEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.pwd, e.target.value));
if (syncPreEl) syncPreEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : ''));
if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow);
if (syncTest) syncTest.addEventListener('click', syncTestConnection);
if (syncNow) syncNow.addEventListener('click', runSyncNow);
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
$('#settingAutoBackupInterval').addEventListener('change', e => {