/* ObsiGate — Sync: SSE client + PWA registration + Offline mode */ import { state } from './state.js'; import { t } from './i18n.js'; import { showToast } from './ui.js'; import { api, AuthManager } from './auth.js'; import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js'; import { loadRecentFiles, initAboutModal } from './config.js'; import { openFile } from './viewer.js'; import { closeEditor, reloadExternalWrite } from './utils.js'; import { initOffline, syncFileIndexFromServer } from './offline.js'; import { initThemes } from './themes.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(t('sync.vault_added', { vault: data.vault, count: data.stats.file_count }), "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(t('sync.vault_removed', { vault: data.vault }), "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(t('sync.index_started', { count: data.total_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(t('sync.index_complete', { count: data.total_files }), "success"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); // #169/#190 — Chat : relais des broadcasts SSE vers les panneaux eventSource.addEventListener("chat_message", (e) => { try { const data = JSON.parse(e.data); import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {}); } catch (err) { console.error("SSE parse error:", err); } }); // #191 — suppression d'un post eventSource.addEventListener("chat_deleted", (e) => { try { const data = JSON.parse(e.data); import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {}); } catch (err) { console.error("SSE parse error:", err); } }); // #192 — accusé de réception (un participant vient d'ouvrir la conversation) eventSource.addEventListener("chat_read", (e) => { try { const data = JSON.parse(e.data); import("./filechat.js").then((m) => m.onChatRead(data)).catch(() => {}); } 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) { // #93 — an inline edition session (or Forge) owns the content area: // re-render the read view below it would destroy the editor mid- // session. Reload the editor buffer from disk instead (same path as // an external AI write). reloadExternalWrite(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(t("sync.index_reloaded"), "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: t('sync.disconnected'), connecting: "Connexion...", connected: t('sync.connected'), syncing: t('sync.updating'), }; 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: t('sync.disconnected'), connecting: "Connexion...", connected: t('sync.connected_short'), syncing: "Synchronisation...", }; let html = `
Synchronisation ${stateLabels[state] || state}
`; if (events.length === 0) { html += `
${t('sync.no_recent_events')}
`; } else { html += `
`; events.slice(0, 10).forEach((ev) => { const time = new Date(ev.timestamp).toLocaleTimeString(); const typeLabels = { index_updated: t('sync.event_index_updated'), index_reloaded: "Rechargement", vault_added: t('sync.event_vault_added'), vault_removed: t('sync.event_vault_removed'), index_start: t('sync.event_index_start'), index_progress: t('sync.event_index_progress'), 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 = t("sync.vault_files", {vault: ev.data.vault, files: ev.data.files}); 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 _aiConfigured = null; // null = unknown, true/false let _aiAutocomplete = null; // null = unknown, {available, server_ok, model_loaded, model, error} /** Check if AI is configured by calling the /api/ai/status endpoint. * Updates _aiConfigured, _aiAutocomplete and refreshes the panel if open. */ async function _checkAIStatus() { try { const status = await api('/api/ai/status'); _aiConfigured = status.configured === true; _aiAutocomplete = status.autocomplete || null; } catch { _aiConfigured = false; _aiAutocomplete = null; } _updateAIStatusInPanel(); } function _renderAIStatusSection(panel) { const section = document.createElement('div'); section.className = 'sync-panel__ai-section'; section.id = 'sync-panel-ai'; // ---- External provider status ---- let extIcon, extText, extClass; if (_aiProcessing) { extIcon = 'loader'; extText = t('sync.processing'); extClass = 'sync-panel__ai--processing'; } else if (_aiConfigured === true) { extIcon = 'check-circle'; extText = 'Fournisseur externe actif'; extClass = 'sync-panel__ai--active'; } else if (_aiConfigured === false) { extIcon = 'x-circle'; extText = t('sync.no_provider'); extClass = 'sync-panel__ai--inactive'; } else { extIcon = 'help-circle'; extText = t('sync.verifying'); extClass = 'sync-panel__ai--unknown'; } // ---- Autocomplete (Ollama) status ---- let acIcon, acText, acClass; if (_aiAutocomplete && _aiAutocomplete.available) { acIcon = 'check-circle'; acText = t('sync.autocomplete_active'); acClass = 'sync-panel__ai--active'; } else if (_aiAutocomplete && _aiAutocomplete.server_ok && !_aiAutocomplete.model_loaded) { acIcon = 'alert-triangle'; acText = t('sync.model_not_loaded'); acClass = 'sync-panel__ai--inactive'; } else if (_aiAutocomplete && _aiAutocomplete.error) { acIcon = 'x-circle'; acText = 'Serveur Ollama injoignable'; acClass = 'sync-panel__ai--inactive'; } else if (_aiAutocomplete === null) { acIcon = 'help-circle'; acText = t('sync.verifying'); acClass = 'sync-panel__ai--unknown'; } else { acIcon = 'x-circle'; acText = t('sync.not_configured'); acClass = 'sync-panel__ai--inactive'; } section.innerHTML = '' + '
Intelligence Artificielle
' + '
' + 'Fournisseur' + '' + '' + extText + '' + '
' + '
' + '' + t('sync.autocomplete') + '' + '' + '' + acText + '' + '
'; 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. * NOTE: now handled via CSS class on #sync-badge. * The visual flash in the editor itself is handled by ai.js * which adds a processing indicator inline. */ 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() { // Check AI status immediately at startup _checkAIStatus(); // 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(); }); // Re-check AI status when sync panel opens (in case config changed) const badge = document.getElementById('sync-badge'); if (badge) { badge.addEventListener('click', () => { _checkAIStatus(); }, { once: false }); } } // --------------------------------------------------------------------------- // PWA Service Worker Registration // --------------------------------------------------------------------------- function registerServiceWorker() { if (!("serviceWorker" in navigator)) return; // Reload once when a new service worker takes control (guarded against // loops). Only relevant when a worker was already controlling the page. const hadController = !!navigator.serviceWorker.controller; let refreshing = false; navigator.serviceWorker.addEventListener("controllerchange", () => { if (!hadController || refreshing) return; refreshing = true; window.location.reload(); }); 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: activate the new worker immediately // so the fresh (network-first) code is served on the next load. registration.addEventListener("updatefound", () => { const newWorker = registration.installing; if (!newWorker) return; newWorker.addEventListener("statechange", () => { if (newWorker.state === "installed") { if (navigator.serviceWorker.controller) { newWorker.postMessage({ type: "SKIP_WAITING" }); 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 !
`; message.querySelector(".pwa-update-btn").addEventListener("click", function() { window.location.reload(); }); message.querySelector(".pwa-update-dismiss").addEventListener("click", function() { message.remove(); }); 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(t("sync.install_success"), "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(); initOffline(); initThemes(); initAboutModal(); // Sync file index after a short delay (let vaults load first). // Gate on auth at execution time (not scheduling time) so that // initAuth() has a chance to complete and set _authEnabled / token. // The login handler triggers syncFileIndexFromServer explicitly on // fresh login, so we only do it here for already-authenticated or // auth-disabled sessions. setTimeout(() => { if (!AuthManager._authEnabled || AuthManager.hasSession()) { syncFileIndexFromServer(); } }, 3000); initDashboardTabs(); // Listen for Forge editor rename events window.addEventListener('message', function(e) { if (!e.data || !e.data.type) return; if (e.data.type === 'forge-renamed') { var vault = e.data.vault; var oldPath = e.data.oldPath; var newPath = e.data.newPath; console.log('[Forge] File renamed:', vault, oldPath, '->', newPath); var titleEl = document.getElementById('editor-title'); if (titleEl && titleEl.textContent.indexOf('Forge:') === 0) { titleEl.textContent = 'Forge: ' + newPath.split('/').pop(); } refreshSidebarTreePreservingState(vault); if (state.currentVault === vault && state.currentPath === oldPath) { import('./viewer.js').then(function(m) { m.openFile(vault, newPath); }); } } if (e.data.type === 'forge-close') { // #93 — shared close path: hands the editor container back to the modal // (inline mode) and re-renders the document read view. closeEditor(); } if (e.data.type === 'forge-open-ai') { // #101 — Forge has no rich AI panel of its own: its AI button asks the // parent to open the shared AI Assistant (same provider/model, skills, // context and history). var openForgeAssistant = function() { import('./bookslm.js').then(function(m) { if (m && m.default) m.default.openForCurrentContext(); }).catch(function(err) { console.warn('[Forge] Unable to open AI assistant', err); }); }; // BUG-056 — the assistant panel is mounted in this document. The native // fullscreen may be owned by the parent (Forge iframe) rather than by the // iframe itself, so the parent must also leave fullscreen before the // panel can be seen. if (document.fullscreenElement && document.exitFullscreen) { try { document.exitFullscreen().catch(function() {}).then(openForgeAssistant, openForgeAssistant); } catch (err) { openForgeAssistant(); } } else { openForgeAssistant(); } } }); }