Files
ObsiGate/frontend/js/sync.js
T
bruno 7cf7d33b6d
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m53s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
fix(pwa): BUG-005 chargement mobile via Cloudflare (SW network-first + no-cache)
2026-09-11 17:13:16 -04:00

658 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/* 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 { 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);
}
});
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(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 = `<div class="sync-panel__header">
<span class="sync-panel__title">Synchronisation</span>
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
</div>`;
if (events.length === 0) {
html += `<div class="sync-panel__empty">${t('sync.no_recent_events')}</div>`;
} else {
html += `<div class="sync-panel__events">`;
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 += `<div class="sync-panel__event">
<span class="sync-panel__event-type">${label}</span>
<span class="sync-panel__event-detail">${detail}</span>
<span class="sync-panel__event-time">${time}</span>
</div>`;
});
html += `</div>`;
}
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 = '' +
'<div class="sync-panel__ai-title">Intelligence Artificielle</div>' +
'<div class="sync-panel__ai-row">' +
'<span class="sync-panel__ai-label">Fournisseur</span>' +
'<span class="sync-panel__ai-state ' + extClass + '" data-lucide-icon="' + extIcon + '">' +
'<i data-lucide="' + extIcon + '" style="width:14px;height:14px"></i>' +
extText +
'</span>' +
'</div>' +
'<div class="sync-panel__ai-row">' +
'<span class="sync-panel__ai-label">' + t('sync.autocomplete') + '</span>' +
'<span class="sync-panel__ai-state ' + acClass + '" data-lucide-icon="' + acIcon + '">' +
'<i data-lucide="' + acIcon + '" style="width:14px;height:14px"></i>' +
acText +
'</span>' +
'</div>';
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 = `
<div class="pwa-update-content">
<span>Une nouvelle version d'ObsiGate est disponible !</span>
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
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.getToken()) {
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') {
var modal = document.getElementById('editor-modal');
var forgeFrame = document.getElementById('forge-iframe');
if (forgeFrame) forgeFrame.remove();
if (modal) modal.classList.remove('active');
// Restore brand text
var brand = modal.querySelector('.editor-brand');
if (brand) brand.textContent = 'ObsiGate';
// Restore header/footer
var headerEl = modal.querySelector('.editor-header');
var footerEl = modal.querySelector('.editor-footer');
if (headerEl) headerEl.style.display = '';
if (footerEl) footerEl.style.display = '';
// Refresh current file content
if (state.currentVault && state.currentPath) {
import('./viewer.js').then(function(m) { m.openFile(state.currentVault, state.currentPath); });
}
}
});
}