server_photo_s85js/public/js/sync.js
2026-08-11 08:45:04 +02:00

994 lines
25 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.

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