server_photo_s35js/public/js/sync.js
2026-07-18 13:51:20 +02:00

851 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ===============================
// 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();
}
});