frontend/js/ structure: state.js (55 lines) — Shared mutable state, constants utils.js (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons auth.js (547 lines) — api(), AuthManager, initLoginForm, AdminPanel search.js (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch sidebar.js (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags viewer.js (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor ui.js (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu dashboard.js (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks) config.js (999 lines) — Config panel, Hidden files, About, Sidebar tabs sync.js (436 lines) — SSE/IndexUpdateManager, PWA registration graph.js (401 lines) — GraphViewManager (force-directed canvas graph) legacy.js (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch) app.js (80 lines) — Thin orchestrator: imports all modules, calls init() index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js"> Original app.js preserved for backward compatibility. All 14 modules pass node --check syntax validation.
438 lines
13 KiB
JavaScript
438 lines
13 KiB
JavaScript
/* ObsiGate — Sync: SSE client + PWA registration */
|
||
import {
|
||
currentVault,
|
||
currentPath,
|
||
activeSidebarTab,
|
||
selectedContextVault
|
||
} from './state.js';
|
||
import { showToast } from './ui.js';
|
||
import { loadVaults, loadTags, refreshTagsForContext } from './sidebar.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 = selectedContextVault === "all" || (data.vaults || []).includes(selectedContextVault);
|
||
if (affectsCurrentVault) {
|
||
try {
|
||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||
// Refresh current file if it was updated
|
||
if (currentVault && currentPath) {
|
||
const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === currentPath);
|
||
if (changed) {
|
||
openFile(currentVault, currentPath);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Error refreshing after index update:", err);
|
||
}
|
||
}
|
||
|
||
// Refresh recent tab if it is active
|
||
if (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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 };
|
||
export function init() {
|
||
registerServiceWorker();
|
||
initDashboardTabs();
|
||
}
|