579 lines
16 KiB
JavaScript
579 lines
16 KiB
JavaScript
// ===============================
|
||
// 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");
|
||
console.log(`🟢 [WS OPEN] Connesso. session_id=${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 FULL RECOVERY");
|
||
|
||
needRecoveryDoneAck = true;
|
||
|
||
setTimeout(async () => {
|
||
try {
|
||
console.log("🔄 [WS] Eseguo progressiveSync() per recovery");
|
||
await progressiveSync();
|
||
} catch (e) {
|
||
console.error("❌ [WS] Errore progressiveSync durante recovery:", e);
|
||
} 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;
|
||
}
|
||
|
||
console.log("ℹ️ [WS] Evento non gestito:", msg);
|
||
|
||
// fallback
|
||
if (event_id) {
|
||
console.log("ℹ️ [WS] Evento non gestito, ACK comunque:", event_id);
|
||
finalize();
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
//console.warn("❌ [WS] Connessione chiusa");
|
||
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] reconnect immediato");
|
||
console.log("🔄 [WS] Tentativo di reconnect...");
|
||
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);
|
||
console.error("⚠️ [WS ERROR]", err);
|
||
};
|
||
}
|
||
|
||
// ===============================
|
||
// INIT
|
||
// ===============================
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
if (AppAuth.isLoggedIn()) {
|
||
progressiveSync();
|
||
startWebSocket();
|
||
}
|
||
});
|