From 4836d6f1d0241c7442223a058be6a4a1bfead287 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 28 May 2026 14:04:50 -0400 Subject: [PATCH] refactor: split app.js (8875 lines) into 12 ES modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..8227584 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,83 @@ +/* ObsiGate — Module orchestrator (ES modules). + Replaces the monolithic frontend/app.js IIFE. + All code lives in frontend/js/*.js modules. */ + +import * as UI from './ui.js'; +import * as Sidebar from './sidebar.js'; +import * as Auth from './auth.js'; +import * as Viewer from './viewer.js'; +import * as Utils from './utils.js'; +import * as Sync from './sync.js'; +import { initGraphView } from './graph.js'; +import * as Legacy from './legacy.js'; + +// ========================================================================= +// Initialization — mirrors the original app.js init() ordering +// ========================================================================= +async function init() { + UI.initTheme(); + UI.initHeaderMenu(); + UI.initCustomDropdowns(); + + document.getElementById("theme-toggle").addEventListener("click", UI.toggleTheme); + document.getElementById("header-logo").addEventListener("click", Legacy.goHome); + const refreshBtn = document.getElementById("header-refresh-btn"); + if (refreshBtn) refreshBtn.addEventListener("click", Legacy.goHome); + + Legacy.initSearch(); + UI.initSidebarToggle(); + UI.initMobile(); + Sidebar.initVaultContext(); + Legacy.initSidebarTabs(); + Legacy.initHelpModal(); + Legacy.initConfigModal(); + Sidebar.initSidebarFilter(); + UI.initSidebarResize(); + Utils.initEditor(); + Auth.initLoginForm(); + Legacy.initRecentTab(); + + UI.RightSidebarManager.init(); + UI.FindInPageManager.init(); + UI.ContextMenuManager.init(); + + const authOk = await Auth.AuthManager.initAuth(); + + if (authOk) { + Legacy.initSyncStatus(); + + try { + await Promise.all([Legacy.loadVaultSettings(), Sidebar.loadVaults(), Sidebar.loadTags()]); + + Legacy.DashboardRecentWidget.init(); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get("popup") === "true") { + document.body.classList.add("popup-mode"); + } + + if (window.location.hash && window.location.hash.startsWith("#file=")) { + const hashVal = window.location.hash.substring(6); + const sepIndex = hashVal.indexOf(":"); + if (sepIndex > -1) { + const vault = decodeURIComponent(hashVal.substring(0, sepIndex)); + const path = decodeURIComponent(hashVal.substring(sepIndex + 1)); + Viewer.openFile(vault, path); + } + } else if (urlParams.get("popup") !== "true") { + Legacy.showWelcome(); + } + } catch (err) { + console.error("Failed to initialize ObsiGate:", err); + UI.showToast("Erreur lors de l'initialisation", "error"); + } + } + + initGraphView(); + Utils.safeCreateIcons(); +} + +document.addEventListener("DOMContentLoaded", () => { + init(); + Sync.init(); +}); diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..6e8a59c --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,547 @@ +/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */ +import * as State from './state.js'; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +/** + * Fetch JSON from an API endpoint with optional AbortSignal support. + * Surfaces errors to the user via toast instead of silently failing. + * + * @param {string} path - API URL path. + * @param {object} [opts] - Fetch options (may include signal). + * @returns {Promise} Parsed JSON response. + */ +async function api(path, opts) { + var res; + try { + // Inject auth header if authenticated + const authHeaders = AuthManager.getAuthHeaders(); + const mergedOpts = opts || {}; + // Auto-set Content-Type for JSON bodies + if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) { + mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" }; + } + if (authHeaders) { + mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders }; + } + mergedOpts.credentials = "include"; + res = await fetch(path, mergedOpts); + } catch (err) { + if (err.name === "AbortError") throw err; // let callers handle abort + showToast("Erreur réseau — vérifiez votre connexion", "error"); + throw err; + } + if (res.status === 401 && AuthManager._authEnabled) { + // Token expired — try refresh + try { + await AuthManager.refreshAccessToken(); + // Retry the request with new token + const retryHeaders = AuthManager.getAuthHeaders(); + const retryOpts = opts || {}; + retryOpts.headers = { ...retryOpts.headers, ...retryHeaders }; + retryOpts.credentials = "include"; + res = await fetch(path, retryOpts); + } catch (refreshErr) { + AuthManager.clearSession(); + AuthManager.showLoginScreen(); + throw new Error("Session expirée"); + } + } + if (!res.ok) { + var detail = ""; + try { + var body = await res.json(); + detail = body.detail || ""; + } catch (_) { + /* no json body */ + } + showToast(detail || "Erreur API : " + res.status, "error"); + throw new Error(detail || "API error: " + res.status); + } + return res.json(); +} + + +// --------------------------------------------------------------------------- +// AuthManager — Authentication state & token management +// --------------------------------------------------------------------------- + +const AuthManager = { + ACCESS_TOKEN_KEY: "obsigate_access_token", + TOKEN_EXPIRY_KEY: "obsigate_token_expiry", + USER_KEY: "obsigate_user", + _authEnabled: false, + + // ── Token storage (sessionStorage) ───────────────────────────── + + saveToken(tokenData) { + const expiresAt = Date.now() + tokenData.expires_in * 1000; + sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token); + sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString()); + if (tokenData.user) { + sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); + } + }, + + getToken() { + return sessionStorage.getItem(this.ACCESS_TOKEN_KEY); + }, + + getUser() { + const raw = sessionStorage.getItem(this.USER_KEY); + return raw ? JSON.parse(raw) : null; + }, + + isTokenExpired() { + const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); + if (!expiry) return true; + // Renew 60s before expiration + return Date.now() > parseInt(expiry) - 60000; + }, + + clearSession() { + sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); + sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY); + sessionStorage.removeItem(this.USER_KEY); + }, + + getAuthHeaders() { + const token = this.getToken(); + if (!token || !this._authEnabled) return null; + return { Authorization: "Bearer " + token }; + }, + + // ── API calls ────────────────────────────────────────────────── + + async login(username, password, rememberMe) { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ username, password, remember_me: rememberMe || false }), + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "Erreur de connexion"); + } + const data = await response.json(); + this.saveToken(data); + return data.user; + }, + + async logout() { + try { + const token = this.getToken(); + await fetch("/api/auth/logout", { + method: "POST", + headers: token ? { Authorization: "Bearer " + token } : {}, + credentials: "include", + }); + } catch (e) { + /* continue even if API fails */ + } + this.clearSession(); + this.showLoginScreen(); + }, + + async refreshAccessToken() { + const response = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + if (!response.ok) { + this.clearSession(); + throw new Error("Session expirée"); + } + const data = await response.json(); + const expiry = Date.now() + data.expires_in * 1000; + sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token); + sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString()); + return data.access_token; + }, + + // ── UI controls ──────────────────────────────────────────────── + + showLoginScreen() { + const app = document.getElementById("app"); + const login = document.getElementById("login-screen"); + if (app) app.classList.add("hidden"); + if (login) { + login.classList.remove("hidden"); + const usernameInput = document.getElementById("login-username"); + if (usernameInput) usernameInput.focus(); + } + }, + + showApp() { + const login = document.getElementById("login-screen"); + const app = document.getElementById("app"); + if (login) login.classList.add("hidden"); + if (app) app.classList.remove("hidden"); + this.renderUserMenu(); + }, + + renderUserMenu() { + const user = this.getUser(); + const userMenu = document.getElementById("user-menu"); + if (!userMenu) return; + if (!user || !this._authEnabled) { + userMenu.innerHTML = ""; + return; + } + userMenu.innerHTML = '' + (user.display_name || user.username) + "" + ''; + safeCreateIcons(); + + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout()); + + const adminRow = document.getElementById("admin-menu-row"); + if (adminRow) { + if (user.role === "admin") { + adminRow.classList.remove("hidden"); + // Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before + adminRow.onclick = () => { + closeHeaderMenu(); + AdminPanel.show(); + }; + } else { + adminRow.classList.add("hidden"); + } + } + }, + + // ── Initialization ────────────────────────────────────────────── + + async checkAuthStatus() { + try { + const res = await fetch("/api/auth/status"); + const data = await res.json(); + this._authEnabled = data.auth_enabled; + return data; + } catch (e) { + this._authEnabled = false; + return { auth_enabled: false }; + } + }, + + async initAuth() { + const status = await this.checkAuthStatus(); + if (!status.auth_enabled) { + // Auth disabled — show app immediately + this.showApp(); + return true; + } + + // Auth enabled — check for existing session + if (this.getToken() && !this.isTokenExpired()) { + this.showApp(); + return true; + } + + // Try silent refresh + try { + await this.refreshAccessToken(); + // Fetch user info + const token = this.getToken(); + const res = await fetch("/api/auth/me", { + headers: { Authorization: "Bearer " + token }, + credentials: "include", + }); + if (res.ok) { + const user = await res.json(); + sessionStorage.setItem(this.USER_KEY, JSON.stringify(user)); + this.showApp(); + return true; + } + } catch (e) { + /* silent refresh failed */ + } + + // No valid session — show login + this.showLoginScreen(); + return false; + }, +}; + + +// --------------------------------------------------------------------------- +// Login form handler +// --------------------------------------------------------------------------- + +function initLoginForm() { + const form = document.getElementById("login-form"); + if (!form) return; + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = document.getElementById("login-username").value; + const password = document.getElementById("login-password").value; + const rememberMe = document.getElementById("remember-me").checked; + const errorEl = document.getElementById("login-error"); + const btn = document.getElementById("login-btn"); + + btn.disabled = true; + btn.querySelector(".btn-spinner").classList.remove("hidden"); + btn.querySelector(".btn-text").textContent = "Connexion..."; + errorEl.classList.add("hidden"); + + try { + await AuthManager.login(username, password, rememberMe); + AuthManager.showApp(); + // Load app data after successful login + try { + await Promise.all([loadVaults(), loadTags()]); + // Start SSE sync now that auth cookie is set + IndexUpdateManager.connect(); + // Show dashboard + showWelcome(); + } catch (err) { + console.error("Failed to load data after login:", err); + } + safeCreateIcons(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove("hidden"); + document.getElementById("login-password").value = ""; + document.getElementById("login-password").focus(); + } finally { + btn.disabled = false; + btn.querySelector(".btn-spinner").classList.add("hidden"); + btn.querySelector(".btn-text").textContent = "Se connecter"; + } + }); + + // Toggle password visibility + const toggleBtn = document.getElementById("toggle-password"); + if (toggleBtn) { + toggleBtn.addEventListener("click", () => { + const input = document.getElementById("login-password"); + input.type = input.type === "password" ? "text" : "password"; + }); + } +} + + +// --------------------------------------------------------------------------- +// Admin Panel — User management (admin only) +// --------------------------------------------------------------------------- + +const AdminPanel = { + _modal: null, + _allVaults: [], + + show() { + this._createModal(); + this._modal.classList.add("active"); + this._loadUsers(); + }, + + hide() { + if (this._modal) this._modal.classList.remove("active"); + }, + + _createModal() { + if (this._modal) return; + this._modal = document.createElement("div"); + this._modal.className = "editor-modal"; + this._modal.id = "admin-modal"; + this._modal.innerHTML = ` +
+
+
⚙️ Administration — Utilisateurs
+
+ +
+
+
+
+ +
+
+
+
+ `; + document.body.appendChild(this._modal); + safeCreateIcons(); + + document.getElementById("admin-close").addEventListener("click", () => this.hide()); + document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null)); + }, + + async _loadUsers() { + try { + const users = await api("/api/auth/admin/users"); + // Also load available vaults + try { + const vaultsData = await api("/api/vaults"); + this._allVaults = vaultsData.map((v) => v.name); + } catch (e) { + this._allVaults = []; + } + this._renderUsers(users); + } catch (err) { + document.getElementById("admin-users-list").innerHTML = '

Erreur : ' + err.message + "

"; + } + }, + + _renderUsers(users) { + const container = document.getElementById("admin-users-list"); + if (!users.length) { + container.innerHTML = '

Aucun utilisateur.

'; + return; + } + let html = '' + "" + ""; + users.forEach((u) => { + const vaults = u.vaults.includes("*") ? "Toutes" : u.vaults.join(", ") || "Aucune"; + const status = u.active ? "✅" : "🔴"; + const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "Jamais"; + html += + "" + + "" + + '" + + '" + + "" + + "" + + '"; + }); + html += "
UtilisateurRôleVaultsStatutDernière connexionActions
" + + u.username + + "" + + (u.display_name && u.display_name !== u.username ? "
" + u.display_name + "" : "") + + "
' + + u.role + + "' + + vaults + + "" + + status + + "" + + lastLogin + + "' + + '' + + '' + + "
"; + container.innerHTML = html; + + // Bind action buttons + container.querySelectorAll('[data-action="edit"]').forEach((btn) => { + btn.addEventListener("click", () => { + const user = users.find((u) => u.username === btn.dataset.username); + if (user) this._showUserForm(user); + }); + }); + container.querySelectorAll('[data-action="delete"]').forEach((btn) => { + btn.addEventListener("click", () => this._deleteUser(btn.dataset.username)); + }); + }, + + _showUserForm(user) { + const isEdit = !!user; + const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur"; + const vaultCheckboxes = this._allVaults + .map((v) => { + const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : ""; + return '"; + }) + .join(""); + const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : ""; + + // Create form modal overlay + const overlay = document.createElement("div"); + overlay.className = "admin-form-overlay"; + overlay.innerHTML = ` +
+

${title}

+
+ ${!isEdit ? '
' : ""} +
+
+
+
+ +
${vaultCheckboxes}
+ +
+ ${isEdit ? '
" : ""} +
+ + +
+
+
+ `; + this._modal.appendChild(overlay); + + document.getElementById("admin-form-cancel").addEventListener("click", () => overlay.remove()); + + document.getElementById("admin-user-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const form = e.target; + const allVaults = document.getElementById("admin-all-vaults").checked; + const selectedVaults = allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value); + + try { + if (isEdit) { + const updates = { + display_name: form.display_name.value || null, + role: form.role.value, + vaults: selectedVaults, + }; + if (form.password.value) updates.password = form.password.value; + const activeCheckbox = form.querySelector('input[name="active"]'); + if (activeCheckbox) updates.active = activeCheckbox.checked; + await api("/api/auth/admin/users/" + user.username, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + } else { + await api("/api/auth/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: form.username.value, + password: form.password.value, + display_name: form.display_name.value || null, + role: form.role.value, + vaults: selectedVaults, + }), + }); + } + overlay.remove(); + this._loadUsers(); + showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success"); + } catch (err) { + showToast(err.message, "error"); + } + }); + }, + + async _deleteUser(username) { + const currentUser = AuthManager.getUser(); + if (currentUser && currentUser.username === username) { + showToast("Impossible de supprimer son propre compte", "error"); + return; + } + if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return; + try { + await api("/api/auth/admin/users/" + username, { method: "DELETE" }); + this._loadUsers(); + showToast("Utilisateur supprimé", "success"); + } catch (err) { + showToast(err.message, "error"); + } + }, +}; + + +export { api, AuthManager, initLoginForm, AdminPanel }; diff --git a/frontend/js/config.js b/frontend/js/config.js new file mode 100644 index 0000000..dd462e5 --- /dev/null +++ b/frontend/js/config.js @@ -0,0 +1,1008 @@ +// config.js — extracted from app.js (3872-4865) +import { selectedContextVault, allVaults, activeSidebarTab, sidebarFilterCaseSensitive } from './state.js'; + +async function loadRecentFiles(vaultFilter) { + const listEl = document.getElementById("recent-list"); + const emptyEl = document.getElementById("recent-empty"); + if (!listEl) return; + + let url = "/api/recent?mode=modified"; + if (vaultFilter) url += `&vault=${encodeURIComponent(vaultFilter)}`; + try { + const data = await api(url); + _recentFilesCache = data.files || []; + renderRecentList(_recentFilesCache); + } catch (err) { + console.error("Failed to load recent files:", err); + listEl.innerHTML = ""; + if (emptyEl) { + emptyEl.classList.remove("hidden"); + } + } +} + +function renderRecentList(files) { + const listEl = document.getElementById("recent-list"); + const emptyEl = document.getElementById("recent-empty"); + if (!listEl) return; + listEl.innerHTML = ""; + + if (!files || files.length === 0) { + if (emptyEl) { + emptyEl.classList.remove("hidden"); + safeCreateIcons(); + } + return; + } + if (emptyEl) emptyEl.classList.add("hidden"); + + files.forEach((f) => { + const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path }); + + // Header row: time + vault badge + const header = el("div", { class: "recent-item-header" }); + const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(f.mtime_human)]); + const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]); + header.appendChild(timeSpan); + header.appendChild(badge); + item.appendChild(header); + + // Title + const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(f.title || f.path.split("/").pop())]); + item.appendChild(titleEl); + + // Path breadcrumb + const pathParts = f.path.split("/"); + if (pathParts.length > 1) { + const pathEl = el("div", { class: "recent-item-path" }, [document.createTextNode(pathParts.slice(0, -1).join(" / "))]); + item.appendChild(pathEl); + } + + // Preview + if (f.preview) { + const previewEl = el("div", { class: "recent-item-preview" }, [document.createTextNode(f.preview)]); + item.appendChild(previewEl); + } + + // Tags + if (f.tags && f.tags.length > 0) { + const tagsEl = el("div", { class: "recent-item-tags" }); + f.tags.forEach((t) => { + tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)])); + }); + item.appendChild(tagsEl); + } + + // Click handler + item.addEventListener("click", () => { + openFile(f.vault, f.path); + closeMobileSidebar(); + }); + + listEl.appendChild(item); + }); + safeCreateIcons(); +} + +function _humanizeDelta(mtime) { + const delta = Date.now() / 1000 - mtime; + if (delta < 60) return "à l'instant"; + if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`; + if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`; + if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`; + return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }); +} + +function _refreshRecentTimestamps() { + if (activeSidebarTab !== "recent" || !_recentFilesCache.length) return; + const items = document.querySelectorAll(".recent-item"); + items.forEach((item, i) => { + if (i < _recentFilesCache.length) { + const timeSpan = item.querySelector(".recent-time"); + if (timeSpan) { + // keep the icon, update text + const textNode = timeSpan.lastChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime); + } + } + } + }); +} + +function _populateRecentVaultFilter() { + const select = document.getElementById("recent-vault-filter"); + if (!select) return; + // keep first option "Tous les vaults" + while (select.options.length > 1) select.remove(1); + allVaults.forEach((v) => { + const opt = document.createElement("option"); + opt.value = v.name; + opt.textContent = v.name; + select.appendChild(opt); + }); + syncVaultSelectors(); +} + +function initRecentTab() { + const select = document.getElementById("recent-vault-filter"); + if (select) { + select.addEventListener("change", async () => { + const val = select.value || "all"; + await setSelectedVaultContext(val, { focusVault: val !== "all" }); + }); + } + // Periodic timestamp refresh (every 60s) + _recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000); +} + +// --------------------------------------------------------------------------- +// Sidebar tabs +// --------------------------------------------------------------------------- +function initSidebarTabs() { + document.querySelectorAll(".sidebar-tab").forEach((tab) => { + tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab)); + }); +} + +function switchSidebarTab(tab) { + activeSidebarTab = tab; + document.querySelectorAll(".sidebar-tab").forEach((btn) => { + const isActive = btn.dataset.tab === tab; + btn.classList.toggle("active", isActive); + btn.setAttribute("aria-selected", isActive ? "true" : "false"); + }); + document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => { + const isActive = panel.id === `sidebar-panel-${tab}`; + panel.classList.toggle("active", isActive); + }); + const filterInput = document.getElementById("sidebar-filter-input"); + if (filterInput) { + const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" }; + filterInput.placeholder = placeholders[tab] || ""; + } + const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; + if (query) { + if (tab === "vaults") performTreeSearch(query); + else if (tab === "tags") filterTagCloud(query); + } + // Auto-load recent files when switching to the recent tab + if (tab === "recent") { + _populateRecentVaultFilter(); + const vaultFilter = document.getElementById("recent-vault-filter"); + loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + } +} + +function initHelpModal() { + const openBtn = document.getElementById("help-open-btn"); + const closeBtn = document.getElementById("help-close"); + const modal = document.getElementById("help-modal"); + if (!openBtn || !closeBtn || !modal) return; + + openBtn.addEventListener("click", () => { + modal.classList.add("active"); + closeHeaderMenu(); + safeCreateIcons(); + initHelpNavigation(); + }); + + closeBtn.addEventListener("click", closeHelpModal); + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeHelpModal(); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeHelpModal(); + } + }); +} + +function initHelpNavigation() { + const helpContent = document.querySelector(".help-content"); + const helpBody = document.getElementById("help-body"); + const navLinks = document.querySelectorAll(".help-nav-link"); + + if (!helpContent || !helpBody || !navLinks.length) return; + + // Handle nav link clicks + navLinks.forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + const targetId = link.getAttribute("href").substring(1); + const targetSection = document.getElementById(targetId); + if (targetSection) { + targetSection.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + }); + + // Scroll spy - update active nav link based on scroll position + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const id = entry.target.getAttribute("id"); + navLinks.forEach((link) => { + if (link.getAttribute("href") === `#${id}`) { + navLinks.forEach((l) => l.classList.remove("active")); + link.classList.add("active"); + } + }); + } + }); + }, + { + root: helpBody, + rootMargin: "-20% 0px -70% 0px", + threshold: 0, + }, + ); + + // Observe all sections + document.querySelectorAll(".help-section").forEach((section) => { + observer.observe(section); + }); +} + +function closeHelpModal() { + const modal = document.getElementById("help-modal"); + if (modal) modal.classList.remove("active"); +} + +function initConfigModal() { + const openBtn = document.getElementById("config-open-btn"); + const closeBtn = document.getElementById("config-close"); + const modal = document.getElementById("config-modal"); + const addBtn = document.getElementById("config-add-btn"); + const patternInput = document.getElementById("config-pattern-input"); + + if (!openBtn || !closeBtn || !modal) return; + + openBtn.addEventListener("click", async () => { + modal.classList.add("active"); + closeHeaderMenu(); + renderConfigFilters(); + loadConfigFields(); + loadDiagnostics(); + loadAbout(); + await loadHiddenFilesSettings(); + loadWebhooksUI(); + loadSharesUI(); + safeCreateIcons(); + }); + + closeBtn.addEventListener("click", closeConfigModal); + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeConfigModal(); + } + }); + + addBtn.addEventListener("click", addConfigFilter); + patternInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + addConfigFilter(); + } + }); + + patternInput.addEventListener("input", updateRegexPreview); + + // Frontend config fields — save to localStorage on change + ["cfg-debounce", "cfg-results-per-page", "cfg-min-query", "cfg-timeout"].forEach((id) => { + const input = document.getElementById(id); + if (input) input.addEventListener("change", saveFrontendConfig); + }); + + // Backend save button + const saveBtn = document.getElementById("cfg-save-backend"); + if (saveBtn) saveBtn.addEventListener("click", saveBackendConfig); + + // Force reindex + const reindexBtn = document.getElementById("cfg-reindex"); + if (reindexBtn) reindexBtn.addEventListener("click", forceReindex); + + // Reset defaults + const resetBtn = document.getElementById("cfg-reset-defaults"); + if (resetBtn) resetBtn.addEventListener("click", resetConfigDefaults); + + // Refresh diagnostics + const diagBtn = document.getElementById("cfg-refresh-diag"); + if (diagBtn) diagBtn.addEventListener("click", loadDiagnostics); + + // Hidden files configuration + const saveHiddenBtn = document.getElementById("cfg-save-hidden-files"); + if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeConfigModal(); + } + }); + + // Load saved frontend config on startup + applyFrontendConfig(); +} + +function closeConfigModal() { + const modal = document.getElementById("config-modal"); + if (modal) modal.classList.remove("active"); +} + +// --- Config field helpers --- +const _FRONTEND_CONFIG_KEY = "obsigate-perf-config"; + +function _getFrontendConfig() { + try { + return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}"); + } catch { + return {}; + } +} + +function applyFrontendConfig() { + const cfg = _getFrontendConfig(); + if (cfg.debounce_ms) { + /* applied dynamically in debounce setTimeout */ + } + if (cfg.results_per_page) { + /* used as ADVANCED_SEARCH_LIMIT override */ + } + if (cfg.min_query_length) { + /* used as MIN_SEARCH_LENGTH override */ + } + if (cfg.search_timeout_ms) { + /* used as SEARCH_TIMEOUT_MS override */ + } +} + +function _getEffective(key, fallback) { + const cfg = _getFrontendConfig(); + return cfg[key] !== undefined ? cfg[key] : fallback; +} + +async function loadConfigFields() { + // Frontend fields from localStorage + const cfg = _getFrontendConfig(); + _setField("cfg-debounce", cfg.debounce_ms || 300); + _setField("cfg-results-per-page", cfg.results_per_page || 50); + _setField("cfg-min-query", cfg.min_query_length || 2); + _setField("cfg-timeout", cfg.search_timeout_ms || 30000); + + // Backend fields from API + try { + const data = await api("/api/config"); + _setField("cfg-workers", data.search_workers); + _setField("cfg-max-content", data.max_content_size); + _setField("cfg-title-boost", data.title_boost); + _setField("cfg-tag-boost", data.tag_boost); + _setField("cfg-prefix-exp", data.prefix_max_expansions); + _setField("cfg-recent-limit", data.recent_files_limit || 20); + // Watcher config + _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); + _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); + _setField("cfg-watcher-interval", data.watcher_polling_interval || 5); + _setField("cfg-watcher-debounce", data.watcher_debounce || 2); + } catch (err) { + console.error("Failed to load backend config:", err); + } +} + +function _setField(id, value) { + const el = document.getElementById(id); + if (el && value !== undefined) el.value = value; +} + +function _setCheckbox(id, checked) { + const el = document.getElementById(id); + if (el) el.checked = !!checked; +} + +function _getCheckbox(id) { + const el = document.getElementById(id); + return el ? el.checked : false; +} + +function _getFieldNum(id, fallback) { + const el = document.getElementById(id); + if (!el) return fallback; + const v = parseFloat(el.value); + return isNaN(v) ? fallback : v; +} + +function saveFrontendConfig() { + const cfg = { + debounce_ms: _getFieldNum("cfg-debounce", 300), + results_per_page: _getFieldNum("cfg-results-per-page", 50), + min_query_length: _getFieldNum("cfg-min-query", 2), + search_timeout_ms: _getFieldNum("cfg-timeout", 30000), + }; + localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg)); + showToast("Paramètres client sauvegardés", "success"); +} + +async function saveBackendConfig() { + const body = { + search_workers: _getFieldNum("cfg-workers", 2), + max_content_size: _getFieldNum("cfg-max-content", 100000), + title_boost: _getFieldNum("cfg-title-boost", 3.0), + tag_boost: _getFieldNum("cfg-tag-boost", 2.0), + prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), + recent_files_limit: _getFieldNum("cfg-recent-limit", 20), + watcher_enabled: _getCheckbox("cfg-watcher-enabled"), + watcher_use_polling: _getCheckbox("cfg-watcher-polling"), + watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), + watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0), + }; + try { + const res = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) { + showToast("Configuration backend sauvegardée", "success"); + } else { + const errorData = await res.json().catch(() => ({})); + showToast(errorData.detail || "Erreur de sauvegarde", "error"); + } + } catch (err) { + console.error("Failed to save backend config:", err); + showToast("Erreur de sauvegarde", "error"); + } +} + +async function forceReindex() { + const btn = document.getElementById("cfg-reindex"); + if (btn) { + btn.disabled = true; + btn.textContent = "Réindexation..."; + } + try { + await api("/api/index/reload"); + showToast("Réindexation terminée", "success"); + loadDiagnostics(); + await Promise.all([loadVaults(), loadTags()]); + } catch (err) { + console.error("Reindex error:", err); + showToast("Erreur de réindexation", "error"); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = "Forcer réindexation"; + } + } +} + +async function resetConfigDefaults() { + // Reset frontend + localStorage.removeItem(_FRONTEND_CONFIG_KEY); + // Reset backend + try { + await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + search_workers: 2, + debounce_ms: 300, + results_per_page: 50, + min_query_length: 2, + search_timeout_ms: 30000, + max_content_size: 100000, + title_boost: 3.0, + path_boost: 1.5, + tag_boost: 2.0, + prefix_max_expansions: 50, + snippet_context_chars: 120, + max_snippet_highlights: 5, + }), + }); + } catch (err) { + console.error("Reset config error:", err); + } + loadConfigFields(); + showToast("Configuration réinitialisée", "success"); +} + +async function loadDiagnostics() { + const container = document.getElementById("config-diagnostics"); + if (!container) return; + container.innerHTML = '
Chargement...
'; + try { + const data = await api("/api/diagnostics"); + renderDiagnostics(container, data); + } catch (err) { + container.innerHTML = '
Erreur de chargement
'; + } +} + +function renderDiagnostics(container, data) { + container.innerHTML = ""; + const sections = [ + { + title: "Index", + rows: [ + ["Fichiers indexés", data.index.total_files], + ["Tags uniques", data.index.total_tags], + ["Vaults", Object.keys(data.index.vaults).join(", ")], + ], + }, + { + title: "Index inversé", + rows: [ + ["Tokens uniques", data.inverted_index.unique_tokens.toLocaleString()], + ["Postings total", data.inverted_index.total_postings.toLocaleString()], + ["Documents", data.inverted_index.documents], + ["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"], + ["Stale", data.inverted_index.is_stale ? "Oui" : "Non"], + ], + }, + { + title: "Moteur de recherche", + rows: [ + ["Executor actif", data.search_executor.active ? "Oui" : "Non"], + ["Workers max", data.search_executor.max_workers], + ], + }, + ]; + sections.forEach((section) => { + const div = document.createElement("div"); + div.className = "config-diag-section"; + const title = document.createElement("div"); + title.className = "config-diag-section-title"; + title.textContent = section.title; + div.appendChild(title); + section.rows.forEach(([label, value]) => { + const row = document.createElement("div"); + row.className = "config-diag-row"; + row.innerHTML = `${label}${value}`; + div.appendChild(row); + }); + container.appendChild(div); + }); +} + +// --- About Section --- + +function loadAbout() { + const container = document.getElementById("config-about"); + if (!container) return; + + // Fetch health info for version + api("/api/health").then((health) => { + container.innerHTML = ""; + + const sections = [ + { + title: "Application", + rows: [ + ["Nom", "ObsiGate"], + ["Version", APP_VERSION], + ["Version API", health.version || "—"], + ["Statut", health.status || "—"], + ], + }, + { + title: "Environnement", + rows: [ + ["Vaults configurés", health.vaults || "—"], + ["Fichiers indexés", health.total_files || "—"], + ["Navigateur", navigator.userAgent.split(" ").pop()], + ["Plateforme", navigator.platform || "—"], + ["Langue", navigator.language || "—"], + ], + }, + { + title: "Composants", + rows: [ + ["Backend", "FastAPI (Python)"], + ["Rendu Markdown", "mistune"], + ["Surveillance fichiers", "watchdog"], + ["Frontend", "Vanilla JavaScript"], + ["Icônes", "Lucide Icons"], + ["Coloration syntaxe", "highlight.js"], + ["Éditeur", "CodeMirror 6"], + ], + }, + ]; + + sections.forEach((section) => { + const div = document.createElement("div"); + div.className = "config-diag-section"; + const title = document.createElement("div"); + title.className = "config-diag-section-title"; + title.textContent = section.title; + div.appendChild(title); + section.rows.forEach(([label, value]) => { + const row = document.createElement("div"); + row.className = "config-diag-row"; + row.innerHTML = `${label}${value}`; + div.appendChild(row); + }); + container.appendChild(div); + }); + }).catch(() => { + container.innerHTML = '
Erreur de chargement
'; + }); +} + +// --- Hidden Files Configuration --- + +async function loadHiddenFilesSettings() { + const container = document.getElementById("hidden-files-vault-list"); + if (!container) return; + + container.innerHTML = '
Chargement...
'; + + try { + const settings = await api("/api/vaults/settings/all"); + renderHiddenFilesSettings(container, settings); + } catch (err) { + console.error("Failed to load hidden files settings:", err); + container.innerHTML = '
Erreur de chargement
'; + } +} + +function renderHiddenFilesSettings(container, allSettings) { + container.innerHTML = ""; + + if (!allVaults || allVaults.length === 0) { + container.innerHTML = '
Aucun vault configuré
'; + return; + } + + allVaults.forEach((vault) => { + const settings = allSettings[vault.name] || { hideHiddenFiles: false }; + + const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name }); + + // Vault header + const header = el("div", { class: "hidden-files-vault-header" }, [el("h3", {}, [document.createTextNode(vault.name)]), el("span", { class: "hidden-files-vault-type" }, [document.createTextNode(vault.type || "VAULT")])]); + + // Hide hidden files toggle + const toggleRow = el("div", { class: "config-row" }, [ + el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]), + el("label", { class: "config-toggle" }, [ + el("input", { + type: "checkbox", + id: `hide-hidden-${vault.name}`, + "data-vault": vault.name, + checked: settings.hideHiddenFiles ? "true" : false, + }), + el("span", { class: "config-toggle-slider" }), + ]), + el("span", { class: "config-hint" }, [document.createTextNode("Masquer les fichiers/dossiers commençant par un point dans l'interface (ils restent indexés et cherchables)")]), + ]); + + vaultCard.appendChild(header); + vaultCard.appendChild(toggleRow); + + container.appendChild(vaultCard); + }); +} + +async function saveHiddenFilesSettings() { + const btn = document.getElementById("cfg-save-hidden-files"); + if (btn) { + btn.disabled = true; + btn.textContent = "Sauvegarde..."; + } + + try { + const vaultCards = document.querySelectorAll(".hidden-files-vault-card"); + const promises = []; + + vaultCards.forEach((card) => { + const vaultName = card.dataset.vault; + const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false; + + const settings = { + hideHiddenFiles, + }; + + promises.push( + api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }), + ); + }); + + await Promise.all(promises); + + // Reload vault settings to update the cache + await loadVaultSettings(); + + showToast("✓ Paramètres sauvegardés", "success"); + + // Refresh the UI to apply the filter + await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + } catch (err) { + console.error("Failed to save hidden files settings:", err); + const errorMsg = err.message || "Erreur inconnue"; + showToast(`Erreur: ${errorMsg}`, "error"); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = "💾 Sauvegarder"; + } + } +} + +// ── Webhooks UI ── +async function loadWebhooksUI() { + const list = document.getElementById("webhooks-list"); + if (!list) return; + try { + const webhooks = await api("/api/webhooks"); + renderWebhooksUI(webhooks); + } catch { list.innerHTML = '
Admin uniquement
'; } +} +function renderWebhooksUI(webhooks) { + const list = document.getElementById("webhooks-list"); + if (!list) return; + if (!webhooks.length) { list.innerHTML = '
Aucun webhook configuré.
'; return; } + list.innerHTML = webhooks.map(w => ` +
+ ${escapeHtml(w.name)} + ${escapeHtml(w.url)} + ${(w.events||[]).join(", ")} + +
+ `).join(""); + list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => { + await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" }); + loadWebhooksUI(); + })); +} +document.addEventListener("click", function(e) { + if (e.target.id === "webhook-add-btn") { + const name = document.getElementById("webhook-name-input").value.trim(); + const url = document.getElementById("webhook-url-input").value.trim(); + if (!url) { showToast("URL requise", "error"); return; } + api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error")); + } +}); + +// ── Shares UI ── +async function loadSharesUI() { + const list = document.getElementById("shares-list"); + if (!list) return; + try { + const shares = await api("/api/shares"); + renderSharesUI(shares); + } catch { list.innerHTML = '
Chargement...
'; } +} +function renderSharesUI(shares) { + const list = document.getElementById("shares-list"); + if (!list) return; + if (!shares.length) { list.innerHTML = '
Aucun partage actif.
'; return; } + list.innerHTML = shares.map(s => ` + + `).join(""); + list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => { + await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); + loadSharesUI(); + })); +} + +// ── Share Dialog (professional) ── +async function openShareDialog(vault, path) { + // First check if already shared + let existingShare = null; + try { + const shares = await api("/api/shares"); + existingShare = shares.find(s => s.vault === vault && s.path === path); + } catch (e) { /* ignore */ } + + const div = document.createElement("div"); + div.className = "share-dialog-overlay"; + + const renderContent = () => { + if (existingShare) { + const url = window.location.origin + existingShare.url; + const expiresInfo = existingShare.expires_at + ? `

Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}

` + : '

Sans expiration

'; + div.innerHTML = ` + `; + div.querySelector(".share-copy-btn").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(url); + } catch (e) { + // Fallback for non-HTTPS contexts + const ta = document.createElement("textarea"); + ta.value = url; ta.style.position = "fixed"; ta.style.left = "-9999px"; + document.body.appendChild(ta); ta.select(); document.execCommand("copy"); + document.body.removeChild(ta); + } + showToast("Lien copié !", "success"); + div.remove(); + }); + div.querySelector(".share-revoke-btn").addEventListener("click", async () => { + try { + await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); + showToast("Partage révoqué", "success"); + existingShare = null; + renderContent(); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + } else { + div.innerHTML = ` + `; + div.querySelector(".share-create-btn").addEventListener("click", async () => { + try { + const expiry = document.getElementById("share-expiry")?.value; + const share = await api(`/api/share/${encodeURIComponent(vault)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), + }); + existingShare = share; + renderContent(); + showToast("Lien créé !", "success"); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + } + div.querySelector(".share-close-btn").addEventListener("click", () => div.remove()); + div.addEventListener("click", (e) => { if (e.target === div) div.remove(); }); + }; + + renderContent(); + document.body.appendChild(div); +} + +function renderConfigFilters() { + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + const container = document.getElementById("config-filters-list"); + + container.innerHTML = ""; + + filters.forEach((filter, index) => { + const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [ + el("span", {}, [document.createTextNode(filter.pattern)]), + el( + "button", + { + class: "config-filter-toggle", + title: filter.enabled ? "Désactiver" : "Activer", + type: "button", + }, + [document.createTextNode(filter.enabled ? "✓" : "○")], + ), + el( + "button", + { + class: "config-filter-remove", + title: "Supprimer", + type: "button", + }, + [document.createTextNode("×")], + ), + ]); + + const toggleBtn = badge.querySelector(".config-filter-toggle"); + const removeBtn = badge.querySelector(".config-filter-remove"); + + toggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + toggleConfigFilter(index); + }); + + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeConfigFilter(index); + }); + + container.appendChild(badge); + }); +} + +function toggleConfigFilter(index) { + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + if (filters[index]) { + filters[index].enabled = !filters[index].enabled; + config.tagFilters = filters; + TagFilterService.saveConfig(config); + renderConfigFilters(); + refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + } +} + +function removeConfigFilter(index) { + const config = TagFilterService.getConfig(); + let filters = config.tagFilters || TagFilterService.defaultFilters; + filters = filters.filter((_, i) => i !== index); + config.tagFilters = filters; + TagFilterService.saveConfig(config); + renderConfigFilters(); + refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); +} + +function addConfigFilter() { + const input = document.getElementById("config-pattern-input"); + const pattern = input.value.trim(); + + if (!pattern) return; + + const regex = TagFilterService.patternToRegex(pattern); + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + + const newFilter = { pattern, regex, enabled: true }; + filters.push(newFilter); + config.tagFilters = filters; + TagFilterService.saveConfig(config); + + input.value = ""; + renderConfigFilters(); + refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + updateRegexPreview(); +} + +function updateRegexPreview() { + const input = document.getElementById("config-pattern-input"); + const preview = document.getElementById("config-regex-preview"); + const code = document.getElementById("config-regex-code"); + const pattern = input.value.trim(); + + if (pattern) { + const regex = TagFilterService.patternToRegex(pattern); + code.textContent = `^${regex}$`; + preview.style.display = "block"; + } else { + preview.style.display = "none"; + } +} + + +export { + initSidebarTabs, + initConfigModal, + initConfigModal as initConfigPanel, + initHelpModal, + closeHelpModal, + initRecentTab, + loadAbout as initAboutSection, + loadHiddenFilesSettings as initHiddenFilesConfig, +}; diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js new file mode 100644 index 0000000..e2b07b7 --- /dev/null +++ b/frontend/js/dashboard.js @@ -0,0 +1,461 @@ +// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870) +import { selectedContextVault, allVaults } from './state.js'; + +// --------------------------------------------------------------------------- +// Recent files +// --------------------------------------------------------------------------- +let _recentRefreshTimer = null; +let _recentTimestampTimer = null; +let _recentFilesCache = []; + +// --------------------------------------------------------------------------- +// Dashboard Recent Files Widget +// --------------------------------------------------------------------------- +// ── Dashboard Stats Widget ── +const DashboardStatsWidget = { + async load() { + const grid = document.getElementById("dashboard-stats-grid"); + if (!grid) return; + grid.innerHTML = '
Chargement...
'; + try { + const data = await api("/api/dashboard"); + this.render(data); + } catch (err) { + grid.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; + } + }, + render(data) { + const grid = document.getElementById("dashboard-stats-grid"); + if (!grid) return; + const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`; + const items = [ + { icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() }, + { icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() }, + { icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) }, + { icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() }, + ]; + grid.innerHTML = items.map(i => ` +
+ + ${i.value} + ${i.label} +
+ `).join(""); + safeCreateIcons(); + } +}; + +// ── Dashboard Shared Widget ── +const DashboardSharedWidget = { + async load() { + const grid = document.getElementById("dashboard-shared-grid"); + const empty = document.getElementById("dashboard-shared-empty"); + if (!grid) return; + try { + const shares = await api("/api/shares"); + if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; } + if (empty) empty.style.display = "none"; + grid.innerHTML = shares.map(s => ` +
+
+ + ${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))} + ${escapeHtml(s.vault)} +
+
+ ${s.access_count || 0} vue(s) + ${s.expires_at ? `Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}` : ""} +
+
+ + + +
+
+ `).join(""); + lucide.createIcons(); + grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => { + e.stopPropagation(); + const url = b.dataset.url; + try { await navigator.clipboard.writeText(url); } catch { const ta = document.createElement("textarea"); ta.value=url; ta.style.position="fixed"; ta.style.left="-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } + showToast("Lien copié !", "success"); + })); + grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => { + e.stopPropagation(); + const card = b.closest(".shared-card"); + if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path); + })); + grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => { + e.stopPropagation(); + await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); + showToast("Partage révoqué", "success"); + this.load(); + })); + grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => { + TabManager.openPreview(card.dataset.vault, card.dataset.path); + })); + } catch (err) { if (empty) empty.style.display = ""; } + } +}; + +// ── Dashboard Conflicts Widget ── +const DashboardConflictsWidget = { + async load() { + const container = document.getElementById("dashboard-conflicts-container"); + if (!container) return; + try { + const data = await api("/api/conflicts"); + if (data.total === 0) { container.innerHTML = ""; return; } + this.render(data.conflicts, container); + } catch (err) { container.innerHTML = ""; } + }, + render(conflicts, container) { + container.innerHTML = ` +
+
+
+ +

Conflits de synchronisation

+ ${conflicts.length} +
+
+
+ ${conflicts.map(c => ` +
+
+ ${escapeHtml(c.vault)} + ${escapeHtml(c.conflict_path.split("/").pop())} + Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")} +
+
+ + +
+
+ `).join("")} +
+
`; + lucide.createIcons(); + container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local"))); + container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict"))); + }, + async _resolve(d, action) { + try { + await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) }); + showToast("Conflit résolu", "success"); + this.load(); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + } +}; + +const DashboardRecentWidget = { + _cache: [], + _currentFilter: "", + + async load(vaultFilter = "") { + const v = vaultFilter || selectedContextVault || "all"; + this._currentFilter = v; + this.showLoading(); + + let url = "/api/recent?mode=opened"; + if (v !== "all") url += `&vault=${encodeURIComponent(v)}`; + + try { + const data = await api(url); + this._cache = data.files || []; + this.render(); + } catch (err) { + console.error("Dashboard: Failed to load recent files:", err); + this.showError(); + } + }, + + async toggleBookmark(vault, path, title, card) { + try { + const data = await api("/api/bookmarks/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vault, path, title }), + }); + + // Refresh both widgets to keep sync + DashboardBookmarkWidget.load(); + + // Update current card icon if it exists + if (card) { + const btn = card.querySelector(".dashboard-card-bookmark-btn"); + if (btn) { + btn.classList.toggle("active", data.bookmarked); + const icon = btn.querySelector("i"); + if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus"); + safeCreateIcons(); + } + } + + // Check if we need to refresh the current list to reflect bookmark status across all cards + // To avoid flickering, just update the cache and re-render if needed or do a silent refresh + this._cache.forEach(f => { + if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked; + }); + } catch (err) { + console.error("Failed to toggle bookmark:", err); + showToast("Erreur lors de l'épinglage", "error"); + } + }, + + showLoading() { + const grid = document.getElementById("dashboard-recent-grid"); + const loading = document.getElementById("dashboard-loading"); + const empty = document.getElementById("dashboard-recent-empty"); + const count = document.getElementById("dashboard-count"); + + if (grid) grid.innerHTML = ""; + if (loading) loading.classList.add("active"); + if (empty) empty.classList.add("hidden"); + if (count) count.textContent = ""; + }, + + render() { + const grid = document.getElementById("dashboard-recent-grid"); + const loading = document.getElementById("dashboard-loading"); + const empty = document.getElementById("dashboard-recent-empty"); + const count = document.getElementById("dashboard-count"); + + if (loading) loading.classList.remove("active"); + + if (!this._cache || this._cache.length === 0) { + this.showEmpty(); + return; + } + + if (empty) empty.classList.add("hidden"); + if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`; + + if (!grid) return; + grid.innerHTML = ""; + + this._cache.forEach((f, index) => { + const card = this._createCard(f, index); + grid.appendChild(card); + }); + + safeCreateIcons(); + }, + + _createCard(file, index) { + const card = document.createElement("div"); + card.className = "dashboard-card"; + card.setAttribute("data-vault", file.vault); + card.setAttribute("data-path", file.path); + card.style.animationDelay = `${Math.min(index * 50, 400)}ms`; + + // Header with icon and vault badge + const header = document.createElement("div"); + header.className = "dashboard-card-header"; + + const iconContainer = document.createElement("div"); + iconContainer.className = "dashboard-card-icon"; + const fileIconName = getFileIcon(file.path); + try { + iconContainer.appendChild(icon(fileIconName, 24)); + } catch (e) { + console.error("Error creating icon:", fileIconName, e); + // Fallback to default file icon + iconContainer.appendChild(icon("file", 24)); + } + + const badge = document.createElement("span"); + badge.className = "dashboard-vault-badge"; + badge.textContent = file.vault; + + const bookmarkBtn = document.createElement("button"); + bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`; + bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; + bookmarkBtn.innerHTML = ``; + + bookmarkBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleBookmark(file.vault, file.path, file.title, card); + }); + + header.appendChild(iconContainer); + header.appendChild(badge); + header.appendChild(bookmarkBtn); + card.appendChild(header); + + // Title + const title = document.createElement("h3"); + title.className = "dashboard-card-title"; + title.textContent = file.title || file.path.split("/").pop(); + title.title = file.title || file.path; + card.appendChild(title); + + // Path (compact) + const pathParts = file.path.split("/"); + if (pathParts.length > 1) { + const path = document.createElement("div"); + path.className = "dashboard-card-path"; + path.textContent = pathParts.slice(0, -1).join(" / "); + path.title = file.path; + card.appendChild(path); + } + + // Footer with time and tags + const footer = document.createElement("div"); + footer.className = "dashboard-card-footer"; + + const time = document.createElement("span"); + time.className = "dashboard-card-time"; + time.innerHTML = ` ${file.mtime_human || this._humanizeDelta(file.mtime)}`; + + footer.appendChild(time); + + // Tags + if (file.tags && file.tags.length > 0) { + const tags = document.createElement("div"); + tags.className = "dashboard-card-tags"; + file.tags.slice(0, 3).forEach((tag) => { + const tagEl = document.createElement("span"); + tagEl.className = "tag-pill"; + tagEl.textContent = tag; + tags.appendChild(tagEl); + }); + footer.appendChild(tags); + } + + card.appendChild(footer); + + // Click handler + card.addEventListener("click", () => { + openFile(file.vault, file.path); + }); + + return card; + }, + + showEmpty() { + const grid = document.getElementById("dashboard-recent-grid"); + const loading = document.getElementById("dashboard-loading"); + const empty = document.getElementById("dashboard-recent-empty"); + const count = document.getElementById("dashboard-count"); + + if (grid) grid.innerHTML = ""; + if (loading) loading.classList.remove("active"); + if (empty) empty.classList.remove("hidden"); + if (count) count.textContent = "0 fichiers"; + safeCreateIcons(); + }, + + showError() { + this.showEmpty(); + const empty = document.getElementById("dashboard-recent-empty"); + if (empty) { + const msg = empty.querySelector("span"); + if (msg) msg.textContent = "Erreur de chargement"; + } + }, + + _humanizeDelta(mtime) { + const delta = Date.now() / 1000 - mtime; + if (delta < 60) return "à l'instant"; + if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`; + if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`; + if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`; + return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }); + }, + + populateVaultFilter() { + const select = document.getElementById("dashboard-vault-filter"); + if (!select) return; + + // Keep first option "Tous les vaults" + while (select.options.length > 1) select.remove(1); + + if (typeof allVaults !== "undefined" && Array.isArray(allVaults)) { + allVaults.forEach((v) => { + const opt = document.createElement("option"); + opt.value = v.name; + opt.textContent = v.name; + select.appendChild(opt); + }); + } + syncVaultSelectors(); + }, + + init() { + const select = document.getElementById("dashboard-vault-filter"); + if (select) { + select.addEventListener("change", async () => { + await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" }); + }); + } + + this.populateVaultFilter(); + }, +}; + +// ── Dashboard Bookmarks Widget ── +// (moved from app.js 3810-3870; logically belongs with dashboard widgets) +const DashboardBookmarkWidget = { + _cache: [], + _currentFilter: "", + + async load(vaultFilter = "") { + const v = vaultFilter || selectedContextVault || "all"; + this._currentFilter = v; + this.showLoading(); + + let url = "/api/bookmarks"; + if (v !== "all") url += `?vault=${encodeURIComponent(v)}`; + + try { + const data = await api(url); + this._cache = data.files || []; + this.render(); + } catch (err) { + console.error("Dashboard: Failed to load bookmarks:", err); + this.showEmpty(); + } + }, + + showLoading() { + const grid = document.getElementById("dashboard-bookmarks-grid"); + const empty = document.getElementById("dashboard-bookmarks-empty"); + const section = document.getElementById("dashboard-bookmarks-section"); + + if (grid) grid.innerHTML = ""; + if (empty) empty.classList.add("hidden"); + }, + + render() { + const grid = document.getElementById("dashboard-bookmarks-grid"); + const empty = document.getElementById("dashboard-bookmarks-empty"); + const section = document.getElementById("dashboard-bookmarks-section"); + + if (!this._cache || this._cache.length === 0) { + if (grid) grid.innerHTML = ""; + if (empty) empty.classList.remove("hidden"); + return; + } + + if (empty) empty.classList.add("hidden"); + if (!grid) return; + grid.innerHTML = ""; + + this._cache.forEach((f, idx) => { + const card = DashboardRecentWidget._createCard(f, idx); + grid.appendChild(card); + }); + + safeCreateIcons(); + }, + + showEmpty() { + const grid = document.getElementById("dashboard-bookmarks-grid"); + const empty = document.getElementById("dashboard-bookmarks-empty"); + if (grid) grid.innerHTML = ""; + if (empty) empty.classList.remove("hidden"); + } +}; + +export { DashboardRecentWidget, DashboardStatsWidget, DashboardBookmarkWidget }; diff --git a/frontend/js/graph.js b/frontend/js/graph.js new file mode 100644 index 0000000..70d47c1 --- /dev/null +++ b/frontend/js/graph.js @@ -0,0 +1,401 @@ +/* ObsiGate — Graph View: interactive file/folder relationship visualization */ +import { api } from './auth.js'; +import { safeCreateIcons } from './utils.js'; +import { openFile } from './viewer.js'; + +// --------------------------------------------------------------------------- +// Graph View Manager — Interactive file/folder relationship visualization +// --------------------------------------------------------------------------- + +export const GraphViewManager = { + _canvas: null, + _ctx: null, + _nodes: [], + _edges: [], + _offsetX: 0, + _offsetY: 0, + _zoom: 1, + _dragging: false, + _dragNode: null, + _panning: false, + _panStartX: 0, + _panStartY: 0, + _animFrame: null, + _vault: null, + _path: null, + _nodePositions: {}, + _width: 0, + _height: 0, + + async open(vault, path, type) { + this._vault = vault; + this._path = path; + + const modal = document.getElementById("graph-modal"); + const title = document.getElementById("graph-title"); + const info = document.getElementById("graph-info"); + const canvas = document.getElementById("graph-canvas"); + + if (!modal || !canvas) return; + + title.textContent = `Vue Graphique — ${vault}${path ? "/" + path : ""}`; + info.textContent = "Chargement..."; + modal.classList.add("active"); + + this._canvas = canvas; + this._ctx = canvas.getContext("2d"); + this._resetView(); + + // Fetch graph data + try { + const data = await api(`/api/graph/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}&depth=1`); + this._nodes = data.nodes || []; + this._edges = data.edges || []; + info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens`; + this._initLayout(); + this._startRender(); + } catch (err) { + info.textContent = "Erreur de chargement"; + console.error("Graph error:", err); + } + + safeCreateIcons(); + }, + + close() { + const modal = document.getElementById("graph-modal"); + if (modal) modal.classList.remove("active"); + if (this._animFrame) { + cancelAnimationFrame(this._animFrame); + this._animFrame = null; + } + }, + + _resetView() { + this._offsetX = 0; + this._offsetY = 0; + this._zoom = 1; + this._nodePositions = {}; + }, + + _initLayout() { + const w = this._canvas.parentElement.clientWidth; + const h = this._canvas.parentElement.clientHeight; + this._canvas.width = w * devicePixelRatio; + this._canvas.height = h * devicePixelRatio; + this._canvas.style.width = w + "px"; + this._canvas.style.height = h + "px"; + this._width = w; + this._height = h; + this._ctx.scale(devicePixelRatio, devicePixelRatio); + + // Position nodes in a circle initially + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) * 0.35; + + this._nodes.forEach((node, i) => { + const angle = (2 * Math.PI * i) / this._nodes.length; + this._nodePositions[node.id] = { + x: cx + radius * Math.cos(angle), + y: cy + radius * Math.sin(angle), + vx: 0, + vy: 0, + }; + }); + }, + + _startRender() { + const self = this; + let lastTime = 0; + + const loop = (time) => { + const dt = Math.min((time - lastTime) / 1000, 0.1); + lastTime = time; + self._simulate(dt); + self._draw(); + self._animFrame = requestAnimationFrame(loop); + }; + + this._animFrame = requestAnimationFrame(loop); + }, + + _simulate(dt) { + if (this._dragging || this._dragNode) return; + + const positions = this._nodePositions; + const cx = this._width / 2; + const cy = this._height / 2; + + // Spring forces (edges) + for (const edge of this._edges) { + const a = positions[edge.source]; + const b = positions[edge.target]; + if (!a || !b) continue; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const targetLen = 80; + const force = (dist - targetLen) * 0.01; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + + a.vx += fx; + a.vy += fy; + b.vx -= fx; + b.vy -= fy; + } + + // Repulsion between all nodes + for (const n1 of this._nodes) { + for (const n2 of this._nodes) { + if (n1.id === n2.id) continue; + const a = positions[n1.id]; + const b = positions[n2.id]; + if (!a || !b) continue; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = 2000 / (dist * dist); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + + a.vx -= fx; + a.vy -= fy; + } + } + + // Center gravity + for (const node of this._nodes) { + const p = positions[node.id]; + if (!p) continue; + p.vx += (cx - p.x) * 0.001; + p.vy += (cy - p.y) * 0.001; + } + + // Apply velocities with damping + for (const node of this._nodes) { + const p = positions[node.id]; + if (!p) continue; + p.vx *= 0.9; + p.vy *= 0.9; + p.x += p.vx * dt * 60; + p.y += p.vy * dt * 60; + } + }, + + _draw() { + const ctx = this._ctx; + const w = this._width; + const h = this._height; + + ctx.save(); + ctx.clearRect(0, 0, w, h); + + // Apply transform (pan + zoom) + ctx.translate(this._offsetX, this._offsetY); + ctx.scale(this._zoom, this._zoom); + + // Draw edges + for (const edge of this._edges) { + const a = this._nodePositions[edge.source]; + const b = this._nodePositions[edge.target]; + if (!a || !b) continue; + + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.strokeStyle = edge.relation === "wikilink" ? "var(--accent-color, #2563eb)" : "var(--text-muted, #888)"; + ctx.lineWidth = edge.relation === "wikilink" ? 2 : 1; + ctx.setLineDash(edge.relation === "wikilink" ? [4, 4] : []); + ctx.stroke(); + } + ctx.setLineDash([]); + + // Draw nodes + for (const node of this._nodes) { + const p = this._nodePositions[node.id]; + if (!p) continue; + + const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); + + // Node circle + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + + switch (node.type) { + case "directory": + ctx.fillStyle = "#5b9bd5"; + break; + case "file": + ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0"; + break; + default: + ctx.fillStyle = "#ffc000"; + break; + } + ctx.fill(); + ctx.strokeStyle = "var(--bg-primary, #1e1e1e)"; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Node label + const label = node.name.length > 20 ? node.name.slice(0, 18) + "..." : node.name; + ctx.font = `${11 / this._zoom}px -apple-system, sans-serif`; + ctx.fillStyle = "var(--text-primary, #ddd)"; + ctx.textAlign = "center"; + ctx.fillText(label, p.x, p.y + r + 12 / this._zoom); + } + + ctx.restore(); + }, + + _getNodeAt(screenX, screenY) { + const x = (screenX - this._offsetX) / this._zoom; + const y = (screenY - this._offsetY) / this._zoom; + + for (const node of this._nodes) { + const p = this._nodePositions[node.id]; + if (!p) continue; + const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); + const dx = x - p.x; + const dy = y - p.y; + if (dx * dx + dy * dy <= r * r + 100) { + return { node, pos: p }; + } + } + return null; + }, + + _onMouseDown(e) { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const hit = this._getNodeAt(mx, my); + if (hit) { + this._dragging = true; + this._dragNode = hit; + this._canvas.style.cursor = "grabbing"; + } else { + this._panning = true; + this._panStartX = e.clientX - this._offsetX; + this._panStartY = e.clientY - this._offsetY; + this._canvas.style.cursor = "grabbing"; + } + }, + + _onMouseMove(e) { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + if (this._dragging && this._dragNode) { + this._dragNode.pos.x = (mx - this._offsetX) / this._zoom; + this._dragNode.pos.y = (my - this._offsetY) / this._zoom; + this._dragNode.pos.vx = 0; + this._dragNode.pos.vy = 0; + } else if (this._panning) { + this._offsetX = e.clientX - this._panStartX; + this._offsetY = e.clientY - this._panStartY; + } else { + const hit = this._getNodeAt(mx, my); + this._canvas.style.cursor = hit ? "pointer" : "grab"; + this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : ""; + } + }, + + _onMouseUp(e) { + if (this._dragging && this._dragNode) { + // Check if it was a click (not a drag) + const rect = this._canvas.getBoundingClientRect(); + const node = this._dragNode.node; + this._dragging = false; + this._dragNode = null; + this._canvas.style.cursor = "grab"; + + // If it's a file, open it on click + if (node.type === "file") { + this.close(); + openFile(this._vault, node.path); + } else if (node.type === "directory" || node.type === "vault") { + // Expand into this directory + this.close(); + this.open(this._vault, node.path, node.type); + } + } + this._panning = false; + }, + + _onWheel(e) { + e.preventDefault(); + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor)); + + this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom); + this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom); + this._zoom = newZoom; + }, + + _onResize() { + if (!this._canvas || !this._nodes.length) return; + const w = this._canvas.parentElement.clientWidth; + const h = this._canvas.parentElement.clientHeight; + this._canvas.width = w * devicePixelRatio; + this._canvas.height = h * devicePixelRatio; + this._canvas.style.width = w + "px"; + this._canvas.style.height = h + "px"; + this._width = w; + this._height = h; + this._ctx.setTransform(1, 0, 0, 1, 0, 0); + this._ctx.scale(devicePixelRatio, devicePixelRatio); + }, +}; + +// --------------------------------------------------------------------------- +// Init graph view event listeners after DOM ready +// --------------------------------------------------------------------------- +export function initGraphView() { + const closeBtn = document.getElementById("graph-close"); + const zoomIn = document.getElementById("graph-zoom-in"); + const zoomOut = document.getElementById("graph-zoom-out"); + const reset = document.getElementById("graph-reset"); + const modal = document.getElementById("graph-modal"); + const canvas = document.getElementById("graph-canvas"); + + if (closeBtn) closeBtn.addEventListener("click", () => GraphViewManager.close()); + if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) GraphViewManager.close(); }); + if (zoomIn) zoomIn.addEventListener("click", () => { GraphViewManager._zoom = Math.min(3, GraphViewManager._zoom * 1.2); }); + if (zoomOut) zoomOut.addEventListener("click", () => { GraphViewManager._zoom = Math.max(0.2, GraphViewManager._zoom * 0.8); }); + if (reset) reset.addEventListener("click", () => { + GraphViewManager._offsetX = 0; + GraphViewManager._offsetY = 0; + GraphViewManager._zoom = 1; + }); + + if (canvas) { + canvas.addEventListener("mousedown", (e) => GraphViewManager._onMouseDown(e)); + canvas.addEventListener("mousemove", (e) => GraphViewManager._onMouseMove(e)); + canvas.addEventListener("mouseup", (e) => GraphViewManager._onMouseUp(e)); + canvas.addEventListener("mouseleave", () => { + GraphViewManager._dragging = false; + GraphViewManager._dragNode = null; + GraphViewManager._panning = false; + canvas.style.cursor = "grab"; + }); + canvas.addEventListener("wheel", (e) => GraphViewManager._onWheel(e), { passive: false }); + window.addEventListener("resize", () => GraphViewManager._onResize()); + } + + document.addEventListener("keydown", (e) => { + if (modal && modal.classList.contains("active") && e.key === "Escape") { + GraphViewManager.close(); + } + }); +} diff --git a/frontend/js/legacy.js b/frontend/js/legacy.js new file mode 100644 index 0000000..bef31ae --- /dev/null +++ b/frontend/js/legacy.js @@ -0,0 +1,560 @@ +/* ObsiGate — Legacy module: remaining functions for the orchestrator + Extracted from the monolithic frontend/app.js IIFE. + Functions already in other modules are re-exported. */ + +// --- State imports --- +import { + currentVault, + currentPath, + showingSource, + cachedRawSource, + searchTimeout, + searchCaseSensitive, + searchWholeWord, + searchRegex, + searchFilterVisible, + advancedSearchOffset, + selectedTags, + selectedContextVault, + vaultSettings, + allVaults, + MIN_SEARCH_LENGTH, +} from './state.js'; + +// --- Search imports --- +import { + AutocompleteDropdown, + SearchChips, + SearchHistory, + performAdvancedSearch, +} from './search.js'; + +// --- Dashboard imports --- +import { + DashboardRecentWidget, + DashboardStatsWidget, + DashboardBookmarkWidget, + DashboardSharedWidget, +} from './dashboard.js'; + +// --- Re-exports from modules that already have these functions --- +export { initSidebarTabs, initConfigModal, initHelpModal, initRecentTab } from './config.js'; +export { initSyncStatus } from './sync.js'; +export { DashboardRecentWidget } from './dashboard.js'; + +// ========================================================================= +// Progress bar helpers (used by showWelcome) +// ========================================================================= + +function showProgressBar() { + const bar = document.getElementById("search-progress-bar"); + if (bar) bar.classList.add("active"); +} + +function hideProgressBar() { + const bar = document.getElementById("search-progress-bar"); + if (bar) bar.classList.remove("active"); +} + +// ========================================================================= +// loadVaultSettings +// ========================================================================= + +async function loadVaultSettings() { + try { + const settings = await api("/api/vaults/settings/all"); + vaultSettings = settings; + } catch (err) { + console.error("Failed to load vault settings:", err); + vaultSettings = {}; + } +} + +// ========================================================================= +// Config helpers (needed by initSearch) +// ========================================================================= + +const _FRONTEND_CONFIG_KEY = "obsigate-perf-config"; + +function _getFrontendConfig() { + try { + return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}"); + } catch { + return {}; + } +} + +function _getEffective(key, fallback) { + const cfg = _getFrontendConfig(); + return cfg[key] !== undefined ? cfg[key] : fallback; +} + +/** Check if user is focused on an input/textarea/contenteditable */ +function _isInputFocused() { + const tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return document.activeElement?.isContentEditable === true; +} + +// ========================================================================= +// initSearch +// ========================================================================= + +function initSearch() { + const input = document.getElementById("search-input"); + if (!input) return; + const caseBtn = document.getElementById("search-case-btn"); + const wordBtn = document.getElementById("search-word-btn"); + const regexBtn = document.getElementById("search-regex-btn"); + const filterBtn = document.getElementById("search-filter-btn"); + const clearBtn = document.getElementById("search-clear-btn"); + const filterRow = document.getElementById("search-filter-row"); + const prevBtn = document.getElementById("search-prev-btn"); + const nextBtn = document.getElementById("search-next-btn"); + const countEl = document.getElementById("search-match-count"); + + function _updateToggleUI() { + caseBtn.classList.toggle("active", searchCaseSensitive); + wordBtn.classList.toggle("active", searchWholeWord); + regexBtn.classList.toggle("active", searchRegex); + filterBtn.classList.toggle("active", searchFilterVisible); + } + + // Toggle buttons + caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; _updateToggleUI(); _research(); }); + if (wordBtn) wordBtn.addEventListener("click", () => { searchWholeWord = !searchWholeWord; _updateToggleUI(); _research(); }); + if (regexBtn) regexBtn.addEventListener("click", () => { searchRegex = !searchRegex; _updateToggleUI(); _research(); }); + if (filterBtn) filterBtn.addEventListener("click", () => { searchFilterVisible = !searchFilterVisible; if (filterRow) filterRow.style.display = searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); + + // Result navigation (up/down arrows + Enter) + let _searchResultIdx = -1; + let _searchResultItems = []; + + function _updateResultHighlight() { + _searchResultItems.forEach((el, i) => { + el.classList.toggle("search-result-active", i === _searchResultIdx); + }); + if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { + _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + const countEl = document.getElementById("search-match-count"); + if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; + } + + function _refreshResultItems() { + _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; + _updateResultHighlight(); + } + + window.navigateSearchResults = function(delta) { + _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + if (_searchResultItems.length === 0) return; + _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); + _updateResultHighlight(); + }; + + if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); + if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); + + function _research() { + const q = input.value.trim(); + if (q.length >= _getEffective("min_query_length", 2)) { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + const vault = document.getElementById("vault-filter").value; + const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; + advancedSearchOffset = 0; + performAdvancedSearch(q, vault, tagFilter); + }, _getEffective("debounce_ms", 300)); + } + } + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } + else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } + else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } + else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } + } + }); + + // Initialize sub-controllers + AutocompleteDropdown.init(); + SearchChips.init(); + + // Initially hide clear button + if (clearBtn) clearBtn.style.display = "none"; + + // Input handler: debounced search + autocomplete dropdown + input.addEventListener("input", () => { + const hasText = input.value.length > 0; + clearBtn.style.display = hasText ? "flex" : "none"; + + // Show autocomplete dropdown while typing + AutocompleteDropdown.populate(input.value, input.selectionStart); + + // Debounced search execution + clearTimeout(searchTimeout); + searchTimeout = setTimeout( + () => { + const q = input.value.trim(); + const vault = document.getElementById("vault-filter").value; + const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; + advancedSearchOffset = 0; + if (q.length >= _getEffective("min_query_length", MIN_SEARCH_LENGTH) || tagFilter) { + performAdvancedSearch(q, vault, tagFilter); + } else if (q.length === 0) { + SearchChips.clear(); + showWelcome(); + } + }, + _getEffective("debounce_ms", 300), + ); + }); + + // Focus handler: show history dropdown + input.addEventListener("focus", () => { + if (input.value.length === 0) { + const historyItems = SearchHistory.filter("").slice(0, 5); + if (historyItems.length > 0) { + AutocompleteDropdown.populate("", 0); + } + } + }); + + // Keyboard navigation in dropdown + input.addEventListener("keydown", (e) => { + if (AutocompleteDropdown.isVisible()) { + if (e.key === "ArrowDown") { + e.preventDefault(); + AutocompleteDropdown.navigateDown(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + AutocompleteDropdown.navigateUp(); + } else if (e.key === "Enter") { + // First: check dropdown suggestions (higher priority than search results) + if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + e.preventDefault(); + return; + } + // Second: navigate search results if visible + const results = document.querySelectorAll(".search-result-item"); + if (results.length > 0 && _searchResultIdx >= 0) { + const el = results[_searchResultIdx]; + if (el) { + const vault = el.dataset.vault; + const path = el.dataset.path; + if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } + } + } + // Third: execute search + AutocompleteDropdown.hide(); + const q = input.value.trim(); + if (q) { + SearchHistory.add(q); + clearTimeout(searchTimeout); + advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; + performAdvancedSearch(q, vault, tagFilter); + } + e.preventDefault(); + } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { + // Navigate search results when dropdown is closed + if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } + } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { + if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } + } else if (e.key === "Escape") { + AutocompleteDropdown.hide(); + e.stopPropagation(); + } + } else if (e.key === "Enter") { + if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + e.preventDefault(); + return; + } + const q = input.value.trim(); + if (q) { + SearchHistory.add(q); + clearTimeout(searchTimeout); + advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; + performAdvancedSearch(q, vault, tagFilter); + } + e.preventDefault(); + } + }); + + clearBtn.addEventListener("click", () => { + input.value = ""; + if (clearBtn) clearBtn.style.display = "none"; + searchCaseSensitive = false; + searchWholeWord = false; + searchRegex = false; + _updateToggleUI(); + SearchChips.clear(); + AutocompleteDropdown.hide(); + showWelcome(); + }); + + // Global keyboard shortcuts + document.addEventListener("keydown", (e) => { + // Ctrl+K or Cmd+K: focus search + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + input.focus(); + input.select(); + } + // "/" key: focus search (when not in an input/textarea) + if (e.key === "/" && !_isInputFocused()) { + e.preventDefault(); + input.focus(); + } + // Escape: blur search input and close dropdown + if (e.key === "Escape" && document.activeElement === input) { + AutocompleteDropdown.hide(); + input.blur(); + } + }); +} + +// ========================================================================= +// showWelcome +// ========================================================================= + +function showWelcome() { + hideProgressBar(); + + // Restore or rebuild the dashboard with tabbed sections + const area = document.getElementById("content-area"); + const home = document.getElementById("dashboard-home"); + + if (area && !home) { + area.innerHTML = ` +
+ +
+ + + + +
+ + +
+
+
Chargement...
+
+
+
+ + +
+
+
+ + Aucun bookmark +

Épinglez des fichiers pour les retrouver ici.

+
+
+ + +
+
+
+ +
+
+
+
+
+
+
+ +
+ + +
+
+
+ + Aucun document partagé +

Partagez un document pour le voir apparaître ici

+
+
+
`; + + // Re-initialize widgets and dashboard tabs + DashboardRecentWidget.init(); + initDashboardTabs(); + safeCreateIcons(); + } else if (home) { + // Dashboard already exists, show it with default tab + home.style.display = ""; + // Reset tabs to default + document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); + const defaultPanel = document.getElementById("dashboard-panel-stats"); + if (defaultTab) defaultTab.classList.add("active"); + if (defaultPanel) defaultPanel.classList.add("active"); + } + + // Load all widgets (they handle missing elements gracefully) + if (typeof DashboardStatsWidget !== "undefined") { + DashboardStatsWidget.load(); + } + if (typeof DashboardConflictsWidget !== "undefined") { + DashboardConflictsWidget.load(); + } + DashboardRecentWidget.load(selectedContextVault); + if (typeof DashboardBookmarkWidget !== "undefined") { + DashboardBookmarkWidget.load(selectedContextVault); + } + if (typeof DashboardSharedWidget !== "undefined") { + DashboardSharedWidget.load(); + } + + // Load saved searches sidebar + loadSavedSearches(); +} + +// ========================================================================= +// goHome +// ========================================================================= + +function goHome() { + const searchInput = document.getElementById("search-input"); + if (searchInput) searchInput.value = ""; + + document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + + currentVault = null; + currentPath = null; + showingSource = false; + cachedRawSource = null; + + closeMobileSidebar(); + showWelcome(); +} + +// ========================================================================= +// loadSavedSearches (needed by showWelcome) +// ========================================================================= + +async function loadSavedSearches() { + const list = document.getElementById("saved-searches-list"); + const empty = document.getElementById("saved-searches-empty"); + if (!list) return; + try { + const searches = await api("/api/saved-searches"); + if (!searches.length) { + list.innerHTML = ""; + if (empty) empty.style.display = ""; + return; + } + if (empty) empty.style.display = "none"; + list.innerHTML = searches.map(s => { + const badges = []; + if (s.case_sensitive) badges.push('Aa'); + if (s.whole_word) badges.push('wd'); + if (s.regex) badges.push('.*'); + const pathFilters = []; + if (s.include_paths) pathFilters.push(`\u{1F4E5} ${escapeHtml(s.include_paths)}`); + if (s.exclude_paths) pathFilters.push(`\u{1F4E4} ${escapeHtml(s.exclude_paths)}`); + const vaultStr = s.vault && s.vault !== "all" ? `\u{1F4C1} ${escapeHtml(s.vault)}` : ""; + return ` +
+
${escapeHtml(s.query)}
+
+ ${badges.join("")} + ${vaultStr} +
+ ${pathFilters.length ? '
' + pathFilters.join(" ") + '
' : ""} + +
+ `}).join(""); + list.querySelectorAll(".saved-search-item").forEach(item => { + item.addEventListener("click", (e) => { + if (e.target.classList.contains("saved-search-delete")) return; + const idx = Array.from(list.children).indexOf(item); + const s = searches[idx]; + if (!s) return; + // Apply the saved search + const input = document.getElementById("search-input"); + if (input) input.value = s.query; + searchCaseSensitive = s.case_sensitive || false; + searchWholeWord = s.whole_word || false; + searchRegex = s.regex || false; + if (typeof _updateToggleUI === "function") _updateToggleUI(); + if (s.include_paths) { + const incl = document.getElementById("search-include-input"); + if (incl) incl.value = s.include_paths; + } + if (s.exclude_paths) { + const excl = document.getElementById("search-exclude-input"); + if (excl) excl.value = s.exclude_paths; + } + // Execute the search + AutocompleteDropdown.hide(); + AutocompleteDropdown._suppressNext = true; + const vault = s.vault || "all"; + if (input) { input.dispatchEvent(new Event("input")); } + clearTimeout(searchTimeout); + advancedSearchOffset = 0; + performAdvancedSearch(s.query, vault, null); + }); + }); + list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { + e.stopPropagation(); + await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); + loadSavedSearches(); + })); + safeCreateIcons(); + } catch (err) { /* silently ignore */ } +} + +// ========================================================================= +// initDashboardTabs (needed by showWelcome) +// ========================================================================= + +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"); + }); + }); +} + +// ========================================================================= +// Exports +// ========================================================================= + +export { + loadVaultSettings, + initSearch, + showWelcome, + goHome, +}; diff --git a/frontend/js/search.js b/frontend/js/search.js new file mode 100644 index 0000000..048b920 --- /dev/null +++ b/frontend/js/search.js @@ -0,0 +1,1106 @@ +/* ObsiGate — Search module (extracted from app.js) */ + +import * as State from './state.js'; +import { safeCreateIcons } from './utils.js'; + +// Re-export constants used internally +const SEARCH_HISTORY_KEY = State.SEARCH_HISTORY_KEY; +const MAX_HISTORY_ENTRIES = State.MAX_HISTORY_ENTRIES; + +// --------------------------------------------------------------------------- +// Search History Service (localStorage, LIFO, max 50, dedup) +// --------------------------------------------------------------------------- +export const SearchHistory = { + _load() { + try { + const raw = localStorage.getItem(SEARCH_HISTORY_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } + }, + _save(entries) { + try { + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(entries)); + } catch {} + }, + getAll() { + return this._load(); + }, + add(query) { + if (!query || !query.trim()) return; + const q = query.trim(); + let entries = this._load().filter((e) => e !== q); + entries.unshift(q); + if (entries.length > MAX_HISTORY_ENTRIES) entries = entries.slice(0, MAX_HISTORY_ENTRIES); + this._save(entries); + }, + remove(query) { + const entries = this._load().filter((e) => e !== query); + this._save(entries); + }, + clear() { + this._save([]); + }, + filter(prefix) { + if (!prefix) return this.getAll().slice(0, 8); + const lp = prefix.toLowerCase(); + return this._load() + .filter((e) => e.toLowerCase().includes(lp)) + .slice(0, 8); + }, +}; + +// --------------------------------------------------------------------------- +// Query Parser — extracts operators (tag:, #, vault:, title:, path:, ext:) +// --------------------------------------------------------------------------- +export const QueryParser = { + parse(raw) { + const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" }; + if (!raw) return result; + const tokens = this._tokenize(raw); + const freeTokens = []; + for (const tok of tokens) { + const lower = tok.toLowerCase(); + if (lower.startsWith("tag:")) { + const v = tok.slice(4).replace(/"/g, "").trim().replace(/^#/, ""); + if (v) result.tags.push(v); + } else if (lower.startsWith("#") && tok.length > 1) { + result.tags.push(tok.slice(1)); + } else if (lower.startsWith("vault:")) { + result.vault = tok.slice(6).replace(/"/g, "").trim(); + } else if (lower.startsWith("title:")) { + result.title = tok.slice(6).replace(/"/g, "").trim(); + } else if (lower.startsWith("path:")) { + result.path = tok.slice(5).replace(/"/g, "").trim(); + } else if (lower.startsWith("ext:")) { + result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase(); + } else { + freeTokens.push(tok); + } + } + result.freeText = freeTokens.join(" "); + return result; + }, + _tokenize(raw) { + const tokens = []; + let i = 0; + const n = raw.length; + while (i < n) { + while (i < n && raw[i] === " ") i++; + if (i >= n) break; + if (raw[i] !== '"') { + let j = i; + while (j < n && raw[j] !== " ") { + if (raw[j] === '"') { + j++; + while (j < n && raw[j] !== '"') j++; + if (j < n) j++; + } else j++; + } + tokens.push(raw.slice(i, j).replace(/"/g, "")); + i = j; + } else { + i++; + let j = i; + while (j < n && raw[j] !== '"') j++; + tokens.push(raw.slice(i, j)); + i = j + 1; + } + } + return tokens; + }, + /** Detect the current operator context at cursor for autocomplete */ + getContext(raw, cursorPos) { + const before = raw.slice(0, cursorPos); + // Check if we're typing a tag: or # value + const tagMatch = before.match(/(?:tag:|#)([\w-]*)$/i); + if (tagMatch) return { type: "tag", prefix: tagMatch[1] }; + // Check if typing title: + const titleMatch = before.match(/title:([\w-]*)$/i); + if (titleMatch) return { type: "title", prefix: titleMatch[1] }; + // Default: free text + const words = before.trim().split(/\s+/); + const lastWord = words[words.length - 1] || ""; + return { type: "text", prefix: lastWord }; + }, +}; + +// --------------------------------------------------------------------------- +// Autocomplete Dropdown Controller +// --------------------------------------------------------------------------- +export const AutocompleteDropdown = { + _dropdown: null, + _historySection: null, + _titlesSection: null, + _tagsSection: null, + _historyList: null, + _titlesList: null, + _tagsList: null, + _emptyEl: null, + _suggestTimer: null, + + init() { + this._dropdown = document.getElementById("search-dropdown"); + this._historySection = document.getElementById("search-dropdown-history"); + this._titlesSection = document.getElementById("search-dropdown-titles"); + this._tagsSection = document.getElementById("search-dropdown-tags"); + this._historyList = document.getElementById("search-dropdown-history-list"); + this._titlesList = document.getElementById("search-dropdown-titles-list"); + this._tagsList = document.getElementById("search-dropdown-tags-list"); + this._emptyEl = document.getElementById("search-dropdown-empty"); + + // Clear history button + const clearBtn = document.getElementById("search-dropdown-clear-history"); + if (clearBtn) { + clearBtn.addEventListener("click", (e) => { + e.stopPropagation(); + SearchHistory.clear(); + this.hide(); + }); + } + + // Close dropdown on outside click + document.addEventListener("click", (e) => { + if (this._dropdown && !this._dropdown.contains(e.target) && e.target.id !== "search-input") { + this.hide(); + } + }); + }, + + show() { + if (this._dropdown) this._dropdown.hidden = false; + }, + + hide() { + if (this._dropdown) this._dropdown.hidden = true; + State.dropdownActiveIndex = -1; + State.dropdownItems = []; + }, + + isVisible() { + return this._dropdown && !this._dropdown.hidden; + }, + + /** Populate and show the dropdown with history, title suggestions, and tag suggestions */ + async populate(inputValue, cursorPos) { + if (this._suppressNext) { this._suppressNext = false; return; } + // Cancel previous suggestion request + if (State.suggestAbortController) { + State.suggestAbortController.abort(); + State.suggestAbortController = null; + } + + const ctx = QueryParser.getContext(inputValue, cursorPos); + const vault = document.getElementById("vault-filter").value; + + // History — always show filtered history + const historyItems = SearchHistory.filter(inputValue).slice(0, 5); + this._renderHistory(historyItems, inputValue); + + // Title and tag suggestions from API (debounced) — always fetch both + clearTimeout(this._suggestTimer); + const prefix = ctx.prefix; + if (prefix && prefix.length >= 2) { + // Only show placeholder if lists are empty (avoid flashing on fast typing) + const hasTitles = this._titlesList.children.length > 0 && !this._titlesList.querySelector(".search-dropdown__item--loading"); + const hasTags = this._tagsList.children.length > 0 && !this._tagsList.querySelector(".search-dropdown__item--loading"); + if (!hasTitles) { + this._titlesList.innerHTML = '
  • Recherche...
  • '; + } + if (!hasTags) { + this._tagsList.innerHTML = '
  • Recherche...
  • '; + } + this._titlesSection.hidden = false; + this._tagsSection.hidden = false; + this.show(); + this._suggestTimer = setTimeout(() => this._fetchSuggestions(prefix, vault), 150); + } else { + this._renderTitles([], ""); + this._renderTags([], ""); + this._titlesSection.hidden = true; + this._tagsSection.hidden = true; + } + + // Show/hide sections + this._historySection.hidden = historyItems.length === 0; + const hasContent = historyItems.length > 0; + if (hasContent || (prefix && prefix.length >= 2)) { + this.show(); + } else { + this.hide(); + } + + this._collectItems(); + }, + + async _fetchSuggestions(prefix, vault) { + State.suggestAbortController = new AbortController(); + // Fetch titles + try { + const titlesRes = await api(`/api/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: State.suggestAbortController.signal }); + this._renderTitles(titlesRes.suggestions || [], prefix); + this._titlesSection.hidden = !(titlesRes.suggestions || []).length; + if (titlesRes.suggestions?.length) this.show(); + } catch (err) { + if (err.name === "AbortError") return; + this._titlesSection.hidden = true; + } + // Fetch tags — keep section always visible to confirm it works + try { + const tagsRes = await api(`/api/tags/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: State.suggestAbortController.signal }); + const items = tagsRes.suggestions || []; + if (items.length > 0) { + this._renderTags(items, prefix); + } else { + this._tagsList.innerHTML = '
  • Aucun tag
  • '; + } + this._tagsSection.hidden = false; + this.show(); + } catch (err) { + if (err.name === "AbortError") return; + this._tagsList.innerHTML = '
  • Erreur chargement
  • '; + this._tagsSection.hidden = false; + } + this._collectItems(); + }, + + _renderHistory(items, query) { + this._historyList.innerHTML = ""; + items.forEach((entry) => { + const li = el("li", { class: "search-dropdown__item search-dropdown__item--history", role: "option" }); + const iconEl = el("span", { class: "search-dropdown__icon" }); + iconEl.innerHTML = ''; + const textEl = el("span", { class: "search-dropdown__text" }); + textEl.textContent = entry; + li.appendChild(iconEl); + li.appendChild(textEl); + li.addEventListener("click", () => { + const input = document.getElementById("search-input"); + input.value = entry; + input.dispatchEvent(new Event("input", { bubbles: true })); + this.hide(); + _triggerAdvancedSearch(entry); + }); + this._historyList.appendChild(li); + }); + }, + + _renderTitles(items, prefix) { + this._titlesList.innerHTML = ""; + items.forEach((item) => { + const li = el("li", { class: "search-dropdown__item search-dropdown__item--title", role: "option" }); + const iconEl = el("span", { class: "search-dropdown__icon" }); + iconEl.innerHTML = ''; + const textEl = el("span", { class: "search-dropdown__text" }); + if (prefix) { + this._highlightText(textEl, item.title, prefix); + } else { + textEl.textContent = item.title; + } + const metaEl = el("span", { class: "search-dropdown__meta" }); + metaEl.textContent = item.vault; + li.appendChild(iconEl); + li.appendChild(textEl); + li.appendChild(metaEl); + li.addEventListener("click", () => { + this.hide(); + TabManager.openPreview(item.vault, item.path); + }); + this._titlesList.appendChild(li); + }); + }, + + _renderTags(items, prefix) { + this._tagsList.innerHTML = ""; + items.forEach((item) => { + const li = el("li", { class: "search-dropdown__item search-dropdown__item--tag", role: "option" }); + const iconEl = el("span", { class: "search-dropdown__icon" }); + iconEl.innerHTML = ''; + const textEl = el("span", { class: "search-dropdown__text" }); + if (prefix) { + this._highlightText(textEl, item.tag, prefix); + } else { + textEl.textContent = item.tag; + } + const badge = el("span", { class: "search-dropdown__badge" }); + badge.textContent = item.count; + li.appendChild(iconEl); + li.appendChild(textEl); + li.appendChild(badge); + li.addEventListener("click", () => { + const input = document.getElementById("search-input"); + const current = input.value; + const cursorPos = input.selectionStart; + const ctx = QueryParser.getContext(current, cursorPos); + if (ctx.type === "tag") { + // Replace the partial tag prefix + const before = current.slice(0, cursorPos - ctx.prefix.length); + input.value = before + item.tag + " "; + } else { + // Replace the last word with tag: operator + const words = current.trim().split(/\s+/); + if (words.length > 0 && ctx.prefix && ctx.prefix.length > 0) { + words[words.length - 1] = ""; // remove last partial word + } + const base = words.filter(w => w).join(" "); + input.value = (base ? base + " " : "") + "tag:" + item.tag + " "; + } + input.dispatchEvent(new Event("input", { bubbles: true })); + this.hide(); + input.focus(); + _triggerAdvancedSearch(input.value); + }); + this._tagsList.appendChild(li); + }); + }, + + _highlightText(container, text, query) { + const lower = text.toLowerCase(); + const needle = query.toLowerCase(); + const pos = lower.indexOf(needle); + if (pos === -1) { + container.textContent = text; + return; + } + container.appendChild(document.createTextNode(text.slice(0, pos))); + const markEl = el("mark", {}, [document.createTextNode(text.slice(pos, pos + query.length))]); + container.appendChild(markEl); + container.appendChild(document.createTextNode(text.slice(pos + query.length))); + }, + + _collectItems() { + State.dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item")); + State.dropdownActiveIndex = -1; + State.dropdownItems.forEach((item) => item.classList.remove("active")); + }, + + navigateDown() { + if (!this.isVisible() || State.dropdownItems.length === 0) return; + if (State.dropdownActiveIndex >= 0) State.dropdownItems[State.dropdownActiveIndex].classList.remove("active"); + State.dropdownActiveIndex = (State.dropdownActiveIndex + 1) % State.dropdownItems.length; + State.dropdownItems[State.dropdownActiveIndex].classList.add("active"); + State.dropdownItems[State.dropdownActiveIndex].scrollIntoView({ block: "nearest" }); + }, + + navigateUp() { + if (!this.isVisible() || State.dropdownItems.length === 0) return; + if (State.dropdownActiveIndex >= 0) State.dropdownItems[State.dropdownActiveIndex].classList.remove("active"); + State.dropdownActiveIndex = State.dropdownActiveIndex <= 0 ? State.dropdownItems.length - 1 : State.dropdownActiveIndex - 1; + State.dropdownItems[State.dropdownActiveIndex].classList.add("active"); + State.dropdownItems[State.dropdownActiveIndex].scrollIntoView({ block: "nearest" }); + }, + + selectActive() { + if (State.dropdownActiveIndex >= 0 && State.dropdownActiveIndex < State.dropdownItems.length) { + State.dropdownItems[State.dropdownActiveIndex].click(); + return true; + } + return false; + }, +}; + +// --------------------------------------------------------------------------- +// Search Chips Controller — renders active filter chips from parsed query +// --------------------------------------------------------------------------- +export const SearchChips = { + _container: null, + init() { + this._container = document.getElementById("search-chips"); + }, + update(parsed) { + if (!this._container) return; + this._container.innerHTML = ""; + let hasChips = false; + parsed.tags.forEach((tag) => { + this._addChip("tag", `tag:${tag}`, tag); + hasChips = true; + }); + if (parsed.vault) { + this._addChip("vault", `vault:${parsed.vault}`, parsed.vault); + hasChips = true; + } + if (parsed.title) { + this._addChip("title", `title:${parsed.title}`, parsed.title); + hasChips = true; + } + if (parsed.path) { + this._addChip("path", `path:${parsed.path}`, parsed.path); + hasChips = true; + } + if (parsed.ext) { + this._addChip("ext", `ext:${parsed.ext}`, parsed.ext); + hasChips = true; + } + this._container.hidden = !hasChips; + }, + clear() { + if (!this._container) return; + this._container.innerHTML = ""; + this._container.hidden = true; + }, + _addChip(type, fullOperator, displayText) { + const chip = el("span", { class: `search-chip search-chip--${type}` }); + const label = el("span", { class: "search-chip__label" }); + label.textContent = fullOperator; + const removeBtn = el("button", { class: "search-chip__remove", title: "Retirer ce filtre", type: "button" }); + removeBtn.innerHTML = ''; + removeBtn.addEventListener("click", () => { + // Remove this operator from the input + const input = document.getElementById("search-input"); + const raw = input.value; + // Remove the operator text from the query + const escaped = fullOperator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + input.value = raw.replace(new RegExp("\\s*" + escaped + "\\s*", "i"), " ").trim(); + _triggerAdvancedSearch(input.value); + }); + chip.appendChild(label); + chip.appendChild(removeBtn); + this._container.appendChild(chip); + safeCreateIcons(); + }, +}; + +// --------------------------------------------------------------------------- +// Helper: trigger advanced search from input value +// --------------------------------------------------------------------------- +export function _triggerAdvancedSearch(rawQuery) { + const q = (rawQuery || "").trim(); + const vault = document.getElementById("vault-filter").value; + const tagFilter = State.selectedTags.length > 0 ? State.selectedTags.join(",") : null; + State.advancedSearchOffset = 0; + if (q.length > 0 || tagFilter) { + SearchHistory.add(q); + performAdvancedSearch(q, vault, tagFilter); + } else { + SearchChips.clear(); + showWelcome(); + } +} + +// --------------------------------------------------------------------------- +// Search (enhanced with autocomplete, keyboard nav, global shortcuts) +// --------------------------------------------------------------------------- +// ── Search toggle state ── + +function initSearch() { + const input = document.getElementById("search-input"); + if (!input) return; + const caseBtn = document.getElementById("search-case-btn"); + const wordBtn = document.getElementById("search-word-btn"); + const regexBtn = document.getElementById("search-regex-btn"); + const filterBtn = document.getElementById("search-filter-btn"); + const clearBtn = document.getElementById("search-clear-btn"); + const filterRow = document.getElementById("search-filter-row"); + const prevBtn = document.getElementById("search-prev-btn"); + const nextBtn = document.getElementById("search-next-btn"); + const countEl = document.getElementById("search-match-count"); + + function _updateToggleUI() { + caseBtn.classList.toggle("active", State.searchCaseSensitive); + wordBtn.classList.toggle("active", State.searchWholeWord); + regexBtn.classList.toggle("active", State.searchRegex); + filterBtn.classList.toggle("active", State.searchFilterVisible); + } + + // Toggle buttons + caseBtn.addEventListener("click", () => { State.searchCaseSensitive = !State.searchCaseSensitive; _updateToggleUI(); _research(); }); + if (wordBtn) wordBtn.addEventListener("click", () => { State.searchWholeWord = !State.searchWholeWord; _updateToggleUI(); _research(); }); + if (regexBtn) regexBtn.addEventListener("click", () => { State.searchRegex = !State.searchRegex; _updateToggleUI(); _research(); }); + if (filterBtn) filterBtn.addEventListener("click", () => { State.searchFilterVisible = !State.searchFilterVisible; if (filterRow) filterRow.style.display = State.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); + + // ── Result navigation (up/down arrows + Enter) ── + let _searchResultIdx = -1; + let _searchResultItems = []; + + function _updateResultHighlight() { + _searchResultItems.forEach((el, i) => { + el.classList.toggle("search-result-active", i === _searchResultIdx); + }); + if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { + _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + const countEl = document.getElementById("search-match-count"); + if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; + } + + function _refreshResultItems() { + _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; + _updateResultHighlight(); + } + + window.navigateSearchResults = function(delta) { + _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + if (_searchResultItems.length === 0) return; + _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); + _updateResultHighlight(); + }; + + if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); + if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); + + function _research() { + const q = input.value.trim(); + if (q.length >= _getEffective("min_query_length", 2)) { + clearTimeout(State.searchTimeout); + State.searchTimeout = setTimeout(() => { + const vault = document.getElementById("vault-filter").value; + const tagFilter = State.selectedTags.length > 0 ? State.selectedTags.join(",") : null; + State.advancedSearchOffset = 0; + performAdvancedSearch(q, vault, tagFilter); + }, _getEffective("debounce_ms", 300)); + } + } + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } + else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } + else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } + else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } + } + }); + + // Initialize sub-controllers + AutocompleteDropdown.init(); + SearchChips.init(); + + // Initially hide clear button + if (clearBtn) clearBtn.style.display = "none"; + + // --- Input handler: debounced search + autocomplete dropdown --- + input.addEventListener("input", () => { + const hasText = input.value.length > 0; + clearBtn.style.display = hasText ? "flex" : "none"; + + // Show autocomplete dropdown while typing + AutocompleteDropdown.populate(input.value, input.selectionStart); + + // Debounced search execution + clearTimeout(State.searchTimeout); + State.searchTimeout = setTimeout( + () => { + const q = input.value.trim(); + const vault = document.getElementById("vault-filter").value; + const tagFilter = State.selectedTags.length > 0 ? State.selectedTags.join(",") : null; + State.advancedSearchOffset = 0; + if (q.length >= _getEffective("min_query_length", State.MIN_SEARCH_LENGTH) || tagFilter) { + performAdvancedSearch(q, vault, tagFilter); + } else if (q.length === 0) { + SearchChips.clear(); + showWelcome(); + } + }, + _getEffective("debounce_ms", 300), + ); + }); + + // --- Focus handler: show history dropdown --- + input.addEventListener("focus", () => { + if (input.value.length === 0) { + const historyItems = SearchHistory.filter("").slice(0, 5); + if (historyItems.length > 0) { + AutocompleteDropdown.populate("", 0); + } + } + }); + + // --- Keyboard navigation in dropdown --- + input.addEventListener("keydown", (e) => { + if (AutocompleteDropdown.isVisible()) { + if (e.key === "ArrowDown") { + e.preventDefault(); + AutocompleteDropdown.navigateDown(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + AutocompleteDropdown.navigateUp(); + } else if (e.key === "Enter") { + // First: check dropdown suggestions (higher priority than search results) + if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + e.preventDefault(); + return; + } + // Second: navigate search results if visible + const results = document.querySelectorAll(".search-result-item"); + if (results.length > 0 && _searchResultIdx >= 0) { + const el = results[_searchResultIdx]; + if (el) { + const vault = el.dataset.vault; + const path = el.dataset.path; + if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } + } + } + // Third: execute search + AutocompleteDropdown.hide(); + const q = input.value.trim(); + if (q) { + SearchHistory.add(q); + clearTimeout(State.searchTimeout); + State.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + const tagFilter = State.selectedTags.length > 0 ? State.selectedTags.join(",") : null; + performAdvancedSearch(q, vault, tagFilter); + } + e.preventDefault(); + } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { + // Navigate search results when dropdown is closed + if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } + } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { + if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } + } else if (e.key === "Escape") { + AutocompleteDropdown.hide(); + e.stopPropagation(); + } + } else if (e.key === "Enter") { + if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + e.preventDefault(); + return; + } + const q = input.value.trim(); + if (q) { + SearchHistory.add(q); + clearTimeout(State.searchTimeout); + State.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + const tagFilter = State.selectedTags.length > 0 ? State.selectedTags.join(",") : null; + performAdvancedSearch(q, vault, tagFilter); + } + e.preventDefault(); + } + }); + + clearBtn.addEventListener("click", () => { + input.value = ""; + if (clearBtn) clearBtn.style.display = "none"; + State.searchCaseSensitive = false; + State.searchWholeWord = false; + State.searchRegex = false; + _updateToggleUI(); + SearchChips.clear(); + AutocompleteDropdown.hide(); + showWelcome(); + }); + + // --- Global keyboard shortcuts --- + document.addEventListener("keydown", (e) => { + // Ctrl+K or Cmd+K: focus search + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + input.focus(); + input.select(); + } + // "/" key: focus search (when not in an input/textarea) + if (e.key === "/" && !_isInputFocused()) { + e.preventDefault(); + input.focus(); + } + // Escape: blur search input and close dropdown + if (e.key === "Escape" && document.activeElement === input) { + AutocompleteDropdown.hide(); + input.blur(); + } + }); +} + +/** Check if user is focused on an input/textarea/contenteditable */ +function _isInputFocused() { + const tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return document.activeElement?.isContentEditable === true; +} + +// --- Backward-compatible search (existing /api/search endpoint) --- +export async function performSearch(query, vaultFilter, tagFilter) { + if (State.searchAbortController) State.searchAbortController.abort(); + State.searchAbortController = new AbortController(); + const searchId = ++State.currentSearchId; + showLoading(); + let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; + if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + try { + const data = await api(url, { signal: State.searchAbortController.signal }); + if (searchId !== State.currentSearchId) return; + renderSearchResults(data, query, tagFilter); + } catch (err) { + if (err.name === "AbortError") return; + if (searchId !== State.currentSearchId) return; + showWelcome(); + } finally { + hideProgressBar(); + if (searchId === State.currentSearchId) State.searchAbortController = null; + } +} + +// --- Advanced search with TF-IDF, facets, pagination --- +export async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) { + if (State.searchAbortController) State.searchAbortController.abort(); + State.searchAbortController = new AbortController(); + const searchId = ++State.currentSearchId; + showLoading(); + + const ofs = offset !== undefined ? offset : State.advancedSearchOffset; + const sortBy = sort || State.advancedSearchSort; + State.advancedSearchLastQuery = query; + + // Update chips from parsed query + const parsed = QueryParser.parse(query); + SearchChips.update(parsed); + + const effectiveLimit = _getEffective("results_per_page", State.ADVANCED_SEARCH_LIMIT); + let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${effectiveLimit}&offset=${ofs}&sort=${sortBy}`; + if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + if (State.searchCaseSensitive) url += "&case_sensitive=true"; + if (State.searchWholeWord) url += "&whole_word=true"; + if (State.searchRegex) url += "®ex=true"; + const includeEl = document.getElementById("search-include-input"); + const excludeEl = document.getElementById("search-exclude-input"); + if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; + if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`; + + // Search timeout — abort if server takes too long + const timeoutId = setTimeout( + () => { + if (State.searchAbortController) State.searchAbortController.abort(); + }, + _getEffective("search_timeout_ms", State.SEARCH_TIMEOUT_MS), + ); + + try { + const data = await api(url, { signal: State.searchAbortController.signal }); + clearTimeout(timeoutId); + if (searchId !== State.currentSearchId) return; + State.advancedSearchTotal = data.total; + State.advancedSearchOffset = ofs; + renderAdvancedSearchResults(data, query, tagFilter); + } catch (err) { + clearTimeout(timeoutId); + if (err.name === "AbortError") return; + if (searchId !== State.currentSearchId) return; + showWelcome(); + } finally { + hideProgressBar(); + if (searchId === State.currentSearchId) State.searchAbortController = null; + } +} + +// --- Legacy search results renderer (kept for backward compat) --- +export function renderSearchResults(data, query, tagFilter) { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const header = buildSearchResultsHeader(data, query, tagFilter); + area.appendChild(header); + if (data.results.length === 0) { + area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); + return; + } + const container = el("div", { class: "search-results" }); + data.results.forEach((r) => { + // Apply client-side filtering for hidden files + if (!shouldDisplayPath(r.path, r.vault)) { + return; // Skip this result + } + + const titleDiv = el("div", { class: "search-result-title" }); + if (query && query.trim()) { + highlightSearchText(titleDiv, r.title, query, State.searchCaseSensitive); + } else { + titleDiv.textContent = r.title; + } + const snippetDiv = el("div", { class: "search-result-snippet" }); + if (r.snippet && r.snippet.includes("")) { + snippetDiv.innerHTML = r.snippet; + } else if (query && query.trim() && r.snippet) { + highlightSearchText(snippetDiv, r.snippet, query, State.searchCaseSensitive); + } else { + snippetDiv.textContent = r.snippet || ""; + } + const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ + el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), + titleDiv, + el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]), + snippetDiv + ]); + if (r.tags && r.tags.length > 0) { + const tagsDiv = el("div", { class: "search-result-tags" }); + r.tags.forEach((tag) => { + if (!TagFilterService.isTagFiltered(tag)) { + const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + addTagFilter(tag); + }); + tagsDiv.appendChild(tagEl); + } + }); + if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); + } + item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + container.appendChild(item); + }); + area.appendChild(container); +} + +// --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) --- +export function renderAdvancedSearchResults(data, query, tagFilter) { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + + // Update match counter + const countEl = document.getElementById("search-match-count"); + if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`; + + // Header with result count and sort controls + const header = el("div", { class: "search-results-header" }); + const summaryText = el("span", { class: "search-results-summary-text" }); + const parsed = QueryParser.parse(query); + const freeText = parsed.freeText; + + if (freeText && tagFilter) { + summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`; + } else if (freeText) { + summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`; + } else if (parsed.tags.length > 0 || tagFilter) { + summaryText.textContent = `${data.total} fichier(s) avec filtres`; + } else { + summaryText.textContent = `${data.total} résultat(s)`; + } + if (data.query_time_ms !== undefined && data.query_time_ms > 0) { + const timeBadge = el("span", { class: "search-time-badge" }); + timeBadge.textContent = `(${data.query_time_ms} ms)`; + summaryText.appendChild(timeBadge); + } + header.appendChild(summaryText); + + // Active filter badges + const filtersRow = el("div", { class: "search-filters-row" }); + if (State.searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")])); + if (State.searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")])); + if (State.searchRegex) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode(".*")])); + const inclEl = document.getElementById("search-include-input"); + const exclEl = document.getElementById("search-exclude-input"); + if (inclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("incl: " + inclEl.value.trim())])); + if (exclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("excl: " + exclEl.value.trim())])); + if (filtersRow.children.length > 0) header.appendChild(filtersRow); + + // Sort controls + const sortDiv = el("div", { class: "search-sort" }); + const btnRelevance = el("button", { class: "search-sort__btn" + (State.advancedSearchSort === "relevance" ? " active" : ""), type: "button" }); + btnRelevance.textContent = "Pertinence"; + btnRelevance.addEventListener("click", () => { + State.advancedSearchSort = "relevance"; + State.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, 0, "relevance"); + }); + const btnDate = el("button", { class: "search-sort__btn" + (State.advancedSearchSort === "modified" ? " active" : ""), type: "button" }); + btnDate.textContent = "Date"; + btnDate.addEventListener("click", () => { + State.advancedSearchSort = "modified"; + State.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, 0, "modified"); + }); + sortDiv.appendChild(btnRelevance); + sortDiv.appendChild(btnDate); + header.appendChild(sortDiv); + + // Save search button + const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" }); + saveBtn.innerHTML = ' Sauver'; + saveBtn.addEventListener("click", async () => { + const inclEl = document.getElementById("search-include-input"); + const exclEl = document.getElementById("search-exclude-input"); + try { + await api("/api/saved-searches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: query, + vault: document.getElementById("vault-filter")?.value || "all", + case_sensitive: State.searchCaseSensitive, + whole_word: State.searchWholeWord, + regex: State.searchRegex, + include_paths: inclEl?.value || "", + exclude_paths: exclEl?.value || "", + }), + }); + showToast("Recherche sauvegardée", "success"); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + header.appendChild(saveBtn); + area.appendChild(header); + + // Active sidebar tag chips + if (State.selectedTags.length > 0) { + const activeTags = el("div", { class: "search-results-active-tags" }); + State.selectedTags.forEach((tag) => { + const removeBtn = el( + "button", + { + class: "search-results-active-tag-remove", + title: `Retirer ${tag} du filtre`, + }, + [document.createTextNode("×")], + ); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeTagFilter(tag); + }); + const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + activeTags.appendChild(chip); + }); + area.appendChild(activeTags); + } + + // Facets panel + if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) { + const facetsDiv = el("div", { class: "search-facets" }); + + // Vault facets + const vaultFacets = data.facets.vaults || {}; + if (Object.keys(vaultFacets).length > 1) { + const group = el("div", { class: "search-facets__group" }); + const label = el("span", { class: "search-facets__label" }); + label.textContent = "Vaults"; + group.appendChild(label); + for (const [vaultName, count] of Object.entries(vaultFacets)) { + const item = el("span", { class: "search-facets__item" }); + item.innerHTML = `${vaultName} ${count}`; + item.addEventListener("click", () => { + const input = document.getElementById("search-input"); + // Add vault: operator + const current = input.value.replace(/vault:\S+\s*/gi, "").trim(); + input.value = current + " vault:" + vaultName; + _triggerAdvancedSearch(input.value); + }); + group.appendChild(item); + } + facetsDiv.appendChild(group); + } + + // Tag facets + const tagFacets = data.facets.tags || {}; + if (Object.keys(tagFacets).length > 0) { + const group = el("div", { class: "search-facets__group" }); + const label = el("span", { class: "search-facets__label" }); + label.textContent = "Tags"; + group.appendChild(label); + const entries = Object.entries(tagFacets).slice(0, 12); + for (const [tagName, count] of entries) { + const item = el("span", { class: "search-facets__item" }); + item.innerHTML = `#${tagName} ${count}`; + item.addEventListener("click", () => { + addTagFilter(tagName); + }); + group.appendChild(item); + } + facetsDiv.appendChild(group); + } + + area.appendChild(facetsDiv); + } + + // Empty state + if (data.results.length === 0) { + area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); + return; + } + + // Results list + const container = el("div", { class: "search-results" }); + data.results.forEach((r) => { + // Apply client-side filtering for hidden files + if (!shouldDisplayPath(r.path, r.vault)) { + return; // Skip this result + } + + const titleDiv = el("div", { class: "search-result-title" }); + if (freeText) { + highlightSearchText(titleDiv, r.title, freeText, State.searchCaseSensitive); + } else { + titleDiv.textContent = r.title; + } + + // Snippet — use HTML from backend (already has tags) + const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" }); + if (r.snippet && r.snippet.includes("")) { + snippetDiv.innerHTML = r.snippet; + } else if (freeText && r.snippet) { + highlightSearchText(snippetDiv, r.snippet, freeText, State.searchCaseSensitive); + } else { + snippetDiv.textContent = r.snippet || ""; + } + + // Score badge + const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" }); + scoreEl.textContent = `score: ${r.score}`; + + const vaultPath = el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path), scoreEl]); + + const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ + el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), + titleDiv, vaultPath, snippetDiv + ]); + + if (r.tags && r.tags.length > 0) { + const tagsDiv = el("div", { class: "search-result-tags" }); + r.tags.forEach((tag) => { + if (!TagFilterService.isTagFiltered(tag)) { + const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + addTagFilter(tag); + }); + tagsDiv.appendChild(tagEl); + } + }); + if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); + } + + item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + container.appendChild(item); + }); + area.appendChild(container); + + // Pagination + if (data.total > State.ADVANCED_SEARCH_LIMIT) { + const paginationDiv = el("div", { class: "search-pagination" }); + const prevBtn = el("button", { class: "search-pagination__btn", type: "button" }); + prevBtn.textContent = "← Précédent"; + prevBtn.disabled = State.advancedSearchOffset === 0; + prevBtn.addEventListener("click", () => { + State.advancedSearchOffset = Math.max(0, State.advancedSearchOffset - State.ADVANCED_SEARCH_LIMIT); + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, State.advancedSearchOffset); + document.getElementById("content-area").scrollTop = 0; + }); + + const info = el("span", { class: "search-pagination__info" }); + const from = State.advancedSearchOffset + 1; + const to = Math.min(State.advancedSearchOffset + State.ADVANCED_SEARCH_LIMIT, data.total); + info.textContent = `${from}–${to} sur ${data.total}`; + + const nextBtn = el("button", { class: "search-pagination__btn", type: "button" }); + nextBtn.textContent = "Suivant →"; + nextBtn.disabled = State.advancedSearchOffset + State.ADVANCED_SEARCH_LIMIT >= data.total; + nextBtn.addEventListener("click", () => { + State.advancedSearchOffset += State.ADVANCED_SEARCH_LIMIT; + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, State.advancedSearchOffset); + document.getElementById("content-area").scrollTop = 0; + }); + + paginationDiv.appendChild(prevBtn); + paginationDiv.appendChild(info); + paginationDiv.appendChild(nextBtn); + area.appendChild(paginationDiv); + } + + safeCreateIcons(); + // Initialize result navigation (select first result) + setTimeout(() => { if (window.navigateSearchResults) window.navigateSearchResults(0); }, 50); +} diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js new file mode 100644 index 0000000..f2c0c2f --- /dev/null +++ b/frontend/js/sidebar.js @@ -0,0 +1,1091 @@ +import { selectedContextVault, allVaults, vaultSettings, activeSidebarTab, sidebarFilterCaseSensitive, currentVault, currentPath, filterDebounce, showingSource, cachedRawSource, selectedTags } from './state.js'; + +// --------------------------------------------------------------------------- +// Vault context switching +// --------------------------------------------------------------------------- +function initVaultContext() { + const filter = document.getElementById("vault-filter"); + const quickSelect = document.getElementById("vault-quick-select"); + if (!filter || !quickSelect) return; + + filter.addEventListener("change", async () => { + await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" }); + }); + + quickSelect.addEventListener("change", async () => { + await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" }); + }); +} + +async function setSelectedVaultContext(vaultName, options) { + selectedContextVault = vaultName; + showingSource = false; + cachedRawSource = null; + syncVaultSelectors(); + await refreshSidebarForContext(); + await refreshTagsForContext(); + + // Synchroniser le dashboard et les fichiers récents + if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) { + DashboardRecentWidget.load(vaultName); + } + if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { + DashboardBookmarkWidget.load(vaultName); + } + if (activeSidebarTab === "recent") { + loadRecentFiles(vaultName === "all" ? null : vaultName); + } + + showWelcome(); + if (options && options.focusVault && vaultName !== "all") { + await focusVaultInSidebar(vaultName); + } +} + +function syncVaultSelectors() { + const filter = document.getElementById("vault-filter"); + const quickSelect = document.getElementById("vault-quick-select"); + const recentFilter = document.getElementById("recent-vault-filter"); + const dashboardFilter = document.getElementById("dashboard-vault-filter"); + const contextText = document.getElementById("vault-context-text"); + + if (filter) filter.value = selectedContextVault; + if (quickSelect) quickSelect.value = selectedContextVault; + if (recentFilter) recentFilter.value = selectedContextVault === "all" ? "" : selectedContextVault; + if (dashboardFilter) dashboardFilter.value = selectedContextVault; + + // Mise à jour visuelle des dropdowns personnalisés + updateCustomDropdownVisual("vault-filter-dropdown", selectedContextVault); + updateCustomDropdownVisual("vault-quick-select-dropdown", selectedContextVault); + + // Update vault context indicator + if (contextText) { + contextText.textContent = selectedContextVault === "all" ? "All" : selectedContextVault; + } +} + +/** + * Updates the visual state of a custom dropdown based on its current value. + */ +function updateCustomDropdownVisual(dropdownId, value) { + const dropdown = document.getElementById(dropdownId); + if (!dropdown) return; + + const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + const options = dropdown.querySelectorAll(".custom-dropdown-option"); + + options.forEach((opt) => { + const optValue = opt.getAttribute("data-value"); + if (optValue === value) { + opt.classList.add("selected"); + if (selectedText) selectedText.textContent = opt.textContent; + } else { + opt.classList.remove("selected"); + } + }); +} + +function scrollTreeItemIntoView(element, alignToTop) { + if (!element) return; + const scrollContainer = document.getElementById("sidebar-panel-vaults"); + if (!scrollContainer) return; + + const containerRect = scrollContainer.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const isAbove = elementRect.top < containerRect.top; + const isBelow = elementRect.bottom > containerRect.bottom; + + if (!isAbove && !isBelow && !alignToTop) return; + + const currentTop = scrollContainer.scrollTop; + const offsetTop = element.offsetTop; + const shouldCenter = alignToTop === "center"; + const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2)); + const targetTop = shouldCenter + ? centeredTop + : alignToTop + ? Math.max(0, offsetTop - 60) + : Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35); + + scrollContainer.scrollTo({ + top: targetTop, + behavior: "smooth", + }); +} + +async function refreshSidebarForContext() { + const container = document.getElementById("vault-tree"); + container.innerHTML = ""; + + const vaultsToShow = selectedContextVault === "all" ? allVaults : allVaults.filter((v) => v.name === selectedContextVault); + + vaultsToShow.forEach((v) => { + const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]); + vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); + + vaultItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); + }); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + + container.appendChild(vaultItem); + + const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); + container.appendChild(childContainer); + }); + + safeCreateIcons(); +} + +async function focusVaultInSidebar(vaultName) { + switchSidebarTab("vaults"); + const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); + if (!vaultItem) return; + document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); + vaultItem.classList.add("focused"); + const childContainer = document.getElementById(`vault-children-${vaultName}`); + if (childContainer && childContainer.classList.contains("collapsed")) { + await toggleVault(vaultItem, vaultName, true); + } + scrollTreeItemIntoView(vaultItem, false); +} + +async function refreshTagsForContext() { + const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`; + const data = await api(`/api/tags${vaultParam}`); + const filteredTags = TagFilterService.filterTags(data.tags); + renderTagCloud(filteredTags); +} + +// --------------------------------------------------------------------------- +// Helper: Check if path should be displayed based on hideHiddenFiles setting +// --------------------------------------------------------------------------- +function shouldDisplayPath(path, vaultName) { + // Get hideHiddenFiles setting for this vault (default: false = show all) + const settings = vaultSettings[vaultName] || { hideHiddenFiles: false }; + + if (!settings.hideHiddenFiles) { + // Show all files + return true; + } + + // Check if any segment of the path starts with a dot (hidden) + const segments = path.split("/").filter(Boolean); + for (const segment of segments) { + if (segment.startsWith(".")) { + return false; // Hide this path + } + } + + return true; // Show this path +} + +async function loadVaultSettings() { + try { + const settings = await api("/api/vaults/settings/all"); + vaultSettings = settings; + } catch (err) { + console.error("Failed to load vault settings:", err); + vaultSettings = {}; + } +} + +// --------------------------------------------------------------------------- +// Sidebar — Vault tree +// --------------------------------------------------------------------------- +async function loadVaults() { + const vaults = await api("/api/vaults"); + allVaults = vaults; + const container = document.getElementById("vault-tree"); + container.innerHTML = ""; + + // Prepare dropdown options + const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))]; + + // Populate custom dropdowns + populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all"); + populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all"); + + // Populate standard selects + _populateRecentVaultFilter(); + if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) { + DashboardRecentWidget.populateVaultFilter(); + } + + vaults.forEach((v) => { + // Sidebar tree entry + const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]); + vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); + + vaultItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); + }); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + + container.appendChild(vaultItem); + + const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); + container.appendChild(childContainer); + }); + + syncVaultSelectors(); + safeCreateIcons(); +} + +/** + * Refreshes the sidebar tree while preserving the expanded state of vaults and folders. + * Optimized to avoid a full sidebar wipe and minimize visible loading states. + */ +/** + * Incrementally update a directory container without wiping existing DOM. + * Only adds new items, removes deleted ones, and updates changed ones. + */ +async function incrementalLoadDirectory(vaultName, dirPath, container) { + let data; + try { + const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + data = await api(url); + } catch (err) { + // Server unavailable — keep existing content + return; + } + + // Build a map of existing DOM elements by path + const existingItems = {}; + const existingChildren = {}; // path -> child container (for directories) + for (let i = 0; i < container.children.length; i++) { + const child = container.children[i]; + if (child.classList.contains("tree-item") && child.dataset.path) { + existingItems[child.dataset.path] = child; + // The next sibling should be the tree-children container for this directory + if (i + 1 < container.children.length) { + const next = container.children[i + 1]; + if (next.classList.contains("tree-children")) { + existingChildren[child.dataset.path] = next; + } + } + } + } + + const fragment = document.createDocumentFragment(); + + data.items.forEach((item) => { + if (!shouldDisplayPath(item.path, vaultName)) return; + + const existing = existingItems[item.path]; + + if (existing) { + // Item already exists — reuse it, but update text/badge if needed + const textEl = existing.querySelector(".tree-item-text"); + const displayName = item.type === "file" && item.name.match(/\.md$/i) + ? item.name.replace(/\.md$/i, "") + : item.name; + if (textEl && textEl.textContent !== displayName) { + textEl.textContent = displayName; + } + // Update badge for directories + if (item.type === "directory") { + const badge = existing.querySelector(".badge-small"); + const newBadge = `(${item.children_count})`; + if (badge && badge.textContent !== newBadge) { + badge.textContent = newBadge; + } else if (!badge) { + existing.appendChild(smallBadge(item.children_count)); + } + } + fragment.appendChild(existing); + // Also re-add the child container for directories + if (item.type === "directory" && existingChildren[item.path]) { + fragment.appendChild(existingChildren[item.path]); + } else if (item.type === "directory") { + // Directory existed but no child container — create one + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + } + } else { + // New item — create it + if (item.type === "directory") { + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + fragment.appendChild(dirItem); + + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + + dirItem.addEventListener("click", async () => { + scrollTreeItemIntoView(dirItem, false); + if (subContainer.classList.contains("collapsed")) { + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, item.path, subContainer); + } + subContainer.classList.remove("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + subContainer.classList.add("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); + }); + } else { + const fileIconName = getFileIcon(item.name); + const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + fileItem.addEventListener("click", () => { + scrollTreeItemIntoView(fileItem, false); + TabManager.openPreview(vaultName, item.path); + closeMobileSidebar(); + }); + + fileItem.addEventListener("dblclick", (e) => { + e.preventDefault(); + TabManager.openPersistent(vaultName, item.path); + }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false); + }); + + fragment.appendChild(fileItem); + } + } + }); + + // Replace container content in a single batch operation to avoid flash + container.textContent = ""; + container.appendChild(fragment); +} + +async function refreshSidebarTreePreservingState() { + // 1. Capture expanded states + const expandedVaults = Array.from(document.querySelectorAll(".vault-item")) + .filter((v) => { + const children = document.getElementById(`vault-children-${v.dataset.vault}`); + return children && !children.classList.contains("collapsed"); + }) + .map((v) => v.dataset.vault); + + const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]")) + .filter((item) => { + const vault = item.dataset.vault; + const path = item.dataset.path; + const children = document.getElementById(`dir-${vault}-${path}`); + return children && !children.classList.contains("collapsed"); + }) + .map((item) => ({ vault: item.dataset.vault, path: item.dataset.path })); + + const selectedItem = document.querySelector(".tree-item.path-selected"); + const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null; + + // 2. Soft update: vault names/counts without wiping the tree + try { + const vaults = await api("/api/vaults"); + allVaults = vaults; + vaults.forEach((v) => { + const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`); + if (vItem) { + const badge = vItem.querySelector(".badge-small"); + if (badge) badge.textContent = `(${v.file_count})`; + } + }); + } catch (e) { + console.warn("Soft vault refresh failed, falling back to full reload", e); + await loadVaults(); + return; + } + + // 3. Incrementally update expanded vaults (no DOM wipe) + for (const vName of expandedVaults) { + const container = document.getElementById(`vault-children-${vName}`); + if (container) { + await incrementalLoadDirectory(vName, "", container); + } + } + + // 4. Incrementally update expanded directories (parents first, no DOM wipe) + expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length); + for (const dir of expandedDirs) { + const container = document.getElementById(`dir-${dir.vault}-${dir.path}`); + if (container) { + try { + await incrementalLoadDirectory(dir.vault, dir.path, container); + container.classList.remove("collapsed"); + const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`); + if (dItem) { + const chev = dItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + } + } catch (e) { + console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e); + } + } + } + + // 5. Restore selection + if (selectedState) { + await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false }); + } + + safeCreateIcons(); +} + +async function toggleVault(itemEl, vaultName, forceExpand) { + const childContainer = document.getElementById(`vault-children-${vaultName}`); + if (!childContainer) return; + + scrollTreeItemIntoView(itemEl, false); + + const shouldExpand = forceExpand || childContainer.classList.contains("collapsed"); + + if (shouldExpand) { + // Expand — load children if empty + if (childContainer.children.length === 0) { + await loadDirectory(vaultName, "", childContainer); + } + childContainer.classList.remove("collapsed"); + // Swap chevron + const chevron = itemEl.querySelector("[data-lucide]"); + if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + childContainer.classList.add("collapsed"); + const chevron = itemEl.querySelector("[data-lucide]"); + if (chevron) chevron.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } +} + +async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) { + const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`); + if (!subContainer) return null; + + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, dirPath, subContainer); + } + + subContainer.classList.remove("collapsed"); + if (dirItem) { + const chevron = dirItem.querySelector("[data-lucide]"); + if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); + } + safeCreateIcons(); + return subContainer; +} + +async function focusPathInSidebar(vaultName, targetPath, options) { + switchSidebarTab("vaults"); + + const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); + if (!vaultItem) return; + + document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); + vaultItem.classList.add("focused"); + + const vaultContainer = document.getElementById(`vault-children-${vaultName}`); + if (!vaultContainer) return; + + if (vaultContainer.classList.contains("collapsed")) { + await toggleVault(vaultItem, vaultName, true); + } + + if (!targetPath) { + // Clear any previous path selection + document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); + scrollTreeItemIntoView(vaultItem, options && options.alignToTop); + return; + } + + const segments = targetPath.split("/").filter(Boolean); + let currentContainer = vaultContainer; + let cumulativePath = ""; + let lastTargetItem = null; + + for (let index = 0; index < segments.length; index++) { + cumulativePath += (cumulativePath ? "/" : "") + segments[index]; + + let targetItem = null; + try { + targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`); + } catch (e) { + targetItem = null; + } + + if (!targetItem) { + return; + } + + lastTargetItem = targetItem; + + const isLastSegment = index === segments.length - 1; + if (!isLastSegment) { + const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem); + if (nextContainer) { + currentContainer = nextContainer; + } + } + } + + if (lastTargetItem && options && options.expandTarget) { + await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem); + } + + // Clear previous path selections and highlight the final target + document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); + if (lastTargetItem) { + lastTargetItem.classList.add("path-selected"); + } + + scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop); +} + +function getParentDirectoryPath(filePath) { + if (!filePath) return ""; + const segments = filePath.split("/").filter(Boolean); + if (segments.length <= 1) return ""; + segments.pop(); + return segments.join("/"); +} + +function syncActiveFileTreeItem(vaultName, filePath) { + document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + if (!vaultName || !filePath) return; + const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`; + try { + const active = document.querySelector(selector); + if (active) active.classList.add("active"); + } catch (e) { + /* selector might fail on special chars */ + } +} + +async function loadDirectory(vaultName, dirPath, container) { + // Only show the loading spinner if the container is currently empty + const isEmpty = container.children.length === 0; + if (isEmpty) { + container.innerHTML = '
    '; + } + + var data; + try { + const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + data = await api(url); + } catch (err) { + container.innerHTML = '
    Erreur de chargement
    '; + return; + } + container.innerHTML = ""; + + const fragment = document.createDocumentFragment(); + + data.items.forEach((item) => { + // Apply client-side filtering for hidden files + if (!shouldDisplayPath(item.path, vaultName)) { + return; // Skip this item + } + + if (item.type === "directory") { + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + fragment.appendChild(dirItem); + + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + + dirItem.addEventListener("click", async () => { + scrollTreeItemIntoView(dirItem, false); + if (subContainer.classList.contains("collapsed")) { + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, item.path, subContainer); + } + subContainer.classList.remove("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + subContainer.classList.add("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); + }); + } else { + const fileIconName = getFileIcon(item.name); + const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + fileItem.addEventListener("click", () => { + scrollTreeItemIntoView(fileItem, false); + TabManager.openPreview(vaultName, item.path); + closeMobileSidebar(); + }); + + fileItem.addEventListener("dblclick", (e) => { + e.preventDefault(); + TabManager.openPersistent(vaultName, item.path); + }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); + }); + + fragment.appendChild(fileItem); + } + }); + + container.appendChild(fragment); + safeCreateIcons(); +} + +// --------------------------------------------------------------------------- +// Sidebar filter +// --------------------------------------------------------------------------- +function initSidebarFilter() { + const input = document.getElementById("sidebar-filter-input"); + const caseBtn = document.getElementById("sidebar-filter-case-btn"); + const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + + input.addEventListener("input", () => { + const hasText = input.value.length > 0; + clearBtn.style.display = hasText ? "flex" : "none"; + clearTimeout(filterDebounce); + filterDebounce = setTimeout(async () => { + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (hasText) { + if (activeSidebarTab === "vaults") { + await performTreeSearch(q); + } else { + filterTagCloud(q); + } + } else { + if (activeSidebarTab === "vaults") { + await restoreSidebarTree(); + } else { + filterTagCloud(""); + } + } + }, 220); + }); + + caseBtn.addEventListener("click", async () => { + sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive; + caseBtn.classList.toggle("active"); + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (input.value.trim()) { + if (activeSidebarTab === "vaults") { + await performTreeSearch(q); + } else { + filterTagCloud(q); + } + } + }); + + clearBtn.addEventListener("click", async () => { + input.value = ""; + clearBtn.style.display = "none"; + sidebarFilterCaseSensitive = false; + caseBtn.classList.remove("active"); + clearTimeout(filterDebounce); + if (activeSidebarTab === "vaults") { + await restoreSidebarTree(); + } else { + filterTagCloud(""); + } + }); + + clearBtn.style.display = "none"; +} + +async function performTreeSearch(query) { + if (!query) { + await restoreSidebarTree(); + return; + } + + try { + const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault; + const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`; + const data = await api(url); + renderFilteredSidebarResults(query, data.results); + } catch (err) { + console.error("Tree search error:", err); + renderFilteredSidebarResults(query, []); + } +} + +async function restoreSidebarTree() { + await refreshSidebarForContext(); + if (currentVault) { + focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {}); + } +} + +function renderFilteredSidebarResults(query, results) { + const container = document.getElementById("vault-tree"); + container.innerHTML = ""; + + const grouped = new Map(); + results.forEach((result) => { + if (!grouped.has(result.vault)) { + grouped.set(result.vault, []); + } + grouped.get(result.vault).push(result); + }); + + if (grouped.size === 0) { + container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")])); + return; + } + + grouped.forEach((entries, vaultName) => { + entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" })); + + const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]); + container.appendChild(vaultHeader); + + const resultsWrapper = el("div", { class: "filter-results-group" }); + entries.forEach((entry) => { + const resultItem = el( + "div", + { + class: `tree-item filter-result-item filter-result-${entry.type}`, + "data-vault": entry.vault, + "data-path": entry.path, + "data-type": entry.type, + }, + [icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)], + ); + + const textWrap = el("div", { class: "filter-result-text" }); + const primary = el("div", { class: "filter-result-primary" }); + appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive); + const secondary = el("div", { class: "filter-result-secondary" }); + appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive); + textWrap.appendChild(primary); + textWrap.appendChild(secondary); + resultItem.appendChild(textWrap); + + resultItem.addEventListener("click", async () => { + const input = document.getElementById("sidebar-filter-input"); + const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + if (input) input.value = ""; + if (clearBtn) clearBtn.style.display = "none"; + await restoreSidebarTree(); + if (entry.type === "directory") { + await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true }); + } else { + await TabManager.openPreview(entry.vault, entry.path); + await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true }); + syncActiveFileTreeItem(entry.vault, entry.path); + } + closeMobileSidebar(); + }); + + resultsWrapper.appendChild(resultItem); + }); + + container.appendChild(resultsWrapper); + }); + + flushIcons(); +} + +function filterSidebarTree(query) { + const tree = document.getElementById("vault-tree"); + const items = tree.querySelectorAll(".tree-item"); + const containers = tree.querySelectorAll(".tree-children"); + + if (!query) { + items.forEach((item) => item.classList.remove("filtered-out")); + containers.forEach((c) => { + c.classList.remove("filtered-out"); + // Keep current collapsed state when clearing filter + }); + return; + } + + // First pass: mark all as filtered out + items.forEach((item) => item.classList.add("filtered-out")); + containers.forEach((c) => c.classList.add("filtered-out")); + + // Second pass: find matching items and mark them + ancestors + descendants + const matchingItems = new Set(); + + items.forEach((item) => { + const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase(); + const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); + if (text.includes(searchQuery)) { + matchingItems.add(item); + item.classList.remove("filtered-out"); + + // Show all ancestor containers + let parent = item.parentElement; + while (parent && parent !== tree) { + parent.classList.remove("filtered-out"); + if (parent.classList.contains("tree-children")) { + parent.classList.remove("collapsed"); + } + parent = parent.parentElement; + } + + // If this is a directory (has a children container after it), show all descendants + const nextEl = item.nextElementSibling; + if (nextEl && nextEl.classList.contains("tree-children")) { + nextEl.classList.remove("filtered-out"); + nextEl.classList.remove("collapsed"); + // Recursively show all children in this container + showAllDescendants(nextEl); + } + } + }); + + // Third pass: show items that are descendants of matching directories + // and ensure their containers are visible + matchingItems.forEach((item) => { + const nextEl = item.nextElementSibling; + if (nextEl && nextEl.classList.contains("tree-children")) { + const children = nextEl.querySelectorAll(".tree-item"); + children.forEach((child) => child.classList.remove("filtered-out")); + } + }); +} + +function showAllDescendants(container) { + const items = container.querySelectorAll(".tree-item"); + items.forEach((item) => { + item.classList.remove("filtered-out"); + // If this item has children, also show them + const nextEl = item.nextElementSibling; + if (nextEl && nextEl.classList.contains("tree-children")) { + nextEl.classList.remove("filtered-out"); + nextEl.classList.remove("collapsed"); + } + }); + // Also ensure all nested containers are visible + const nestedContainers = container.querySelectorAll(".tree-children"); + nestedContainers.forEach((c) => { + c.classList.remove("filtered-out"); + c.classList.remove("collapsed"); + }); +} + +function filterTagCloud(query) { + const tags = document.querySelectorAll("#tag-cloud .tag-item"); + tags.forEach((tag) => { + const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase(); + const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); + if (!query || text.includes(searchQuery)) { + tag.classList.remove("filtered-out"); + } else { + tag.classList.add("filtered-out"); + } + }); +} + +// --------------------------------------------------------------------------- +// Tag Filter Service +// --------------------------------------------------------------------------- +const TagFilterService = { + defaultFilters: [ + { pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true }, + { pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true }, + { pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true }, + ], + + getConfig() { + const stored = localStorage.getItem("obsigate-tag-filters"); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + return { tagFilters: this.defaultFilters }; + } + } + return { tagFilters: this.defaultFilters }; + }, + + saveConfig(config) { + localStorage.setItem("obsigate-tag-filters", JSON.stringify(config)); + }, + + patternToRegex(pattern) { + // 1. Escape ALL special regex characters + // We use a broader set including * and . + let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // 2. Convert escaped '*' to '.*' (wildcard) + regex = regex.replace(/\\\*/g, ".*"); + + // 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*' + // We also handle optional whitespace around it to make it more user-friendly + regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*"); + + return regex; + }, + + isTagFiltered(tag) { + const config = this.getConfig(); + const filters = config.tagFilters || this.defaultFilters; + const tagWithHash = `#${tag}`; + + for (const filter of filters) { + if (!filter.enabled) continue; + try { + // Robustly handle regex with or without ^/$ + let patternStr = filter.regex; + if (!patternStr.startsWith("^")) patternStr = "^" + patternStr; + if (!patternStr.endsWith("$")) patternStr = patternStr + "$"; + + const regex = new RegExp(patternStr); + if (regex.test(tagWithHash)) { + return true; + } + } catch (e) { + console.warn("Invalid regex:", filter.regex, e); + } + } + return false; + }, + + filterTags(tags) { + const filtered = {}; + Object.entries(tags).forEach(([tag, count]) => { + if (!this.isTagFiltered(tag)) { + filtered[tag] = count; + } + }); + return filtered; + }, +}; + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- +async function loadTags() { + const data = await api("/api/tags"); + const filteredTags = TagFilterService.filterTags(data.tags); + renderTagCloud(filteredTags); +} + +function renderTagCloud(tags) { + const cloud = document.getElementById("tag-cloud"); + cloud.innerHTML = ""; + + const counts = Object.values(tags); + if (counts.length === 0) return; + + const maxCount = Math.max(...counts); + const minSize = 0.7; + const maxSize = 1.25; + + Object.entries(tags).forEach(([tag, count]) => { + const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; + const size = minSize + ratio * (maxSize - minSize); + const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", () => searchByTag(tag)); + cloud.appendChild(tagEl); + }); +} + +function addTagFilter(tag) { + if (!selectedTags.includes(tag)) { + selectedTags.push(tag); + performTagSearch(); + } +} + +function removeTagFilter(tag) { + selectedTags = selectedTags.filter((t) => t !== tag); + if (selectedTags.length > 0) { + performTagSearch(); + } else { + const input = document.getElementById("search-input"); + if (input.value.trim()) { + performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null); + } else { + showWelcome(); + } + } +} + +function performTagSearch() { + const input = document.getElementById("search-input"); + const query = input.value.trim(); + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null); +} + +function buildSearchResultsHeader(data, query, tagFilter) { + const header = el("div", { class: "search-results-header" }); + const summaryText = el("span", { class: "search-results-summary-text" }); + + if (query && tagFilter) { + summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`; + } else if (query) { + summaryText.textContent = `${data.count} résultat(s) pour "${query}"`; + } else if (tagFilter) { + summaryText.textContent = `${data.count} fichier(s) avec les tags`; + } else { + summaryText.textContent = `${data.count} résultat(s)`; + } + + header.appendChild(summaryText); + + if (selectedTags.length > 0) { + const activeTags = el("div", { class: "search-results-active-tags" }); + selectedTags.forEach((tag) => { + const removeBtn = el( + "button", + { + class: "search-results-active-tag-remove", + title: `Retirer ${tag} du filtre`, + "aria-label": `Retirer ${tag} du filtre`, + }, + [document.createTextNode("×")], + ); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeTagFilter(tag); + }); + + const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + activeTags.appendChild(chip); + }); + header.appendChild(activeTags); + } + + return header; +} + +function searchByTag(tag) { + addTagFilter(tag); +} + + +export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext }; diff --git a/frontend/js/sidebar_raw.js b/frontend/js/sidebar_raw.js new file mode 100644 index 0000000..78c708a --- /dev/null +++ b/frontend/js/sidebar_raw.js @@ -0,0 +1,1087 @@ +// --------------------------------------------------------------------------- +// Vault context switching +// --------------------------------------------------------------------------- +function initVaultContext() { + const filter = document.getElementById("vault-filter"); + const quickSelect = document.getElementById("vault-quick-select"); + if (!filter || !quickSelect) return; + + filter.addEventListener("change", async () => { + await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" }); + }); + + quickSelect.addEventListener("change", async () => { + await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" }); + }); +} + +async function setSelectedVaultContext(vaultName, options) { + selectedContextVault = vaultName; + showingSource = false; + cachedRawSource = null; + syncVaultSelectors(); + await refreshSidebarForContext(); + await refreshTagsForContext(); + + // Synchroniser le dashboard et les fichiers récents + if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) { + DashboardRecentWidget.load(vaultName); + } + if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { + DashboardBookmarkWidget.load(vaultName); + } + if (activeSidebarTab === "recent") { + loadRecentFiles(vaultName === "all" ? null : vaultName); + } + + showWelcome(); + if (options && options.focusVault && vaultName !== "all") { + await focusVaultInSidebar(vaultName); + } +} + +function syncVaultSelectors() { + const filter = document.getElementById("vault-filter"); + const quickSelect = document.getElementById("vault-quick-select"); + const recentFilter = document.getElementById("recent-vault-filter"); + const dashboardFilter = document.getElementById("dashboard-vault-filter"); + const contextText = document.getElementById("vault-context-text"); + + if (filter) filter.value = selectedContextVault; + if (quickSelect) quickSelect.value = selectedContextVault; + if (recentFilter) recentFilter.value = selectedContextVault === "all" ? "" : selectedContextVault; + if (dashboardFilter) dashboardFilter.value = selectedContextVault; + + // Mise à jour visuelle des dropdowns personnalisés + updateCustomDropdownVisual("vault-filter-dropdown", selectedContextVault); + updateCustomDropdownVisual("vault-quick-select-dropdown", selectedContextVault); + + // Update vault context indicator + if (contextText) { + contextText.textContent = selectedContextVault === "all" ? "All" : selectedContextVault; + } +} + +/** + * Updates the visual state of a custom dropdown based on its current value. + */ +function updateCustomDropdownVisual(dropdownId, value) { + const dropdown = document.getElementById(dropdownId); + if (!dropdown) return; + + const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + const options = dropdown.querySelectorAll(".custom-dropdown-option"); + + options.forEach((opt) => { + const optValue = opt.getAttribute("data-value"); + if (optValue === value) { + opt.classList.add("selected"); + if (selectedText) selectedText.textContent = opt.textContent; + } else { + opt.classList.remove("selected"); + } + }); +} + +function scrollTreeItemIntoView(element, alignToTop) { + if (!element) return; + const scrollContainer = document.getElementById("sidebar-panel-vaults"); + if (!scrollContainer) return; + + const containerRect = scrollContainer.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const isAbove = elementRect.top < containerRect.top; + const isBelow = elementRect.bottom > containerRect.bottom; + + if (!isAbove && !isBelow && !alignToTop) return; + + const currentTop = scrollContainer.scrollTop; + const offsetTop = element.offsetTop; + const shouldCenter = alignToTop === "center"; + const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2)); + const targetTop = shouldCenter + ? centeredTop + : alignToTop + ? Math.max(0, offsetTop - 60) + : Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35); + + scrollContainer.scrollTo({ + top: targetTop, + behavior: "smooth", + }); +} + +async function refreshSidebarForContext() { + const container = document.getElementById("vault-tree"); + container.innerHTML = ""; + + const vaultsToShow = selectedContextVault === "all" ? allVaults : allVaults.filter((v) => v.name === selectedContextVault); + + vaultsToShow.forEach((v) => { + const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]); + vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); + + vaultItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); + }); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + + container.appendChild(vaultItem); + + const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); + container.appendChild(childContainer); + }); + + safeCreateIcons(); +} + +async function focusVaultInSidebar(vaultName) { + switchSidebarTab("vaults"); + const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); + if (!vaultItem) return; + document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); + vaultItem.classList.add("focused"); + const childContainer = document.getElementById(`vault-children-${vaultName}`); + if (childContainer && childContainer.classList.contains("collapsed")) { + await toggleVault(vaultItem, vaultName, true); + } + scrollTreeItemIntoView(vaultItem, false); +} + +async function refreshTagsForContext() { + const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`; + const data = await api(`/api/tags${vaultParam}`); + const filteredTags = TagFilterService.filterTags(data.tags); + renderTagCloud(filteredTags); +} + +// --------------------------------------------------------------------------- +// Helper: Check if path should be displayed based on hideHiddenFiles setting +// --------------------------------------------------------------------------- +function shouldDisplayPath(path, vaultName) { + // Get hideHiddenFiles setting for this vault (default: false = show all) + const settings = vaultSettings[vaultName] || { hideHiddenFiles: false }; + + if (!settings.hideHiddenFiles) { + // Show all files + return true; + } + + // Check if any segment of the path starts with a dot (hidden) + const segments = path.split("/").filter(Boolean); + for (const segment of segments) { + if (segment.startsWith(".")) { + return false; // Hide this path + } + } + + return true; // Show this path +} + +async function loadVaultSettings() { + try { + const settings = await api("/api/vaults/settings/all"); + vaultSettings = settings; + } catch (err) { + console.error("Failed to load vault settings:", err); + vaultSettings = {}; + } +} + +// --------------------------------------------------------------------------- +// Sidebar — Vault tree +// --------------------------------------------------------------------------- +async function loadVaults() { + const vaults = await api("/api/vaults"); + allVaults = vaults; + const container = document.getElementById("vault-tree"); + container.innerHTML = ""; + + // Prepare dropdown options + const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))]; + + // Populate custom dropdowns + populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all"); + populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all"); + + // Populate standard selects + _populateRecentVaultFilter(); + if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) { + DashboardRecentWidget.populateVaultFilter(); + } + + vaults.forEach((v) => { + // Sidebar tree entry + const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]); + vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); + + vaultItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); + }); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + + container.appendChild(vaultItem); + + const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); + container.appendChild(childContainer); + }); + + syncVaultSelectors(); + safeCreateIcons(); +} + +/** + * Refreshes the sidebar tree while preserving the expanded state of vaults and folders. + * Optimized to avoid a full sidebar wipe and minimize visible loading states. + */ +/** + * Incrementally update a directory container without wiping existing DOM. + * Only adds new items, removes deleted ones, and updates changed ones. + */ +async function incrementalLoadDirectory(vaultName, dirPath, container) { + let data; + try { + const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + data = await api(url); + } catch (err) { + // Server unavailable — keep existing content + return; + } + + // Build a map of existing DOM elements by path + const existingItems = {}; + const existingChildren = {}; // path -> child container (for directories) + for (let i = 0; i < container.children.length; i++) { + const child = container.children[i]; + if (child.classList.contains("tree-item") && child.dataset.path) { + existingItems[child.dataset.path] = child; + // The next sibling should be the tree-children container for this directory + if (i + 1 < container.children.length) { + const next = container.children[i + 1]; + if (next.classList.contains("tree-children")) { + existingChildren[child.dataset.path] = next; + } + } + } + } + + const fragment = document.createDocumentFragment(); + + data.items.forEach((item) => { + if (!shouldDisplayPath(item.path, vaultName)) return; + + const existing = existingItems[item.path]; + + if (existing) { + // Item already exists — reuse it, but update text/badge if needed + const textEl = existing.querySelector(".tree-item-text"); + const displayName = item.type === "file" && item.name.match(/\.md$/i) + ? item.name.replace(/\.md$/i, "") + : item.name; + if (textEl && textEl.textContent !== displayName) { + textEl.textContent = displayName; + } + // Update badge for directories + if (item.type === "directory") { + const badge = existing.querySelector(".badge-small"); + const newBadge = `(${item.children_count})`; + if (badge && badge.textContent !== newBadge) { + badge.textContent = newBadge; + } else if (!badge) { + existing.appendChild(smallBadge(item.children_count)); + } + } + fragment.appendChild(existing); + // Also re-add the child container for directories + if (item.type === "directory" && existingChildren[item.path]) { + fragment.appendChild(existingChildren[item.path]); + } else if (item.type === "directory") { + // Directory existed but no child container — create one + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + } + } else { + // New item — create it + if (item.type === "directory") { + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + fragment.appendChild(dirItem); + + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + + dirItem.addEventListener("click", async () => { + scrollTreeItemIntoView(dirItem, false); + if (subContainer.classList.contains("collapsed")) { + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, item.path, subContainer); + } + subContainer.classList.remove("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + subContainer.classList.add("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); + }); + } else { + const fileIconName = getFileIcon(item.name); + const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + fileItem.addEventListener("click", () => { + scrollTreeItemIntoView(fileItem, false); + TabManager.openPreview(vaultName, item.path); + closeMobileSidebar(); + }); + + fileItem.addEventListener("dblclick", (e) => { + e.preventDefault(); + TabManager.openPersistent(vaultName, item.path); + }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false); + }); + + fragment.appendChild(fileItem); + } + } + }); + + // Replace container content in a single batch operation to avoid flash + container.textContent = ""; + container.appendChild(fragment); +} + +async function refreshSidebarTreePreservingState() { + // 1. Capture expanded states + const expandedVaults = Array.from(document.querySelectorAll(".vault-item")) + .filter((v) => { + const children = document.getElementById(`vault-children-${v.dataset.vault}`); + return children && !children.classList.contains("collapsed"); + }) + .map((v) => v.dataset.vault); + + const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]")) + .filter((item) => { + const vault = item.dataset.vault; + const path = item.dataset.path; + const children = document.getElementById(`dir-${vault}-${path}`); + return children && !children.classList.contains("collapsed"); + }) + .map((item) => ({ vault: item.dataset.vault, path: item.dataset.path })); + + const selectedItem = document.querySelector(".tree-item.path-selected"); + const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null; + + // 2. Soft update: vault names/counts without wiping the tree + try { + const vaults = await api("/api/vaults"); + allVaults = vaults; + vaults.forEach((v) => { + const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`); + if (vItem) { + const badge = vItem.querySelector(".badge-small"); + if (badge) badge.textContent = `(${v.file_count})`; + } + }); + } catch (e) { + console.warn("Soft vault refresh failed, falling back to full reload", e); + await loadVaults(); + return; + } + + // 3. Incrementally update expanded vaults (no DOM wipe) + for (const vName of expandedVaults) { + const container = document.getElementById(`vault-children-${vName}`); + if (container) { + await incrementalLoadDirectory(vName, "", container); + } + } + + // 4. Incrementally update expanded directories (parents first, no DOM wipe) + expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length); + for (const dir of expandedDirs) { + const container = document.getElementById(`dir-${dir.vault}-${dir.path}`); + if (container) { + try { + await incrementalLoadDirectory(dir.vault, dir.path, container); + container.classList.remove("collapsed"); + const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`); + if (dItem) { + const chev = dItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + } + } catch (e) { + console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e); + } + } + } + + // 5. Restore selection + if (selectedState) { + await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false }); + } + + safeCreateIcons(); +} + +async function toggleVault(itemEl, vaultName, forceExpand) { + const childContainer = document.getElementById(`vault-children-${vaultName}`); + if (!childContainer) return; + + scrollTreeItemIntoView(itemEl, false); + + const shouldExpand = forceExpand || childContainer.classList.contains("collapsed"); + + if (shouldExpand) { + // Expand — load children if empty + if (childContainer.children.length === 0) { + await loadDirectory(vaultName, "", childContainer); + } + childContainer.classList.remove("collapsed"); + // Swap chevron + const chevron = itemEl.querySelector("[data-lucide]"); + if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + childContainer.classList.add("collapsed"); + const chevron = itemEl.querySelector("[data-lucide]"); + if (chevron) chevron.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } +} + +async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) { + const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`); + if (!subContainer) return null; + + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, dirPath, subContainer); + } + + subContainer.classList.remove("collapsed"); + if (dirItem) { + const chevron = dirItem.querySelector("[data-lucide]"); + if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); + } + safeCreateIcons(); + return subContainer; +} + +async function focusPathInSidebar(vaultName, targetPath, options) { + switchSidebarTab("vaults"); + + const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); + if (!vaultItem) return; + + document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); + vaultItem.classList.add("focused"); + + const vaultContainer = document.getElementById(`vault-children-${vaultName}`); + if (!vaultContainer) return; + + if (vaultContainer.classList.contains("collapsed")) { + await toggleVault(vaultItem, vaultName, true); + } + + if (!targetPath) { + // Clear any previous path selection + document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); + scrollTreeItemIntoView(vaultItem, options && options.alignToTop); + return; + } + + const segments = targetPath.split("/").filter(Boolean); + let currentContainer = vaultContainer; + let cumulativePath = ""; + let lastTargetItem = null; + + for (let index = 0; index < segments.length; index++) { + cumulativePath += (cumulativePath ? "/" : "") + segments[index]; + + let targetItem = null; + try { + targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`); + } catch (e) { + targetItem = null; + } + + if (!targetItem) { + return; + } + + lastTargetItem = targetItem; + + const isLastSegment = index === segments.length - 1; + if (!isLastSegment) { + const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem); + if (nextContainer) { + currentContainer = nextContainer; + } + } + } + + if (lastTargetItem && options && options.expandTarget) { + await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem); + } + + // Clear previous path selections and highlight the final target + document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); + if (lastTargetItem) { + lastTargetItem.classList.add("path-selected"); + } + + scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop); +} + +function getParentDirectoryPath(filePath) { + if (!filePath) return ""; + const segments = filePath.split("/").filter(Boolean); + if (segments.length <= 1) return ""; + segments.pop(); + return segments.join("/"); +} + +function syncActiveFileTreeItem(vaultName, filePath) { + document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + if (!vaultName || !filePath) return; + const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`; + try { + const active = document.querySelector(selector); + if (active) active.classList.add("active"); + } catch (e) { + /* selector might fail on special chars */ + } +} + +async function loadDirectory(vaultName, dirPath, container) { + // Only show the loading spinner if the container is currently empty + const isEmpty = container.children.length === 0; + if (isEmpty) { + container.innerHTML = '
    '; + } + + var data; + try { + const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + data = await api(url); + } catch (err) { + container.innerHTML = '
    Erreur de chargement
    '; + return; + } + container.innerHTML = ""; + + const fragment = document.createDocumentFragment(); + + data.items.forEach((item) => { + // Apply client-side filtering for hidden files + if (!shouldDisplayPath(item.path, vaultName)) { + return; // Skip this item + } + + if (item.type === "directory") { + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + fragment.appendChild(dirItem); + + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + + dirItem.addEventListener("click", async () => { + scrollTreeItemIntoView(dirItem, false); + if (subContainer.classList.contains("collapsed")) { + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, item.path, subContainer); + } + subContainer.classList.remove("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + subContainer.classList.add("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); + }); + } else { + const fileIconName = getFileIcon(item.name); + const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + fileItem.addEventListener("click", () => { + scrollTreeItemIntoView(fileItem, false); + TabManager.openPreview(vaultName, item.path); + closeMobileSidebar(); + }); + + fileItem.addEventListener("dblclick", (e) => { + e.preventDefault(); + TabManager.openPersistent(vaultName, item.path); + }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); + }); + + fragment.appendChild(fileItem); + } + }); + + container.appendChild(fragment); + safeCreateIcons(); +} + +// --------------------------------------------------------------------------- +// Sidebar filter +// --------------------------------------------------------------------------- +function initSidebarFilter() { + const input = document.getElementById("sidebar-filter-input"); + const caseBtn = document.getElementById("sidebar-filter-case-btn"); + const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + + input.addEventListener("input", () => { + const hasText = input.value.length > 0; + clearBtn.style.display = hasText ? "flex" : "none"; + clearTimeout(filterDebounce); + filterDebounce = setTimeout(async () => { + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (hasText) { + if (activeSidebarTab === "vaults") { + await performTreeSearch(q); + } else { + filterTagCloud(q); + } + } else { + if (activeSidebarTab === "vaults") { + await restoreSidebarTree(); + } else { + filterTagCloud(""); + } + } + }, 220); + }); + + caseBtn.addEventListener("click", async () => { + sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive; + caseBtn.classList.toggle("active"); + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (input.value.trim()) { + if (activeSidebarTab === "vaults") { + await performTreeSearch(q); + } else { + filterTagCloud(q); + } + } + }); + + clearBtn.addEventListener("click", async () => { + input.value = ""; + clearBtn.style.display = "none"; + sidebarFilterCaseSensitive = false; + caseBtn.classList.remove("active"); + clearTimeout(filterDebounce); + if (activeSidebarTab === "vaults") { + await restoreSidebarTree(); + } else { + filterTagCloud(""); + } + }); + + clearBtn.style.display = "none"; +} + +async function performTreeSearch(query) { + if (!query) { + await restoreSidebarTree(); + return; + } + + try { + const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault; + const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`; + const data = await api(url); + renderFilteredSidebarResults(query, data.results); + } catch (err) { + console.error("Tree search error:", err); + renderFilteredSidebarResults(query, []); + } +} + +async function restoreSidebarTree() { + await refreshSidebarForContext(); + if (currentVault) { + focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {}); + } +} + +function renderFilteredSidebarResults(query, results) { + const container = document.getElementById("vault-tree"); + container.innerHTML = ""; + + const grouped = new Map(); + results.forEach((result) => { + if (!grouped.has(result.vault)) { + grouped.set(result.vault, []); + } + grouped.get(result.vault).push(result); + }); + + if (grouped.size === 0) { + container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")])); + return; + } + + grouped.forEach((entries, vaultName) => { + entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" })); + + const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]); + container.appendChild(vaultHeader); + + const resultsWrapper = el("div", { class: "filter-results-group" }); + entries.forEach((entry) => { + const resultItem = el( + "div", + { + class: `tree-item filter-result-item filter-result-${entry.type}`, + "data-vault": entry.vault, + "data-path": entry.path, + "data-type": entry.type, + }, + [icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)], + ); + + const textWrap = el("div", { class: "filter-result-text" }); + const primary = el("div", { class: "filter-result-primary" }); + appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive); + const secondary = el("div", { class: "filter-result-secondary" }); + appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive); + textWrap.appendChild(primary); + textWrap.appendChild(secondary); + resultItem.appendChild(textWrap); + + resultItem.addEventListener("click", async () => { + const input = document.getElementById("sidebar-filter-input"); + const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + if (input) input.value = ""; + if (clearBtn) clearBtn.style.display = "none"; + await restoreSidebarTree(); + if (entry.type === "directory") { + await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true }); + } else { + await TabManager.openPreview(entry.vault, entry.path); + await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true }); + syncActiveFileTreeItem(entry.vault, entry.path); + } + closeMobileSidebar(); + }); + + resultsWrapper.appendChild(resultItem); + }); + + container.appendChild(resultsWrapper); + }); + + flushIcons(); +} + +function filterSidebarTree(query) { + const tree = document.getElementById("vault-tree"); + const items = tree.querySelectorAll(".tree-item"); + const containers = tree.querySelectorAll(".tree-children"); + + if (!query) { + items.forEach((item) => item.classList.remove("filtered-out")); + containers.forEach((c) => { + c.classList.remove("filtered-out"); + // Keep current collapsed state when clearing filter + }); + return; + } + + // First pass: mark all as filtered out + items.forEach((item) => item.classList.add("filtered-out")); + containers.forEach((c) => c.classList.add("filtered-out")); + + // Second pass: find matching items and mark them + ancestors + descendants + const matchingItems = new Set(); + + items.forEach((item) => { + const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase(); + const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); + if (text.includes(searchQuery)) { + matchingItems.add(item); + item.classList.remove("filtered-out"); + + // Show all ancestor containers + let parent = item.parentElement; + while (parent && parent !== tree) { + parent.classList.remove("filtered-out"); + if (parent.classList.contains("tree-children")) { + parent.classList.remove("collapsed"); + } + parent = parent.parentElement; + } + + // If this is a directory (has a children container after it), show all descendants + const nextEl = item.nextElementSibling; + if (nextEl && nextEl.classList.contains("tree-children")) { + nextEl.classList.remove("filtered-out"); + nextEl.classList.remove("collapsed"); + // Recursively show all children in this container + showAllDescendants(nextEl); + } + } + }); + + // Third pass: show items that are descendants of matching directories + // and ensure their containers are visible + matchingItems.forEach((item) => { + const nextEl = item.nextElementSibling; + if (nextEl && nextEl.classList.contains("tree-children")) { + const children = nextEl.querySelectorAll(".tree-item"); + children.forEach((child) => child.classList.remove("filtered-out")); + } + }); +} + +function showAllDescendants(container) { + const items = container.querySelectorAll(".tree-item"); + items.forEach((item) => { + item.classList.remove("filtered-out"); + // If this item has children, also show them + const nextEl = item.nextElementSibling; + if (nextEl && nextEl.classList.contains("tree-children")) { + nextEl.classList.remove("filtered-out"); + nextEl.classList.remove("collapsed"); + } + }); + // Also ensure all nested containers are visible + const nestedContainers = container.querySelectorAll(".tree-children"); + nestedContainers.forEach((c) => { + c.classList.remove("filtered-out"); + c.classList.remove("collapsed"); + }); +} + +function filterTagCloud(query) { + const tags = document.querySelectorAll("#tag-cloud .tag-item"); + tags.forEach((tag) => { + const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase(); + const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); + if (!query || text.includes(searchQuery)) { + tag.classList.remove("filtered-out"); + } else { + tag.classList.add("filtered-out"); + } + }); +} + +// --------------------------------------------------------------------------- +// Tag Filter Service +// --------------------------------------------------------------------------- +const TagFilterService = { + defaultFilters: [ + { pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true }, + { pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true }, + { pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true }, + ], + + getConfig() { + const stored = localStorage.getItem("obsigate-tag-filters"); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + return { tagFilters: this.defaultFilters }; + } + } + return { tagFilters: this.defaultFilters }; + }, + + saveConfig(config) { + localStorage.setItem("obsigate-tag-filters", JSON.stringify(config)); + }, + + patternToRegex(pattern) { + // 1. Escape ALL special regex characters + // We use a broader set including * and . + let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // 2. Convert escaped '*' to '.*' (wildcard) + regex = regex.replace(/\\\*/g, ".*"); + + // 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*' + // We also handle optional whitespace around it to make it more user-friendly + regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*"); + + return regex; + }, + + isTagFiltered(tag) { + const config = this.getConfig(); + const filters = config.tagFilters || this.defaultFilters; + const tagWithHash = `#${tag}`; + + for (const filter of filters) { + if (!filter.enabled) continue; + try { + // Robustly handle regex with or without ^/$ + let patternStr = filter.regex; + if (!patternStr.startsWith("^")) patternStr = "^" + patternStr; + if (!patternStr.endsWith("$")) patternStr = patternStr + "$"; + + const regex = new RegExp(patternStr); + if (regex.test(tagWithHash)) { + return true; + } + } catch (e) { + console.warn("Invalid regex:", filter.regex, e); + } + } + return false; + }, + + filterTags(tags) { + const filtered = {}; + Object.entries(tags).forEach(([tag, count]) => { + if (!this.isTagFiltered(tag)) { + filtered[tag] = count; + } + }); + return filtered; + }, +}; + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- +async function loadTags() { + const data = await api("/api/tags"); + const filteredTags = TagFilterService.filterTags(data.tags); + renderTagCloud(filteredTags); +} + +function renderTagCloud(tags) { + const cloud = document.getElementById("tag-cloud"); + cloud.innerHTML = ""; + + const counts = Object.values(tags); + if (counts.length === 0) return; + + const maxCount = Math.max(...counts); + const minSize = 0.7; + const maxSize = 1.25; + + Object.entries(tags).forEach(([tag, count]) => { + const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; + const size = minSize + ratio * (maxSize - minSize); + const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", () => searchByTag(tag)); + cloud.appendChild(tagEl); + }); +} + +function addTagFilter(tag) { + if (!selectedTags.includes(tag)) { + selectedTags.push(tag); + performTagSearch(); + } +} + +function removeTagFilter(tag) { + selectedTags = selectedTags.filter((t) => t !== tag); + if (selectedTags.length > 0) { + performTagSearch(); + } else { + const input = document.getElementById("search-input"); + if (input.value.trim()) { + performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null); + } else { + showWelcome(); + } + } +} + +function performTagSearch() { + const input = document.getElementById("search-input"); + const query = input.value.trim(); + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null); +} + +function buildSearchResultsHeader(data, query, tagFilter) { + const header = el("div", { class: "search-results-header" }); + const summaryText = el("span", { class: "search-results-summary-text" }); + + if (query && tagFilter) { + summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`; + } else if (query) { + summaryText.textContent = `${data.count} résultat(s) pour "${query}"`; + } else if (tagFilter) { + summaryText.textContent = `${data.count} fichier(s) avec les tags`; + } else { + summaryText.textContent = `${data.count} résultat(s)`; + } + + header.appendChild(summaryText); + + if (selectedTags.length > 0) { + const activeTags = el("div", { class: "search-results-active-tags" }); + selectedTags.forEach((tag) => { + const removeBtn = el( + "button", + { + class: "search-results-active-tag-remove", + title: `Retirer ${tag} du filtre`, + "aria-label": `Retirer ${tag} du filtre`, + }, + [document.createTextNode("×")], + ); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeTagFilter(tag); + }); + + const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + activeTags.appendChild(chip); + }); + header.appendChild(activeTags); + } + + return header; +} + +function searchByTag(tag) { + addTagFilter(tag); +} + diff --git a/frontend/js/state.js b/frontend/js/state.js new file mode 100644 index 0000000..1b8cf06 --- /dev/null +++ b/frontend/js/state.js @@ -0,0 +1,55 @@ +/* ObsiGate — Shared application state */ +export const APP_VERSION = "1.5.0"; + +// Core navigation state +export let currentVault = null; +export let currentPath = null; +export let allVaults = []; +export let selectedContextVault = "all"; + +// Search state +export let searchTimeout = null; +export let searchAbortController = null; +export let advancedSearchOffset = 0; +export let advancedSearchTotal = 0; +export let advancedSearchSort = "relevance"; +export let advancedSearchLastQuery = ""; +export let suggestAbortController = null; +export let dropdownActiveIndex = -1; +export let dropdownItems = []; +export let currentSearchId = 0; +export let selectedTags = []; +export let searchCaseSensitive = false; +export let searchWholeWord = false; +export let searchRegex = false; +export let searchFilterVisible = false; + +// Search constants +export const SEARCH_HISTORY_KEY = "obsigate_search_history"; +export const MAX_HISTORY_ENTRIES = 50; +export const SUGGEST_DEBOUNCE_MS = 150; +export const ADVANCED_SEARCH_LIMIT = 50; +export const MIN_SEARCH_LENGTH = 2; +export const SEARCH_TIMEOUT_MS = 30000; + +// Viewer state +export let showingSource = false; +export let cachedRawSource = null; +export let editorView = null; +export let editorVault = null; +export let editorPath = null; +export let fallbackEditorEl = null; +export let _iconDebounceTimer = null; + +// Outline/TOC state +export let outlineObserver = null; +export let activeHeadingId = null; +export let headingsCache = []; +export let rightSidebarVisible = true; +export let rightSidebarWidth = 280; + +// Sidebar state +export let sidebarFilterCaseSensitive = false; +export let activeSidebarTab = "vaults"; +export let filterDebounce = null; +export let vaultSettings = {}; diff --git a/frontend/js/sync.js b/frontend/js/sync.js new file mode 100644 index 0000000..81159b8 --- /dev/null +++ b/frontend/js/sync.js @@ -0,0 +1,437 @@ +/* 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 = `
    + 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; +} + +// --------------------------------------------------------------------------- +// 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 }; +export function init() { + registerServiceWorker(); + initDashboardTabs(); +} diff --git a/frontend/js/ui.js b/frontend/js/ui.js new file mode 100644 index 0000000..d39ec2b --- /dev/null +++ b/frontend/js/ui.js @@ -0,0 +1,2250 @@ +/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */ +import { rightSidebarVisible, rightSidebarWidth, currentVault, currentPath, selectedContextVault } from './state.js'; + +// --------------------------------------------------------------------------- +// Right Sidebar Manager +// --------------------------------------------------------------------------- + +const RightSidebarManager = { + init() { + this.loadState(); + this.initToggle(); + this.initResize(); + }, + + loadState() { + const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible"); + const savedWidth = localStorage.getItem("obsigate-right-sidebar-width"); + + if (savedVisible !== null) { + rightSidebarVisible = savedVisible === "true"; + } + + if (savedWidth) { + rightSidebarWidth = parseInt(savedWidth) || 280; + } + + this.applyState(); + }, + + applyState() { + const sidebar = document.getElementById("right-sidebar"); + const handle = document.getElementById("right-sidebar-resize-handle"); + const tocBtn = document.getElementById("toc-toggle-btn"); + const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn"); + + if (!sidebar) return; + + if (rightSidebarVisible) { + sidebar.classList.remove("hidden"); + sidebar.style.width = `${rightSidebarWidth}px`; + if (handle) handle.classList.remove("hidden"); + if (tocBtn) { + tocBtn.classList.add("active"); + tocBtn.title = "Masquer le sommaire"; + } + if (headerToggleBtn) { + headerToggleBtn.title = "Masquer le panneau"; + headerToggleBtn.setAttribute("aria-label", "Masquer le panneau"); + } + } else { + sidebar.classList.add("hidden"); + if (handle) handle.classList.add("hidden"); + if (tocBtn) { + tocBtn.classList.remove("active"); + tocBtn.title = "Afficher le sommaire"; + } + if (headerToggleBtn) { + headerToggleBtn.title = "Afficher le panneau"; + headerToggleBtn.setAttribute("aria-label", "Afficher le panneau"); + } + } + + // Update icons + safeCreateIcons(); + }, + + toggle() { + rightSidebarVisible = !rightSidebarVisible; + localStorage.setItem("obsigate-right-sidebar-visible", rightSidebarVisible); + this.applyState(); + }, + + initToggle() { + const toggleBtn = document.getElementById("right-sidebar-toggle-btn"); + if (toggleBtn) { + toggleBtn.addEventListener("click", () => this.toggle()); + } + }, + + initResize() { + const handle = document.getElementById("right-sidebar-resize-handle"); + const sidebar = document.getElementById("right-sidebar"); + + if (!handle || !sidebar) return; + + let isResizing = false; + let startX = 0; + let startWidth = 0; + + const onMouseDown = (e) => { + isResizing = true; + startX = e.clientX; + startWidth = sidebar.offsetWidth; + handle.classList.add("active"); + document.body.style.cursor = "ew-resize"; + document.body.style.userSelect = "none"; + }; + + const onMouseMove = (e) => { + if (!isResizing) return; + + const delta = startX - e.clientX; + let newWidth = startWidth + delta; + + // Constrain width + newWidth = Math.max(200, Math.min(400, newWidth)); + + sidebar.style.width = `${newWidth}px`; + rightSidebarWidth = newWidth; + }; + + const onMouseUp = () => { + if (!isResizing) return; + + isResizing = false; + handle.classList.remove("active"); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + + localStorage.setItem("obsigate-right-sidebar-width", rightSidebarWidth); + }; + + handle.addEventListener("mousedown", onMouseDown); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }, +}; + +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- +export function initTheme() { + const saved = localStorage.getItem("obsigate-theme") || "dark"; + applyTheme(saved); +} + +export function applyTheme(theme) { + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("obsigate-theme", theme); + + // Update theme button icon and label + const themeBtn = document.getElementById("theme-toggle"); + const themeLabel = document.getElementById("theme-label"); + if (themeBtn && themeLabel) { + const icon = themeBtn.querySelector("i"); + if (icon) { + icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun"); + } + themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair"; + safeCreateIcons(); + } + + // Swap highlight.js theme + const darkSheet = document.getElementById("hljs-theme-dark"); + const lightSheet = document.getElementById("hljs-theme-light"); + if (darkSheet && lightSheet) { + darkSheet.disabled = theme !== "dark"; + lightSheet.disabled = theme !== "light"; + } +} + +export function toggleTheme() { + const current = document.documentElement.getAttribute("data-theme"); + applyTheme(current === "dark" ? "light" : "dark"); +} + +export function initHeaderMenu() { + const menuBtn = document.getElementById("header-menu-btn"); + const menuDropdown = document.getElementById("header-menu-dropdown"); + + if (!menuBtn || !menuDropdown) return; + + menuBtn.addEventListener("click", (e) => { + e.stopPropagation(); + menuBtn.classList.toggle("active"); + menuDropdown.classList.toggle("active"); + }); + + // Close menu when clicking outside + document.addEventListener("click", (e) => { + if (!menuDropdown.contains(e.target) && e.target !== menuBtn) { + menuBtn.classList.remove("active"); + menuDropdown.classList.remove("active"); + } + }); + + // Prevent menu from closing when clicking inside + menuDropdown.addEventListener("click", (e) => { + e.stopPropagation(); + }); +} + +function closeHeaderMenu() { + const menuBtn = document.getElementById("header-menu-btn"); + const menuDropdown = document.getElementById("header-menu-dropdown"); + if (!menuBtn || !menuDropdown) return; + menuBtn.classList.remove("active"); + menuDropdown.classList.remove("active"); +} + +// --------------------------------------------------------------------------- +// Custom Dropdowns +// --------------------------------------------------------------------------- +export function initCustomDropdowns() { + document.querySelectorAll(".custom-dropdown").forEach((dropdown) => { + const trigger = dropdown.querySelector(".custom-dropdown-trigger"); + const options = dropdown.querySelectorAll(".custom-dropdown-option"); + const hiddenInput = dropdown.querySelector('input[type="hidden"]'); + const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + const menu = dropdown.querySelector(".custom-dropdown-menu"); + + if (!trigger) return; + + // Toggle dropdown + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = dropdown.classList.contains("open"); + + // Close all other dropdowns + document.querySelectorAll(".custom-dropdown.open").forEach((d) => { + if (d !== dropdown) d.classList.remove("open"); + }); + + dropdown.classList.toggle("open", !isOpen); + trigger.setAttribute("aria-expanded", !isOpen); + + // Position fixed menu for sidebar dropdowns + if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) { + const rect = trigger.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 4}px`; + menu.style.left = `${rect.left}px`; + menu.style.width = `${rect.width}px`; + } + }); + + // Handle option selection + options.forEach((option) => { + option.addEventListener("click", (e) => { + e.stopPropagation(); + const value = option.getAttribute("data-value"); + const text = option.textContent; + + // Update hidden input + if (hiddenInput) { + hiddenInput.value = value; + // Trigger change event + hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); + } + + // Update selected text + if (selectedText) { + selectedText.textContent = text; + } + + // Update visual selection + options.forEach((opt) => opt.classList.remove("selected")); + option.classList.add("selected"); + + // Close dropdown + dropdown.classList.remove("open"); + trigger.setAttribute("aria-expanded", "false"); + }); + }); + }); + + // Close dropdowns when clicking outside + document.addEventListener("click", () => { + document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => { + dropdown.classList.remove("open"); + const trigger = dropdown.querySelector(".custom-dropdown-trigger"); + if (trigger) trigger.setAttribute("aria-expanded", "false"); + }); + }); +} + +// Helper to populate custom dropdown options +function populateCustomDropdown(dropdownId, optionsList, defaultValue) { + const dropdown = document.getElementById(dropdownId); + if (!dropdown) return; + + const optionsContainer = dropdown.querySelector(".custom-dropdown-menu"); + const hiddenInput = dropdown.querySelector('input[type="hidden"]'); + const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + + if (!optionsContainer) return; + + // Clear existing options (keep the first one if it's the default) + optionsContainer.innerHTML = ""; + + // Add new options + optionsList.forEach((opt) => { + const li = document.createElement("li"); + li.className = "custom-dropdown-option"; + li.setAttribute("role", "option"); + li.setAttribute("data-value", opt.value); + li.textContent = opt.text; + if (opt.value === defaultValue) { + li.classList.add("selected"); + if (selectedText) selectedText.textContent = opt.text; + if (hiddenInput) hiddenInput.value = opt.value; + } + optionsContainer.appendChild(li); + }); + + // Re-initialize click handlers + optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => { + option.addEventListener("click", (e) => { + e.stopPropagation(); + const value = option.getAttribute("data-value"); + const text = option.textContent; + + if (hiddenInput) { + hiddenInput.value = value; + hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); + } + + if (selectedText) { + selectedText.textContent = text; + } + + optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected")); + option.classList.add("selected"); + + dropdown.classList.remove("open"); + const trigger = dropdown.querySelector(".custom-dropdown-trigger"); + if (trigger) trigger.setAttribute("aria-expanded", "false"); + }); + }); +} + +// --------------------------------------------------------------------------- +// Toast notifications +// --------------------------------------------------------------------------- + +/** Display a brief toast message at the bottom of the viewport. */ +export function showToast(message, type) { + console.log("showToast called with:", message, type); + type = type || "info"; + let container = document.getElementById("toast-container"); + if (!container) { + container = document.createElement("div"); + container.id = "toast-container"; + container.className = "toast-container"; + container.setAttribute("aria-live", "polite"); + document.body.appendChild(container); + } + var toast = document.createElement("div"); + toast.className = "toast toast-" + type; + toast.textContent = message; + container.appendChild(toast); + // Trigger entrance animation + requestAnimationFrame(function () { + toast.classList.add("show"); + }); + setTimeout(function () { + toast.classList.remove("show"); + toast.addEventListener("transitionend", function () { + toast.remove(); + }); + }, 3500); +} + +// --------------------------------------------------------------------------- +// Sidebar toggle (desktop) +// --------------------------------------------------------------------------- +export function initSidebarToggle() { + const toggleBtn = document.getElementById("sidebar-toggle-btn"); + const sidebar = document.getElementById("sidebar"); + const resizeHandle = document.getElementById("sidebar-resize-handle"); + + if (!toggleBtn || !sidebar || !resizeHandle) return; + + // Restore saved state + const savedState = localStorage.getItem("obsigate-sidebar-hidden"); + if (savedState === "true") { + sidebar.classList.add("hidden"); + resizeHandle.classList.add("hidden"); + toggleBtn.classList.add("active"); + } + + toggleBtn.addEventListener("click", () => { + const isHidden = sidebar.classList.toggle("hidden"); + resizeHandle.classList.toggle("hidden", isHidden); + toggleBtn.classList.toggle("active", isHidden); + localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false"); + }); +} + +// --------------------------------------------------------------------------- +// Mobile sidebar +// --------------------------------------------------------------------------- +export function initMobile() { + const hamburger = document.getElementById("hamburger-btn"); + const overlay = document.getElementById("sidebar-overlay"); + const sidebar = document.getElementById("sidebar"); + + hamburger.addEventListener("click", () => { + sidebar.classList.toggle("mobile-open"); + overlay.classList.toggle("active"); + }); + + overlay.addEventListener("click", () => { + sidebar.classList.remove("mobile-open"); + overlay.classList.remove("active"); + }); +} + +function closeMobileSidebar() { + const sidebar = document.getElementById("sidebar"); + const overlay = document.getElementById("sidebar-overlay"); + if (sidebar) sidebar.classList.remove("mobile-open"); + if (overlay) overlay.classList.remove("active"); +} + +// --------------------------------------------------------------------------- +// Resizable sidebar (horizontal) +// --------------------------------------------------------------------------- +export function initSidebarResize() { + const handle = document.getElementById("sidebar-resize-handle"); + const sidebar = document.getElementById("sidebar"); + if (!handle || !sidebar) return; + + // Restore saved width + const savedWidth = localStorage.getItem("obsigate-sidebar-width"); + if (savedWidth) { + sidebar.style.width = savedWidth + "px"; + } + + let startX = 0; + let startWidth = 0; + + function onMouseMove(e) { + const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX))); + sidebar.style.width = newWidth + "px"; + } + + function onMouseUp() { + document.body.classList.remove("resizing"); + handle.classList.remove("active"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width)); + } + + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + startX = e.clientX; + startWidth = sidebar.getBoundingClientRect().width; + document.body.classList.add("resizing"); + handle.classList.add("active"); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); +} + +// --------------------------------------------------------------------------- +// Resizable tag section (vertical) +// --------------------------------------------------------------------------- +export function initTagResize() { + const handle = document.getElementById("tag-resize-handle"); + const tagSection = document.getElementById("tag-cloud-section"); + if (!handle || !tagSection) return; + + // Restore saved height + const savedHeight = localStorage.getItem("obsigate-tag-height"); + if (savedHeight) { + tagSection.style.height = savedHeight + "px"; + } + + let startY = 0; + let startHeight = 0; + + function onMouseMove(e) { + // Dragging up increases height, dragging down decreases + const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY))); + tagSection.style.height = newHeight + "px"; + } + + function onMouseUp() { + document.body.classList.remove("resizing-v"); + handle.classList.remove("active"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height)); + } + + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + startY = e.clientY; + startHeight = tagSection.getBoundingClientRect().height; + document.body.classList.add("resizing-v"); + handle.classList.add("active"); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); +} + +// --------------------------------------------------------------------------- +// Frontmatter Accent Card Builder +// --------------------------------------------------------------------------- + +function buildFrontmatterCard(frontmatter) { + // Helper: format date + function formatDate(iso) { + if (!iso) return "—"; + const d = new Date(iso); + const date = d.toISOString().slice(0, 10); + const time = d.toTimeString().slice(0, 5); + return `${date} · ${time}`; + } + + // Extract boolean flags + const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); + + // Toggle state + let isOpen = true; + + // Build header with chevron + const chevron = el("span", { class: "fm-chevron open" }); + chevron.innerHTML = ''; + + const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); + + // ZONE 1: Top strip + const topBadges = []; + + // Title badge + const title = frontmatter.titre || frontmatter.title || ""; + if (title) { + topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); + } + + // Status badge + if (frontmatter.statut) { + const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); + topBadges.push(statusBadge); + } + + // Category badge + if (frontmatter.catégorie || frontmatter.categorie) { + const cat = frontmatter.catégorie || frontmatter.categorie; + const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); + topBadges.push(catBadge); + } + + // Publish badge + if (frontmatter.publish) { + topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); + } + + // Favoris badge + if (frontmatter.favoris) { + topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); + } + + const acTop = el("div", { class: "ac-top" }, topBadges); + + // ZONE 2: Body 2 columns + const leftCol = el("div", { class: "ac-col" }, [ + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]), + ]); + + const rightCol = el("div", { class: "ac-col" }, [ + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), + ]); + + const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); + + // ZONE 3: Tags row + const tagPills = []; + if (frontmatter.tags && frontmatter.tags.length > 0) { + frontmatter.tags.forEach((tag) => { + tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); + }); + } + + const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); + + // ZONE 4: Flags row + const flagChips = []; + booleanFlags.forEach((flag) => { + const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; + flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); + }); + + const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); + + // Assemble the card + const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); + + // Toggle functionality + fmHeader.addEventListener("click", () => { + isOpen = !isOpen; + if (isOpen) { + acCard.style.display = "block"; + chevron.classList.remove("closed"); + chevron.classList.add("open"); + } else { + acCard.style.display = "none"; + chevron.classList.remove("open"); + chevron.classList.add("closed"); + } + safeCreateIcons(); + }); + + // Wrap in section + const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); + + return fmSection; +} + +// --------------------------------------------------------------------------- +// Context Menu Manager +// --------------------------------------------------------------------------- +export const ContextMenuManager = { + _menu: null, + _targetElement: null, + _targetVault: null, + _targetPath: null, + _targetType: null, + + init() { + this._menu = document.createElement('div'); + this._menu.className = 'context-menu'; + this._menu.id = 'context-menu'; + document.body.appendChild(this._menu); + + document.addEventListener('click', () => this.hide()); + document.addEventListener('contextmenu', (e) => { + if (!e.target.closest('.tree-item')) { + this.hide(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.hide(); + }); + + document.addEventListener('scroll', () => this.hide(), true); + }, + + show(x, y, vault, path, type, isReadonly) { + this._targetVault = vault; + this._targetPath = path; + this._targetType = type; + + this._menu.innerHTML = ''; + + // Copy path — available for all types + const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; + this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); + + // Graph view — available for all types + const graphPath = type === 'vault' ? '' : path; + this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); + + this._addSeparator(); + + if (type === 'vault') { + this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); + this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); + } else if (type === 'directory') { + this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); + this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); + this._addSeparator(); + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); + } else if (type === 'file') { + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); + this._addSeparator(); + this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false); + } + + this._menu.classList.add('active'); + + const rect = this._menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let finalX = x; + let finalY = y; + + if (x + rect.width > viewportWidth) { + finalX = viewportWidth - rect.width - 10; + } + + if (y + rect.height > viewportHeight) { + finalY = viewportHeight - rect.height - 10; + } + + this._menu.style.left = `${finalX}px`; + this._menu.style.top = `${finalY}px`; + + safeCreateIcons(); + }, + + hide() { + if (this._menu) { + this._menu.classList.remove('active'); + } + }, + + _addItem(icon, label, callback, disabled) { + const item = document.createElement('div'); + item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); + item.innerHTML = ` + + ${label} + `; + + if (!disabled) { + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.hide(); + callback(); + }); + } else { + item.title = 'Vault en lecture seule'; + } + + this._menu.appendChild(item); + }, + + _addSeparator() { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + this._menu.appendChild(sep); + }, + + _createDirectory() { + FileOperationsManager.showCreateDirectoryModal(this._targetVault, this._targetPath); + }, + + _createFile() { + FileOperationsManager.showCreateFileModal(this._targetVault, this._targetPath); + }, + + _renameItem() { + FileOperationsManager.startInlineRename(this._targetVault, this._targetPath, this._targetType); + }, + + _deleteDirectory() { + FileOperationsManager.confirmDeleteDirectory(this._targetVault, this._targetPath); + }, + + _deleteFile() { + FileOperationsManager.confirmDeleteFile(this._targetVault, this._targetPath); + }, + + _copyPath(path) { + // Try modern clipboard API first, fall back to execCommand for non-secure contexts + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(path).then(() => { + showToast(`Chemin copié : ${path}`, 'success'); + }).catch(() => { + this._copyPathFallback(path); + }); + } else { + this._copyPathFallback(path); + } + }, + + _copyPathFallback(path) { + const textarea = document.createElement('textarea'); + textarea.value = path; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + const success = document.execCommand('copy'); + if (success) { + showToast(`Chemin copié : ${path}`, 'success'); + } else { + showToast('Erreur lors de la copie', 'error'); + } + } catch (e) { + showToast('Erreur lors de la copie', 'error'); + } + document.body.removeChild(textarea); + }, + + async _toggleBookmark() { + try { + const data = await api("/api/bookmarks/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }), + }); + showToast(data.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); + if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { + DashboardBookmarkWidget.load(); + } + } catch (err) { showToast("Erreur: " + err.message, "error"); } + } +}; + +// --------------------------------------------------------------------------- +// File Operations Manager +// --------------------------------------------------------------------------- + +export const FileOperationsManager = { + showCreateDirectoryModal(vault, parentPath) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
    +

    Créer un dossier

    +
    +
    + +
    + + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + + const input = modal.querySelector('#dir-name-input'); + const errorDiv = modal.querySelector('#dir-error'); + const createBtn = modal.querySelector('#dir-create-btn'); + const cancelBtn = modal.querySelector('#dir-cancel-btn'); + + input.focus(); + + const validateName = (name) => { + if (!name.trim()) return 'Le nom ne peut pas être vide'; + if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + return null; + }; + + input.addEventListener('input', () => { + const error = validateName(input.value); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + input.classList.add('error'); + } else { + errorDiv.style.display = 'none'; + input.classList.remove('error'); + } + }); + + const create = async () => { + const name = input.value.trim(); + const error = validateName(name); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + return; + } + + const path = parentPath ? `${parentPath}/${name}` : name; + createBtn.disabled = true; + createBtn.textContent = 'Création...'; + + try { + await api(`/api/directory/${encodeURIComponent(vault)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + + showToast(`Dossier "${name}" créé`, 'success'); + this._closeModal(overlay); + await refreshSidebarTreePreservingState(); + } catch (err) { + showToast(err.message || 'Erreur lors de la création', 'error'); + createBtn.disabled = false; + createBtn.textContent = 'Créer'; + } + }; + + createBtn.addEventListener('click', create); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') create(); + if (e.key === 'Escape') this._closeModal(overlay); + }); + }, + + showCreateFileModal(vault, parentPath) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
    +

    Créer un fichier

    +
    +
    + + +
    + + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + + const input = modal.querySelector('#file-name-input'); + const extSelect = modal.querySelector('#file-ext-select'); + const errorDiv = modal.querySelector('#file-error'); + const createBtn = modal.querySelector('#file-create-btn'); + const cancelBtn = modal.querySelector('#file-cancel-btn'); + + input.focus(); + + const validateName = (name) => { + if (!name.trim()) return 'Le nom ne peut pas être vide'; + if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + return null; + }; + + input.addEventListener('input', () => { + const error = validateName(input.value); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + input.classList.add('error'); + } else { + errorDiv.style.display = 'none'; + input.classList.remove('error'); + } + }); + + const create = async () => { + let name = input.value.trim(); + const error = validateName(name); + if (error) { + errorDiv.textContent = error; + errorDiv.style.display = 'block'; + return; + } + + const ext = extSelect.value; + if (!name.endsWith(ext)) { + name += ext; + } + + const path = parentPath ? `${parentPath}/${name}` : name; + createBtn.disabled = true; + createBtn.textContent = 'Création...'; + + try { + await api(`/api/file/${encodeURIComponent(vault)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content: '' }), + }); + + showToast(`Fichier "${name}" créé`, 'success'); + this._closeModal(overlay); + await refreshSidebarTreePreservingState(); + openFile(vault, path); + } catch (err) { + showToast(err.message || 'Erreur lors de la création', 'error'); + createBtn.disabled = false; + createBtn.textContent = 'Créer'; + } + }; + + createBtn.addEventListener('click', create); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') create(); + if (e.key === 'Escape') this._closeModal(overlay); + }); + }, + + async startInlineRename(vault, path, type) { + const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`); + if (!item) { + showToast('Élément introuvable dans l’arborescence', 'error'); + return; + } + + const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); + if (!textNode) { + showToast('Impossible de renommer cet élément', 'error'); + return; + } + + const originalText = textNode.textContent; + const trimmedOriginal = originalText.trim(); + const currentName = path.split('/').pop() || trimmedOriginal; + const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName; + const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : ''; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'sidebar-item-input'; + input.value = baseName; + + textNode.textContent = ' '; + const badge = item.querySelector('.badge-small'); + if (badge) { + item.insertBefore(input, badge); + } else { + item.appendChild(input); + } + + const restore = () => { + input.remove(); + textNode.textContent = originalText; + }; + + const validateName = (name) => { + if (!name.trim()) return 'Le nom ne peut pas être vide'; + if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + return null; + }; + + const submit = async () => { + const name = input.value.trim(); + const error = validateName(name); + if (error) { + showToast(error, 'error'); + input.focus(); + input.select(); + return; + } + + const newName = `${name}${extension}`; + if (newName === currentName) { + restore(); + return; + } + + input.disabled = true; + try { + const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`; + const result = await api(endpoint, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, new_name: newName }), + }); + + const nextPath = result.new_path; + await refreshSidebarTreePreservingState(); + + if (type === 'file' && currentVault === vault && currentPath === path) { + await openFile(vault, nextPath); + } else if (type === 'directory' && currentVault === vault && currentPath && (currentPath === path || currentPath.startsWith(`${path}/`))) { + const suffix = currentPath === path ? '' : currentPath.slice(path.length); + currentPath = `${nextPath}${suffix}`; + await focusPathInSidebar(vault, currentPath, { alignToTop: false }); + } + + showToast(type === 'directory' ? 'Dossier renommé' : 'Fichier renommé', 'success'); + } catch (err) { + input.disabled = false; + showToast(err.message || 'Erreur lors du renommage', 'error'); + input.focus(); + input.select(); + return; + } + }; + + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('keydown', async (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + await submit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + restore(); + } + }); + input.addEventListener('blur', async () => { + if (!input.disabled) { + await submit(); + } + }); + + input.focus(); + input.setSelectionRange(0, input.value.length); + }, + + confirmDeleteDirectory(vault, path) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
    +

    Supprimer le dossier

    +
    +
    + + +
    + + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + safeCreateIcons(); + + const confirmBtn = modal.querySelector('#del-confirm-btn'); + const cancelBtn = modal.querySelector('#del-cancel-btn'); + + const deleteDir = async () => { + confirmBtn.disabled = true; + confirmBtn.textContent = 'Suppression...'; + + try { + const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + }); + + showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success'); + this._closeModal(overlay); + await refreshSidebarTreePreservingState(); + + if (currentVault === vault && currentPath && currentPath.startsWith(path)) { + showWelcome(); + } + } catch (err) { + showToast(err.message || 'Erreur lors de la suppression', 'error'); + confirmBtn.disabled = false; + confirmBtn.textContent = 'Supprimer définitivement'; + } + }; + + confirmBtn.addEventListener('click', deleteDir); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + }, + + confirmDeleteFile(vault, path) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +
    +

    Supprimer le fichier

    +
    +
    + + +
    + + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + setTimeout(() => overlay.classList.add('active'), 10); + safeCreateIcons(); + + const confirmBtn = modal.querySelector('#del-confirm-btn'); + const cancelBtn = modal.querySelector('#del-cancel-btn'); + + const deleteFile = async () => { + confirmBtn.disabled = true; + confirmBtn.textContent = 'Suppression...'; + + try { + await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + }); + + showToast('Fichier supprimé', 'success'); + this._closeModal(overlay); + await refreshSidebarTreePreservingState(); + + if (currentVault === vault && currentPath === path) { + showWelcome(); + } + } catch (err) { + showToast(err.message || 'Erreur lors de la suppression', 'error'); + confirmBtn.disabled = false; + confirmBtn.textContent = 'Supprimer définitivement'; + } + }; + + confirmBtn.addEventListener('click', deleteFile); + cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + }, + + _createModalOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'obsigate-modal-overlay'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this._closeModal(overlay); + } + }); + return overlay; + }, + + _closeModal(overlay) { + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 200); + } +}; + +// --------------------------------------------------------------------------- +// Find in Page Manager +// --------------------------------------------------------------------------- +export const FindInPageManager = { + isOpen: false, + searchTerm: "", + matches: [], + currentIndex: -1, + options: { + caseSensitive: false, + wholeWord: false, + useRegex: false, + }, + debounceTimer: null, + previousFocus: null, + + init() { + const bar = document.getElementById("find-in-page-bar"); + const input = document.getElementById("find-input"); + const prevBtn = document.getElementById("find-prev"); + const nextBtn = document.getElementById("find-next"); + const closeBtn = document.getElementById("find-close"); + const caseSensitiveBtn = document.getElementById("find-case-sensitive"); + const wholeWordBtn = document.getElementById("find-whole-word"); + const regexBtn = document.getElementById("find-regex"); + + if (!bar || !input) return; + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + // Ctrl+F or Cmd+F to open + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + e.preventDefault(); + this.open(); + } + // Escape to close + if (e.key === "Escape" && this.isOpen) { + e.preventDefault(); + this.close(); + } + // Enter to go to next + if (e.key === "Enter" && this.isOpen && document.activeElement === input) { + e.preventDefault(); + if (e.shiftKey) { + this.goToPrevious(); + } else { + this.goToNext(); + } + } + // F3 for next/previous + if (e.key === "F3" && this.isOpen) { + e.preventDefault(); + if (e.shiftKey) { + this.goToPrevious(); + } else { + this.goToNext(); + } + } + }); + + // Input event with debounce + input.addEventListener("input", (e) => { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.search(e.target.value); + }, 250); + }); + + // Navigation buttons + prevBtn.addEventListener("click", () => this.goToPrevious()); + nextBtn.addEventListener("click", () => this.goToNext()); + + // Close button + closeBtn.addEventListener("click", () => this.close()); + + // Option toggles + caseSensitiveBtn.addEventListener("click", () => { + this.options.caseSensitive = !this.options.caseSensitive; + caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + wholeWordBtn.addEventListener("click", () => { + this.options.wholeWord = !this.options.wholeWord; + wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + regexBtn.addEventListener("click", () => { + this.options.useRegex = !this.options.useRegex; + regexBtn.setAttribute("aria-pressed", this.options.useRegex); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + // Load saved state + this.loadState(); + }, + + open() { + const bar = document.getElementById("find-in-page-bar"); + const input = document.getElementById("find-input"); + if (!bar || !input) return; + + this.previousFocus = document.activeElement; + this.isOpen = true; + bar.hidden = false; + input.focus(); + input.select(); + safeCreateIcons(); + }, + + close() { + const bar = document.getElementById("find-in-page-bar"); + if (!bar) return; + + this.isOpen = false; + bar.hidden = true; + this.clearHighlights(); + this.matches = []; + this.currentIndex = -1; + this.searchTerm = ""; + + // Restore previous focus + if (this.previousFocus && this.previousFocus.focus) { + this.previousFocus.focus(); + } + }, + + search(term) { + this.searchTerm = term; + this.clearHighlights(); + this.hideError(); + + if (!term || term.trim().length === 0) { + this.updateCounter(); + this.updateNavButtons(); + return; + } + + const contentArea = document.querySelector(".md-content"); + if (!contentArea) { + this.updateCounter(); + this.updateNavButtons(); + return; + } + + try { + const regex = this.createRegex(term); + this.matches = []; + this.findMatches(contentArea, regex); + this.currentIndex = this.matches.length > 0 ? 0 : -1; + this.highlightMatches(); + this.updateCounter(); + this.updateNavButtons(); + + if (this.matches.length > 0) { + this.scrollToMatch(0); + } + } catch (err) { + this.showError(err.message); + this.matches = []; + this.currentIndex = -1; + this.updateCounter(); + this.updateNavButtons(); + } + }, + + createRegex(term) { + let pattern = term; + + if (!this.options.useRegex) { + // Escape special regex characters + pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + if (this.options.wholeWord) { + pattern = "\\b" + pattern + "\\b"; + } + + const flags = this.options.caseSensitive ? "g" : "gi"; + return new RegExp(pattern, flags); + }, + + findMatches(container, regex) { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + // Skip code blocks, scripts, styles + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + const tagName = parent.tagName.toLowerCase(); + if (["code", "pre", "script", "style"].includes(tagName)) { + return NodeFilter.FILTER_REJECT; + } + // Skip empty text nodes + if (!node.textContent || node.textContent.trim().length === 0) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let node; + while ((node = walker.nextNode())) { + const text = node.textContent; + let match; + regex.lastIndex = 0; // Reset regex + + while ((match = regex.exec(text)) !== null) { + this.matches.push({ + node: node, + index: match.index, + length: match[0].length, + text: match[0], + }); + + // Prevent infinite loop with zero-width matches + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + } + }, + + highlightMatches() { + const matchesByNode = new Map(); + + this.matches.forEach((match, idx) => { + if (!matchesByNode.has(match.node)) { + matchesByNode.set(match.node, []); + } + matchesByNode.get(match.node).push({ match, idx }); + }); + + matchesByNode.forEach((entries, node) => { + if (!node || !node.parentNode) return; + + const text = node.textContent || ""; + let cursor = 0; + const fragment = document.createDocumentFragment(); + + entries.sort((a, b) => a.match.index - b.match.index); + + entries.forEach(({ match, idx }) => { + if (match.index > cursor) { + fragment.appendChild(document.createTextNode(text.substring(cursor, match.index))); + } + + const matchText = text.substring(match.index, match.index + match.length); + const mark = document.createElement("mark"); + mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight"; + mark.textContent = matchText; + mark.setAttribute("data-find-index", idx); + fragment.appendChild(mark); + + match.element = mark; + cursor = match.index + match.length; + }); + + if (cursor < text.length) { + fragment.appendChild(document.createTextNode(text.substring(cursor))); + } + + node.parentNode.replaceChild(fragment, node); + }); + }, + + clearHighlights() { + const contentArea = document.querySelector(".md-content"); + if (!contentArea) return; + + const marks = contentArea.querySelectorAll("mark.find-highlight"); + marks.forEach((mark) => { + if (!mark.parentNode) return; + const text = mark.textContent; + const textNode = document.createTextNode(text); + mark.parentNode.replaceChild(textNode, mark); + }); + + // Normalize text nodes to merge adjacent text nodes + contentArea.normalize(); + }, + + goToNext() { + if (this.matches.length === 0) return; + + // Remove active class from current + if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); + } + + // Move to next (with wrapping) + this.currentIndex = (this.currentIndex + 1) % this.matches.length; + + // Add active class to new current + if (this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.add("find-highlight-active"); + } + + this.scrollToMatch(this.currentIndex); + this.updateCounter(); + }, + + goToPrevious() { + if (this.matches.length === 0) return; + + // Remove active class from current + if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); + } + + // Move to previous (with wrapping) + this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; + + // Add active class to new current + if (this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.add("find-highlight-active"); + } + + this.scrollToMatch(this.currentIndex); + this.updateCounter(); + }, + + scrollToMatch(index) { + if (index < 0 || index >= this.matches.length) return; + + const match = this.matches[index]; + if (!match.element) return; + + const contentArea = document.getElementById("content-area"); + if (!contentArea) { + match.element.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + + // Calculate position with offset for header + const elementTop = match.element.offsetTop; + const offset = 100; // Offset for header + + contentArea.scrollTo({ + top: elementTop - offset, + behavior: "smooth", + }); + }, + + updateCounter() { + const counter = document.getElementById("find-counter"); + if (!counter) return; + + const count = this.matches.length; + if (count === 0) { + counter.textContent = "0 occurrence"; + } else if (count === 1) { + counter.textContent = "1 occurrence"; + } else { + counter.textContent = `${count} occurrences`; + } + }, + + updateNavButtons() { + const prevBtn = document.getElementById("find-prev"); + const nextBtn = document.getElementById("find-next"); + if (!prevBtn || !nextBtn) return; + + const hasMatches = this.matches.length > 0; + prevBtn.disabled = !hasMatches; + nextBtn.disabled = !hasMatches; + }, + + showError(message) { + const errorEl = document.getElementById("find-error"); + if (!errorEl) return; + + errorEl.textContent = message; + errorEl.hidden = false; + }, + + hideError() { + const errorEl = document.getElementById("find-error"); + if (!errorEl) return; + + errorEl.hidden = true; + }, + + saveState() { + try { + const state = { + options: this.options, + }; + localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state)); + } catch (e) { + // Ignore localStorage errors + } + }, + + loadState() { + try { + const saved = localStorage.getItem("obsigate-find-in-page-state"); + if (saved) { + const state = JSON.parse(saved); + if (state.options) { + this.options = { ...this.options, ...state.options }; + + // Update button states + const caseSensitiveBtn = document.getElementById("find-case-sensitive"); + const wholeWordBtn = document.getElementById("find-whole-word"); + const regexBtn = document.getElementById("find-regex"); + + if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); + if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); + if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex); + } + } + } catch (e) { + // Ignore localStorage errors + } + }, +}; + +// --------------------------------------------------------------------------- +// Tab Manager +// --------------------------------------------------------------------------- +export const TabManager = { + _tabs: [], + _activeTabId: null, + _previewTabId: null, // single-click preview tab (temporary, replaced on next preview) + _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } + _tabBar: null, + _tabList: null, + _dirtyTabs: new Set(), + + init() { + this._tabBar = document.getElementById("tab-bar"); + this._tabList = document.getElementById("tab-list"); + }, + + /** Open a file as a preview tab (single-click). + * Replaces any existing preview tab. If the file is already + * open as a persistent tab, just activates it. */ + async openPreview(vault, path) { + const tabId = `${vault}::${path}`; + + // If already open as persistent tab, just activate it + const existing = this._tabs.find(t => t.id === tabId && !t.preview); + if (existing) { + this.activate(tabId); + return; + } + + // Close existing preview tab + if (this._previewTabId && this._previewTabId !== tabId) { + this.close(this._previewTabId); + } + + // If already open as preview, just focus it + const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); + if (previewExisting) { + this.activate(tabId); + return; + } + + // Create preview tab + const name = path.split("/").pop().replace(/\.md$/i, ""); + const icon = getFileIcon(name + ".md"); + + this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + this._previewTabId = tabId; + + this._renderTabs(); + this.activate(tabId); + }, + + /** Convert a preview tab to a persistent tab (double-click). + * If already persistent, opens a new duplicate (same file, different tab). */ + async openPersistent(vault, path) { + const tabId = `${vault}::${path}`; + + // If it's already a preview tab, convert it to persistent + const previewTab = this._tabs.find(t => t.id === tabId && t.preview); + if (previewTab) { + previewTab.preview = false; + if (this._previewTabId === tabId) { + this._previewTabId = null; + } + this._renderTabs(); + this.activate(tabId); + return; + } + + // If already persistent, just focus it + const existing = this._tabs.find(t => t.id === tabId && !t.preview); + if (existing) { + this.activate(tabId); + return; + } + + // Create a new persistent tab + this.open(vault, path); + }, + + /** Open a file in a tab (or focus existing) */ + async open(vault, path, options = {}) { + const tabId = `${vault}::${path}`; + + // If already open, just focus it + const existing = this._tabs.find(t => t.id === tabId); + if (existing) { + // Convert preview to persistent if needed + if (existing.preview) { + existing.preview = false; + if (this._previewTabId === tabId) this._previewTabId = null; + this._renderTabs(); + } + this.activate(tabId); + return; + } + + // Create new tab + const name = path.split("/").pop().replace(/\.md$/i, ""); + const icon = getFileIcon(name + ".md"); + + this._tabs.push({ id: tabId, vault, path, name, icon }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + + this._renderTabs(); + this.activate(tabId); + }, + + /** Activate a specific tab */ + async activate(tabId) { + if (this._activeTabId === tabId && this._tabs.length > 0) return; + + // Save current tab state + if (this._activeTabId && this._tabCache[this._activeTabId]) { + this._saveCurrentTabState(); + } + + this._activeTabId = tabId; + this._renderTabs(); + + // Load tab content + const cache = this._tabCache[tabId]; + if (!cache) return; + + // Update global state + currentVault = cache.vault; + currentPath = cache.path; + syncActiveFileTreeItem(cache.vault, cache.path); + + const area = document.getElementById("content-area"); + + if (cache.data) { + // Use cached data + this._restoreTabContent(cache, area); + } else { + // Fetch file content + area.innerHTML = '
    Chargement...
    '; + try { + const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`); + cache.data = data; + cache.title = data.title; + renderFile(cache.data); + + // Restore source view if needed + if (cache.sourceView) { + await this._toggleSourceView(cache, area); + } + if (cache.scrollTop) { + area.scrollTop = cache.scrollTop; + } + } catch (err) { + area.innerHTML = `
    Erreur: ${escapeHtml(err.message)}
    `; + } + } + + // Update URL hash + if (history.pushState) { + history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); + } + + // Hide dashboard + const dashboard = document.getElementById("dashboard-home"); + if (dashboard) dashboard.style.display = "none"; + }, + + /** Close a tab */ + close(tabId) { + const idx = this._tabs.findIndex(t => t.id === tabId); + if (idx === -1) return; + + this._tabs.splice(idx, 1); + delete this._tabCache[tabId]; + this._dirtyTabs.delete(tabId); + + if (this._tabs.length === 0) { + this._activeTabId = null; + this._showDashboard(); + this._tabBar.hidden = true; + } else if (this._activeTabId === tabId) { + // Activate adjacent tab + const newIdx = Math.min(idx, this._tabs.length - 1); + this.activate(this._tabs[newIdx].id); + } + + this._renderTabs(); + }, + + /** Close all tabs */ + closeAll() { + this._tabs = []; + this._tabCache = {}; + this._dirtyTabs.clear(); + this._activeTabId = null; + this._showDashboard(); + this._tabBar.hidden = true; + }, + + /** Close tabs to the right */ + closeRight(tabId) { + const idx = this._tabs.findIndex(t => t.id === tabId); + if (idx === -1) return; + const toClose = this._tabs.slice(idx + 1); + for (const tab of toClose) { + delete this._tabCache[tab.id]; + this._dirtyTabs.delete(tab.id); + } + this._tabs = this._tabs.slice(0, idx + 1); + if (!this._tabs.find(t => t.id === this._activeTabId)) { + this.activate(tabId); + } + this._renderTabs(); + }, + + /** Close other tabs */ + closeOthers(tabId) { + const tab = this._tabs.find(t => t.id === tabId); + if (!tab) return; + for (const t of this._tabs) { + if (t.id !== tabId) { + delete this._tabCache[t.id]; + this._dirtyTabs.delete(t.id); + } + } + this._tabs = [tab]; + this.activate(tabId); + this._renderTabs(); + }, + + /** Reorder tabs by drag and drop */ + moveTab(fromIdx, toIdx) { + if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return; + const tab = this._tabs.splice(fromIdx, 1)[0]; + this._tabs.splice(toIdx, 0, tab); + this._renderTabs(); + }, + + /** Save current tab state before switching */ + _saveCurrentTabState() { + const cache = this._tabCache[this._activeTabId]; + if (!cache) return; + + const area = document.getElementById("content-area"); + const rendered = document.getElementById("file-rendered-content"); + + cache.scrollTop = area.scrollTop; + cache.sourceView = rendered ? rendered.style.display === "none" : false; + }, + + /** Restore tab content from cache */ + _restoreTabContent(cache, area) { + renderFile(cache.data); + if (cache.sourceView) { + this._restoreSourceView(cache, area); + } + if (cache.scrollTop) { + area.scrollTop = cache.scrollTop; + } + }, + + async _toggleSourceView(cache, area) { + const rendered = document.getElementById("file-rendered-content"); + const raw = document.getElementById("file-raw-content"); + if (!rendered || !raw) return; + + if (!cache.rawSource) { + const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`); + cache.rawSource = rawData.raw; + } + raw.textContent = cache.rawSource; + rendered.style.display = "none"; + raw.style.display = "block"; + }, + + _restoreSourceView(cache, area) { + requestAnimationFrame(() => { + const rendered = document.getElementById("file-rendered-content"); + const raw = document.getElementById("file-raw-content"); + if (rendered && raw && cache.rawSource) { + raw.textContent = cache.rawSource; + rendered.style.display = "none"; + raw.style.display = "block"; + } + }); + }, + + _showDashboard() { + const area = document.getElementById("content-area"); + // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) + let dashboard = document.getElementById("dashboard-home"); + if (!dashboard) { + // Dashboard was destroyed — rebuild via showWelcome + area.innerHTML = ""; + showWelcome(); + return; + } + area.innerHTML = ""; + dashboard.style.display = ""; + area.appendChild(dashboard); + // Refresh widgets after restoring + if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load(); + if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load(); + if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(selectedContextVault); + if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(selectedContextVault); + if (history.pushState) { + history.pushState(null, "", "#"); + } + }, + + /** Render the tab bar */ + _renderTabs() { + if (!this._tabList) return; + + this._tabList.innerHTML = ""; + + if (this._tabs.length === 0) { + this._tabBar.hidden = true; + return; + } + + this._tabBar.hidden = false; + + this._tabs.forEach((tab, idx) => { + const el = document.createElement("div"); + el.className = "tab-item" + (tab.id === this._activeTabId ? " active" : "") + (tab.preview ? " preview" : ""); + el.draggable = true; + el.dataset.tabId = tab.id; + el.dataset.index = idx; + + // Icon + const iconEl = document.createElement("i"); + iconEl.setAttribute("data-lucide", tab.icon); + iconEl.className = "tab-icon"; + iconEl.style.width = "14px"; + iconEl.style.height = "14px"; + el.appendChild(iconEl); + + // Name + const nameEl = document.createElement("span"); + nameEl.className = "tab-name"; + nameEl.textContent = tab.name; + nameEl.title = `${tab.vault}/${tab.path}`; + el.appendChild(nameEl); + + // Close button + const closeEl = document.createElement("span"); + closeEl.className = "tab-close"; + closeEl.innerHTML = ''; + closeEl.addEventListener("click", (e) => { + e.stopPropagation(); + this.close(tab.id); + }); + el.appendChild(closeEl); + + // Click to activate + el.addEventListener("click", () => this.activate(tab.id)); + + // Double-click to close + el.addEventListener("dblclick", (e) => { + e.preventDefault(); + this.close(tab.id); + }); + + // Middle-click to close + el.addEventListener("mousedown", (e) => { + if (e.button === 1) { + e.preventDefault(); + this.close(tab.id); + } + }); + + // Context menu on tab + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + this._showTabContextMenu(e.clientX, e.clientY, tab.id); + }); + + // Drag and drop + el.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("text/plain", String(idx)); + el.classList.add("dragging"); + }); + el.addEventListener("dragend", () => { + el.classList.remove("dragging"); + document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); + }); + el.addEventListener("dragover", (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + const rect = el.getBoundingClientRect(); + const mid = rect.left + rect.width / 2; + document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); + const indicator = document.createElement("div"); + indicator.className = "tab-drop-indicator"; + if (e.clientX < mid) { + el.before(indicator); + } else { + el.after(indicator); + } + }); + el.addEventListener("drop", (e) => { + e.preventDefault(); + document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); + const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); + const rect = el.getBoundingClientRect(); + const mid = rect.left + rect.width / 2; + const toIdx = e.clientX < mid ? idx : idx + 1; + if (fromIdx !== toIdx && fromIdx !== toIdx - 1) { + this.moveTab(fromIdx, toIdx); + } + }); + + this._tabList.appendChild(el); + }); + + safeCreateIcons(); + }, + + _showTabContextMenu(x, y, tabId) { + const existing = document.getElementById("tab-context-menu"); + if (existing) existing.remove(); + + const menu = document.createElement("div"); + menu.id = "tab-context-menu"; + menu.className = "context-menu active"; + menu.style.left = x + "px"; + menu.style.top = y + "px"; + menu.innerHTML = ` +
    Fermer
    +
    Fermer les autres
    +
    Fermer à droite
    +
    +
    Fermer tout
    + `; + document.body.appendChild(menu); + safeCreateIcons(); + + menu.addEventListener("click", (e) => { + const action = e.target.closest(".context-menu-item")?.dataset.action; + if (action === "close") this.close(tabId); + else if (action === "closeOthers") this.closeOthers(tabId); + else if (action === "closeRight") this.closeRight(tabId); + else if (action === "closeAll") this.closeAll(); + menu.remove(); + }); + + const closeMenu = () => menu.remove(); + document.addEventListener("click", closeMenu, { once: true }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") { menu.remove(); } }, { once: true }); + }, +}; + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- +async function init() { + initTheme(); + initHeaderMenu(); + initCustomDropdowns(); + document.getElementById("theme-toggle").addEventListener("click", toggleTheme); + document.getElementById("header-logo").addEventListener("click", goHome); + const refreshBtn = document.getElementById("header-refresh-btn"); + if (refreshBtn) refreshBtn.addEventListener("click", goHome); + initSearch(); + initSidebarToggle(); + initMobile(); + initVaultContext(); + initSidebarTabs(); + initHelpModal(); + initConfigModal(); + initSidebarFilter(); + initSidebarResize(); + initEditor(); + initLoginForm(); + initRecentTab(); + RightSidebarManager.init(); + FindInPageManager.init(); + ContextMenuManager.init(); + + // Check auth status first + const authOk = await AuthManager.initAuth(); + + if (authOk) { + // Start SSE sync AFTER auth is established (cookie available) + initSyncStatus(); + + try { + await Promise.all([loadVaultSettings(), loadVaults(), loadTags()]); + + // Initialize dashboard widgets now that vaults are loaded + if (typeof DashboardRecentWidget !== "undefined") { + DashboardRecentWidget.init(); + } + + // Check for popup mode query parameter + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get("popup") === "true") { + document.body.classList.add("popup-mode"); + } + + // Handle direct deep-link to file via #file=... + if (window.location.hash && window.location.hash.startsWith("#file=")) { + const hashVal = window.location.hash.substring(6); + const sepIndex = hashVal.indexOf(":"); + if (sepIndex > -1) { + const vault = decodeURIComponent(hashVal.substring(0, sepIndex)); + const path = decodeURIComponent(hashVal.substring(sepIndex + 1)); + openFile(vault, path); + } + } else if (urlParams.get("popup") !== "true") { + // Default to dashboard if no deep link and not in popup mode + showWelcome(); + } + } catch (err) { + console.error("Failed to initialize ObsiGate:", err); + showToast("Erreur lors de l'initialisation", "error"); + } + } + + safeCreateIcons(); +} + +// ---- Modify openFile to use TabManager ---- +const _originalOpenFile = openFile; +openFile = function(vault, path) { + TabManager.open(vault, path); +}; + +// ---- Keyboard shortcuts for tabs ---- +document.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === "w" || e.key === "W") { + e.preventDefault(); + if (TabManager._activeTabId) { + TabManager.close(TabManager._activeTabId); + } + } else if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + const tabs = TabManager._tabs; + const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); + if (currentIdx >= 0 && tabs.length > 1) { + const nextIdx = (currentIdx + 1) % tabs.length; + TabManager.activate(tabs[nextIdx].id); + } + } else if (e.key === "Tab" && e.shiftKey) { + e.preventDefault(); + const tabs = TabManager._tabs; + const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); + if (currentIdx >= 0 && tabs.length > 1) { + const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length; + TabManager.activate(tabs[prevIdx].id); + } + } + } +}); + +// ---- Modify init to include TabManager ---- +const _origInit2 = init; +init = function() { + _origInit2(); + TabManager.init(); +}; diff --git a/frontend/js/utils.js b/frontend/js/utils.js new file mode 100644 index 0000000..5687d32 --- /dev/null +++ b/frontend/js/utils.js @@ -0,0 +1,510 @@ +import * as State from "./state.js"; + +// --------------------------------------------------------------------------- +// File extension → Lucide icon mapping +// --------------------------------------------------------------------------- +const EXT_ICONS = { + // Text files + ".md": "file-text", + ".txt": "file-text", + ".log": "file-text", + ".readme": "file-text", + ".rst": "file-text", + ".adoc": "file-text", + + // Web development + ".html": "file-code", + ".htm": "file-code", + ".css": "file-code", + ".scss": "file-code", + ".sass": "file-code", + ".less": "file-code", + ".js": "file-code", + ".jsx": "file-code", + ".ts": "file-code", + ".tsx": "file-code", + ".vue": "file-code", + ".svelte": "file-code", + + // Programming languages + ".py": "file-code", + ".java": "file-code", + ".c": "file-code", + ".cpp": "file-code", + ".cc": "file-code", + ".cxx": "file-code", + ".h": "file-code", + ".hpp": "file-code", + ".cs": "file-code", + ".go": "file-code", + ".rs": "file-code", + ".rb": "file-code", + ".php": "file-code", + ".swift": "file-code", + ".kt": "file-code", + ".scala": "file-code", + ".r": "file-code", + ".m": "file-code", + ".pl": "file-code", + ".lua": "file-code", + ".dart": "file-code", + ".nim": "file-code", + ".zig": "file-code", + ".odin": "file-code", + ".v": "file-code", + ".cr": "file-code", + ".ex": "file-code", + ".exs": "file-code", + ".elm": "file-code", + ".purs": "file-code", + ".hs": "file-code", + ".ml": "file-code", + ".ocaml": "file-code", + ".fs": "file-code", + ".fsx": "file-code", + ".vb": "file-code", + ".pas": "file-code", + ".pp": "file-code", + ".inc": "file-code", + + // Data formats + ".json": "file-json", + ".yaml": "file-cog", + ".yml": "file-cog", + ".toml": "file-cog", + ".xml": "file-code", + ".csv": "table", + ".tsv": "table", + ".sql": "database", + ".db": "database", + ".sqlite": "database", + ".sqlite3": "database", + ".parquet": "database", + ".avro": "database", + + // Configuration files + ".ini": "file-cog", + ".cfg": "file-cog", + ".conf": "file-cog", + ".env": "file-cog", + ".dockerfile": "file-cog", + ".gitignore": "file-cog", + ".gitattributes": "file-cog", + ".editorconfig": "file-cog", + ".eslintrc": "file-cog", + ".prettierrc": "file-cog", + ".babelrc": "file-cog", + ".tsconfig": "file-cog", + "package.json": "file-cog", + "package-lock.json": "file-cog", + "yarn.lock": "file-cog", + "composer.json": "file-cog", + "requirements.txt": "file-cog", + "pipfile": "file-cog", + "gemfile": "file-cog", + "cargo.toml": "file-cog", + "go.mod": "file-cog", + "go.sum": "file-cog", + "pom.xml": "file-cog", + "build.gradle": "file-cog", + "cmakelists.txt": "file-cog", + "makefile": "file-cog", + + // Shell scripts + ".sh": "terminal", + ".bash": "terminal", + ".zsh": "terminal", + ".fish": "terminal", + ".bat": "terminal", + ".cmd": "terminal", + ".ps1": "terminal", + ".psm1": "terminal", + ".psd1": "terminal", + + // Document formats + ".pdf": "file-text", + ".doc": "file-text", + ".docx": "file-text", + ".rtf": "file-text", + ".odt": "file-text", + ".tex": "file-text", + ".latex": "file-text", + + // Image files + ".png": "file-image", + ".jpg": "file-image", + ".jpeg": "file-image", + ".gif": "file-image", + ".svg": "file-image", + ".webp": "file-image", + ".bmp": "file-image", + ".ico": "file-image", + ".tiff": "file-image", + ".tif": "file-image", + + // Audio files + ".mp3": "file-music", + ".wav": "file-music", + ".flac": "file-music", + ".aac": "file-music", + ".ogg": "file-music", + ".m4a": "file-music", + ".wma": "file-music", + + // Video files + ".mp4": "play", + ".avi": "play", + ".mov": "play", + ".wmv": "play", + ".flv": "play", + ".webm": "play", + ".mkv": "play", + ".m4v": "play", + ".3gp": "play", + + // Archive files + ".zip": "file-archive", + ".rar": "file-archive", + ".7z": "file-archive", + ".tar": "file-archive", + ".gz": "file-archive", + ".tgz": "file-archive", + ".bz2": "file-archive", + ".xz": "file-archive", + ".deb": "file-archive", + ".rpm": "file-archive", + ".dmg": "file-archive", + ".pkg": "file-archive", + ".msi": "file-archive", + ".exe": "file-archive", + + // Font files + ".ttf": "file-type", + ".otf": "file-type", + ".woff": "file-type", + ".woff2": "file-type", + ".eot": "file-type", + + // Other common files + ".key": "file-cog", + ".pem": "file-cog", + ".crt": "file-cog", + ".cert": "file-cog", + ".p12": "file-cog", + ".pfx": "file-cog", + ".lock": "file-cog", + ".tmp": "file", + ".bak": "file", + ".old": "file", + ".orig": "file", + ".save": "file", +}; + +function getFileIcon(name) { + const ext = "." + name.split(".").pop().toLowerCase(); + return EXT_ICONS[ext] || "file"; +} + +// --------------------------------------------------------------------------- +// Safe CDN helpers +// --------------------------------------------------------------------------- + +let _iconDebounceTimer = null; + +/** + * Debounced icon creation — batches multiple rapid calls into one + * DOM scan to avoid excessive reflows when building large trees. + */ +function safeCreateIcons() { + if (typeof lucide === "undefined" || !lucide.createIcons) return; + if (_iconDebounceTimer) return; // already scheduled + _iconDebounceTimer = requestAnimationFrame(() => { + _iconDebounceTimer = null; + try { + lucide.createIcons(); + } catch (e) { + /* CDN not loaded */ + } + }); +} + +/** Force-flush icon creation immediately (use sparingly). */ +function flushIcons() { + if (_iconDebounceTimer) { + cancelAnimationFrame(_iconDebounceTimer); + _iconDebounceTimer = null; + } + if (typeof lucide !== "undefined" && lucide.createIcons) { + try { + lucide.createIcons(); + } catch (e) { + /* CDN not loaded */ + } + } +} + +function safeHighlight(block) { + if (typeof hljs !== "undefined" && hljs.highlightElement) { + try { + hljs.highlightElement(block); + } catch (e) { + /* CDN not loaded */ + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function escapeHtml(str) { + if (!str) return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// --------------------------------------------------------------------------- +// Editor (CodeMirror) +// --------------------------------------------------------------------------- +async function openEditor(vaultName, filePath) { + editorVault = vaultName; + editorPath = filePath; + + const modal = document.getElementById("editor-modal"); + const titleEl = document.getElementById("editor-title"); + const bodyEl = document.getElementById("editor-body"); + + titleEl.textContent = `Édition: ${filePath.split("/").pop()}`; + + // Fetch raw content + const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; + const rawData = await api(rawUrl); + + // Clear previous editor + bodyEl.innerHTML = ""; + if (editorView) { + editorView.destroy(); + editorView = null; + } + fallbackEditorEl = null; + + try { + await waitForCodeMirror(); + + const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror; + + const currentTheme = document.documentElement.getAttribute("data-theme"); + const fileExt = filePath.split(".").pop().toLowerCase(); + + const extensions = [ + basicSetup, + keymap.of([ + { + key: "Mod-s", + run: () => { + saveFile(); + return true; + }, + }, + ]), + EditorView.lineWrapping, + ]; + + // Add language support based on file extension + const langMap = { + md: markdown, + markdown: markdown, + py: python, + js: javascript, + jsx: javascript, + ts: javascript, + tsx: javascript, + mjs: javascript, + cjs: javascript, + html: html, + htm: html, + css: css, + scss: css, + less: css, + json: json, + xml: xml, + svg: xml, + sql: sql, + php: php, + cpp: cpp, + cc: cpp, + cxx: cpp, + c: cpp, + h: cpp, + hpp: cpp, + java: java, + rs: rust, + sh: javascript, // Using javascript for shell scripts as fallback + bash: javascript, + zsh: javascript, + }; + + const langMode = langMap[fileExt]; + if (langMode) { + extensions.push(langMode()); + } + + if (currentTheme === "dark") { + extensions.push(oneDark); + } + + const state = EditorState.create({ + doc: rawData.raw, + extensions: extensions, + }); + + editorView = new EditorView({ + state: state, + parent: bodyEl, + }); + } catch (err) { + console.error("CodeMirror init failed, falling back to textarea:", err); + fallbackEditorEl = document.createElement("textarea"); + fallbackEditorEl.className = "fallback-editor"; + fallbackEditorEl.value = rawData.raw; + bodyEl.appendChild(fallbackEditorEl); + } + + modal.classList.add("active"); + safeCreateIcons(); +} + +async function waitForCodeMirror() { + let attempts = 0; + while (!window.CodeMirror && attempts < 50) { + await new Promise((resolve) => setTimeout(resolve, 100)); + attempts++; + } + if (!window.CodeMirror) { + throw new Error("CodeMirror failed to load"); + } +} + +function closeEditor() { + const modal = document.getElementById("editor-modal"); + modal.classList.remove("active"); + if (editorView) { + editorView.destroy(); + editorView = null; + } + fallbackEditorEl = null; + editorVault = null; + editorPath = null; +} + +async function saveFile() { + if ((!editorView && !fallbackEditorEl) || !editorVault || !editorPath) return; + + const content = editorView ? editorView.state.doc.toString() : fallbackEditorEl.value; + const saveBtn = document.getElementById("editor-save"); + const originalHTML = saveBtn.innerHTML; + + try { + saveBtn.disabled = true; + saveBtn.innerHTML = ''; + safeCreateIcons(); + + const response = await fetch(`/api/file/${encodeURIComponent(editorVault)}/save?path=${encodeURIComponent(editorPath)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || "Erreur de sauvegarde"); + } + + saveBtn.innerHTML = ''; + safeCreateIcons(); + + setTimeout(() => { + closeEditor(); + if (currentVault === editorVault && currentPath === editorPath) { + openFile(currentVault, currentPath); + } + }, 800); + } catch (err) { + console.error("Save error:", err); + alert(`Erreur: ${err.message}`); + saveBtn.innerHTML = originalHTML; + saveBtn.disabled = false; + safeCreateIcons(); + } +} + +async function deleteFile() { + if (!editorVault || !editorPath) return; + + const deleteBtn = document.getElementById("editor-delete"); + const originalHTML = deleteBtn.innerHTML; + + try { + deleteBtn.disabled = true; + deleteBtn.innerHTML = ''; + safeCreateIcons(); + + const response = await fetch(`/api/file/${encodeURIComponent(editorVault)}?path=${encodeURIComponent(editorPath)}`, { method: "DELETE" }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || "Erreur de suppression"); + } + + closeEditor(); + showWelcome(); + await refreshSidebarForContext(); + await refreshTagsForContext(); + } catch (err) { + console.error("Delete error:", err); + alert(`Erreur: ${err.message}`); + deleteBtn.innerHTML = originalHTML; + deleteBtn.disabled = false; + safeCreateIcons(); + } +} + +function initEditor() { + const cancelBtn = document.getElementById("editor-cancel"); + const deleteBtn = document.getElementById("editor-delete"); + const saveBtn = document.getElementById("editor-save"); + const modal = document.getElementById("editor-modal"); + + cancelBtn.addEventListener("click", closeEditor); + deleteBtn.addEventListener("click", deleteFile); + saveBtn.addEventListener("click", saveFile); + + // Close on overlay click + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeEditor(); + } + }); + + // ESC to close + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeEditor(); + } + }); + + // Fix mouse wheel scrolling in editor + modal.addEventListener( + "wheel", + (e) => { + const editorBody = document.getElementById("editor-body"); + if (editorBody && editorBody.contains(e.target)) { + // Let the editor handle the scroll + return; + } + // Prevent modal from scrolling if not in editor area + e.preventDefault(); + }, + { passive: false }, + ); +} + +export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor }; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js new file mode 100644 index 0000000..0bf741b --- /dev/null +++ b/frontend/js/viewer.js @@ -0,0 +1,1554 @@ +/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */ +import * as State from "./state.js"; +import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from "./utils.js"; + +// initEditor is defined in utils.js — re-exported below. + +// --------------------------------------------------------------------------- +// Outline/TOC Manager +// --------------------------------------------------------------------------- + +const OutlineManager = { + /** + * Slugify text to create valid IDs + */ + slugify(text) { + return ( + text + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .trim() || "heading" + ); + }, + + /** + * Parse headings from markdown content + */ + parseHeadings() { + const contentArea = document.querySelector(".md-content"); + if (!contentArea) return []; + + const headings = []; + const h2s = contentArea.querySelectorAll("h2"); + const h3s = contentArea.querySelectorAll("h3"); + const allHeadings = [...h2s, ...h3s].sort((a, b) => { + return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + + const usedIds = new Map(); + + allHeadings.forEach((heading) => { + const text = heading.textContent.trim(); + if (!text) return; + + const level = parseInt(heading.tagName[1]); + let id = this.slugify(text); + + // Handle duplicate IDs + if (usedIds.has(id)) { + const count = usedIds.get(id) + 1; + usedIds.set(id, count); + id = `${id}-${count}`; + } else { + usedIds.set(id, 1); + } + + // Inject ID into heading if not present + if (!heading.id) { + heading.id = id; + } else { + id = heading.id; + } + + headings.push({ + id, + level, + text, + element: heading, + }); + }); + + return headings; + }, + + /** + * Render outline list + */ + renderOutline(headings) { + const outlineList = document.getElementById("outline-list"); + const outlineEmpty = document.getElementById("outline-empty"); + + if (!outlineList) return; + + outlineList.innerHTML = ""; + + if (!headings || headings.length === 0) { + outlineList.hidden = true; + if (outlineEmpty) { + outlineEmpty.hidden = false; + safeCreateIcons(); + } + return; + } + + outlineList.hidden = false; + if (outlineEmpty) outlineEmpty.hidden = true; + + headings.forEach((heading) => { + const item = el( + "a", + { + class: `outline-item level-${heading.level}`, + href: `#${heading.id}`, + "data-heading-id": heading.id, + role: "link", + }, + [document.createTextNode(heading.text)], + ); + + item.addEventListener("click", (e) => { + e.preventDefault(); + this.scrollToHeading(heading.id); + }); + + outlineList.appendChild(item); + }); + + headingsCache = headings; + }, + + /** + * Scroll to heading with smooth behavior + */ + scrollToHeading(headingId) { + const heading = document.getElementById(headingId); + if (!heading) return; + + const contentArea = document.getElementById("content-area"); + if (!contentArea) return; + + // Calculate offset for fixed header (if any) + const headerHeight = 80; + const headingTop = heading.offsetTop; + + contentArea.scrollTo({ + top: headingTop - headerHeight, + behavior: "smooth", + }); + + // Update active state immediately + this.setActiveHeading(headingId); + }, + + /** + * Set active heading in outline + */ + setActiveHeading(headingId) { + if (activeHeadingId === headingId) return; + + activeHeadingId = headingId; + + const items = document.querySelectorAll(".outline-item"); + items.forEach((item) => { + if (item.getAttribute("data-heading-id") === headingId) { + item.classList.add("active"); + item.setAttribute("aria-current", "location"); + // Scroll outline item into view + item.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } else { + item.classList.remove("active"); + item.removeAttribute("aria-current"); + } + }); + }, + + /** + * Initialize outline for current document + */ + init() { + const headings = this.parseHeadings(); + this.renderOutline(headings); + ScrollSpyManager.init(headings); + ReadingProgressManager.init(); + }, + + /** + * Cleanup + */ + destroy() { + ScrollSpyManager.destroy(); + ReadingProgressManager.destroy(); + headingsCache = []; + activeHeadingId = null; + }, +}; + + +// --------------------------------------------------------------------------- +// Scroll Spy Manager +// --------------------------------------------------------------------------- + +const ScrollSpyManager = { + observer: null, + headings: [], + + init(headings) { + this.destroy(); + this.headings = headings; + + if (!headings || headings.length === 0) return; + + const contentArea = document.getElementById("content-area"); + if (!contentArea) return; + + const options = { + root: contentArea, + rootMargin: "-20% 0px -70% 0px", + threshold: [0, 0.3, 0.5, 1.0], + }; + + this.observer = new IntersectionObserver((entries) => { + // Find the most visible heading + let mostVisible = null; + let maxRatio = 0; + + entries.forEach((entry) => { + if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { + maxRatio = entry.intersectionRatio; + mostVisible = entry.target; + } + }); + + if (mostVisible && mostVisible.id) { + OutlineManager.setActiveHeading(mostVisible.id); + } + }, options); + + // Observe all headings + headings.forEach((heading) => { + if (heading.element) { + this.observer.observe(heading.element); + } + }); + }, + + destroy() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + this.headings = []; + }, +}; + + +// --------------------------------------------------------------------------- +// Reading Progress Manager +// --------------------------------------------------------------------------- + +const ReadingProgressManager = { + scrollHandler: null, + + init() { + this.destroy(); + + const contentArea = document.getElementById("content-area"); + if (!contentArea) return; + + this.scrollHandler = this.throttle(() => { + this.updateProgress(); + }, 100); + + contentArea.addEventListener("scroll", this.scrollHandler); + this.updateProgress(); + }, + + updateProgress() { + const contentArea = document.getElementById("content-area"); + const progressFill = document.getElementById("reading-progress-fill"); + const progressText = document.getElementById("reading-progress-text"); + + if (!contentArea || !progressFill || !progressText) return; + + const scrollTop = contentArea.scrollTop; + const scrollHeight = contentArea.scrollHeight; + const clientHeight = contentArea.clientHeight; + + const maxScroll = scrollHeight - clientHeight; + const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; + + progressFill.style.width = `${percentage}%`; + progressText.textContent = `${percentage}%`; + }, + + throttle(func, delay) { + let lastCall = 0; + return function (...args) { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + func.apply(this, args); + } + }; + }, + + destroy() { + const contentArea = document.getElementById("content-area"); + if (contentArea && this.scrollHandler) { + contentArea.removeEventListener("scroll", this.scrollHandler); + } + this.scrollHandler = null; + + // Reset progress + const progressFill = document.getElementById("reading-progress-fill"); + const progressText = document.getElementById("reading-progress-text"); + if (progressFill) progressFill.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + }, +}; + + +// --------------------------------------------------------------------------- +// File viewer +// --------------------------------------------------------------------------- +async function openFile(vaultName, filePath) { + currentVault = vaultName; + currentPath = filePath; + showingSource = false; + cachedRawSource = null; + + // Highlight active + syncActiveFileTreeItem(vaultName, filePath); + + // Show loading state while fetching + const area = document.getElementById("content-area"); + area.innerHTML = '
    Chargement...
    '; + + try { + const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; + const data = await api(url); + renderFile(data); + } catch (err) { + area.innerHTML = '

    Impossible de charger le fichier.

    '; + } +} + +async function renderBacklinksPanel(vault, path, container) { + try { + const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`); + if (!data.backlinks || data.backlinks.length === 0) return; + + const panel = el("div", { class: "backlinks-panel" }); + const header = el("div", { class: "backlinks-header" }, [ + icon("link", 14), + document.createTextNode(` ${data.total} lien(s) entrant(s)`), + ]); + panel.appendChild(header); + + const list = el("div", { class: "backlinks-list" }); + data.backlinks.forEach((bl) => { + const item = el("div", { class: "backlink-item" }); + const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]); + const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]); + item.appendChild(icon(getFileIcon(bl.path), 12)); + item.appendChild(vaultBadge); + item.appendChild(titleEl); + item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path)); + item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); }); + list.appendChild(item); + }); + panel.appendChild(list); + container.appendChild(panel); + } catch (err) { + // Silently ignore — backlinks are optional + console.debug("Backlinks fetch failed:", err); + } +} + +function renderFile(data) { + const area = document.getElementById("content-area"); + + // Handle unsupported (binary) files + if (data.unsupported) { + const sizeStr = data.size_bytes + ? data.size_bytes < 1024 ? `${data.size_bytes} o` + : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko` + : `${(data.size_bytes / 1048576).toFixed(1)} Mo` + : ""; + area.innerHTML = ` +
    + +
    ${escapeHtml(data.path.split("/").pop())}
    +
    Ce fichier est binaire et ne peut pas être affiché.
    + ${sizeStr ? `
    Taille : ${sizeStr}
    ` : ""} + +
    `; + lucide.createIcons(); + document.getElementById("unsupported-download-btn").addEventListener("click", () => { + const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; + window.open(dlUrl, "_blank"); + }); + return; + } + + // Breadcrumb + const parts = data.path.split("/"); + const breadcrumbEls = []; + breadcrumbEls.push( + makeBreadcrumbSpan(data.vault, () => { + focusPathInSidebar(data.vault, "", { alignToTop: "center" }); + }), + ); + let accumulated = ""; + parts.forEach((part, i) => { + breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); + accumulated += (accumulated ? "/" : "") + part; + const p = accumulated; + if (i < parts.length - 1) { + breadcrumbEls.push( + makeBreadcrumbSpan(part, () => { + focusPathInSidebar(data.vault, p, { alignToTop: "center" }); + }), + ); + } else { + breadcrumbEls.push( + makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { + focusPathInSidebar(data.vault, data.path, { alignToTop: "center" }); + }), + ); + } + }); + + const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); + + // Tags + const tagsDiv = el("div", { class: "file-tags" }); + (data.tags || []).forEach((tag) => { + if (!TagFilterService.isTagFiltered(tag)) { + const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + t.addEventListener("click", () => searchByTag(tag)); + tagsDiv.appendChild(t); + } + }); + + // Action buttons + const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]); + copyBtn.addEventListener("click", async () => { + try { + // Fetch raw content if not already cached + if (!cachedRawSource) { + const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; + const rawData = await api(rawUrl); + cachedRawSource = rawData.raw; + } + await navigator.clipboard.writeText(cachedRawSource); + copyBtn.lastChild.textContent = "Copié !"; + setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500); + } catch (err) { + console.error("Copy error:", err); + showToast("Erreur lors de la copie", "error"); + } + }); + + const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]); + + // MD download button + const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]); + mdBtn.addEventListener("click", () => { + const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; + const a = document.createElement("a"); + a.href = dlUrl; + a.download = data.path.split("/").pop(); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }); + + // PDF download button + const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]); + pdfBtn.addEventListener("click", () => { + const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; + window.open(pdfUrl, "_blank"); + }); + + const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]); + editBtn.addEventListener("click", () => { + openEditor(data.vault, data.path); + }); + + const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]); + openNewWindowBtn.addEventListener("click", () => { + const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`; + window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no"); + }); + + const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]); + tocBtn.addEventListener("click", () => { + RightSidebarManager.toggle(); + }); + + // Share button — check if already shared + const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); + // Check if already shared and color the button + (async () => { + try { + const shares = await api("/api/shares"); + if (shares.some(s => s.vault === data.vault && s.path === data.path)) { + shareBtn.classList.add("shared"); + shareBtn.title = "Document partagé — cliquer pour gérer"; + } + } catch (e) { /* ignore */ } + })(); + shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); + + // Bookmark button — check if already bookmarked + const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); + // Check bookmark status and color the button + (async () => { + try { + const bms = await api("/api/bookmarks"); + if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) { + bookmarkBtn.classList.add("active"); + bookmarkBtn.title = "Retirer des bookmarks"; + } + } catch (e) { /* ignore */ } + })(); + bookmarkBtn.addEventListener("click", async () => { + try { + const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) }); + bookmarkBtn.classList.toggle("active", res.bookmarked); + bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; + showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); + if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + + // Frontmatter — Accent Card + let fmSection = null; + if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { + fmSection = buildFrontmatterCard(data.frontmatter); + } + + // Content container (rendered HTML) + const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); + mdDiv.innerHTML = data.html; + + // Raw source container (hidden initially) + const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); + + // Source button toggle logic + sourceBtn.addEventListener("click", async () => { + const rendered = document.getElementById("file-rendered-content"); + const raw = document.getElementById("file-raw-content"); + if (!rendered || !raw) return; + + showingSource = !showingSource; + if (showingSource) { + sourceBtn.classList.add("active"); + if (!cachedRawSource) { + const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; + const rawData = await api(rawUrl); + cachedRawSource = rawData.raw; + } + raw.textContent = cachedRawSource; + rendered.style.display = "none"; + raw.style.display = "block"; + } else { + sourceBtn.classList.remove("active"); + rendered.style.display = "block"; + raw.style.display = "none"; + } + }); + + // Assemble + area.innerHTML = ""; + area.appendChild(breadcrumb); + area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])])); + if (fmSection) area.appendChild(fmSection); + area.appendChild(mdDiv); + area.appendChild(rawDiv); + + // Backlinks panel + if (data.is_markdown) { + renderBacklinksPanel(data.vault, data.path, area); + } + + // Highlight code blocks + area.querySelectorAll("pre code").forEach((block) => { + safeHighlight(block); + }); + + // Wire up wikilinks + area.querySelectorAll(".wikilink").forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + const v = link.getAttribute("data-vault"); + const p = link.getAttribute("data-path"); + if (v && p) openFile(v, p); + }); + }); + + safeCreateIcons(); + area.scrollTop = 0; + + // Initialize outline/TOC for this document + OutlineManager.init(); +} + + +function buildFrontmatterCard(frontmatter) { + // Helper: format date + function formatDate(iso) { + if (!iso) return "—"; + const d = new Date(iso); + const date = d.toISOString().slice(0, 10); + const time = d.toTimeString().slice(0, 5); + return `${date} · ${time}`; + } + + // Extract boolean flags + const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); + + // Toggle state + let isOpen = true; + + // Build header with chevron + const chevron = el("span", { class: "fm-chevron open" }); + chevron.innerHTML = ''; + + const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); + + // ZONE 1: Top strip + const topBadges = []; + + // Title badge + const title = frontmatter.titre || frontmatter.title || ""; + if (title) { + topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); + } + + // Status badge + if (frontmatter.statut) { + const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); + topBadges.push(statusBadge); + } + + // Category badge + if (frontmatter.catégorie || frontmatter.categorie) { + const cat = frontmatter.catégorie || frontmatter.categorie; + const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); + topBadges.push(catBadge); + } + + // Publish badge + if (frontmatter.publish) { + topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); + } + + // Favoris badge + if (frontmatter.favoris) { + topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); + } + + const acTop = el("div", { class: "ac-top" }, topBadges); + + // ZONE 2: Body 2 columns + const leftCol = el("div", { class: "ac-col" }, [ + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]), + ]); + + const rightCol = el("div", { class: "ac-col" }, [ + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), + el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), + ]); + + const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); + + // ZONE 3: Tags row + const tagPills = []; + if (frontmatter.tags && frontmatter.tags.length > 0) { + frontmatter.tags.forEach((tag) => { + tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); + }); + } + + const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); + + // ZONE 4: Flags row + const flagChips = []; + booleanFlags.forEach((flag) => { + const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; + flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); + }); + + const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); + + // Assemble the card + const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); + + // Toggle functionality + fmHeader.addEventListener("click", () => { + isOpen = !isOpen; + if (isOpen) { + acCard.style.display = "block"; + chevron.classList.remove("closed"); + chevron.classList.add("open"); + } else { + acCard.style.display = "none"; + chevron.classList.remove("open"); + chevron.classList.add("closed"); + } + safeCreateIcons(); + }); + + // Wrap in section + const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); + + return fmSection; +} + + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +// escapeHtml imported from utils.js above + +function el(tag, attrs, children) { + const e = document.createElement(tag); + if (attrs) { + Object.entries(attrs).forEach(([k, v]) => { + // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug + if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) { + return; + } + e.setAttribute(k, v); + }); + } + if (children) { + children.forEach((c) => { + if (c) e.appendChild(c); + }); + } + return e; +} + +function icon(name, size) { + const i = document.createElement("i"); + i.setAttribute("data-lucide", name); + i.style.width = size + "px"; + i.style.height = size + "px"; + i.classList.add("icon"); + return i; +} + +function smallBadge(count) { + const s = document.createElement("span"); + s.className = "badge-small"; + s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; + s.textContent = `(${count})`; + return s; +} + +function getContextMenuPositionFromElement(target) { + const rect = target.getBoundingClientRect(); + return { + x: Math.min(rect.right - 8, window.innerWidth - 16), + y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16), + }; +} + +function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tree-item-action-btn"; + button.setAttribute("aria-label", "Afficher le menu d’actions"); + button.setAttribute("title", "Actions"); + const iconEl = icon("more-vertical", 16); + button.appendChild(iconEl); + button.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const pos = getContextMenuPositionFromElement(button); + ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly); + }); + itemEl.appendChild(button); + // Ensure Lucide icons are rendered for the button + setTimeout(() => { + safeCreateIcons(); + }, 0); +} + +function attachTreeItemLongPress(itemEl, getMenuData) { + let pressTimer = null; + let pressHandled = false; + let startX = 0; + let startY = 0; + const longPressDelay = 550; + const moveThreshold = 10; + + const clearPressTimer = () => { + if (pressTimer) { + clearTimeout(pressTimer); + pressTimer = null; + } + }; + + itemEl.addEventListener("touchstart", (e) => { + if (!e.touches || e.touches.length !== 1) return; + pressHandled = false; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + clearPressTimer(); + pressTimer = setTimeout(() => { + const data = getMenuData(); + if (!data) return; + pressHandled = true; + ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly); + }, longPressDelay); + }, { passive: true }); + + itemEl.addEventListener("touchmove", (e) => { + if (!e.touches || e.touches.length !== 1) return; + const dx = Math.abs(e.touches[0].clientX - startX); + const dy = Math.abs(e.touches[0].clientY - startY); + if (dx > moveThreshold || dy > moveThreshold) { + clearPressTimer(); + } + }, { passive: true }); + + itemEl.addEventListener("touchend", () => { + clearPressTimer(); + }, { passive: true }); + + itemEl.addEventListener("touchcancel", () => { + clearPressTimer(); + }, { passive: true }); + + itemEl.addEventListener("click", (e) => { + if (pressHandled) { + e.preventDefault(); + e.stopPropagation(); + setTimeout(() => { + pressHandled = false; + }, 0); + } + }, true); +} + +function getVaultIcon(vaultName, size = 16) { + const v = allVaults.find((val) => val.name === vaultName); + const type = v ? v.type : "VAULT"; + + if (type === "DIR") { + const i = icon("folder", size); + i.style.color = "#eab308"; // yellow tint + return i; + } else { + const purple = "#8b5cf6"; + const svgNS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNS, "svg"); + svg.setAttribute("xmlns", svgNS); + svg.setAttribute("width", size); + svg.setAttribute("height", size); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", purple); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.classList.add("icon"); + + const path1 = document.createElementNS(svgNS, "path"); + path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z"); + const path2 = document.createElementNS(svgNS, "path"); + path2.setAttribute("d", "M11 3 8 9l4 12"); + const path3 = document.createElementNS(svgNS, "path"); + path3.setAttribute("d", "M12 21l4-12-3-6"); + const path4 = document.createElementNS(svgNS, "path"); + path4.setAttribute("d", "M2 9h20"); + + svg.appendChild(path1); + svg.appendChild(path2); + svg.appendChild(path3); + svg.appendChild(path4); + return svg; + } +} + +function makeBreadcrumbSpan(text, onClick) { + const s = document.createElement("span"); + s.textContent = text; + if (onClick) { + s.addEventListener("click", async (event) => { + event.preventDefault(); + if (s.dataset.busy === "true") return; + s.dataset.busy = "true"; + s.style.pointerEvents = "none"; + try { + await onClick(event); + } finally { + s.dataset.busy = "false"; + s.style.pointerEvents = ""; + } + }); + } + return s; +} + +function appendHighlightedText(container, text, query, caseSensitive) { + container.textContent = ""; + if (!query) { + container.appendChild(document.createTextNode(text)); + return; + } + + const source = caseSensitive ? text : text.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + let start = 0; + let index = source.indexOf(needle, start); + + if (index === -1) { + container.appendChild(document.createTextNode(text)); + return; + } + + while (index !== -1) { + if (index > start) { + container.appendChild(document.createTextNode(text.slice(start, index))); + } + const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); + container.appendChild(mark); + start = index + query.length; + index = source.indexOf(needle, start); + } + + if (start < text.length) { + container.appendChild(document.createTextNode(text.slice(start))); + } +} + +function highlightSearchText(container, text, query, caseSensitive) { + container.textContent = ""; + if (!query || !text) { + container.appendChild(document.createTextNode(text || "")); + return; + } + + const source = caseSensitive ? text : text.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + let start = 0; + let index = source.indexOf(needle, start); + + if (index === -1) { + container.appendChild(document.createTextNode(text)); + return; + } + + while (index !== -1) { + if (index > start) { + container.appendChild(document.createTextNode(text.slice(start, index))); + } + const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); + container.appendChild(mark); + start = index + query.length; + index = source.indexOf(needle, start); + } + + if (start < text.length) { + container.appendChild(document.createTextNode(text.slice(start))); + } +} + +function showWelcome() { + hideProgressBar(); + + // Restore or rebuild the dashboard with tabbed sections + const area = document.getElementById("content-area"); + const home = document.getElementById("dashboard-home"); + + if (area && !home) { + area.innerHTML = ` +
    + +
    + + + + +
    + + +
    +
    +
    Chargement...
    +
    +
    +
    + + +
    +
    +
    + + Aucun bookmark +

    Épinglez des fichiers pour les retrouver ici.

    +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    + + Aucun document partagé +

    Partagez un document pour le voir apparaître ici

    +
    +
    +
    `; + + // Re-initialize widgets and dashboard tabs + if (typeof DashboardRecentWidget !== "undefined") { + DashboardRecentWidget.init(); + } + initDashboardTabs(); + safeCreateIcons(); + } else if (home) { + // Dashboard already exists, show it with default tab + home.style.display = ""; + // Reset tabs to default + document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); + const defaultPanel = document.getElementById("dashboard-panel-stats"); + if (defaultTab) defaultTab.classList.add("active"); + if (defaultPanel) defaultPanel.classList.add("active"); + } + + // Load all widgets (they handle missing elements gracefully) + if (typeof DashboardStatsWidget !== "undefined") { + DashboardStatsWidget.load(); + } + if (typeof DashboardConflictsWidget !== "undefined") { + DashboardConflictsWidget.load(); + } + if (typeof DashboardRecentWidget !== "undefined") { + DashboardRecentWidget.load(selectedContextVault); + } + if (typeof DashboardBookmarkWidget !== "undefined") { + DashboardBookmarkWidget.load(selectedContextVault); + } + if (typeof DashboardSharedWidget !== "undefined") { + DashboardSharedWidget.load(); + } + + // Load saved searches sidebar + loadSavedSearches(); +} + +async function loadSavedSearches() { + const list = document.getElementById("saved-searches-list"); + const empty = document.getElementById("saved-searches-empty"); + if (!list) return; + try { + const searches = await api("/api/saved-searches"); + if (!searches.length) { + list.innerHTML = ""; + if (empty) empty.style.display = ""; + return; + } + if (empty) empty.style.display = "none"; + list.innerHTML = searches.map(s => { + const badges = []; + if (s.case_sensitive) badges.push('Aa'); + if (s.whole_word) badges.push('wd'); + if (s.regex) badges.push('.*'); + const pathFilters = []; + if (s.include_paths) pathFilters.push(`📥 ${escapeHtml(s.include_paths)}`); + if (s.exclude_paths) pathFilters.push(`📤 ${escapeHtml(s.exclude_paths)}`); + const vaultStr = s.vault && s.vault !== "all" ? `📁 ${escapeHtml(s.vault)}` : ""; + return ` +
    +
    ${escapeHtml(s.query)}
    +
    + ${badges.join("")} + ${vaultStr} +
    + ${pathFilters.length ? '
    ' + pathFilters.join(" ") + '
    ' : ""} + +
    + `}).join(""); + list.querySelectorAll(".saved-search-item").forEach(item => { + item.addEventListener("click", (e) => { + if (e.target.classList.contains("saved-search-delete")) return; + const idx = Array.from(list.children).indexOf(item); + const s = searches[idx]; + if (!s) return; + // Apply the saved search + const input = document.getElementById("search-input"); + if (input) input.value = s.query; + searchCaseSensitive = s.case_sensitive || false; + searchWholeWord = s.whole_word || false; + searchRegex = s.regex || false; + if (typeof _updateToggleUI === "function") _updateToggleUI(); + if (s.include_paths) { + const incl = document.getElementById("search-include-input"); + if (incl) incl.value = s.include_paths; + } + if (s.exclude_paths) { + const excl = document.getElementById("search-exclude-input"); + if (excl) excl.value = s.exclude_paths; + } + // Execute the search — suppress dropdown from appearing + AutocompleteDropdown.hide(); + AutocompleteDropdown._suppressNext = true; + const vault = s.vault || "all"; + if (input) { input.dispatchEvent(new Event("input")); } + clearTimeout(searchTimeout); + advancedSearchOffset = 0; + performAdvancedSearch(s.query, vault, null); + }); + }); + list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { + e.stopPropagation(); + await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); + loadSavedSearches(); + })); + safeCreateIcons(); + } catch (err) { /* silently ignore */ } +} + +function showLoading() { + const area = document.getElementById("content-area"); + area.innerHTML = ` +
    +
    +
    Recherche en cours...
    +
    `; + showProgressBar(); +} + +function showProgressBar() { + const bar = document.getElementById("search-progress-bar"); + if (bar) bar.classList.add("active"); +} + +function hideProgressBar() { + const bar = document.getElementById("search-progress-bar"); + if (bar) bar.classList.remove("active"); +} + +function goHome() { + const searchInput = document.getElementById("search-input"); + if (searchInput) searchInput.value = ""; + + document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + + currentVault = null; + currentPath = null; + showingSource = false; + cachedRawSource = null; + + closeMobileSidebar(); + showWelcome(); +} + + +// initEditor wires up the editor modal — editor functions (openEditor, closeEditor, saveFile, deleteFile) are in utils.js +function initEditor() { + const cancelBtn = document.getElementById("editor-cancel"); + const deleteBtn = document.getElementById("editor-delete"); + const saveBtn = document.getElementById("editor-save"); + const modal = document.getElementById("editor-modal"); + + cancelBtn.addEventListener("click", closeEditor); + deleteBtn.addEventListener("click", deleteFile); + saveBtn.addEventListener("click", saveFile); + + // Close on overlay click + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeEditor(); + } + }); + + // ESC to close + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeEditor(); + } + }); + + // Fix mouse wheel scrolling in editor + modal.addEventListener( + "wheel", + (e) => { + const editorBody = document.getElementById("editor-body"); + if (editorBody && editorBody.contains(e.target)) { + // Let the editor handle the scroll + return; + } + // Prevent modal from scrolling if not in editor area + e.preventDefault(); + }, + { passive: false }, + ); +} + + +// --------------------------------------------------------------------------- +// SSE Client — IndexUpdateManager +// --------------------------------------------------------------------------- +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 }; +})(); + +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 = `
    + 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; +} + +export { OutlineManager, ScrollSpyManager, ReadingProgressManager, openFile, buildFrontmatterCard, initEditor }; +