- Vault home (#12): recursive file listing (rglob), rel_dir in metadata, 'recursif' badge, updated roadmap description - Backup viewer: fix close button (inline onclick + event delegation fallback for text nodes), improve error messages - Backup API: remove response_model to prevent Pydantic 500 errors, add try/except + logging on backups/diff/restore endpoints, per-entry error handling in _list_backup_files, encoding safety (errors='replace') on read_text - AI status: call /api/ai/status at startup in _initAIStatus instead of waiting for user click on sync badge
555 lines
17 KiB
JavaScript
555 lines
17 KiB
JavaScript
/* ObsiGate — Sync: SSE client + PWA registration */
|
||
import { state } from './state.js';
|
||
import { showToast } from './ui.js';
|
||
import { api } from './auth.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 = `<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">Aucun événement récent</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: "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 += `<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
|
||
|
||
/** Check if AI is configured by calling the /api/ai/status endpoint.
|
||
* Updates _aiConfigured and refreshes the panel if open. */
|
||
async function _checkAIStatus() {
|
||
try {
|
||
const status = await api('/api/ai/status');
|
||
_aiConfigured = status.configured === true;
|
||
} catch {
|
||
_aiConfigured = false;
|
||
}
|
||
_updateAIStatusInPanel();
|
||
}
|
||
|
||
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 = `
|
||
<div class="sync-panel__ai-header">
|
||
<span class="sync-panel__ai-title">Intelligence Artificielle</span>
|
||
<span class="sync-panel__ai-state ${statusClass}" data-lucide-icon="${statusIcon}">
|
||
<i data-lucide="${statusIcon}" style="width:14px;height:14px"></i>
|
||
${statusText}
|
||
</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) {
|
||
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 = `
|
||
<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("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();
|
||
}
|