diff --git a/frontend/index.html b/frontend/index.html index 76f6911..702e295 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1133,6 +1133,6 @@ - + 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 = ` +
+

📤 Document partagé

+

${escapeHtml(vault)}/${escapeHtml(path)}

+ ${expiresInfo} +

${existingShare.access_count} vue(s)

+ + +
`; + 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 = ` +
+

📤 Partager ce document

+

${escapeHtml(vault)}/${escapeHtml(path)}

+

Ce lien sera accessible publiquement, sans authentification.

+ + +
`; + 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 }; +