// =============================== // sync.js โ€” Full load + Progressive Sync + WS // Compatibile con server bulk add_dir / del_dir: // { type:"add_dir", mode:"bulk", since:"ISO", count:n, folder:"..." } // { type:"del_dir", mode:"bulk", since:"ISO", count:n, folder:"..." } // // Protocollo WS: // auth_ok.need_full_sync = full bootstrap vero, quasi sempre false // auth_ok.need_recovery = recovery/progressive sync richiesta // need_recovery = recovery richiesta dopo client_recovered // recovery_done = inviato dopo recovery completata // client_recovered = inviato quando il client รจ pronto a ricevere pending // =============================== const WS_URL = "wss://prova-ws.patachina.it"; // retention server-side: 30gg const RETENTION_DAYS = 30; const RETENTION_MS = RETENTION_DAYS * 86400000; // WS reconnect window: 2 minuti const WS_DORMANT_MS = 120000; const WS_RECONNECT_DELAY_MS = 1500; const WS_NEED_FULLSYNC_DELAY_MS = 800; // processed events ring buffer const MAX_PROCESSED_EVENTS = 2000; // gestione burst "added" const BATCH_SIZE = 200; const FLUSH_DEBOUNCE_MS = 250; const TOO_MANY_THRESHOLD = 1200; // =============================================== // SESSIONI E DEVICE ID // =============================================== function getSessionId() { let id = localStorage.getItem("ws_session_id"); if (!id) { id = crypto.randomUUID(); localStorage.setItem("ws_session_id", id); } return id; } function getDeviceId() { let id = localStorage.getItem("ws_device_id"); if (!id) { id = crypto.randomUUID(); localStorage.setItem("ws_device_id", id); } return id; } function _ensureSessionId() { return getSessionId(); } // =============================================== // RECOVERY DONE / SYNC DONE // =============================================== let needRecoveryDoneAck = false; function _maybeSendRecoveryDone(ws) { if (!needRecoveryDoneAck) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; console.log(">>> SENDING recovery_done"); _send(ws, { type: "recovery_done" }); needRecoveryDoneAck = false; } async function sendSyncDone(session_id) { try { console.log(">>> CALLING sync_done", { session_id, device_id: getDeviceId(), }); await fetch("/photos/sync_done", { method: "POST", headers: { ..._authHeaders(), "Content-Type": "application/json", }, body: JSON.stringify({ session_id, device_id: getDeviceId(), last_sync: Date.now(), }), }); console.log("๐Ÿ“จ [WS] sync_done inviato"); } catch (e) { console.error("โŒ [WS] Errore sync_done:", e); } } // =============================================== // AUTH HEADERS // =============================================== function _authHeaders() { const token = localStorage.getItem("token"); return { Authorization: "Bearer " + token }; } // =============================================== // API HELPERS // =============================================== async function getAllPhotos() { const res = await fetch("/photos", { headers: _authHeaders(), }); return await res.json(); } async function getChanges(since) { const res = await fetch(`/photos/changes?since=${encodeURIComponent(since)}`, { headers: _authHeaders(), }); return await res.json(); } async function getDeletedHard(since) { const res = await fetch(`/photos/deleted_hard?since=${encodeURIComponent(since)}`, { headers: _authHeaders(), }); const json = await res.json(); return json.deleted || []; } async function fetchPhotosByIds(ids) { if (!ids || !ids.length) return []; const qs = ids.map((id) => `id=${encodeURIComponent(id)}`).join("&"); const payload = parseJwt(localStorage.getItem("token") || ""); const user = payload?.name || "Common"; const url = `/photos/byIds?${qs}&user=${encodeURIComponent(user)}`; const res = await fetch(url, { headers: _authHeaders(), }); return await res.json(); } // =============================================== // TIME HELPERS // =============================================== function _nowIso() { return new Date().toISOString(); } function _parseIsoMs(iso) { const t = Date.parse(iso); return Number.isFinite(t) ? t : 0; } function _isTooOldForDelta(lastSyncIso) { if (!lastSyncIso) return true; const lastMs = _parseIsoMs(lastSyncIso); if (!lastMs) return true; return Date.now() - lastMs > RETENTION_MS; } // =============================================== // MAP UTILS // =============================================== function _toMapById(arr) { const m = new Map(); for (const p of arr || []) { if (p && p.id != null) { m.set(String(p.id), p); } } return m; } // =============================================== // FULL LOAD // =============================================== async function fullLoad() { console.log("๐ŸŸฆ FULL LOAD โ†’ caricamento completo"); const photos = await getAllPhotos(); console.log(`๐Ÿ“ฅ FULL LOAD โ†’ ricevute ${photos.length} foto`); // Non filtriamo soft delete. setLocalPhotos(photos); saveLocalState(); refreshGallery(); const now = _nowIso(); setLastSync(now); console.log(`๐Ÿ•’ FULL LOAD โ†’ lastSync = ${now}`); } // =============================================== // PROGRESSIVE SYNC // =============================================== async function progressiveSync() { if (window._syncInProgress) { console.warn("โณ progressiveSync giร  in corso"); return; } window._syncInProgress = true; try { console.log("=============================================="); console.log("๐Ÿงช [SYNC DEBUG] progressiveSync() chiamato"); console.log("๐Ÿš€ progressiveSync() START"); const lastSync = getLastSync(); const localArr = getLocalPhotos() || []; console.log(`๐Ÿ•’ lastSync: ${lastSync}`); console.log(`๐Ÿ“ธ Foto locali cache: ${localArr.length}`); // Primo avvio / cache vuota: // progressiveSync fa automaticamente fullLoad. if (!lastSync || localArr.length === 0) { console.warn("๐ŸŸฆ Cache locale vuota o lastSync mancante โ†’ fullLoad()"); await fullLoad(); console.log("๐Ÿ progressiveSync() COMPLETATO con fullLoad"); console.log("=============================================="); return; } if (_isTooOldForDelta(lastSync)) { console.warn(`๐ŸŸฅ lastSync > ${RETENTION_DAYS}gg โ†’ FULL LOAD richiesto`); await fullLoad(); console.log("๐Ÿ progressiveSync() COMPLETATO con fullLoad: lastSync troppo vecchio"); console.log("=============================================="); return; } console.log("๐ŸŸฉ PROGRESSIVE SYNC โ†’ changes + deleted_hard"); const changed = await getChanges(lastSync); console.log(`๐ŸŸจ changes: ${Array.isArray(changed) ? changed.length : 0}`); const hardDeleted = await getDeletedHard(lastSync); console.log(`๐ŸŸฅ deleted_hard: ${hardDeleted.length}`); const localMap = _toMapById(localArr); if (Array.isArray(changed)) { for (const p of changed) { if (!p || p.id == null) continue; localMap.set(String(p.id), p); } } for (const d of hardDeleted) { if (!d || d.id == null) continue; localMap.delete(String(d.id)); } const merged = Array.from(localMap.values()); setLocalPhotos(merged); saveLocalState(); refreshGallery(); const now = _nowIso(); setLastSync(now); console.log(`๐Ÿ•’ Aggiorno lastSync โ†’ ${now}`); console.log("๐Ÿ progressiveSync() COMPLETATO"); console.log("=============================================="); } finally { window._syncInProgress = false; } } // =============================================== // PROGRESSIVE SYNC MIRATO DA since WS // =============================================== async function progressiveSyncFrom(sinceIso) { if (!sinceIso) return progressiveSync(); console.log(`๐ŸŸฆ progressiveSyncFrom(${sinceIso})`); if (_isTooOldForDelta(sinceIso)) { console.warn("๐ŸŸฅ since troppo vecchio โ†’ fullLoad()"); await fullLoad(); return; } const localArr = getLocalPhotos() || []; if (!localArr.length) { await fullLoad(); return; } const changed = await getChanges(sinceIso); const hardDeleted = await getDeletedHard(sinceIso); const localMap = _toMapById(localArr); for (const p of changed || []) { if (!p || p.id == null) continue; localMap.set(String(p.id), p); } for (const d of hardDeleted || []) { if (!d || d.id == null) continue; localMap.delete(String(d.id)); } setLocalPhotos(Array.from(localMap.values())); saveLocalState(); refreshGallery(); setLastSync(_nowIso()); } // =============================================== // PROCESSED EVENTS โ€” Ring Buffer // =============================================== function _loadProcessedRing() { try { const arr = JSON.parse(localStorage.getItem("processed_events") || "[]"); if (!Array.isArray(arr)) return []; return arr.slice(-MAX_PROCESSED_EVENTS); } catch { return []; } } function _saveProcessedRing(ring) { localStorage.setItem( "processed_events", JSON.stringify(ring.slice(-MAX_PROCESSED_EVENTS)) ); } const processedRing = _loadProcessedRing(); const processedSet = new Set(processedRing); function isProcessed(eventId) { return !!eventId && processedSet.has(eventId); } function markProcessed(eventId) { if (!eventId) return; if (processedSet.has(eventId)) return; processedRing.push(eventId); processedSet.add(eventId); while (processedRing.length > MAX_PROCESSED_EVENTS) { const old = processedRing.shift(); if (old) { processedSet.delete(old); } } _saveProcessedRing(processedRing); } // =============================================== // WS ADDED BURST HANDLER // =============================================== let addedQueue = []; let addedSet = new Set(); let flushTimer = null; let flushing = false; function enqueueAdded(id) { if (!id) return; const sid = String(id); if (addedSet.has(sid)) return; addedSet.add(sid); addedQueue.push(sid); if (addedQueue.length >= TOO_MANY_THRESHOLD) { console.warn( `๐ŸŸฅ [WS] Burst added (${addedQueue.length}) โ†’ fallback progressiveSync()` ); if (flushTimer) { clearTimeout(flushTimer); } flushTimer = null; addedQueue = []; addedSet.clear(); progressiveSync().then(() => { if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { _maybeSendRecoveryDone(wsInstance); } }); return; } if (!flushTimer) { flushTimer = setTimeout(() => { flushTimer = null; flushAddedQueue(); }, FLUSH_DEBOUNCE_MS); } } async function flushAddedQueue() { if (flushing) return; if (addedQueue.length === 0) return; flushing = true; try { const chunk = addedQueue.splice(0, BATCH_SIZE); chunk.forEach((id) => addedSet.delete(id)); const items = await fetchPhotosByIds(chunk); if (Array.isArray(items) && items.length) { for (const p of items) { addPhotoLocal(p); } saveLocalState(); refreshGallery(); } if (addedQueue.length > 0) { setTimeout(flushAddedQueue, 0); } } catch (e) { console.error("โŒ [WS] flushAddedQueue error:", e); addedQueue = []; addedSet.clear(); await progressiveSync(); if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { _maybeSendRecoveryDone(wsInstance); } } finally { flushing = false; } } // =============================================== // WEBSOCKET REAL-TIME // =============================================== let wsInstance = null; function _setLastSeenNow() { localStorage.setItem("ws_last_seen", String(Date.now())); } function _getLastSeen() { return parseInt(localStorage.getItem("ws_last_seen") || "0", 10); } function _safeJsonParse(raw) { try { return JSON.parse(raw); } catch { return null; } } function _send(ws, obj) { try { ws.send(JSON.stringify(obj)); } catch (e) { console.warn("โš ๏ธ [WS] send failed:", e); } } function _ack(ws, event_id) { if (!event_id) return; _send(ws, { type: "ack", event_id, }); } // =============================================== // START WEBSOCKET // =============================================== function startWebSocket() { console.log("๐Ÿงช [WS DEBUG] startWebSocket() chiamato"); const token = localStorage.getItem("token"); if (!token) { console.error("โŒ [WS] Nessun token JWT trovato"); return; } if ( wsInstance && ( wsInstance.readyState === WebSocket.OPEN || wsInstance.readyState === WebSocket.CONNECTING ) ) { console.log("โš ๏ธ [WS] Connessione giร  attiva, ignoro startWebSocket()"); return; } if (wsInstance) { try { wsInstance.close(); } catch (e) {} } console.log("๐Ÿ”Œ [WS] Creo nuova connessione WebSocket..."); wsInstance = new WebSocket(WS_URL); const ws = wsInstance; const session_id = _ensureSessionId(); const device_id = getDeviceId(); ws.onopen = () => { console.log(`๐ŸŸข [WS OPEN] Connesso. session_id=${session_id}`); console.log("[WS] device_id:", device_id); _send(ws, { type: "auth", token, session_id, device_id, }); }; ws.onmessage = async (ev) => { console.log("๐Ÿ“ฉ [WS RAW] Messaggio ricevuto:", ev.data); _setLastSeenNow(); const msg = _safeJsonParse(ev.data); if (!msg) { console.error("โŒ [WS] Errore parsing JSON"); return; } console.log("๐Ÿ“ฉ [WS PARSED]:", msg); // ----------------------------------------------- // AUTH OK // ----------------------------------------------- if (msg.type === "auth_ok") { console.log("๐Ÿ” WS autenticato come:", msg.user, "session:", msg.session_id); const needFullSync = msg.need_full_sync === true; const needRecovery = needFullSync || msg.need_recovery === true; const lastSync = getLastSync(); const localArr = getLocalPhotos() || []; const localCacheMissing = !lastSync || localArr.length === 0; console.log("๐Ÿงช [WS AUTH OK DECISION]", { need_full_sync: msg.need_full_sync, need_recovery: msg.need_recovery, needFullSync, needRecovery, reason: msg.reason, lastSync, localCount: localArr.length, localCacheMissing, }); if (needRecovery || localCacheMissing) { if (needRecovery) { console.warn("๐ŸŸจ [WS] Server richiede recovery/progressive sync"); needRecoveryDoneAck = true; } else { console.warn("๐ŸŸฆ [WS] Primo avvio o cache locale vuota โ†’ fullLoad tramite progressiveSync()"); } setTimeout(async () => { try { console.log("๐Ÿ”„ [WS] Eseguo progressiveSync()"); await progressiveSync(); } catch (e) { console.error("โŒ [WS] Errore progressiveSync:", e); } finally { if (needRecovery) { _maybeSendRecoveryDone(ws); await sendSyncDone(msg.session_id); } _send(ws, { type: "client_recovered", }); } }, needFullSync ? WS_NEED_FULLSYNC_DELAY_MS : 0); return; } console.log("๐ŸŸข [WS] Nessuna recovery richiesta e cache locale presente โ†’ client_recovered"); _send(ws, { type: "client_recovered", }); return; } // ----------------------------------------------- // NEED RECOVERY // ----------------------------------------------- if (msg.type === "need_recovery") { console.warn("๐ŸŸจ [WS] need_recovery ricevuto:", msg.reason); needRecoveryDoneAck = true; try { await progressiveSync(); } catch (e) { console.error("โŒ [WS] Errore progressiveSync su need_recovery:", e); } finally { _maybeSendRecoveryDone(ws); await sendSyncDone(session_id); _send(ws, { type: "client_recovered", }); } return; } // ----------------------------------------------- // PING // ----------------------------------------------- if (msg.type === "ping") { _send(ws, { type: "pong", }); return; } const event_id = msg.event_id; if (event_id && isProcessed(event_id)) { console.log("โ™ป๏ธ [WS] Evento giร  processato, invio solo ACK:", event_id); _ack(ws, event_id); return; } const finalize = () => { if (event_id) { markProcessed(event_id); _ack(ws, event_id); } }; // ----------------------------------------------- // BULK ADD_DIR // ----------------------------------------------- if (msg.type === "add_dir") { if (msg.mode === "bulk") { console.log( `๐Ÿ“ฆ [WS] add_dir bulk folder=${msg.folder} count=${msg.count} โ†’ progressiveSyncFrom(since)` ); await progressiveSyncFrom(msg.since); } else { console.log(`๐Ÿ“ [WS] add_dir folder=${msg.folder}`); } finalize(); return; } // ----------------------------------------------- // BULK DEL_DIR // ----------------------------------------------- if (msg.type === "del_dir") { if (msg.mode === "bulk") { console.log( `๐Ÿ“ฆ [WS] del_dir bulk folder=${msg.folder} count=${msg.count} โ†’ progressiveSyncFrom(since)` ); await progressiveSyncFrom(msg.since); } else { console.log(`๐Ÿ“ [WS] del_dir folder=${msg.folder}`); } finalize(); return; } // ----------------------------------------------- // ADDED // ----------------------------------------------- if (msg.type === "added") { enqueueAdded(msg.id); finalize(); return; } // ----------------------------------------------- // HARD DELETE // ----------------------------------------------- if (msg.type === "del") { removePhotoLocal(msg.id); saveLocalState(); refreshGallery(); finalize(); return; } // ----------------------------------------------- // REMOVED API // ----------------------------------------------- if (msg.type === "removed") { removePhotoLocal(msg.id); saveLocalState(); refreshGallery(); finalize(); return; } // ----------------------------------------------- // UPDATED // ----------------------------------------------- if (msg.type === "updated") { updateLocalPhoto(msg.id, { deleted_at: msg.deleted_at, }); saveLocalState(); refreshGallery(); finalize(); return; } // ----------------------------------------------- // DONE EVENTS // ----------------------------------------------- if (msg.type === "add_dir_done" || msg.type === "del_dir_done") { console.log(`โœ… [WS] ${msg.type} folder=${msg.folder} count=${msg.count}`); finalize(); return; } console.log("โ„น๏ธ [WS] Evento non gestito:", msg); if (event_id) { console.log("โ„น๏ธ [WS] Evento non gestito, ACK comunque:", event_id); finalize(); } }; ws.onclose = () => { console.warn(`โŒ [WS CLOSE] Connessione chiusa. session_id=${session_id}`); if (wsInstance === ws) { wsInstance = null; } const now = Date.now(); const lastSeen = _getLastSeen(); if (now - lastSeen < WS_DORMANT_MS) { console.log("๐Ÿ”„ [WS] Tentativo di reconnect..."); setTimeout(startWebSocket, WS_RECONNECT_DELAY_MS); } else { console.log("๐ŸŸฆ [WS] sessione dormiente โ†’ recovery al prossimo avvio"); } }; ws.onerror = (err) => { console.error("โš ๏ธ [WS ERROR]", err); }; } // =============================================== // INIT // =============================================== document.addEventListener("DOMContentLoaded", () => { if (AppAuth.isLoggedIn()) { startWebSocket(); } });