// =============================================================================
// CLOUD AUTO-SYNC — Real-Time Encrypted Inventory Sync (STAK-149)
// =============================================================================
//
// Automatic background sync: when inventory changes, pushes an encrypted
// .stvault to Dropbox. On other devices, a background poller detects the
// new file via staktrakr-sync.json and prompts the user to pull.
//
// Sync file: /StakTrakr/staktrakr-sync.stvault (full encrypted snapshot)
// Metadata: /StakTrakr/staktrakr-sync.json (lightweight pointer, polled)
//
// Depends on: cloud-storage.js, vault.js, constants.js, utils.js
// =============================================================================
// ---------------------------------------------------------------------------
// Module state
// ---------------------------------------------------------------------------
/** @type {number|null} setInterval handle for the polling loop */
var _syncPollerTimer = null;
/** @type {boolean} Whether a push is currently in progress */
var _syncPushInFlight = false;
/** @type {number} Retry backoff multiplier for 429 / network errors */
var _syncRetryDelay = 2000;
/** @type {Function} Debounced version of pushSyncVault */
var scheduleSyncPush = null;
/** @type {string} Currently active sync provider */
var _syncProvider = 'dropbox';
// ---------------------------------------------------------------------------
// Device identity
// ---------------------------------------------------------------------------
/**
* Get or create a stable per-device UUID, persisted in localStorage.
* @returns {string}
*/
function getSyncDeviceId() {
var stored = localStorage.getItem('cloud_sync_device_id');
if (stored) return stored;
var id = typeof generateUUID === 'function' ? generateUUID() : _syncFallbackUUID();
try { localStorage.setItem('cloud_sync_device_id', id); } catch (_) { /* ignore */ }
return id;
}
/** Fallback UUID generator when generateUUID from utils.js is unavailable */
function _syncFallbackUUID() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, function (c) {
return (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
});
}
// ---------------------------------------------------------------------------
// Sync state helpers
// ---------------------------------------------------------------------------
function syncGetLastPush() {
try { return JSON.parse(localStorage.getItem('cloud_sync_last_push') || 'null'); } catch (_) { return null; }
}
function syncSetLastPush(meta) {
try { localStorage.setItem('cloud_sync_last_push', JSON.stringify(meta)); } catch (_) { /* ignore */ }
}
function syncGetLastPull() {
try { return JSON.parse(localStorage.getItem('cloud_sync_last_pull') || 'null'); } catch (_) { return null; }
}
function syncSetLastPull(meta) {
try { localStorage.setItem('cloud_sync_last_pull', JSON.stringify(meta)); } catch (_) { /* ignore */ }
}
function syncGetCursor() {
return localStorage.getItem('cloud_sync_cursor') || null;
}
function syncSetCursor(rev) {
try { localStorage.setItem('cloud_sync_cursor', rev || ''); } catch (_) { /* ignore */ }
}
function syncIsEnabled() {
return localStorage.getItem('cloud_sync_enabled') === 'true';
}
// ---------------------------------------------------------------------------
// Override backup — snapshot local data before a remote pull overwrites it
// ---------------------------------------------------------------------------
/**
* Snapshot all SYNC_SCOPE_KEYS raw localStorage strings into a single JSON blob.
* Called immediately before vaultDecryptAndRestore() in pullSyncVault().
*/
function syncSaveOverrideBackup() {
try {
var keys = typeof SYNC_SCOPE_KEYS !== 'undefined' ? SYNC_SCOPE_KEYS : [];
var data = {};
for (var i = 0; i < keys.length; i++) {
var raw = localStorage.getItem(keys[i]);
if (raw !== null) data[keys[i]] = raw;
}
var backup = {
timestamp: Date.now(),
itemCount: typeof inventory !== 'undefined' ? inventory.length : 0,
appVersion: typeof APP_VERSION !== 'undefined' ? APP_VERSION : 'unknown',
data: data,
};
localStorage.setItem('cloud_sync_override_backup', JSON.stringify(backup));
debugLog('[CloudSync] Override backup saved:', Object.keys(data).length, 'keys');
} catch (err) {
debugLog('[CloudSync] Override backup failed:', err);
}
}
/**
* Restore the pre-pull local snapshot saved by syncSaveOverrideBackup().
* Prompts for confirmation, writes raw strings back, and refreshes the UI.
*/
async function syncRestoreOverrideBackup() {
var backup = null;
try { backup = JSON.parse(localStorage.getItem('cloud_sync_override_backup') || 'null'); } catch (_) {}
if (!backup || !backup.data) {
if (typeof showAppAlert === 'function') await showAppAlert('No snapshot available.', 'Sync History');
return;
}
var ts = new Date(backup.timestamp).toLocaleString();
var msg = 'Restore local snapshot from ' + ts + '?\n\n' +
'Items at snapshot: ' + (backup.itemCount || '?') + '\n' +
'App version: v' + (backup.appVersion || '?') + '\n\n' +
'This will overwrite your current inventory and cannot be undone.';
var confirmed = typeof showAppConfirm === 'function'
? await showAppConfirm(msg, 'Restore Snapshot')
: window.confirm(msg);
if (!confirmed) return;
try {
var bkeys = Object.keys(backup.data);
for (var j = 0; j < bkeys.length; j++) {
if (typeof ALLOWED_STORAGE_KEYS !== 'undefined' && ALLOWED_STORAGE_KEYS.indexOf(bkeys[j]) !== -1) {
localStorage.setItem(bkeys[j], backup.data[bkeys[j]]);
}
}
if (typeof loadItemTags === 'function') loadItemTags();
if (typeof loadInventory === 'function') loadInventory();
if (typeof updateSummary === 'function') updateSummary();
if (typeof renderTable === 'function') renderTable();
if (typeof renderActiveFilters === 'function') renderActiveFilters();
if (typeof loadSpotHistory === 'function') loadSpotHistory();
logCloudSyncActivity('override_restore', 'success', 'Snapshot from ' + ts + ' restored');
if (typeof showCloudToast === 'function') showCloudToast('Local snapshot restored successfully.');
if (typeof renderSyncHistorySection === 'function') renderSyncHistorySection();
} catch (err) {
debugLog('[CloudSync] Restore failed:', err);
if (typeof showAppAlert === 'function') await showAppAlert('Restore failed: ' + String(err.message || err), 'Sync History');
}
}
// ---------------------------------------------------------------------------
// Sync status indicator (small badge in Settings cloud card)
// ---------------------------------------------------------------------------
/**
* Update the auto-sync status indicator in the Settings UI.
* @param {'idle'|'syncing'|'error'|'disabled'} state
* @param {string} [detail] optional status text (e.g. "Just now", error message)
*/
function updateSyncStatusIndicator(state, detail) {
var el = document.getElementById('cloudAutoSyncStatus');
if (!el) return;
var dot = el.querySelector('.cloud-sync-dot');
var text = el.querySelector('.cloud-sync-status-text');
if (dot) {
dot.className = 'cloud-sync-dot';
if (state === 'syncing') dot.classList.add('cloud-sync-dot--syncing');
else if (state === 'error') dot.classList.add('cloud-sync-dot--error');
else if (state === 'idle') dot.classList.add('cloud-sync-dot--ok');
// 'disabled' = no extra class (grey)
}
if (text) {
var label = '';
if (state === 'syncing') label = 'Syncing\u2026';
else if (state === 'error') label = detail || 'Sync error';
else if (state === 'idle') label = detail || 'Synced';
else label = 'Auto-sync off';
text.textContent = label;
}
}
/**
* Refresh the "Last synced" text and toggle state in the cloud card.
* Called by syncCloudUI() when switching to the Cloud settings panel.
*/
function refreshSyncUI() {
// Sync toggle
var toggle = document.getElementById('cloudAutoSyncToggle');
if (toggle) toggle.checked = syncIsEnabled();
// Last synced label
var lastPush = syncGetLastPush();
var lastSyncEl = document.getElementById('cloudAutoSyncLastSync');
if (lastSyncEl) {
if (lastPush && lastPush.timestamp) {
lastSyncEl.textContent = _syncRelativeTime(lastPush.timestamp);
} else {
lastSyncEl.textContent = 'Never';
}
}
// Sync Now button — enabled only when connected AND sync is on
var syncNowBtn = document.getElementById('cloudSyncNowBtn');
if (syncNowBtn) {
var connected = typeof cloudIsConnected === 'function' ? cloudIsConnected(_syncProvider) : false;
syncNowBtn.disabled = !(syncIsEnabled() && connected);
}
// Status dot
if (!syncIsEnabled()) {
updateSyncStatusIndicator('disabled');
} else {
var lp = syncGetLastPush();
if (lp && lp.timestamp) {
updateSyncStatusIndicator('idle', _syncRelativeTime(lp.timestamp));
} else {
updateSyncStatusIndicator('idle', 'Not yet synced');
}
}
if (typeof renderSyncHistorySection === 'function') renderSyncHistorySection();
}
/** Format a timestamp as a relative time string ("just now", "5 min ago", etc.) */
function _syncRelativeTime(ts) {
var diff = Math.floor((Date.now() - ts) / 1000);
if (diff < 10) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
var d = new Date(ts);
var pad = function (n) { return n < 10 ? '0' + n : String(n); };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
// ---------------------------------------------------------------------------
// Password management
// ---------------------------------------------------------------------------
/**
* Get the session-cached sync password, or open the sync password modal.
* Returns a Promise that resolves with the password string, or null if cancelled.
* @returns {Promise<string|null>}
*/
function getSyncPassword() {
// Try cached password first
var cached = typeof cloudGetCachedPassword === 'function'
? cloudGetCachedPassword(_syncProvider)
: null;
if (cached) return Promise.resolve(cached);
// Open the dedicated sync password modal and resolve when the user submits/cancels
return new Promise(function (resolve) {
var modal = document.getElementById('cloudSyncPasswordModal');
var input = document.getElementById('syncPasswordInput');
var confirmBtn = document.getElementById('syncPasswordConfirmBtn');
var cancelBtn = document.getElementById('syncPasswordCancelBtn');
var cancelBtn2 = document.getElementById('syncPasswordCancelBtn2');
var errorEl = document.getElementById('syncPasswordError');
if (!modal || !input || !confirmBtn) {
// DOM not ready — last-resort fallback only used during startup edge cases
var pw = window.prompt('Vault password for sync:');
if (pw && typeof cloudCachePassword === 'function') cloudCachePassword(_syncProvider, pw);
resolve(pw || null);
return;
}
// Reset state
input.value = '';
if (errorEl) { errorEl.textContent = ''; errorEl.style.display = 'none'; }
var cleanup = function () {
confirmBtn.removeEventListener('click', onConfirm);
if (cancelBtn) cancelBtn.removeEventListener('click', onCancel);
if (cancelBtn2) cancelBtn2.removeEventListener('click', onCancel);
input.removeEventListener('keydown', onKeydown);
if (typeof closeModalById === 'function') closeModalById('cloudSyncPasswordModal');
else modal.style.display = 'none';
};
var onConfirm = function () {
var pw = input.value;
if (!pw || pw.length < 8) {
if (errorEl) {
errorEl.textContent = 'Password must be at least 8 characters.';
errorEl.style.display = '';
}
return;
}
cleanup();
if (typeof cloudCachePassword === 'function') cloudCachePassword(_syncProvider, pw);
resolve(pw);
};
var onCancel = function () {
cleanup();
resolve(null);
};
var onKeydown = function (e) {
if (e.key === 'Enter') onConfirm();
if (e.key === 'Escape') onCancel();
};
confirmBtn.addEventListener('click', onConfirm);
if (cancelBtn) cancelBtn.addEventListener('click', onCancel);
if (cancelBtn2) cancelBtn2.addEventListener('click', onCancel);
input.addEventListener('keydown', onKeydown);
if (typeof openModalById === 'function') openModalById('cloudSyncPasswordModal');
else modal.style.display = 'flex';
// Focus the input after the modal opens
setTimeout(function () { input.focus(); }, 50);
});
}
// ---------------------------------------------------------------------------
// Activity logging
// ---------------------------------------------------------------------------
function logCloudSyncActivity(action, result, detail, duration) {
if (typeof recordCloudActivity === 'function') {
recordCloudActivity({
action: action,
provider: _syncProvider,
result: result || 'success',
detail: detail || '',
duration: duration != null ? duration : null,
});
}
}
// ---------------------------------------------------------------------------
// Push (upload encrypted vault to Dropbox)
// ---------------------------------------------------------------------------
/**
* Encrypt the sync-scoped inventory and upload to Dropbox.
* Also updates the lightweight staktrakr-sync.json metadata pointer.
* Skips silently if not connected or sync is disabled.
*/
async function pushSyncVault() {
debugLog('[CloudSync] pushSyncVault called. enabled:', syncIsEnabled(), 'provider:', _syncProvider);
if (!syncIsEnabled()) {
debugLog('[CloudSync] Push skipped — sync not enabled');
return;
}
var token = typeof cloudGetToken === 'function' ? await cloudGetToken(_syncProvider) : null;
debugLog('[CloudSync] Token obtained:', !!token);
if (!token) {
debugLog('[CloudSync] No token — push skipped');
updateSyncStatusIndicator('error', 'Not connected');
return;
}
if (_syncPushInFlight) {
debugLog('[CloudSync] Push already in flight — skipped');
return;
}
var password = await getSyncPassword();
debugLog('[CloudSync] Password obtained:', !!password);
if (!password) {
debugLog('[CloudSync] No password — push skipped');
return;
}
_syncPushInFlight = true;
updateSyncStatusIndicator('syncing');
var pushStart = Date.now();
try {
// Encrypt sync-scoped payload
debugLog('[CloudSync] Encrypting payload…');
var fileBytes = typeof vaultEncryptToBytesScoped === 'function'
? await vaultEncryptToBytesScoped(password)
: await vaultEncryptToBytes(password);
debugLog('[CloudSync] Encrypted:', fileBytes.byteLength, 'bytes');
var syncId = typeof generateUUID === 'function' ? generateUUID() : _syncFallbackUUID();
var now = Date.now();
var itemCount = typeof inventory !== 'undefined' ? inventory.length : 0;
var appVersion = typeof APP_VERSION !== 'undefined' ? APP_VERSION : 'unknown';
var deviceId = getSyncDeviceId();
// Upload the vault file (overwrite)
debugLog('[CloudSync] Uploading vault to', SYNC_FILE_PATH, '…');
var vaultArg = JSON.stringify({
path: SYNC_FILE_PATH,
mode: 'overwrite',
autorename: false,
mute: true,
});
var vaultResp = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': vaultArg,
},
body: fileBytes,
});
debugLog('[CloudSync] Vault upload response:', vaultResp.status);
if (vaultResp.status === 429) {
_syncRetryDelay = Math.min(_syncRetryDelay * 2, 300000); // cap at 5 min
throw new Error('Rate limited (429). Retry in ' + Math.round(_syncRetryDelay / 1000) + 's');
}
if (!vaultResp.ok) {
var errBody = await vaultResp.text().catch(function () { return ''; });
throw new Error('Vault upload failed: ' + vaultResp.status + ' ' + errBody);
}
_syncRetryDelay = 2000; // reset backoff on success
var vaultResult = await vaultResp.json();
var rev = vaultResult.rev || '';
console.log('[CloudSync] Vault uploaded, rev:', rev);
// Upload the metadata pointer JSON
var metaPayload = {
rev: rev,
timestamp: now,
appVersion: appVersion,
itemCount: itemCount,
syncId: syncId,
deviceId: deviceId,
};
var metaBytes = new TextEncoder().encode(JSON.stringify(metaPayload));
var metaArg = JSON.stringify({
path: SYNC_META_PATH,
mode: 'overwrite',
autorename: false,
mute: true,
});
var metaResp = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': metaArg,
},
body: metaBytes,
});
if (!metaResp.ok) throw new Error('Metadata upload failed: ' + metaResp.status);
// Persist push state
var pushMeta = { syncId: syncId, timestamp: now, rev: rev, itemCount: itemCount };
syncSetLastPush(pushMeta);
syncSetCursor(rev);
var duration = Date.now() - pushStart;
logCloudSyncActivity('auto_sync_push', 'success', itemCount + ' items, ' + Math.round(fileBytes.byteLength / 1024) + ' KB', duration);
debugLog('[CloudSync] Push complete:', syncId, 'rev:', rev, '(' + duration + 'ms)');
updateSyncStatusIndicator('idle', 'just now');
refreshSyncUI();
} catch (err) {
var errMsg = String(err.message || err);
console.error('[CloudSync] Push failed:', errMsg, err);
logCloudSyncActivity('auto_sync_push', 'fail', errMsg);
updateSyncStatusIndicator('error', errMsg.slice(0, 60));
} finally {
_syncPushInFlight = false;
}
}
// ---------------------------------------------------------------------------
// Poll (check remote for changes)
// ---------------------------------------------------------------------------
/**
* Download staktrakr-sync.json and compare syncId with last pull.
* If different, hand off to handleRemoteChange().
* Skips silently if not connected or sync is disabled.
*/
async function pollForRemoteChanges() {
if (!syncIsEnabled()) return;
if (document.hidden) return; // Page Visibility API: skip background polls
var token = typeof cloudGetToken === 'function' ? await cloudGetToken(_syncProvider) : null;
if (!token) return;
try {
var apiArg = JSON.stringify({ path: SYNC_META_PATH });
var resp = await fetch('https://content.dropboxapi.com/2/files/download', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Dropbox-API-Arg': apiArg,
},
});
if (resp.status === 409) {
// File not found — no sync file yet (first device)
debugLog('[CloudSync] No remote sync file yet');
return;
}
if (resp.status === 429) {
_syncRetryDelay = Math.min(_syncRetryDelay * 2, 300000);
debugLog('[CloudSync] Poll rate limited — backing off');
return;
}
if (!resp.ok) {
debugLog('[CloudSync] Poll meta fetch failed:', resp.status);
return;
}
_syncRetryDelay = SYNC_POLL_INTERVAL;
var remoteMeta = await resp.json();
if (!remoteMeta || !remoteMeta.syncId) return;
var lastPull = syncGetLastPull();
// Echo detection: if this device pushed this syncId, just record the pull
if (remoteMeta.deviceId === getSyncDeviceId()) {
debugLog('[CloudSync] Poll: remote is our own push, skipping');
if (!lastPull || lastPull.syncId !== remoteMeta.syncId) {
syncSetLastPull({ syncId: remoteMeta.syncId, timestamp: remoteMeta.timestamp, rev: remoteMeta.rev });
}
return;
}
// No change since last pull
if (lastPull && lastPull.syncId === remoteMeta.syncId) {
debugLog('[CloudSync] Poll: no new changes');
return;
}
debugLog('[CloudSync] Poll: remote change detected — syncId:', remoteMeta.syncId);
logCloudSyncActivity('auto_sync_poll', 'success', 'Remote change detected: ' + remoteMeta.itemCount + ' items');
await handleRemoteChange(remoteMeta);
} catch (err) {
debugLog('[CloudSync] Poll error:', err);
}
}
// ---------------------------------------------------------------------------
// Conflict detection & resolution
// ---------------------------------------------------------------------------
/**
* Determine whether we have local unpushed changes.
* We consider local "dirty" if our last push was more recent than our last pull
* (meaning we've pushed something that predates the remote change, so both
* sides have diverged independently).
* @returns {boolean}
*/
function syncHasLocalChanges() {
var lastPush = syncGetLastPush();
var lastPull = syncGetLastPull();
if (!lastPush) return false;
if (!lastPull) return true; // pushed but never pulled
return lastPush.timestamp > lastPull.timestamp;
}
/**
* Show the "Update available" modal and return a Promise that resolves true
* (user accepted) or false (user dismissed / closed).
* @param {object} remoteMeta - The parsed staktrakr-sync.json content
* @returns {Promise<boolean>}
*/
function showSyncUpdateModal(remoteMeta) {
return new Promise(function (resolve) {
var modal = document.getElementById('cloudSyncUpdateModal');
if (!modal) { resolve(false); return; } // fallback: decline if no modal in DOM
// Populate metadata fields
var itemCountEl = document.getElementById('syncUpdateItemCount');
var timestampEl = document.getElementById('syncUpdateTimestamp');
var deviceEl = document.getElementById('syncUpdateDevice');
if (itemCountEl) itemCountEl.textContent = remoteMeta.itemCount != null ? String(remoteMeta.itemCount) : '—';
if (timestampEl) {
var ts = remoteMeta.timestamp ? new Date(remoteMeta.timestamp) : null;
timestampEl.textContent = ts ? ts.toLocaleString() : '—';
}
if (deviceEl) {
var devId = remoteMeta.deviceId || '';
deviceEl.textContent = devId ? devId.slice(0, 8) + '\u2026' : 'unknown';
}
modal.style.display = 'flex';
function cleanup(result) {
modal.style.display = 'none';
acceptBtn.removeEventListener('click', onAccept);
dismissBtn.removeEventListener('click', onDismiss);
dismissX.removeEventListener('click', onDismiss);
resolve(result);
}
function onAccept() { cleanup(true); }
function onDismiss() { cleanup(false); }
var acceptBtn = document.getElementById('syncUpdateAcceptBtn');
var dismissBtn = document.getElementById('syncUpdateDismissBtn');
var dismissX = document.getElementById('syncUpdateDismissX');
if (acceptBtn) acceptBtn.addEventListener('click', onAccept);
if (dismissBtn) dismissBtn.addEventListener('click', onDismiss);
if (dismissX) dismissX.addEventListener('click', onDismiss);
});
}
/**
* Handle a detected remote change.
* If no local changes → show update-available modal, then pull on Accept.
* If both sides changed → show conflict modal.
* @param {object} remoteMeta - The parsed staktrakr-sync.json content
*/
async function handleRemoteChange(remoteMeta) {
var hasLocal = syncHasLocalChanges();
if (!hasLocal) {
// Show the update-available modal — let user decide before password prompt
debugLog('[CloudSync] Remote change detected — showing update modal');
var accepted = await showSyncUpdateModal(remoteMeta);
if (!accepted) {
debugLog('[CloudSync] User dismissed update — will retry next poll');
return;
}
await pullSyncVault(remoteMeta);
return;
}
// Conflict: both sides have changes
debugLog('[CloudSync] Conflict detected — showing conflict modal');
var lastPush = syncGetLastPush();
showSyncConflictModal({
local: {
itemCount: typeof inventory !== 'undefined' ? inventory.length : 0,
timestamp: lastPush ? lastPush.timestamp : null,
appVersion: typeof APP_VERSION !== 'undefined' ? APP_VERSION : 'unknown',
},
remote: {
itemCount: remoteMeta.itemCount || 0,
timestamp: remoteMeta.timestamp || null,
appVersion: remoteMeta.appVersion || 'unknown',
deviceId: remoteMeta.deviceId || '',
},
remoteMeta: remoteMeta,
});
}
// ---------------------------------------------------------------------------
// Pull (download and restore remote vault)
// ---------------------------------------------------------------------------
/**
* Download staktrakr-sync.stvault, decrypt, and restore inventory.
* @param {object} remoteMeta - Remote sync metadata (from pollForRemoteChanges)
*/
async function pullSyncVault(remoteMeta) {
var password = await getSyncPassword();
if (!password) {
debugLog('[CloudSync] Pull cancelled — no password');
return;
}
var token = typeof cloudGetToken === 'function' ? await cloudGetToken(_syncProvider) : null;
if (!token) throw new Error('Not connected to cloud provider');
var pullStart = Date.now();
updateSyncStatusIndicator('syncing');
try {
var apiArg = JSON.stringify({ path: SYNC_FILE_PATH });
var resp = await fetch('https://content.dropboxapi.com/2/files/download', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Dropbox-API-Arg': apiArg,
},
});
if (!resp.ok) throw new Error('Vault download failed: ' + resp.status);
var bytes = new Uint8Array(await resp.arrayBuffer());
syncSaveOverrideBackup();
if (typeof vaultDecryptAndRestore === 'function') {
await vaultDecryptAndRestore(bytes, password);
} else {
throw new Error('vaultDecryptAndRestore not available');
}
// Record the pull
var pullMeta = {
syncId: remoteMeta ? remoteMeta.syncId : null,
timestamp: remoteMeta ? remoteMeta.timestamp : Date.now(),
rev: remoteMeta ? remoteMeta.rev : null,
};
syncSetLastPull(pullMeta);
var duration = Date.now() - pullStart;
logCloudSyncActivity('auto_sync_pull', 'success', (remoteMeta ? remoteMeta.itemCount : '?') + ' items restored', duration);
debugLog('[CloudSync] Pull complete (' + duration + 'ms)');
if (typeof showCloudToast === 'function') {
showCloudToast('Auto-sync: inventory updated from another device.');
}
updateSyncStatusIndicator('idle', 'just now');
refreshSyncUI();
} catch (err) {
var errMsg = String(err.message || err);
debugLog('[CloudSync] Pull failed:', errMsg);
logCloudSyncActivity('auto_sync_pull', 'fail', errMsg);
updateSyncStatusIndicator('error', errMsg.slice(0, 60));
if (typeof showCloudToast === 'function') showCloudToast('Auto-sync pull failed: ' + errMsg);
}
}
// ---------------------------------------------------------------------------
// Conflict modal
// ---------------------------------------------------------------------------
/**
* Show the sync conflict modal with local vs. remote comparison.
* @param {{local: object, remote: object, remoteMeta: object}} opts
*/
function showSyncConflictModal(opts) {
var modal = document.getElementById('cloudSyncConflictModal');
if (!modal) {
// Fallback: simple confirm if modal not in DOM
var msg = 'Sync conflict detected.\n\n' +
'Local: ' + opts.local.itemCount + ' items\n' +
'Remote: ' + opts.remote.itemCount + ' items\n\n' +
'Keep YOUR local version? (Cancel to keep the remote version)';
if (window.confirm(msg)) {
pushSyncVault();
} else {
pullSyncVault(opts.remoteMeta);
}
return;
}
// Populate modal fields
var setEl = function (id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text || '\u2014';
};
setEl('syncConflictLocalItems', opts.local.itemCount + ' items');
setEl('syncConflictLocalTime', opts.local.timestamp ? _syncRelativeTime(opts.local.timestamp) : 'Unknown');
setEl('syncConflictLocalVersion', 'v' + opts.local.appVersion);
setEl('syncConflictRemoteItems', opts.remote.itemCount + ' items');
setEl('syncConflictRemoteTime', opts.remote.timestamp ? _syncRelativeTime(opts.remote.timestamp) : 'Unknown');
setEl('syncConflictRemoteVersion', 'v' + opts.remote.appVersion);
setEl('syncConflictRemoteDevice', opts.remote.deviceId ? opts.remote.deviceId.slice(0, 8) + '\u2026' : 'Another device');
// Wire buttons
var keepMineBtn = document.getElementById('syncConflictKeepMine');
var keepTheirsBtn = document.getElementById('syncConflictKeepTheirs');
var skipBtn = document.getElementById('syncConflictSkip');
var closeModal = function () {
modal.style.display = 'none';
if (typeof closeModalById === 'function') closeModalById('cloudSyncConflictModal');
};
if (keepMineBtn) {
keepMineBtn.onclick = function () {
closeModal();
pushSyncVault();
};
}
if (keepTheirsBtn) {
keepTheirsBtn.onclick = function () {
closeModal();
pullSyncVault(opts.remoteMeta);
};
}
if (skipBtn) {
skipBtn.onclick = closeModal;
}
if (typeof openModalById === 'function') {
openModalById('cloudSyncConflictModal');
} else {
modal.style.display = 'flex';
}
}
// ---------------------------------------------------------------------------
// Poller lifecycle
// ---------------------------------------------------------------------------
/** Schedule the next poll using the current _syncRetryDelay (respects backoff). */
function _schedulePoll() {
_syncPollerTimer = setTimeout(async function () {
await pollForRemoteChanges();
if (_syncPollerTimer !== null) _schedulePoll();
}, _syncRetryDelay);
}
/** Start the background polling loop. Uses setTimeout so backoff delay is honoured. */
function startSyncPoller() {
stopSyncPoller();
_syncRetryDelay = SYNC_POLL_INTERVAL;
_schedulePoll();
debugLog('[CloudSync] Poller started (initial delay', SYNC_POLL_INTERVAL / 60000, 'min)');
}
/** Stop the background polling loop. */
function stopSyncPoller() {
if (_syncPollerTimer !== null) {
clearTimeout(_syncPollerTimer);
_syncPollerTimer = null;
debugLog('[CloudSync] Poller stopped');
}
}
// ---------------------------------------------------------------------------
// Enable / disable
// ---------------------------------------------------------------------------
/**
* Enable auto-sync: do an initial push, then start the poller.
* @param {string} [provider='dropbox']
*/
async function enableCloudSync(provider) {
_syncProvider = provider || 'dropbox';
try { localStorage.setItem('cloud_sync_enabled', 'true'); } catch (_) { /* ignore */ }
debugLog('[CloudSync] Enabling auto-sync for', _syncProvider);
// Ensure we have a device ID
getSyncDeviceId();
// Update UI immediately so Sync Now button is enabled before the async push
refreshSyncUI();
// Initial push (this will open the password modal if no cached password)
await pushSyncVault();
// Start the poller
startSyncPoller();
// Update UI again with post-push state (last-synced timestamp)
refreshSyncUI();
if (typeof showCloudToast === 'function') showCloudToast('Auto-sync enabled. Your inventory will sync automatically.');
logCloudSyncActivity('auto_sync_enable', 'success', 'Auto-sync enabled');
}
/**
* Disable auto-sync: persist the disabled flag, stop the poller, and update UI.
*/
function disableCloudSync() {
try { localStorage.setItem('cloud_sync_enabled', 'false'); } catch (_) { /* ignore */ }
stopSyncPoller();
refreshSyncUI();
updateSyncStatusIndicator('disabled');
logCloudSyncActivity('auto_sync_disable', 'success', 'Auto-sync disabled');
debugLog('[CloudSync] Auto-sync disabled');
}
// ---------------------------------------------------------------------------
// Initialization (called from init.js Phase 13)
// ---------------------------------------------------------------------------
/**
* Initialize the cloud sync module.
* Creates the debounced push function and starts the poller if sync was enabled.
*/
function initCloudSync() {
// Build the debounced push wrapper
if (typeof debounce === 'function') {
scheduleSyncPush = debounce(pushSyncVault, SYNC_PUSH_DEBOUNCE);
} else {
// Fallback: simple delayed call (no de-duplication)
scheduleSyncPush = (function () {
var _timer = null;
return function () {
clearTimeout(_timer);
_timer = setTimeout(pushSyncVault, SYNC_PUSH_DEBOUNCE);
};
}());
}
// Expose globally so saveInventory() hook can reach it
window.scheduleSyncPush = scheduleSyncPush;
if (!syncIsEnabled()) {
debugLog('[CloudSync] Auto-sync is disabled — poller not started');
return;
}
var connected = typeof cloudIsConnected === 'function' ? cloudIsConnected(_syncProvider) : false;
if (!connected) {
debugLog('[CloudSync] Auto-sync enabled but not connected to', _syncProvider);
return;
}
debugLog('[CloudSync] Resuming auto-sync from previous session');
startSyncPoller();
// Poll immediately on startup to catch any changes while app was closed
setTimeout(function () { pollForRemoteChanges(); }, 3000);
}
// ---------------------------------------------------------------------------
// Page Visibility API: pause/resume poller
// ---------------------------------------------------------------------------
document.addEventListener('visibilitychange', function () {
if (!syncIsEnabled()) return;
if (document.hidden) {
// Tab hidden: pause is automatic since pollForRemoteChanges() checks document.hidden
debugLog('[CloudSync] Tab hidden — polls will skip');
} else {
// Tab visible again: fire an immediate poll
debugLog('[CloudSync] Tab visible — polling for remote changes');
setTimeout(function () { pollForRemoteChanges(); }, 500);
}
});
// ---------------------------------------------------------------------------
// Window exports
// ---------------------------------------------------------------------------
window.initCloudSync = initCloudSync;
window.enableCloudSync = enableCloudSync;
window.disableCloudSync = disableCloudSync;
window.pushSyncVault = pushSyncVault;
window.pullSyncVault = pullSyncVault;
window.pollForRemoteChanges = pollForRemoteChanges;
window.showSyncConflictModal = showSyncConflictModal;
window.showSyncUpdateModal = showSyncUpdateModal;
window.refreshSyncUI = refreshSyncUI;
window.updateSyncStatusIndicator = updateSyncStatusIndicator;
window.getSyncDeviceId = getSyncDeviceId;
window.syncIsEnabled = syncIsEnabled;
window.syncSaveOverrideBackup = syncSaveOverrideBackup;
window.syncRestoreOverrideBackup = syncRestoreOverrideBackup;