// =============================== // 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:"..." } // =============================== 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" (utile se server manda per-file sotto soglia) const BATCH_SIZE = 200; const FLUSH_DEBOUNCE_MS = 250; const TOO_MANY_THRESHOLD = 1200; // ------------------------------------ // RECOVERY DONE (server patched behavior) // ------------------------------------ let needRecoveryDoneAck = false; // βœ… set true quando auth_ok.need_full_sync=true function _maybeSendRecoveryDone(ws) { // invia una sola volta dopo una recovery richiesta dal server if (!needRecoveryDoneAck) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; console.log("βœ… [WS] Invio recovery_done al server"); _send(ws, { type: "recovery_done" }); needRecoveryDoneAck = false; } // ------------------------------- // 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(); // array di foto normalizzate } async function getDeletedHard(since) { const res = await fetch(`/photos/deleted_hard?since=${encodeURIComponent(since)}`, { headers: _authHeaders(), }); const json = await res.json(); return json.deleted || []; // [{id, deleted_at}] } 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(); // array di foto } // ------------------------------- // 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 (snapshot completo) // ------------------------------- async function fullLoad() { console.log("🟦 FULL LOAD β†’ caricamento completo"); const photos = await getAllPhotos(); console.log(`πŸ“₯ FULL LOAD β†’ ricevute ${photos.length} foto`); // πŸ”₯ NON filtriamo i soft delete setLocalPhotos(photos); saveLocalState(); refreshGallery(); const now = _nowIso(); setLastSync(now); console.log(`πŸ•’ FULL LOAD β†’ lastSync = ${now}`); } // ------------------------------- // PROGRESSIVE SYNC (entro 30gg: changes + deleted_hard) // ------------------------------- async function progressiveSync() { console.log("=============================================="); console.log("πŸš€ progressiveSync() START"); const lastSync = getLastSync(); const localArr = getLocalPhotos() || []; console.log(`πŸ•’ lastSync: ${lastSync}`); console.log(`πŸ“Έ Foto locali (cache): ${localArr.length}`); if (!lastSync || localArr.length === 0) { await fullLoad(); console.log("🏁 progressiveSync() COMPLETATO (fullLoad: no lastSync o cache vuota)"); console.log("=============================================="); return; } if (_isTooOldForDelta(lastSync)) { console.warn(`πŸŸ₯ lastSync > ${RETENTION_DAYS}gg β†’ FULL LOAD richiesto`); await fullLoad(); console.log("🏁 progressiveSync() COMPLETATO (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("=============================================="); } // ------------------------------- // PROGRESSIVE SYNC β€œMIRATO” usando since del server WS bulk // (usato per add_dir/del_dir bulk) // ------------------------------- async function progressiveSyncFrom(sinceIso) { if (!sinceIso) return progressiveSync(); console.log(`🟦 progressiveSyncFrom(${sinceIso})`); // se troppo vecchio, fai full 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(); // aggiorna lastSync a "now" setLastSync(_nowIso()); } // =============================================== // PROCESSED EVENTS β€” Ring Buffer (max 2000) // =============================================== 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 (queue + batch byIds) // =============================================== 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); // se burst troppo grande -> fallback a progressive sync 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(() => { // se la recovery era richiesta dal server, invia recovery_done 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 (auth + ping/pong + ack) // =============================================== let wsInstance = null; function _ensureSessionId() { let session_id = localStorage.getItem("ws_session_id"); if (!session_id) { session_id = crypto.randomUUID(); localStorage.setItem("ws_session_id", session_id); } return session_id; } 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 }); } function startWebSocket() { 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(); ws.onopen = () => { console.log("🟒 [WS] Connesso β†’ invio token JWT + session_id"); _send(ws, { type: "auth", token, session_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); if (msg.type === "auth_ok") { console.log("πŸ” WS autenticato come:", msg.user, "session:", msg.session_id); if (msg.need_full_sync) { console.warn("⚠️ [WS] Server richiede recovery β†’ progressiveSync() ritardato"); needRecoveryDoneAck = true; setTimeout(async () => { try { await progressiveSync(); } finally { _maybeSendRecoveryDone(ws); } }, WS_NEED_FULLSYNC_DELAY_MS); } return; } 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: progressive sync da since 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: progressive sync da since 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 (per-file): enqueue e batch byIds if (msg.type === "added") { enqueueAdded(msg.id); finalize(); return; } // HARD DELETE (per-file) 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 (soft delete / restore) if (msg.type === "updated") { updateLocalPhoto(msg.id, { deleted_at: msg.deleted_at }); saveLocalState(); refreshGallery(); finalize(); return; } // add_dir_done / del_dir_done (opzionali) if (msg.type === "add_dir_done" || msg.type === "del_dir_done") { console.log(`βœ… [WS] ${msg.type} folder=${msg.folder} count=${msg.count}`); finalize(); return; } // fallback if (event_id) { console.log("ℹ️ [WS] Evento non gestito, ACK comunque:", event_id); finalize(); } }; ws.onclose = () => { console.warn("❌ [WS] Connessione chiusa"); if (wsInstance === ws) wsInstance = null; const now = Date.now(); const lastSeen = _getLastSeen(); if (now - lastSeen < WS_DORMANT_MS) { console.log("πŸ”„ [WS] reconnect immediato"); setTimeout(startWebSocket, WS_RECONNECT_DELAY_MS); } else { console.log("🟦 [WS] sessione dormiente β†’ progressiveSync/full al prossimo avvio"); localStorage.removeItem("ws_session_id"); // opzionale: anche last_seen // localStorage.removeItem("ws_last_seen"); } }; ws.onerror = (err) => { console.error("⚠️ [WS] Errore WebSocket:", err); }; } // =============================== // INIT // =============================== document.addEventListener("DOMContentLoaded", () => { if (AppAuth.isLoggedIn()) { progressiveSync(); startWebSocket(); } });