/* ObsiGate — Sync: SSE client + PWA registration */ import { state } from './state.js'; import { showToast } from './ui.js'; import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js'; import { loadRecentFiles } from './config.js'; import { openFile } from './viewer.js'; // --------------------------------------------------------------------------- // SSE Client — IndexUpdateManager // --------------------------------------------------------------------------- export const IndexUpdateManager = (() => { let eventSource = null; let reconnectTimer = null; let reconnectDelay = 1000; const MAX_RECONNECT_DELAY = 30000; let recentEvents = []; const MAX_RECENT_EVENTS = 20; let connectionState = "disconnected"; // disconnected | connecting | connected function connect() { if (eventSource) { eventSource.close(); } connectionState = "connecting"; _updateBadge(); eventSource = new EventSource("/api/events"); eventSource.addEventListener("connected", (e) => { connectionState = "connected"; reconnectDelay = 1000; _updateBadge(); }); eventSource.addEventListener("index_updated", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_updated", data); _onIndexUpdated(data); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_reloaded", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_reloaded", data); _onIndexReloaded(data); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("vault_added", (e) => { try { const data = JSON.parse(e.data); _addEvent("vault_added", data); showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("vault_removed", (e) => { try { const data = JSON.parse(e.data); _addEvent("vault_removed", data); showToast(`Vault "${data.vault}" supprimé`, "info"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_start", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_start", data); connectionState = "syncing"; _updateBadge(); showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info"); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_progress", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_progress", data); connectionState = "syncing"; _updateBadge(); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_complete", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_complete", data); connectionState = "connected"; _updateBadge(); showToast(`Indexation terminée (${data.total_files} fichiers)`, "success"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.onerror = () => { connectionState = "disconnected"; _updateBadge(); eventSource.close(); eventSource = null; _scheduleReconnect(); }; } function _scheduleReconnect() { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); connect(); }, reconnectDelay); } function _addEvent(type, data) { recentEvents.unshift({ type, data, timestamp: new Date().toISOString(), }); if (recentEvents.length > MAX_RECENT_EVENTS) { recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS); } } async function _onIndexUpdated(data) { // Brief syncing state connectionState = "syncing"; _updateBadge(); const n = data.total_changes || 0; const vaults = (data.vaults || []).join(", "); // Toast removed: silent auto-indexing — no notification needed // Refresh sidebar and tags if affected vault matches current context const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault); if (affectsCurrentVault) { try { await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); // Refresh current file if it was updated if (state.currentVault && state.currentPath) { const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath); if (changed) { openFile(state.currentVault, state.currentPath); } } } catch (err) { console.error("Error refreshing after index update:", err); } } // Refresh recent tab if it is active if (state.activeSidebarTab === "recent") { const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } setTimeout(() => { connectionState = "connected"; _updateBadge(); }, 1500); } async function _onIndexReloaded(data) { connectionState = "syncing"; _updateBadge(); showToast("Index complet rechargé", "info"); try { await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Error refreshing after full reload:", err); } setTimeout(() => { connectionState = "connected"; _updateBadge(); }, 1500); } function _updateBadge() { const badge = document.getElementById("sync-badge"); if (!badge) return; badge.className = "sync-badge sync-badge--" + connectionState; const labels = { disconnected: "Déconnecté", connecting: "Connexion...", connected: "Synchronisé", syncing: "Mise à jour...", }; badge.title = labels[connectionState] || connectionState; } function disconnect() { if (eventSource) { eventSource.close(); eventSource = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } connectionState = "disconnected"; _updateBadge(); } function getState() { return connectionState; } function getRecentEvents() { return recentEvents; } return { connect, disconnect, getState, getRecentEvents }; })(); // --------------------------------------------------------------------------- // Sync status badge and panel // --------------------------------------------------------------------------- function initSyncStatus() { const badge = document.getElementById("sync-badge"); if (!badge) return; badge.addEventListener("click", (e) => { e.stopPropagation(); toggleSyncPanel(); }); IndexUpdateManager.connect(); // Initialize AI status tracking _initAIStatus(); } function toggleSyncPanel() { let panel = document.getElementById("sync-panel"); if (panel) { panel.remove(); return; } // Auto reconnect if disconnected when user opens the panel if (IndexUpdateManager.getState() === "disconnected") { IndexUpdateManager.connect(); } panel = document.createElement("div"); panel.id = "sync-panel"; panel.className = "sync-panel"; _renderSyncPanel(panel); document.body.appendChild(panel); // Close on outside click setTimeout(() => { document.addEventListener("click", _closeSyncPanelOutside, { once: true }); }, 0); } function _closeSyncPanelOutside(e) { const panel = document.getElementById("sync-panel"); if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") { panel.remove(); } } function _renderSyncPanel(panel) { const state = IndexUpdateManager.getState(); const events = IndexUpdateManager.getRecentEvents(); const stateLabels = { disconnected: "Déconnecté", connecting: "Connexion...", connected: "Connecté", syncing: "Synchronisation...", }; let html = `
Synchronisation ${stateLabels[state] || state}
`; if (events.length === 0) { html += `
Aucun événement récent
`; } else { html += `
`; events.slice(0, 10).forEach((ev) => { const time = new Date(ev.timestamp).toLocaleTimeString(); const typeLabels = { index_updated: "Mise à jour", index_reloaded: "Rechargement", vault_added: "Vault ajouté", vault_removed: "Vault supprimé", index_start: "Démarrage index.", index_progress: "Vault indexé", index_complete: "Indexation tech.", }; const label = typeLabels[ev.type] || ev.type; let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || ""; if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`; if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`; if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`; html += `
${label} ${detail} ${time}
`; }); html += `
`; } panel.innerHTML = html; // ── AI Status section ── _renderAIStatusSection(panel); } // --------------------------------------------------------------------------- // AI Status Indicator (inside sync panel + header badge animation) // --------------------------------------------------------------------------- let _aiProcessing = false; let _aiStatusChecked = false; let _aiConfigured = null; // null = unknown, true/false /** Check if AI is configured by calling the status endpoint. */ async function _checkAIStatus() { if (_aiStatusChecked) return; try { const resp = await api('/api/ai/status'); _aiConfigured = !!(resp && resp.configured); } catch (e) { _aiConfigured = false; } _aiStatusChecked = true; } function _renderAIStatusSection(panel) { const section = document.createElement('div'); section.className = 'sync-panel__ai-section'; section.id = 'sync-panel-ai'; let statusIcon, statusText, statusClass; if (_aiProcessing) { statusIcon = 'loader'; statusText = 'Traitement en cours…'; statusClass = 'sync-panel__ai--processing'; } else if (_aiConfigured === true) { statusIcon = 'check-circle'; statusText = 'IA configurée et active'; statusClass = 'sync-panel__ai--active'; } else if (_aiConfigured === false) { statusIcon = 'x-circle'; statusText = 'IA non configurée'; statusClass = 'sync-panel__ai--inactive'; } else { statusIcon = 'help-circle'; statusText = 'Statut IA inconnu'; statusClass = 'sync-panel__ai--unknown'; } section.innerHTML = `
Intelligence Artificielle ${statusText}
`; panel.appendChild(section); // Trigger icon refresh via global if (window._obsigateRefreshIcons) window._obsigateRefreshIcons(); } /** Update the AI section in the open sync panel. */ function _updateAIStatusInPanel() { const section = document.getElementById('sync-panel-ai'); if (!section) return; section.innerHTML = ''; const panel = document.getElementById('sync-panel'); if (panel) { // Re-render just the AI section const oldSection = panel.querySelector('.sync-panel__ai-section'); if (oldSection) oldSection.remove(); _renderAIStatusSection(panel); } } /** Flash the sync badge white during AI processing. */ function _setBadgeAIProcessing(active) { const badge = document.getElementById('sync-badge'); if (!badge) return; if (active) { badge.classList.add('sync-badge--ai-processing'); } else { badge.classList.remove('sync-badge--ai-processing'); } } /** Initialize AI status tracking. */ function _initAIStatus() { // Listen for AI processing events from ai.js document.addEventListener('ai-processing-start', () => { _aiProcessing = true; _setBadgeAIProcessing(true); _updateAIStatusInPanel(); }); document.addEventListener('ai-processing-end', () => { _aiProcessing = false; _setBadgeAIProcessing(false); _updateAIStatusInPanel(); }); // Check AI status on first sync panel open const badge = document.getElementById('sync-badge'); if (badge) { const origClick = badge.onclick; badge.addEventListener('click', () => { _checkAIStatus().then(() => _updateAIStatusInPanel()); }, { once: false }); } } // --------------------------------------------------------------------------- // PWA Service Worker Registration // --------------------------------------------------------------------------- function registerServiceWorker() { if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker .register("/sw.js") .then((registration) => { console.log("[PWA] Service Worker registered successfully:", registration.scope); // Check for updates periodically setInterval(() => { registration.update(); }, 60000); // Check every minute // Handle service worker updates registration.addEventListener("updatefound", () => { const newWorker = registration.installing; newWorker.addEventListener("statechange", () => { if (newWorker.state === "installed" && navigator.serviceWorker.controller) { // New service worker available showUpdateNotification(); } }); }); }) .catch((error) => { console.log("[PWA] Service Worker registration failed:", error); }); }); } } function showUpdateNotification() { const message = document.createElement("div"); message.className = "pwa-update-notification"; message.innerHTML = `
Une nouvelle version d'ObsiGate est disponible !
`; document.body.appendChild(message); // Auto-dismiss after 30 seconds setTimeout(() => { if (message.parentElement) { message.remove(); } }, 30000); } // Handle install prompt let deferredPrompt; window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredPrompt = e; // Show install button if desired const installBtn = document.getElementById("pwa-install-btn"); if (installBtn) { installBtn.style.display = "block"; installBtn.addEventListener("click", async () => { if (deferredPrompt) { deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log(`[PWA] User response to install prompt: ${outcome}`); deferredPrompt = null; installBtn.style.display = "none"; } }); } }); // Log when app is installed window.addEventListener("appinstalled", () => { console.log("[PWA] ObsiGate has been installed"); showToast("ObsiGate installé avec succès !", "success"); }); // ── Dashboard tab switching (runs on page load and after rebuild) ── function initDashboardTabs() { document.querySelectorAll(".dashboard-tab").forEach(tab => { // Remove existing listeners by cloning const newTab = tab.cloneNode(true); tab.parentNode.replaceChild(newTab, tab); newTab.addEventListener("click", function() { document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); this.classList.add("active"); const panel = document.getElementById("dashboard-panel-" + this.dataset.tab); if (panel) panel.classList.add("active"); }); }); } // --------------------------------------------------------------------------- // Init — called by app.js orchestrator // --------------------------------------------------------------------------- export { initSyncStatus, initDashboardTabs }; export function init() { registerServiceWorker(); initDashboardTabs(); }