// public/js/sync.js // =============================== // sync.js β€” Full load + Progressive Sync + WS // Photos + Activities // =============================== // WS config invariato const WS_URL = "wss://prova-ws.patachina.it"; const RETENTION_DAYS = 30; const RETENTION_MS = RETENTION_DAYS * 86400000; const WS_DORMANT_MS = 120000; const WS_RECONNECT_DELAY_MS = 1500; const WS_NEED_FULLSYNC_DELAY_MS = 800; const MAX_PROCESSED_EVENTS = 2000; const BATCH_SIZE = 200; const FLUSH_DEBOUNCE_MS = 250; const TOO_MANY_THRESHOLD = 1200; // =============================================== // MODE / CACHE HELPERS β€” IMPORTANTI // =============================================== function _normalizeMode(mode) { return mode === "activities" ? "activities" : "photos"; } function _cacheKeyForMode(mode) { return _normalizeMode(mode) === "photos" ? "photosCache" : "activitiesCache"; } function _lastSyncKeyForMode(mode) { return _normalizeMode(mode) === "photos" ? "lastSyncPhotos" : "lastSyncActivities"; } function getLocalItemsForMode(mode) { const safeMode = _normalizeMode(mode); try { const raw = localStorage.getItem(_cacheKeyForMode(safeMode)); if (!raw) return []; const arr = JSON.parse(raw); return Array.isArray(arr) ? arr : []; } catch (e) { console.warn("[getLocalItemsForMode] error:", e); return []; } } function setLocalItemsForMode(mode, arr) { const safeMode = _normalizeMode(mode); const safeArr = Array.isArray(arr) ? arr : []; try { localStorage.setItem(_cacheKeyForMode(safeMode), JSON.stringify(safeArr)); // Aggiorna state.items solo se sto salvando la modalitΓ  attualmente visibile if (window.state && state.mode === safeMode) { state.items = safeArr; } } catch (e) { console.warn("[setLocalItemsForMode] error:", e); } } function setLocalPhotos(arr) { setLocalItemsForMode("photos", arr); } function setLocalActivities(arr) { setLocalItemsForMode("activities", arr); } function getLastSyncForMode(mode) { return localStorage.getItem(_lastSyncKeyForMode(mode)); } function setLastSyncForMode(mode, ts) { if (!ts) return; localStorage.setItem(_lastSyncKeyForMode(mode), ts); } function removeLocalItemForMode(mode, id) { if (!id) return; const safeMode = _normalizeMode(mode); const arr = getLocalItemsForMode(safeMode); const before = arr.length; const next = arr.filter(x => String(x.id) !== String(id)); setLocalItemsForMode(safeMode, next); console.log("[removeLocalItemForMode]", { mode: safeMode, id, before, after: next.length }); } function addLocalItemForMode(mode, item) { if (!item || item.id == null) return; const safeMode = _normalizeMode(mode); const arr = getLocalItemsForMode(safeMode); const next = arr.filter(x => String(x.id) !== String(item.id)); next.push(item); setLocalItemsForMode(safeMode, next); } function refreshIfVisible(mode) { const safeMode = _normalizeMode(mode); if (window.state && state.mode === safeMode) { if (typeof loadLocalState === "function") { loadLocalState(); } if (typeof refreshGallery === "function") { refreshGallery(); } } } // =============================================== // 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, mode = state.mode) { const syncMode = _normalizeMode(mode); try { console.log(">>> CALLING sync_done", { session_id, device_id: getDeviceId(), mode: syncMode }); const endpoint = syncMode === "photos" ? "/photos/sync_done" : "/activities/sync_done"; await fetch(endpoint, { 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", syncMode); } catch (e) { console.error("❌ [WS] Errore sync_done:", e); } } // =============================================== // AUTH HEADERS // =============================================== function _authHeaders() { const token = localStorage.getItem("token"); return { Authorization: "Bearer " + token }; } // =============================================== // API HELPERS β€” PER MODALITΓ€ // =============================================== function _endpointBase(mode = state.mode) { const syncMode = _normalizeMode(mode); return syncMode === "photos" ? "/photos" : "/activities"; } async function getAllItems(mode = state.mode) { const syncMode = _normalizeMode(mode); const res = await fetch(_endpointBase(syncMode), { headers: _authHeaders(), }); return await res.json(); } async function getChanges(since, mode = state.mode) { const syncMode = _normalizeMode(mode); const res = await fetch( `${_endpointBase(syncMode)}/changes?since=${encodeURIComponent(since)}`, { headers: _authHeaders() } ); return await res.json(); } async function getDeletedHard(since, mode = state.mode) { const syncMode = _normalizeMode(mode); const endpoint = syncMode === "photos" ? "/photos/deleted_hard" : "/activities/deleted_hard"; const res = await fetch( `${endpoint}?since=${encodeURIComponent(since)}`, { headers: _authHeaders() } ); const json = await res.json(); return json.deleted || []; } async function fetchItemsByIds(ids, mode = state.mode) { const syncMode = _normalizeMode(mode); 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 = `${_endpointBase(syncMode)}/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 β€” SICURO PER MODALITΓ€ // =============================================== async function fullLoad(mode = state.mode) { const syncMode = _normalizeMode(mode); console.log("🟦 FULL LOAD β†’ caricamento completo", syncMode); const items = await getAllItems(syncMode); console.log(`πŸ“₯ FULL LOAD β†’ ricevuti ${items.length} elementi (${syncMode})`); if (syncMode === "photos") { setLocalPhotos(items); } else { setLocalActivities(items); } refreshIfVisible(syncMode); const now = _nowIso(); setLastSyncForMode(syncMode, now); console.log(`πŸ•’ FULL LOAD β†’ lastSync(${syncMode}) = ${now}`); } // =============================================== // PROGRESSIVE SYNC β€” SICURO PER MODALITΓ€ // =============================================== async function progressiveSync(mode = state.mode) { const syncMode = _normalizeMode(mode); 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 β€” mode:", syncMode); const lastSync = getLastSyncForMode(syncMode); const localArr = getLocalItemsForMode(syncMode) || []; console.log(`πŸ•’ lastSync(${syncMode}): ${lastSync}`); console.log(`πŸ“¦ Cache locale (${syncMode}): ${localArr.length}`); // Primo avvio / cache vuota β†’ fullLoad if (!lastSync || localArr.length === 0) { console.warn("🟦 Cache locale vuota o lastSync mancante β†’ fullLoad()"); await fullLoad(syncMode); console.log("🏁 progressiveSync() COMPLETATO con fullLoad"); console.log("=============================================="); return; } // lastSync troppo vecchio β†’ fullLoad if (_isTooOldForDelta(lastSync)) { console.warn(`πŸŸ₯ lastSync > ${RETENTION_DAYS}gg β†’ FULL LOAD richiesto`); await fullLoad(syncMode); console.log("🏁 progressiveSync() COMPLETATO con fullLoad: lastSync troppo vecchio"); console.log("=============================================="); return; } console.log("🟩 PROGRESSIVE SYNC β†’ changes + deleted_hard", syncMode); const changed = await getChanges(lastSync, syncMode); console.log(`🟨 changes (${syncMode}): ${Array.isArray(changed) ? changed.length : 0}`); const hardDeleted = await getDeletedHard(lastSync, syncMode); console.log(`πŸŸ₯ deleted_hard (${syncMode}): ${hardDeleted.length}`); const localMap = _toMapById(localArr); // Merge changed if (Array.isArray(changed)) { for (const p of changed) { if (!p || p.id == null) continue; localMap.set(String(p.id), p); } } // Hard delete solo per le foto for (const d of hardDeleted) { if (!d || d.id == null) continue; localMap.delete(String(d.id)); } const merged = Array.from(localMap.values()); if (syncMode === "photos") { setLocalPhotos(merged); } else { setLocalActivities(merged); } refreshIfVisible(syncMode); const now = _nowIso(); setLastSyncForMode(syncMode, now); console.log(`πŸ•’ Aggiorno lastSync(${syncMode}) β†’ ${now}`); console.log("🏁 progressiveSync() COMPLETATO"); console.log("=============================================="); } finally { window._syncInProgress = false; } } // =============================================== // PROGRESSIVE SYNC MIRATO DA since WS // =============================================== async function progressiveSyncFrom(sinceIso, mode = state.mode) { const syncMode = _normalizeMode(mode); if (!sinceIso) return progressiveSync(syncMode); console.log(`🟦 progressiveSyncFrom(${sinceIso}) β€” mode: ${syncMode}`); if (_isTooOldForDelta(sinceIso)) { console.warn("πŸŸ₯ since troppo vecchio β†’ fullLoad()"); await fullLoad(syncMode); return; } const localArr = getLocalItemsForMode(syncMode) || []; if (!localArr.length) { await fullLoad(syncMode); return; } const changed = await getChanges(sinceIso, syncMode); const hardDeleted = await getDeletedHard(sinceIso, syncMode); 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)); } const merged = Array.from(localMap.values()); if (syncMode === "photos") { setLocalPhotos(merged); } else { setLocalActivities(merged); } refreshIfVisible(syncMode); setLastSyncForMode(syncMode, _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 β€” PER MODALITΓ€ // =============================================== let addedQueue = []; let addedSet = new Set(); let flushTimer = null; let flushing = false; function enqueueAdded(id, mode = state.mode) { if (!id) return; const syncMode = _normalizeMode(mode); const key = `${syncMode}:${String(id)}`; if (addedSet.has(key)) return; addedSet.add(key); addedQueue.push({ id: String(id), mode: syncMode }); 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(syncMode).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); for (const q of chunk) { addedSet.delete(`${q.mode}:${q.id}`); } // Raggruppo per modalitΓ , cosΓ¬ non mischio mai foto e attivitΓ  const byMode = { photos: [], activities: [] }; for (const q of chunk) { byMode[q.mode].push(q.id); } for (const mode of ["photos", "activities"]) { const ids = byMode[mode]; if (!ids.length) continue; const items = await fetchItemsByIds(ids, mode); if (Array.isArray(items) && items.length) { for (const item of items) { addLocalItemForMode(mode, item); } refreshIfVisible(mode); } } if (addedQueue.length > 0) { setTimeout(flushAddedQueue, 0); } } catch (e) { console.error("❌ [WS] flushAddedQueue error:", e); const fallbackMode = addedQueue[0]?.mode || state.mode || "photos"; addedQueue = []; addedSet.clear(); await progressiveSync(fallbackMode); 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 {} } const wsMode = _normalizeMode(state.mode); console.log("πŸ”Œ [WS] Creo nuova connessione WebSocket...", wsMode); 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); console.log("[WS] mode:", wsMode); _send(ws, { type: "auth", token, session_id, device_id, mode: wsMode, }); }; 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); const eventMode = _normalizeMode(msg.mode || wsMode || state.mode); // ----------------------------------------------- // 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 = getLastSyncForMode(eventMode); const localArr = getLocalItemsForMode(eventMode) || []; const localCacheMissing = !lastSync || localArr.length === 0; console.log("πŸ§ͺ [WS AUTH OK DECISION]", { mode: eventMode, 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()", eventMode); await progressiveSync(eventMode); } catch (e) { console.error("❌ [WS] Errore progressiveSync:", e); } finally { if (needRecovery) { _maybeSendRecoveryDone(ws); await sendSyncDone(msg.session_id, eventMode); } _send(ws, { type: "client_recovered" }); } }, needFullSync ? WS_NEED_FULLSYNC_DELAY_MS : 0); return; } console.log("🟒 [WS] Nessuna recovery richiesta β†’ 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(eventMode); } catch (e) { console.error("❌ [WS] Errore progressiveSync su need_recovery:", e); } finally { _maybeSendRecoveryDone(ws); await sendSyncDone(session_id, eventMode); _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.bulk === true) { console.log( `πŸ“¦ [WS] add_dir bulk folder=${msg.folder} count=${msg.count} β†’ progressiveSyncFrom(since)` ); await progressiveSyncFrom(msg.since, eventMode); } 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, eventMode); } else { console.log(`πŸ“ [WS] del_dir folder=${msg.folder}`); } finalize(); return; } // ----------------------------------------------- // ADDED // ----------------------------------------------- if (msg.type === "added") { enqueueAdded(msg.id, eventMode); finalize(); return; } // ----------------------------------------------- // HARD DELETE / REMOVED // ----------------------------------------------- if (msg.type === "del" || msg.type === "removed") { removeLocalItemForMode(eventMode, msg.id); refreshIfVisible(eventMode); finalize(); return; } // ----------------------------------------------- // UPDATED // ----------------------------------------------- if (msg.type === "updated") { const arr = getLocalItemsForMode(eventMode); const item = arr.find(p => String(p.id) === String(msg.id)); if (item) { item.deleted_at = msg.deleted_at; addLocalItemForMode(eventMode, item); } else { // Se non ho l'item locale, provo a recuperarlo const fetched = await fetchItemsByIds([msg.id], eventMode); if (Array.isArray(fetched) && fetched[0]) { addLocalItemForMode(eventMode, fetched[0]); } } refreshIfVisible(eventMode); 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); }; } // =============================================== // EXPORT UTILI // =============================================== window.fullLoad = fullLoad; window.progressiveSync = progressiveSync; window.progressiveSyncFrom = progressiveSyncFrom; window.startWebSocket = startWebSocket; window.getLocalItemsForMode = getLocalItemsForMode; window.setLocalItemsForMode = setLocalItemsForMode; window.setLocalPhotos = setLocalPhotos; window.setLocalActivities = setLocalActivities; // =============================================== // INIT // =============================================== document.addEventListener("DOMContentLoaded", () => { if (AppAuth.isLoggedIn()) { startWebSocket(); } });