diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 6e8a59c..f7ef6da 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -1,547 +1,548 @@ -/* 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 }; + 1|/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */ +import { state } from './state.js'; + 3| + 4|// --------------------------------------------------------------------------- + 5|// API helpers + 6|// --------------------------------------------------------------------------- + 7| + 8|/** + 9| * Fetch JSON from an API endpoint with optional AbortSignal support. + 10| * Surfaces errors to the user via toast instead of silently failing. + 11| * + 12| * @param {string} path - API URL path. + 13| * @param {object} [opts] - Fetch options (may include signal). + 14| * @returns {Promise} Parsed JSON response. + 15| */ + 16|async function api(path, opts) { + 17| var res; + 18| try { + 19| // Inject auth header if authenticated + 20| const authHeaders = AuthManager.getAuthHeaders(); + 21| const mergedOpts = opts || {}; + 22| // Auto-set Content-Type for JSON bodies + 23| if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) { + 24| mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" }; + 25| } + 26| if (authHeaders) { + 27| mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders }; + 28| } + 29| mergedOpts.credentials = "include"; + 30| res = await fetch(path, mergedOpts); + 31| } catch (err) { + 32| if (err.name === "AbortError") throw err; // let callers handle abort + 33| showToast("Erreur réseau — vérifiez votre connexion", "error"); + 34| throw err; + 35| } + 36| if (res.status === 401 && AuthManager._authEnabled) { + 37| // Token expired — try refresh + 38| try { + 39| await AuthManager.refreshAccessToken(); + 40| // Retry the request with new token + 41| const retryHeaders = AuthManager.getAuthHeaders(); + 42| const retryOpts = opts || {}; + 43| retryOpts.headers = { ...retryOpts.headers, ...retryHeaders }; + 44| retryOpts.credentials = "include"; + 45| res = await fetch(path, retryOpts); + 46| } catch (refreshErr) { + 47| AuthManager.clearSession(); + 48| AuthManager.showLoginScreen(); + 49| throw new Error("Session expirée"); + 50| } + 51| } + 52| if (!res.ok) { + 53| var detail = ""; + 54| try { + 55| var body = await res.json(); + 56| detail = body.detail || ""; + 57| } catch (_) { + 58| /* no json body */ + 59| } + 60| showToast(detail || "Erreur API : " + res.status, "error"); + 61| throw new Error(detail || "API error: " + res.status); + 62| } + 63| return res.json(); + 64|} + 65| + 66| + 67|// --------------------------------------------------------------------------- + 68|// AuthManager — Authentication state & token management + 69|// --------------------------------------------------------------------------- + 70| + 71|const AuthManager = { + 72| ACCESS_TOKEN_KEY: "obsigate_access_token", + 73| TOKEN_EXPIRY_KEY: "obsigate_token_expiry", + 74| USER_KEY: "obsigate_user", + 75| _authEnabled: false, + 76| + 77| // ── Token storage (sessionStorage) ───────────────────────────── + 78| + 79| saveToken(tokenData) { + 80| const expiresAt = Date.now() + tokenData.expires_in * 1000; + 81| sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token); + 82| sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString()); + 83| if (tokenData.user) { + 84| sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); + 85| } + 86| }, + 87| + 88| getToken() { + 89| return sessionStorage.getItem(this.ACCESS_TOKEN_KEY); + 90| }, + 91| + 92| getUser() { + 93| const raw = sessionStorage.getItem(this.USER_KEY); + 94| return raw ? JSON.parse(raw) : null; + 95| }, + 96| + 97| isTokenExpired() { + 98| const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); + 99| if (!expiry) return true; + 100| // Renew 60s before expiration + 101| return Date.now() > parseInt(expiry) - 60000; + 102| }, + 103| + 104| clearSession() { + 105| sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); + 106| sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY); + 107| sessionStorage.removeItem(this.USER_KEY); + 108| }, + 109| + 110| getAuthHeaders() { + 111| const token = this.getToken(); + 112| if (!token || !this._authEnabled) return null; + 113| return { Authorization: "Bearer " + token }; + 114| }, + 115| + 116| // ── API calls ────────────────────────────────────────────────── + 117| + 118| async login(username, password, rememberMe) { + 119| const response = await fetch("/api/auth/login", { + 120| method: "POST", + 121| headers: { "Content-Type": "application/json" }, + 122| credentials: "include", + 123| body: JSON.stringify({ username, password, remember_me: rememberMe || false }), + 124| }); + 125| if (!response.ok) { + 126| const err = await response.json(); + 127| throw new Error(err.detail || "Erreur de connexion"); + 128| } + 129| const data = await response.json(); + 130| this.saveToken(data); + 131| return data.user; + 132| }, + 133| + 134| async logout() { + 135| try { + 136| const token = this.getToken(); + 137| await fetch("/api/auth/logout", { + 138| method: "POST", + 139| headers: token ? { Authorization: "Bearer " + token } : {}, + 140| credentials: "include", + 141| }); + 142| } catch (e) { + 143| /* continue even if API fails */ + 144| } + 145| this.clearSession(); + 146| this.showLoginScreen(); + 147| }, + 148| + 149| async refreshAccessToken() { + 150| const response = await fetch("/api/auth/refresh", { + 151| method: "POST", + 152| credentials: "include", + 153| }); + 154| if (!response.ok) { + 155| this.clearSession(); + 156| throw new Error("Session expirée"); + 157| } + 158| const data = await response.json(); + 159| const expiry = Date.now() + data.expires_in * 1000; + 160| sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token); + 161| sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString()); + 162| return data.access_token; + 163| }, + 164| + 165| // ── UI controls ──────────────────────────────────────────────── + 166| + 167| showLoginScreen() { + 168| const app = document.getElementById("app"); + 169| const login = document.getElementById("login-screen"); + 170| if (app) app.classList.add("hidden"); + 171| if (login) { + 172| login.classList.remove("hidden"); + 173| const usernameInput = document.getElementById("login-username"); + 174| if (usernameInput) usernameInput.focus(); + 175| } + 176| }, + 177| + 178| showApp() { + 179| const login = document.getElementById("login-screen"); + 180| const app = document.getElementById("app"); + 181| if (login) login.classList.add("hidden"); + 182| if (app) app.classList.remove("hidden"); + 183| this.renderUserMenu(); + 184| }, + 185| + 186| renderUserMenu() { + 187| const user = this.getUser(); + 188| const userMenu = document.getElementById("user-menu"); + 189| if (!userMenu) return; + 190| if (!user || !this._authEnabled) { + 191| userMenu.innerHTML = ""; + 192| return; + 193| } + 194| userMenu.innerHTML = '' + (user.display_name || user.username) + "" + ''; + 195| safeCreateIcons(); + 196| + 197| const logoutBtn = document.getElementById("logout-btn"); + 198| if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout()); + 199| + 200| const adminRow = document.getElementById("admin-menu-row"); + 201| if (adminRow) { + 202| if (user.role === "admin") { + 203| adminRow.classList.remove("hidden"); + 204| // Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before + 205| adminRow.onclick = () => { + 206| closeHeaderMenu(); + 207| AdminPanel.show(); + 208| }; + 209| } else { + 210| adminRow.classList.add("hidden"); + 211| } + 212| } + 213| }, + 214| + 215| // ── Initialization ────────────────────────────────────────────── + 216| + 217| async checkAuthStatus() { + 218| try { + 219| const res = await fetch("/api/auth/status"); + 220| const data = await res.json(); + 221| this._authEnabled = data.auth_enabled; + 222| return data; + 223| } catch (e) { + 224| this._authEnabled = false; + 225| return { auth_enabled: false }; + 226| } + 227| }, + 228| + 229| async initAuth() { + 230| const status = await this.checkAuthStatus(); + 231| if (!status.auth_enabled) { + 232| // Auth disabled — show app immediately + 233| this.showApp(); + 234| return true; + 235| } + 236| + 237| // Auth enabled — check for existing session + 238| if (this.getToken() && !this.isTokenExpired()) { + 239| this.showApp(); + 240| return true; + 241| } + 242| + 243| // Try silent refresh + 244| try { + 245| await this.refreshAccessToken(); + 246| // Fetch user info + 247| const token = this.getToken(); + 248| const res = await fetch("/api/auth/me", { + 249| headers: { Authorization: "Bearer " + token }, + 250| credentials: "include", + 251| }); + 252| if (res.ok) { + 253| const user = await res.json(); + 254| sessionStorage.setItem(this.USER_KEY, JSON.stringify(user)); + 255| this.showApp(); + 256| return true; + 257| } + 258| } catch (e) { + 259| /* silent refresh failed */ + 260| } + 261| + 262| // No valid session — show login + 263| this.showLoginScreen(); + 264| return false; + 265| }, + 266|}; + 267| + 268| + 269|// --------------------------------------------------------------------------- + 270|// Login form handler + 271|// --------------------------------------------------------------------------- + 272| + 273|function initLoginForm() { + 274| const form = document.getElementById("login-form"); + 275| if (!form) return; + 276| + 277| form.addEventListener("submit", async (e) => { + 278| e.preventDefault(); + 279| const username = document.getElementById("login-username").value; + 280| const password = document.getElementById("login-password").value; + 281| const rememberMe = document.getElementById("remember-me").checked; + 282| const errorEl = document.getElementById("login-error"); + 283| const btn = document.getElementById("login-btn"); + 284| + 285| btn.disabled = true; + 286| btn.querySelector(".btn-spinner").classList.remove("hidden"); + 287| btn.querySelector(".btn-text").textContent = "Connexion..."; + 288| errorEl.classList.add("hidden"); + 289| + 290| try { + 291| await AuthManager.login(username, password, rememberMe); + 292| AuthManager.showApp(); + 293| // Load app data after successful login + 294| try { + 295| await Promise.all([loadVaults(), loadTags()]); + 296| // Start SSE sync now that auth cookie is set + 297| IndexUpdateManager.connect(); + 298| // Show dashboard + 299| showWelcome(); + 300| } catch (err) { + 301| console.error("Failed to load data after login:", err); + 302| } + 303| safeCreateIcons(); + 304| } catch (err) { + 305| errorEl.textContent = err.message; + 306| errorEl.classList.remove("hidden"); + 307| document.getElementById("login-password").value = ""; + 308| document.getElementById("login-password").focus(); + 309| } finally { + 310| btn.disabled = false; + 311| btn.querySelector(".btn-spinner").classList.add("hidden"); + 312| btn.querySelector(".btn-text").textContent = "Se connecter"; + 313| } + 314| }); + 315| + 316| // Toggle password visibility + 317| const toggleBtn = document.getElementById("toggle-password"); + 318| if (toggleBtn) { + 319| toggleBtn.addEventListener("click", () => { + 320| const input = document.getElementById("login-password"); + 321| input.type = input.type === "password" ? "text" : "password"; + 322| }); + 323| } + 324|} + 325| + 326| + 327|// --------------------------------------------------------------------------- + 328|// Admin Panel — User management (admin only) + 329|// --------------------------------------------------------------------------- + 330| + 331|const AdminPanel = { + 332| _modal: null, + 333| _allVaults: [], + 334| + 335| show() { + 336| this._createModal(); + 337| this._modal.classList.add("active"); + 338| this._loadUsers(); + 339| }, + 340| + 341| hide() { + 342| if (this._modal) this._modal.classList.remove("active"); + 343| }, + 344| + 345| _createModal() { + 346| if (this._modal) return; + 347| this._modal = document.createElement("div"); + 348| this._modal.className = "editor-modal"; + 349| this._modal.id = "admin-modal"; + 350| this._modal.innerHTML = ` + 351|
+ 352|
+ 353|
⚙️ Administration — Utilisateurs
+ 354|
+ 355| + 358|
+ 359|
+ 360|
+ 361|
+ 362| + 363|
+ 364|
+ 365|
+ 366|
+ 367| `; + 368| document.body.appendChild(this._modal); + 369| safeCreateIcons(); + 370| + 371| document.getElementById("admin-close").addEventListener("click", () => this.hide()); + 372| document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null)); + 373| }, + 374| + 375| async _loadUsers() { + 376| try { + 377| const users = await api("/api/auth/admin/users"); + 378| // Also load available vaults + 379| try { + 380| const vaultsData = await api("/api/vaults"); + 381| this._allVaults = vaultsData.map((v) => v.name); + 382| } catch (e) { + 383| this._allVaults = []; + 384| } + 385| this._renderUsers(users); + 386| } catch (err) { + 387| document.getElementById("admin-users-list").innerHTML = '

Erreur : ' + err.message + "

"; + 388| } + 389| }, + 390| + 391| _renderUsers(users) { + 392| const container = document.getElementById("admin-users-list"); + 393| if (!users.length) { + 394| container.innerHTML = '

Aucun utilisateur.

'; + 395| return; + 396| } + 397| let html = '' + "" + ""; + 398| users.forEach((u) => { + 399| const vaults = u.vaults.includes("*") ? "Toutes" : u.vaults.join(", ") || "Aucune"; + 400| const status = u.active ? "✅" : "🔴"; + 401| 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"; + 402| html += + 403| "" + + 404| "" + + 409| '" + + 414| '" + + 417| "" + + 420| "" + + 423| '"; + 431| }); + 432| html += "
UtilisateurRôleVaultsStatutDernière connexionActions
" + + 405| u.username + + 406| "" + + 407| (u.display_name && u.display_name !== u.username ? "
" + u.display_name + "" : "") + + 408| "
' + + 412| u.role + + 413| "' + + 415| vaults + + 416| "" + + 418| status + + 419| "" + + 421| lastLogin + + 422| "' + + 424| '' + + 427| '' + + 430| "
"; + 433| container.innerHTML = html; + 434| + 435| // Bind action buttons + 436| container.querySelectorAll('[data-action="edit"]').forEach((btn) => { + 437| btn.addEventListener("click", () => { + 438| const user = users.find((u) => u.username === btn.dataset.username); + 439| if (user) this._showUserForm(user); + 440| }); + 441| }); + 442| container.querySelectorAll('[data-action="delete"]').forEach((btn) => { + 443| btn.addEventListener("click", () => this._deleteUser(btn.dataset.username)); + 444| }); + 445| }, + 446| + 447| _showUserForm(user) { + 448| const isEdit = !!user; + 449| const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur"; + 450| const vaultCheckboxes = this._allVaults + 451| .map((v) => { + 452| const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : ""; + 453| return '"; + 454| }) + 455| .join(""); + 456| const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : ""; + 457| + 458| // Create form modal overlay + 459| const overlay = document.createElement("div"); + 460| overlay.className = "admin-form-overlay"; + 461| overlay.innerHTML = ` + 462|
+ 463|

${title}

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

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

` - : '

Sans expiration

'; - div.innerHTML = ` - `; - div.querySelector(".share-copy-btn").addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(url); - } catch (e) { - // Fallback for non-HTTPS contexts - const ta = document.createElement("textarea"); - ta.value = url; ta.style.position = "fixed"; ta.style.left = "-9999px"; - document.body.appendChild(ta); ta.select(); document.execCommand("copy"); - document.body.removeChild(ta); - } - showToast("Lien copié !", "success"); - div.remove(); - }); - div.querySelector(".share-revoke-btn").addEventListener("click", async () => { - try { - await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); - showToast("Partage révoqué", "success"); - existingShare = null; - renderContent(); - } catch (err) { showToast("Erreur: " + err.message, "error"); } - }); - } else { - div.innerHTML = ` - `; - div.querySelector(".share-create-btn").addEventListener("click", async () => { - try { - const expiry = document.getElementById("share-expiry")?.value; - const share = await api(`/api/share/${encodeURIComponent(vault)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), - }); - existingShare = share; - renderContent(); - showToast("Lien créé !", "success"); - } catch (err) { showToast("Erreur: " + err.message, "error"); } - }); - } - div.querySelector(".share-close-btn").addEventListener("click", () => div.remove()); - div.addEventListener("click", (e) => { if (e.target === div) div.remove(); }); - }; - - renderContent(); - document.body.appendChild(div); -} - -function renderConfigFilters() { - const config = TagFilterService.getConfig(); - const filters = config.tagFilters || TagFilterService.defaultFilters; - const container = document.getElementById("config-filters-list"); - - container.innerHTML = ""; - - filters.forEach((filter, index) => { - const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [ - el("span", {}, [document.createTextNode(filter.pattern)]), - el( - "button", - { - class: "config-filter-toggle", - title: filter.enabled ? "Désactiver" : "Activer", - type: "button", - }, - [document.createTextNode(filter.enabled ? "✓" : "○")], - ), - el( - "button", - { - class: "config-filter-remove", - title: "Supprimer", - type: "button", - }, - [document.createTextNode("×")], - ), - ]); - - const toggleBtn = badge.querySelector(".config-filter-toggle"); - const removeBtn = badge.querySelector(".config-filter-remove"); - - toggleBtn.addEventListener("click", (e) => { - e.stopPropagation(); - toggleConfigFilter(index); - }); - - removeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - removeConfigFilter(index); - }); - - container.appendChild(badge); - }); -} - -function toggleConfigFilter(index) { - const config = TagFilterService.getConfig(); - const filters = config.tagFilters || TagFilterService.defaultFilters; - if (filters[index]) { - filters[index].enabled = !filters[index].enabled; - config.tagFilters = filters; - TagFilterService.saveConfig(config); - renderConfigFilters(); - refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); - } -} - -function removeConfigFilter(index) { - const config = TagFilterService.getConfig(); - let filters = config.tagFilters || TagFilterService.defaultFilters; - filters = filters.filter((_, i) => i !== index); - config.tagFilters = filters; - TagFilterService.saveConfig(config); - renderConfigFilters(); - refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); -} - -function addConfigFilter() { - const input = document.getElementById("config-pattern-input"); - const pattern = input.value.trim(); - - if (!pattern) return; - - const regex = TagFilterService.patternToRegex(pattern); - const config = TagFilterService.getConfig(); - const filters = config.tagFilters || TagFilterService.defaultFilters; - - const newFilter = { pattern, regex, enabled: true }; - filters.push(newFilter); - config.tagFilters = filters; - TagFilterService.saveConfig(config); - - input.value = ""; - renderConfigFilters(); - refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); - updateRegexPreview(); -} - -function updateRegexPreview() { - const input = document.getElementById("config-pattern-input"); - const preview = document.getElementById("config-regex-preview"); - const code = document.getElementById("config-regex-code"); - const pattern = input.value.trim(); - - if (pattern) { - const regex = TagFilterService.patternToRegex(pattern); - code.textContent = `^${regex}$`; - preview.style.display = "block"; - } else { - preview.style.display = "none"; - } -} - - -export { - initSidebarTabs, - initConfigModal, - initConfigModal as initConfigPanel, - initHelpModal, - closeHelpModal, - initRecentTab, - loadAbout as initAboutSection, - loadHiddenFilesSettings as initHiddenFilesConfig, -}; + 1|// config.js — extracted from app.js (3872-4865) +import { state } from './state.js'; + 3| + 4|let _recentTimestampTimer = null; + 5|let _recentFilesCache = []; + 6|let _recentRefreshTimer = null; + 7| + 8|async function loadRecentFiles(vaultFilter) { + 9| const listEl = document.getElementById("recent-list"); + 10| const emptyEl = document.getElementById("recent-empty"); + 11| if (!listEl) return; + 12| + 13| let url = "/api/recent?mode=modified"; + 14| if (vaultFilter) url += `&vault=${encodeURIComponent(vaultFilter)}`; + 15| try { + 16| const data = await api(url); + 17| _recentFilesCache = data.files || []; + 18| renderRecentList(_recentFilesCache); + 19| } catch (err) { + 20| console.error("Failed to load recent files:", err); + 21| listEl.innerHTML = ""; + 22| if (emptyEl) { + 23| emptyEl.classList.remove("hidden"); + 24| } + 25| } + 26|} + 27| + 28|function renderRecentList(files) { + 29| const listEl = document.getElementById("recent-list"); + 30| const emptyEl = document.getElementById("recent-empty"); + 31| if (!listEl) return; + 32| listEl.innerHTML = ""; + 33| + 34| if (!files || files.length === 0) { + 35| if (emptyEl) { + 36| emptyEl.classList.remove("hidden"); + 37| safeCreateIcons(); + 38| } + 39| return; + 40| } + 41| if (emptyEl) emptyEl.classList.add("hidden"); + 42| + 43| files.forEach((f) => { + 44| const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path }); + 45| + 46| // Header row: time + vault badge + 47| const header = el("div", { class: "recent-item-header" }); + 48| const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(f.mtime_human)]); + 49| const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]); + 50| header.appendChild(timeSpan); + 51| header.appendChild(badge); + 52| item.appendChild(header); + 53| + 54| // Title + 55| const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(f.title || f.path.split("/").pop())]); + 56| item.appendChild(titleEl); + 57| + 58| // Path breadcrumb + 59| const pathParts = f.path.split("/"); + 60| if (pathParts.length > 1) { + 61| const pathEl = el("div", { class: "recent-item-path" }, [document.createTextNode(pathParts.slice(0, -1).join(" / "))]); + 62| item.appendChild(pathEl); + 63| } + 64| + 65| // Preview + 66| if (f.preview) { + 67| const previewEl = el("div", { class: "recent-item-preview" }, [document.createTextNode(f.preview)]); + 68| item.appendChild(previewEl); + 69| } + 70| + 71| // Tags + 72| if (f.tags && f.tags.length > 0) { + 73| const tagsEl = el("div", { class: "recent-item-tags" }); + 74| f.tags.forEach((t) => { + 75| tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)])); + 76| }); + 77| item.appendChild(tagsEl); + 78| } + 79| + 80| // Click handler + 81| item.addEventListener("click", () => { + 82| openFile(f.vault, f.path); + 83| closeMobileSidebar(); + 84| }); + 85| + 86| listEl.appendChild(item); + 87| }); + 88| safeCreateIcons(); + 89|} + 90| + 91|function _humanizeDelta(mtime) { + 92| const delta = Date.now() / 1000 - mtime; + 93| if (delta < 60) return "à l'instant"; + 94| if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`; + 95| if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`; + 96| if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`; + 97| return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }); + 98|} + 99| + 100|function _refreshRecentTimestamps() { + 101| if (activeSidebarTab !== "recent" || !_recentFilesCache.length) return; + 102| const items = document.querySelectorAll(".recent-item"); + 103| items.forEach((item, i) => { + 104| if (i < _recentFilesCache.length) { + 105| const timeSpan = item.querySelector(".recent-time"); + 106| if (timeSpan) { + 107| // keep the icon, update text + 108| const textNode = timeSpan.lastChild; + 109| if (textNode && textNode.nodeType === Node.TEXT_NODE) { + 110| textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime); + 111| } + 112| } + 113| } + 114| }); + 115|} + 116| + 117|function _populateRecentVaultFilter() { + 118| const select = document.getElementById("recent-vault-filter"); + 119| if (!select) return; + 120| // keep first option "Tous les vaults" + 121| while (select.options.length > 1) select.remove(1); + 122| state.allVaults.forEach((v) => { + 123| const opt = document.createElement("option"); + 124| opt.value = v.name; + 125| opt.textContent = v.name; + 126| select.appendChild(opt); + 127| }); + 128| syncVaultSelectors(); + 129|} + 130| + 131|function initRecentTab() { + 132| const select = document.getElementById("recent-vault-filter"); + 133| if (select) { + 134| select.addEventListener("change", async () => { + 135| const val = select.value || "all"; + 136| await setSelectedVaultContext(val, { focusVault: val !== "all" }); + 137| }); + 138| } + 139| // Periodic timestamp refresh (every 60s) + 140| _recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000); + 141|} + 142| + 143|// --------------------------------------------------------------------------- + 144|// Sidebar tabs + 145|// --------------------------------------------------------------------------- + 146|function initSidebarTabs() { + 147| document.querySelectorAll(".sidebar-tab").forEach((tab) => { + 148| tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab)); + 149| }); + 150|} + 151| + 152|function switchSidebarTab(tab) { + 153| state.activeSidebarTab = tab; + 154| document.querySelectorAll(".sidebar-tab").forEach((btn) => { + 155| const isActive = btn.dataset.tab === tab; + 156| btn.classList.toggle("active", isActive); + 157| btn.setAttribute("aria-selected", isActive ? "true" : "false"); + 158| }); + 159| document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => { + 160| const isActive = panel.id === `sidebar-panel-${tab}`; + 161| panel.classList.toggle("active", isActive); + 162| }); + 163| const filterInput = document.getElementById("sidebar-filter-input"); + 164| if (filterInput) { + 165| const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" }; + 166| filterInput.placeholder = placeholders[tab] || ""; + 167| } + 168| const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; + 169| if (query) { + 170| if (tab === "vaults") performTreeSearch(query); + 171| else if (tab === "tags") filterTagCloud(query); + 172| } + 173| // Auto-load recent files when switching to the recent tab + 174| if (tab === "recent") { + 175| _populateRecentVaultFilter(); + 176| const vaultFilter = document.getElementById("recent-vault-filter"); + 177| loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + 178| } + 179|} + 180| + 181|function initHelpModal() { + 182| const openBtn = document.getElementById("help-open-btn"); + 183| const closeBtn = document.getElementById("help-close"); + 184| const modal = document.getElementById("help-modal"); + 185| if (!openBtn || !closeBtn || !modal) return; + 186| + 187| openBtn.addEventListener("click", () => { + 188| modal.classList.add("active"); + 189| closeHeaderMenu(); + 190| safeCreateIcons(); + 191| initHelpNavigation(); + 192| }); + 193| + 194| closeBtn.addEventListener("click", closeHelpModal); + 195| modal.addEventListener("click", (e) => { + 196| if (e.target === modal) { + 197| closeHelpModal(); + 198| } + 199| }); + 200| + 201| document.addEventListener("keydown", (e) => { + 202| if (e.key === "Escape" && modal.classList.contains("active")) { + 203| closeHelpModal(); + 204| } + 205| }); + 206|} + 207| + 208|function initHelpNavigation() { + 209| const helpContent = document.querySelector(".help-content"); + 210| const helpBody = document.getElementById("help-body"); + 211| const navLinks = document.querySelectorAll(".help-nav-link"); + 212| + 213| if (!helpContent || !helpBody || !navLinks.length) return; + 214| + 215| // Handle nav link clicks + 216| navLinks.forEach((link) => { + 217| link.addEventListener("click", (e) => { + 218| e.preventDefault(); + 219| const targetId = link.getAttribute("href").substring(1); + 220| const targetSection = document.getElementById(targetId); + 221| if (targetSection) { + 222| targetSection.scrollIntoView({ behavior: "smooth", block: "start" }); + 223| } + 224| }); + 225| }); + 226| + 227| // Scroll spy - update active nav link based on scroll position + 228| const observer = new IntersectionObserver( + 229| (entries) => { + 230| entries.forEach((entry) => { + 231| if (entry.isIntersecting) { + 232| const id = entry.target.getAttribute("id"); + 233| navLinks.forEach((link) => { + 234| if (link.getAttribute("href") === `#${id}`) { + 235| navLinks.forEach((l) => l.classList.remove("active")); + 236| link.classList.add("active"); + 237| } + 238| }); + 239| } + 240| }); + 241| }, + 242| { + 243| root: helpBody, + 244| rootMargin: "-20% 0px -70% 0px", + 245| threshold: 0, + 246| }, + 247| ); + 248| + 249| // Observe all sections + 250| document.querySelectorAll(".help-section").forEach((section) => { + 251| observer.observe(section); + 252| }); + 253|} + 254| + 255|function closeHelpModal() { + 256| const modal = document.getElementById("help-modal"); + 257| if (modal) modal.classList.remove("active"); + 258|} + 259| + 260|function initConfigModal() { + 261| const openBtn = document.getElementById("config-open-btn"); + 262| const closeBtn = document.getElementById("config-close"); + 263| const modal = document.getElementById("config-modal"); + 264| const addBtn = document.getElementById("config-add-btn"); + 265| const patternInput = document.getElementById("config-pattern-input"); + 266| + 267| if (!openBtn || !closeBtn || !modal) return; + 268| + 269| openBtn.addEventListener("click", async () => { + 270| modal.classList.add("active"); + 271| closeHeaderMenu(); + 272| renderConfigFilters(); + 273| loadConfigFields(); + 274| loadDiagnostics(); + 275| loadAbout(); + 276| await loadHiddenFilesSettings(); + 277| loadWebhooksUI(); + 278| loadSharesUI(); + 279| safeCreateIcons(); + 280| }); + 281| + 282| closeBtn.addEventListener("click", closeConfigModal); + 283| modal.addEventListener("click", (e) => { + 284| if (e.target === modal) { + 285| closeConfigModal(); + 286| } + 287| }); + 288| + 289| addBtn.addEventListener("click", addConfigFilter); + 290| patternInput.addEventListener("keypress", (e) => { + 291| if (e.key === "Enter") { + 292| addConfigFilter(); + 293| } + 294| }); + 295| + 296| patternInput.addEventListener("input", updateRegexPreview); + 297| + 298| // Frontend config fields — save to localStorage on change + 299| ["cfg-debounce", "cfg-results-per-page", "cfg-min-query", "cfg-timeout"].forEach((id) => { + 300| const input = document.getElementById(id); + 301| if (input) input.addEventListener("change", saveFrontendConfig); + 302| }); + 303| + 304| // Backend save button + 305| const saveBtn = document.getElementById("cfg-save-backend"); + 306| if (saveBtn) saveBtn.addEventListener("click", saveBackendConfig); + 307| + 308| // Force reindex + 309| const reindexBtn = document.getElementById("cfg-reindex"); + 310| if (reindexBtn) reindexBtn.addEventListener("click", forceReindex); + 311| + 312| // Reset defaults + 313| const resetBtn = document.getElementById("cfg-reset-defaults"); + 314| if (resetBtn) resetBtn.addEventListener("click", resetConfigDefaults); + 315| + 316| // Refresh diagnostics + 317| const diagBtn = document.getElementById("cfg-refresh-diag"); + 318| if (diagBtn) diagBtn.addEventListener("click", loadDiagnostics); + 319| + 320| // Hidden files configuration + 321| const saveHiddenBtn = document.getElementById("cfg-save-hidden-files"); + 322| if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings); + 323| + 324| document.addEventListener("keydown", (e) => { + 325| if (e.key === "Escape" && modal.classList.contains("active")) { + 326| closeConfigModal(); + 327| } + 328| }); + 329| + 330| // Load saved frontend config on startup + 331| applyFrontendConfig(); + 332|} + 333| + 334|function closeConfigModal() { + 335| const modal = document.getElementById("config-modal"); + 336| if (modal) modal.classList.remove("active"); + 337|} + 338| + 339|// --- Config field helpers --- + 340|const _FRONTEND_CONFIG_KEY = "obsigate-perf-config"; + 341| + 342|function _getFrontendConfig() { + 343| try { + 344| return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}"); + 345| } catch { + 346| return {}; + 347| } + 348|} + 349| + 350|function applyFrontendConfig() { + 351| const cfg = _getFrontendConfig(); + 352| if (cfg.debounce_ms) { + 353| /* applied dynamically in debounce setTimeout */ + 354| } + 355| if (cfg.results_per_page) { + 356| /* used as ADVANCED_SEARCH_LIMIT override */ + 357| } + 358| if (cfg.min_query_length) { + 359| /* used as MIN_SEARCH_LENGTH override */ + 360| } + 361| if (cfg.search_timeout_ms) { + 362| /* used as SEARCH_TIMEOUT_MS override */ + 363| } + 364|} + 365| + 366|function _getEffective(key, fallback) { + 367| const cfg = _getFrontendConfig(); + 368| return cfg[key] !== undefined ? cfg[key] : fallback; + 369|} + 370| + 371|async function loadConfigFields() { + 372| // Frontend fields from localStorage + 373| const cfg = _getFrontendConfig(); + 374| _setField("cfg-debounce", cfg.debounce_ms || 300); + 375| _setField("cfg-results-per-page", cfg.results_per_page || 50); + 376| _setField("cfg-min-query", cfg.min_query_length || 2); + 377| _setField("cfg-timeout", cfg.search_timeout_ms || 30000); + 378| + 379| // Backend fields from API + 380| try { + 381| const data = await api("/api/config"); + 382| _setField("cfg-workers", data.search_workers); + 383| _setField("cfg-max-content", data.max_content_size); + 384| _setField("cfg-title-boost", data.title_boost); + 385| _setField("cfg-tag-boost", data.tag_boost); + 386| _setField("cfg-prefix-exp", data.prefix_max_expansions); + 387| _setField("cfg-recent-limit", data.recent_files_limit || 20); + 388| // Watcher config + 389| _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); + 390| _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); + 391| _setField("cfg-watcher-interval", data.watcher_polling_interval || 5); + 392| _setField("cfg-watcher-debounce", data.watcher_debounce || 2); + 393| } catch (err) { + 394| console.error("Failed to load backend config:", err); + 395| } + 396|} + 397| + 398|function _setField(id, value) { + 399| const el = document.getElementById(id); + 400| if (el && value !== undefined) el.value = value; + 401|} + 402| + 403|function _setCheckbox(id, checked) { + 404| const el = document.getElementById(id); + 405| if (el) el.checked = !!checked; + 406|} + 407| + 408|function _getCheckbox(id) { + 409| const el = document.getElementById(id); + 410| return el ? el.checked : false; + 411|} + 412| + 413|function _getFieldNum(id, fallback) { + 414| const el = document.getElementById(id); + 415| if (!el) return fallback; + 416| const v = parseFloat(el.value); + 417| return isNaN(v) ? fallback : v; + 418|} + 419| + 420|function saveFrontendConfig() { + 421| const cfg = { + 422| debounce_ms: _getFieldNum("cfg-debounce", 300), + 423| results_per_page: _getFieldNum("cfg-results-per-page", 50), + 424| min_query_length: _getFieldNum("cfg-min-query", 2), + 425| search_timeout_ms: _getFieldNum("cfg-timeout", 30000), + 426| }; + 427| localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg)); + 428| showToast("Paramètres client sauvegardés", "success"); + 429|} + 430| + 431|async function saveBackendConfig() { + 432| const body = { + 433| search_workers: _getFieldNum("cfg-workers", 2), + 434| max_content_size: _getFieldNum("cfg-max-content", 100000), + 435| title_boost: _getFieldNum("cfg-title-boost", 3.0), + 436| tag_boost: _getFieldNum("cfg-tag-boost", 2.0), + 437| prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), + 438| recent_files_limit: _getFieldNum("cfg-recent-limit", 20), + 439| watcher_enabled: _getCheckbox("cfg-watcher-enabled"), + 440| watcher_use_polling: _getCheckbox("cfg-watcher-polling"), + 441| watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), + 442| watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0), + 443| }; + 444| try { + 445| const res = await fetch("/api/config", { + 446| method: "POST", + 447| headers: { "Content-Type": "application/json" }, + 448| body: JSON.stringify(body), + 449| }); + 450| if (res.ok) { + 451| showToast("Configuration backend sauvegardée", "success"); + 452| } else { + 453| const errorData = await res.json().catch(() => ({})); + 454| showToast(errorData.detail || "Erreur de sauvegarde", "error"); + 455| } + 456| } catch (err) { + 457| console.error("Failed to save backend config:", err); + 458| showToast("Erreur de sauvegarde", "error"); + 459| } + 460|} + 461| + 462|async function forceReindex() { + 463| const btn = document.getElementById("cfg-reindex"); + 464| if (btn) { + 465| btn.disabled = true; + 466| btn.textContent = "Réindexation..."; + 467| } + 468| try { + 469| await api("/api/index/reload"); + 470| showToast("Réindexation terminée", "success"); + 471| loadDiagnostics(); + 472| await Promise.all([loadVaults(), loadTags()]); + 473| } catch (err) { + 474| console.error("Reindex error:", err); + 475| showToast("Erreur de réindexation", "error"); + 476| } finally { + 477| if (btn) { + 478| btn.disabled = false; + 479| btn.textContent = "Forcer réindexation"; + 480| } + 481| } + 482|} + 483| + 484|async function resetConfigDefaults() { + 485| // Reset frontend + 486| localStorage.removeItem(_FRONTEND_CONFIG_KEY); + 487| // Reset backend + 488| try { + 489| await fetch("/api/config", { + 490| method: "POST", + 491| headers: { "Content-Type": "application/json" }, + 492| body: JSON.stringify({ + 493| search_workers: 2, + 494| debounce_ms: 300, + 495| results_per_page: 50, + 496| min_query_length: 2, + 497| search_timeout_ms: 30000, + 498| max_content_size: 100000, + 499| title_boost: 3.0, + 500| path_boost: 1.5, + 501| tag_boost: 2.0, + 502| prefix_max_expansions: 50, + 503| snippet_context_chars: 120, + 504| max_snippet_highlights: 5, + 505| }), + 506| }); + 507| } catch (err) { + 508| console.error("Reset config error:", err); + 509| } + 510| loadConfigFields(); + 511| showToast("Configuration réinitialisée", "success"); + 512|} + 513| + 514|async function loadDiagnostics() { + 515| const container = document.getElementById("config-diagnostics"); + 516| if (!container) return; + 517| container.innerHTML = '
Chargement...
'; + 518| try { + 519| const data = await api("/api/diagnostics"); + 520| renderDiagnostics(container, data); + 521| } catch (err) { + 522| container.innerHTML = '
Erreur de chargement
'; + 523| } + 524|} + 525| + 526|function renderDiagnostics(container, data) { + 527| container.innerHTML = ""; + 528| const sections = [ + 529| { + 530| title: "Index", + 531| rows: [ + 532| ["Fichiers indexés", data.index.total_files], + 533| ["Tags uniques", data.index.total_tags], + 534| ["Vaults", Object.keys(data.index.vaults).join(", ")], + 535| ], + 536| }, + 537| { + 538| title: "Index inversé", + 539| rows: [ + 540| ["Tokens uniques", data.inverted_index.unique_tokens.toLocaleString()], + 541| ["Postings total", data.inverted_index.total_postings.toLocaleString()], + 542| ["Documents", data.inverted_index.documents], + 543| ["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"], + 544| ["Stale", data.inverted_index.is_stale ? "Oui" : "Non"], + 545| ], + 546| }, + 547| { + 548| title: "Moteur de recherche", + 549| rows: [ + 550| ["Executor actif", data.search_executor.active ? "Oui" : "Non"], + 551| ["Workers max", data.search_executor.max_workers], + 552| ], + 553| }, + 554| ]; + 555| sections.forEach((section) => { + 556| const div = document.createElement("div"); + 557| div.className = "config-diag-section"; + 558| const title = document.createElement("div"); + 559| title.className = "config-diag-section-title"; + 560| title.textContent = section.title; + 561| div.appendChild(title); + 562| section.rows.forEach(([label, value]) => { + 563| const row = document.createElement("div"); + 564| row.className = "config-diag-row"; + 565| row.innerHTML = `${label}${value}`; + 566| div.appendChild(row); + 567| }); + 568| container.appendChild(div); + 569| }); + 570|} + 571| + 572|// --- About Section --- + 573| + 574|function loadAbout() { + 575| const container = document.getElementById("config-about"); + 576| if (!container) return; + 577| + 578| // Fetch health info for version + 579| api("/api/health").then((health) => { + 580| container.innerHTML = ""; + 581| + 582| const sections = [ + 583| { + 584| title: "Application", + 585| rows: [ + 586| ["Nom", "ObsiGate"], + 587| ["Version", state.APP_VERSION], + 588| ["Version API", health.version || "—"], + 589| ["Statut", health.status || "—"], + 590| ], + 591| }, + 592| { + 593| title: "Environnement", + 594| rows: [ + 595| ["Vaults configurés", health.vaults || "—"], + 596| ["Fichiers indexés", health.total_files || "—"], + 597| ["Navigateur", navigator.userAgent.split(" ").pop()], + 598| ["Plateforme", navigator.platform || "—"], + 599| ["Langue", navigator.language || "—"], + 600| ], + 601| }, + 602| { + 603| title: "Composants", + 604| rows: [ + 605| ["Backend", "FastAPI (Python)"], + 606| ["Rendu Markdown", "mistune"], + 607| ["Surveillance fichiers", "watchdog"], + 608| ["Frontend", "Vanilla JavaScript"], + 609| ["Icônes", "Lucide Icons"], + 610| ["Coloration syntaxe", "highlight.js"], + 611| ["Éditeur", "CodeMirror 6"], + 612| ], + 613| }, + 614| ]; + 615| + 616| sections.forEach((section) => { + 617| const div = document.createElement("div"); + 618| div.className = "config-diag-section"; + 619| const title = document.createElement("div"); + 620| title.className = "config-diag-section-title"; + 621| title.textContent = section.title; + 622| div.appendChild(title); + 623| section.rows.forEach(([label, value]) => { + 624| const row = document.createElement("div"); + 625| row.className = "config-diag-row"; + 626| row.innerHTML = `${label}${value}`; + 627| div.appendChild(row); + 628| }); + 629| container.appendChild(div); + 630| }); + 631| }).catch(() => { + 632| container.innerHTML = '
Erreur de chargement
'; + 633| }); + 634|} + 635| + 636|// --- Hidden Files Configuration --- + 637| + 638|async function loadHiddenFilesSettings() { + 639| const container = document.getElementById("hidden-files-vault-list"); + 640| if (!container) return; + 641| + 642| container.innerHTML = '
Chargement...
'; + 643| + 644| try { + 645| const settings = await api("/api/vaults/settings/all"); + 646| renderHiddenFilesSettings(container, settings); + 647| } catch (err) { + 648| console.error("Failed to load hidden files settings:", err); + 649| container.innerHTML = '
Erreur de chargement
'; + 650| } + 651|} + 652| + 653|function renderHiddenFilesSettings(container, allSettings) { + 654| container.innerHTML = ""; + 655| + 656| if (!allVaults || state.allVaults.length === 0) { + 657| container.innerHTML = '
Aucun vault configuré
'; + 658| return; + 659| } + 660| + 661| state.allVaults.forEach((vault) => { + 662| const settings = allSettings[vault.name] || { hideHiddenFiles: false }; + 663| + 664| const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name }); + 665| + 666| // Vault header + 667| 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")])]); + 668| + 669| // Hide hidden files toggle + 670| const toggleRow = el("div", { class: "config-row" }, [ + 671| el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]), + 672| el("label", { class: "config-toggle" }, [ + 673| el("input", { + 674| type: "checkbox", + 675| id: `hide-hidden-${vault.name}`, + 676| "data-vault": vault.name, + 677| checked: settings.hideHiddenFiles ? "true" : false, + 678| }), + 679| el("span", { class: "config-toggle-slider" }), + 680| ]), + 681| 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)")]), + 682| ]); + 683| + 684| vaultCard.appendChild(header); + 685| vaultCard.appendChild(toggleRow); + 686| + 687| container.appendChild(vaultCard); + 688| }); + 689|} + 690| + 691|async function saveHiddenFilesSettings() { + 692| const btn = document.getElementById("cfg-save-hidden-files"); + 693| if (btn) { + 694| btn.disabled = true; + 695| btn.textContent = "Sauvegarde..."; + 696| } + 697| + 698| try { + 699| const vaultCards = document.querySelectorAll(".hidden-files-vault-card"); + 700| const promises = []; + 701| + 702| vaultCards.forEach((card) => { + 703| const vaultName = card.dataset.vault; + 704| const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false; + 705| + 706| const settings = { + 707| hideHiddenFiles, + 708| }; + 709| + 710| promises.push( + 711| api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, { + 712| method: "POST", + 713| headers: { "Content-Type": "application/json" }, + 714| body: JSON.stringify(settings), + 715| }), + 716| ); + 717| }); + 718| + 719| await Promise.all(promises); + 720| + 721| // Reload vault settings to update the cache + 722| await loadVaultSettings(); + 723| + 724| showToast("✓ Paramètres sauvegardés", "success"); + 725| + 726| // Refresh the UI to apply the filter + 727| await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + 728| } catch (err) { + 729| console.error("Failed to save hidden files settings:", err); + 730| const errorMsg = err.message || "Erreur inconnue"; + 731| showToast(`Erreur: ${errorMsg}`, "error"); + 732| } finally { + 733| if (btn) { + 734| btn.disabled = false; + 735| btn.textContent = "💾 Sauvegarder"; + 736| } + 737| } + 738|} + 739| + 740|// ── Webhooks UI ── + 741|async function loadWebhooksUI() { + 742| const list = document.getElementById("webhooks-list"); + 743| if (!list) return; + 744| try { + 745| const webhooks = await api("/api/webhooks"); + 746| renderWebhooksUI(webhooks); + 747| } catch { list.innerHTML = '
Admin uniquement
'; } + 748|} + 749|function renderWebhooksUI(webhooks) { + 750| const list = document.getElementById("webhooks-list"); + 751| if (!list) return; + 752| if (!webhooks.length) { list.innerHTML = '
Aucun webhook configuré.
'; return; } + 753| list.innerHTML = webhooks.map(w => ` + 754|
+ 755| ${escapeHtml(w.name)} + 756| ${escapeHtml(w.url)} + 757| ${(w.events||[]).join(", ")} + 758| + 759|
+ 760| `).join(""); + 761| list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => { + 762| await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" }); + 763| loadWebhooksUI(); + 764| })); + 765|} + 766|document.addEventListener("click", function(e) { + 767| if (e.target.id === "webhook-add-btn") { + 768| const name = document.getElementById("webhook-name-input").value.trim(); + 769| const url = document.getElementById("webhook-url-input").value.trim(); + 770| if (!url) { showToast("URL requise", "error"); return; } + 771| 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")); + 772| } + 773|}); + 774| + 775|// ── Shares UI ── + 776|async function loadSharesUI() { + 777| const list = document.getElementById("shares-list"); + 778| if (!list) return; + 779| try { + 780| const shares = await api("/api/shares"); + 781| renderSharesUI(shares); + 782| } catch { list.innerHTML = '
Chargement...
'; } + 783|} + 784|function renderSharesUI(shares) { + 785| const list = document.getElementById("shares-list"); + 786| if (!list) return; + 787| if (!shares.length) { list.innerHTML = '
Aucun partage actif.
'; return; } + 788| list.innerHTML = shares.map(s => ` + 789| + 795| `).join(""); + 796| list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => { + 797| await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); + 798| loadSharesUI(); + 799| })); + 800|} + 801| + 802|// ── Share Dialog (professional) ── + 803|async function openShareDialog(vault, path) { + 804| // First check if already shared + 805| let existingShare = null; + 806| try { + 807| const shares = await api("/api/shares"); + 808| existingShare = shares.find(s => s.vault === vault && s.path === path); + 809| } catch (e) { /* ignore */ } + 810| + 811| const div = document.createElement("div"); + 812| div.className = "share-dialog-overlay"; + 813| + 814| const renderContent = () => { + 815| if (existingShare) { + 816| const url = window.location.origin + existingShare.url; + 817| const expiresInfo = existingShare.expires_at + 818| ? `

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

` + 819| : '

Sans expiration

'; + 820| div.innerHTML = ` + 821| `; + 833| div.querySelector(".share-copy-btn").addEventListener("click", async () => { + 834| try { + 835| await navigator.clipboard.writeText(url); + 836| } catch (e) { + 837| // Fallback for non-HTTPS contexts + 838| const ta = document.createElement("textarea"); + 839| ta.value = url; ta.style.position = "fixed"; ta.style.left = "-9999px"; + 840| document.body.appendChild(ta); ta.select(); document.execCommand("copy"); + 841| document.body.removeChild(ta); + 842| } + 843| showToast("Lien copié !", "success"); + 844| div.remove(); + 845| }); + 846| div.querySelector(".share-revoke-btn").addEventListener("click", async () => { + 847| try { + 848| await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); + 849| showToast("Partage révoqué", "success"); + 850| existingShare = null; + 851| renderContent(); + 852| } catch (err) { showToast("Erreur: " + err.message, "error"); } + 853| }); + 854| } else { + 855| div.innerHTML = ` + 856| `; + 875| div.querySelector(".share-create-btn").addEventListener("click", async () => { + 876| try { + 877| const expiry = document.getElementById("share-expiry")?.value; + 878| const share = await api(`/api/share/${encodeURIComponent(vault)}`, { + 879| method: "POST", + 880| headers: { "Content-Type": "application/json" }, + 881| body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), + 882| }); + 883| existingShare = share; + 884| renderContent(); + 885| showToast("Lien créé !", "success"); + 886| } catch (err) { showToast("Erreur: " + err.message, "error"); } + 887| }); + 888| } + 889| div.querySelector(".share-close-btn").addEventListener("click", () => div.remove()); + 890| div.addEventListener("click", (e) => { if (e.target === div) div.remove(); }); + 891| }; + 892| + 893| renderContent(); + 894| document.body.appendChild(div); + 895|} + 896| + 897|function renderConfigFilters() { + 898| const config = TagFilterService.getConfig(); + 899| const filters = config.tagFilters || TagFilterService.defaultFilters; + 900| const container = document.getElementById("config-filters-list"); + 901| + 902| container.innerHTML = ""; + 903| + 904| filters.forEach((filter, index) => { + 905| const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [ + 906| el("span", {}, [document.createTextNode(filter.pattern)]), + 907| el( + 908| "button", + 909| { + 910| class: "config-filter-toggle", + 911| title: filter.enabled ? "Désactiver" : "Activer", + 912| type: "button", + 913| }, + 914| [document.createTextNode(filter.enabled ? "✓" : "○")], + 915| ), + 916| el( + 917| "button", + 918| { + 919| class: "config-filter-remove", + 920| title: "Supprimer", + 921| type: "button", + 922| }, + 923| [document.createTextNode("×")], + 924| ), + 925| ]); + 926| + 927| const toggleBtn = badge.querySelector(".config-filter-toggle"); + 928| const removeBtn = badge.querySelector(".config-filter-remove"); + 929| + 930| toggleBtn.addEventListener("click", (e) => { + 931| e.stopPropagation(); + 932| toggleConfigFilter(index); + 933| }); + 934| + 935| removeBtn.addEventListener("click", (e) => { + 936| e.stopPropagation(); + 937| removeConfigFilter(index); + 938| }); + 939| + 940| container.appendChild(badge); + 941| }); + 942|} + 943| + 944|function toggleConfigFilter(index) { + 945| const config = TagFilterService.getConfig(); + 946| const filters = config.tagFilters || TagFilterService.defaultFilters; + 947| if (filters[index]) { + 948| filters[index].enabled = !filters[index].enabled; + 949| config.tagFilters = filters; + 950| TagFilterService.saveConfig(config); + 951| renderConfigFilters(); + 952| refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + 953| } + 954|} + 955| + 956|function removeConfigFilter(index) { + 957| const config = TagFilterService.getConfig(); + 958| let filters = config.tagFilters || TagFilterService.defaultFilters; + 959| filters = filters.filter((_, i) => i !== index); + 960| config.tagFilters = filters; + 961| TagFilterService.saveConfig(config); + 962| renderConfigFilters(); + 963| refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + 964|} + 965| + 966|function addConfigFilter() { + 967| const input = document.getElementById("config-pattern-input"); + 968| const pattern = input.value.trim(); + 969| + 970| if (!pattern) return; + 971| + 972| const regex = TagFilterService.patternToRegex(pattern); + 973| const config = TagFilterService.getConfig(); + 974| const filters = config.tagFilters || TagFilterService.defaultFilters; + 975| + 976| const newFilter = { pattern, regex, enabled: true }; + 977| filters.push(newFilter); + 978| config.tagFilters = filters; + 979| TagFilterService.saveConfig(config); + 980| + 981| input.value = ""; + 982| renderConfigFilters(); + 983| refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + 984| updateRegexPreview(); + 985|} + 986| + 987|function updateRegexPreview() { + 988| const input = document.getElementById("config-pattern-input"); + 989| const preview = document.getElementById("config-regex-preview"); + 990| const code = document.getElementById("config-regex-code"); + 991| const pattern = input.value.trim(); + 992| + 993| if (pattern) { + 994| const regex = TagFilterService.patternToRegex(pattern); + 995| code.textContent = `^${regex}$`; + 996| preview.style.display = "block"; + 997| } else { + 998| preview.style.display = "none"; + 999| } + 1000|} + 1001| + 1002| + 1003|export { + 1004| initSidebarTabs, + 1005| initConfigModal, + 1006| initConfigModal as initConfigPanel, + 1007| initHelpModal, + 1008| closeHelpModal, + 1009| initRecentTab, + 1010| loadAbout as initAboutSection, + 1011| loadHiddenFilesSettings as initHiddenFilesConfig, + 1012|}; + 1013| \ No newline at end of file diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js index 02db16a..da19c0f 100644 --- a/frontend/js/dashboard.js +++ b/frontend/js/dashboard.js @@ -1,461 +1,462 @@ -// 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, DashboardSharedWidget }; + 1|// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870) +import { state } from './state.js'; + 3| + 4|// --------------------------------------------------------------------------- + 5|// Recent files + 6|// --------------------------------------------------------------------------- + 7|let _recentRefreshTimer = null; + 8|let _recentTimestampTimer = null; + 9|let _recentFilesCache = []; + 10| + 11|// --------------------------------------------------------------------------- + 12|// Dashboard Recent Files Widget + 13|// --------------------------------------------------------------------------- + 14|// ── Dashboard Stats Widget ── + 15|const DashboardStatsWidget = { + 16| async load() { + 17| const grid = document.getElementById("dashboard-stats-grid"); + 18| if (!grid) return; + 19| grid.innerHTML = '
Chargement...
'; + 20| try { + 21| const data = await api("/api/dashboard"); + 22| this.render(data); + 23| } catch (err) { + 24| grid.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; + 25| } + 26| }, + 27| render(data) { + 28| const grid = document.getElementById("dashboard-stats-grid"); + 29| if (!grid) return; + 30| 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`; + 31| const items = [ + 32| { icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() }, + 33| { icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() }, + 34| { icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) }, + 35| { icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() }, + 36| ]; + 37| grid.innerHTML = items.map(i => ` + 38|
+ 39| + 40| ${i.value} + 41| ${i.label} + 42|
+ 43| `).join(""); + 44| safeCreateIcons(); + 45| } + 46|}; + 47| + 48|// ── Dashboard Shared Widget ── + 49|const DashboardSharedWidget = { + 50| async load() { + 51| const grid = document.getElementById("dashboard-shared-grid"); + 52| const empty = document.getElementById("dashboard-shared-empty"); + 53| if (!grid) return; + 54| try { + 55| const shares = await api("/api/shares"); + 56| if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; } + 57| if (empty) empty.style.display = "none"; + 58| grid.innerHTML = shares.map(s => ` + 59|
+ 60|
+ 61| + 62| ${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))} + 63| ${escapeHtml(s.vault)} + 64|
+ 65|
+ 66| ${s.access_count || 0} vue(s) + 67| ${s.expires_at ? `Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}` : ""} + 68|
+ 69|
+ 70| + 71| + 72| + 73|
+ 74|
+ 75| `).join(""); + 76| lucide.createIcons(); + 77| grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => { + 78| e.stopPropagation(); + 79| const url = b.dataset.url; + 80| 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); } + 81| showToast("Lien copié !", "success"); + 82| })); + 83| grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => { + 84| e.stopPropagation(); + 85| const card = b.closest(".shared-card"); + 86| if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path); + 87| })); + 88| grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => { + 89| e.stopPropagation(); + 90| await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); + 91| showToast("Partage révoqué", "success"); + 92| this.load(); + 93| })); + 94| grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => { + 95| TabManager.openPreview(card.dataset.vault, card.dataset.path); + 96| })); + 97| } catch (err) { if (empty) empty.style.display = ""; } + 98| } + 99|}; + 100| + 101|// ── Dashboard Conflicts Widget ── + 102|const DashboardConflictsWidget = { + 103| async load() { + 104| const container = document.getElementById("dashboard-conflicts-container"); + 105| if (!container) return; + 106| try { + 107| const data = await api("/api/conflicts"); + 108| if (data.total === 0) { container.innerHTML = ""; return; } + 109| this.render(data.conflicts, container); + 110| } catch (err) { container.innerHTML = ""; } + 111| }, + 112| render(conflicts, container) { + 113| container.innerHTML = ` + 114|
+ 115|
+ 116|
+ 117| + 118|

Conflits de synchronisation

+ 119| ${conflicts.length} + 120|
+ 121|
+ 122|
+ 123| ${conflicts.map(c => ` + 124|
+ 125|
+ 126| ${escapeHtml(c.vault)} + 127| ${escapeHtml(c.conflict_path.split("/").pop())} + 128| Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")} + 129|
+ 130|
+ 131| + 132| + 133|
+ 134|
+ 135| `).join("")} + 136|
+ 137|
`; + 138| lucide.createIcons(); + 139| container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local"))); + 140| container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict"))); + 141| }, + 142| async _resolve(d, action) { + 143| try { + 144| await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) }); + 145| showToast("Conflit résolu", "success"); + 146| this.load(); + 147| } catch (err) { showToast("Erreur: " + err.message, "error"); } + 148| } + 149|}; + 150| + 151|const DashboardRecentWidget = { + 152| _cache: [], + 153| _currentFilter: "", + 154| + 155| async load(vaultFilter = "") { + 156| const v = vaultFilter || selectedContextVault || "all"; + 157| this._currentFilter = v; + 158| this.showLoading(); + 159| + 160| let url = "/api/recent?mode=opened"; + 161| if (v !== "all") url += `&vault=${encodeURIComponent(v)}`; + 162| + 163| try { + 164| const data = await api(url); + 165| this._cache = data.files || []; + 166| this.render(); + 167| } catch (err) { + 168| console.error("Dashboard: Failed to load recent files:", err); + 169| this.showError(); + 170| } + 171| }, + 172| + 173| async toggleBookmark(vault, path, title, card) { + 174| try { + 175| const data = await api("/api/bookmarks/toggle", { + 176| method: "POST", + 177| headers: { "Content-Type": "application/json" }, + 178| body: JSON.stringify({ vault, path, title }), + 179| }); + 180| + 181| // Refresh both widgets to keep sync + 182| DashboardBookmarkWidget.load(); + 183| + 184| // Update current card icon if it exists + 185| if (card) { + 186| const btn = card.querySelector(".dashboard-card-bookmark-btn"); + 187| if (btn) { + 188| btn.classList.toggle("active", data.bookmarked); + 189| const icon = btn.querySelector("i"); + 190| if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus"); + 191| safeCreateIcons(); + 192| } + 193| } + 194| + 195| // Check if we need to refresh the current list to reflect bookmark status across all cards + 196| // To avoid flickering, just update the cache and re-render if needed or do a silent refresh + 197| this._cache.forEach(f => { + 198| if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked; + 199| }); + 200| } catch (err) { + 201| console.error("Failed to toggle bookmark:", err); + 202| showToast("Erreur lors de l'épinglage", "error"); + 203| } + 204| }, + 205| + 206| showLoading() { + 207| const grid = document.getElementById("dashboard-recent-grid"); + 208| const loading = document.getElementById("dashboard-loading"); + 209| const empty = document.getElementById("dashboard-recent-empty"); + 210| const count = document.getElementById("dashboard-count"); + 211| + 212| if (grid) grid.innerHTML = ""; + 213| if (loading) loading.classList.add("active"); + 214| if (empty) empty.classList.add("hidden"); + 215| if (count) count.textContent = ""; + 216| }, + 217| + 218| render() { + 219| const grid = document.getElementById("dashboard-recent-grid"); + 220| const loading = document.getElementById("dashboard-loading"); + 221| const empty = document.getElementById("dashboard-recent-empty"); + 222| const count = document.getElementById("dashboard-count"); + 223| + 224| if (loading) loading.classList.remove("active"); + 225| + 226| if (!this._cache || this._cache.length === 0) { + 227| this.showEmpty(); + 228| return; + 229| } + 230| + 231| if (empty) empty.classList.add("hidden"); + 232| if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`; + 233| + 234| if (!grid) return; + 235| grid.innerHTML = ""; + 236| + 237| this._cache.forEach((f, index) => { + 238| const card = this._createCard(f, index); + 239| grid.appendChild(card); + 240| }); + 241| + 242| safeCreateIcons(); + 243| }, + 244| + 245| _createCard(file, index) { + 246| const card = document.createElement("div"); + 247| card.className = "dashboard-card"; + 248| card.setAttribute("data-vault", file.vault); + 249| card.setAttribute("data-path", file.path); + 250| card.style.animationDelay = `${Math.min(index * 50, 400)}ms`; + 251| + 252| // Header with icon and vault badge + 253| const header = document.createElement("div"); + 254| header.className = "dashboard-card-header"; + 255| + 256| const iconContainer = document.createElement("div"); + 257| iconContainer.className = "dashboard-card-icon"; + 258| const fileIconName = getFileIcon(file.path); + 259| try { + 260| iconContainer.appendChild(icon(fileIconName, 24)); + 261| } catch (e) { + 262| console.error("Error creating icon:", fileIconName, e); + 263| // Fallback to default file icon + 264| iconContainer.appendChild(icon("file", 24)); + 265| } + 266| + 267| const badge = document.createElement("span"); + 268| badge.className = "dashboard-vault-badge"; + 269| badge.textContent = file.vault; + 270| + 271| const bookmarkBtn = document.createElement("button"); + 272| bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`; + 273| bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; + 274| bookmarkBtn.innerHTML = ``; + 275| + 276| bookmarkBtn.addEventListener("click", (e) => { + 277| e.stopPropagation(); + 278| this.toggleBookmark(file.vault, file.path, file.title, card); + 279| }); + 280| + 281| header.appendChild(iconContainer); + 282| header.appendChild(badge); + 283| header.appendChild(bookmarkBtn); + 284| card.appendChild(header); + 285| + 286| // Title + 287| const title = document.createElement("h3"); + 288| title.className = "dashboard-card-title"; + 289| title.textContent = file.title || file.path.split("/").pop(); + 290| title.title = file.title || file.path; + 291| card.appendChild(title); + 292| + 293| // Path (compact) + 294| const pathParts = file.path.split("/"); + 295| if (pathParts.length > 1) { + 296| const path = document.createElement("div"); + 297| path.className = "dashboard-card-path"; + 298| path.textContent = pathParts.slice(0, -1).join(" / "); + 299| path.title = file.path; + 300| card.appendChild(path); + 301| } + 302| + 303| // Footer with time and tags + 304| const footer = document.createElement("div"); + 305| footer.className = "dashboard-card-footer"; + 306| + 307| const time = document.createElement("span"); + 308| time.className = "dashboard-card-time"; + 309| time.innerHTML = ` ${file.mtime_human || this._humanizeDelta(file.mtime)}`; + 310| + 311| footer.appendChild(time); + 312| + 313| // Tags + 314| if (file.tags && file.tags.length > 0) { + 315| const tags = document.createElement("div"); + 316| tags.className = "dashboard-card-tags"; + 317| file.tags.slice(0, 3).forEach((tag) => { + 318| const tagEl = document.createElement("span"); + 319| tagEl.className = "tag-pill"; + 320| tagEl.textContent = tag; + 321| tags.appendChild(tagEl); + 322| }); + 323| footer.appendChild(tags); + 324| } + 325| + 326| card.appendChild(footer); + 327| + 328| // Click handler + 329| card.addEventListener("click", () => { + 330| openFile(file.vault, file.path); + 331| }); + 332| + 333| return card; + 334| }, + 335| + 336| showEmpty() { + 337| const grid = document.getElementById("dashboard-recent-grid"); + 338| const loading = document.getElementById("dashboard-loading"); + 339| const empty = document.getElementById("dashboard-recent-empty"); + 340| const count = document.getElementById("dashboard-count"); + 341| + 342| if (grid) grid.innerHTML = ""; + 343| if (loading) loading.classList.remove("active"); + 344| if (empty) empty.classList.remove("hidden"); + 345| if (count) count.textContent = "0 fichiers"; + 346| safeCreateIcons(); + 347| }, + 348| + 349| showError() { + 350| this.showEmpty(); + 351| const empty = document.getElementById("dashboard-recent-empty"); + 352| if (empty) { + 353| const msg = empty.querySelector("span"); + 354| if (msg) msg.textContent = "Erreur de chargement"; + 355| } + 356| }, + 357| + 358| _humanizeDelta(mtime) { + 359| const delta = Date.now() / 1000 - mtime; + 360| if (delta < 60) return "à l'instant"; + 361| if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`; + 362| if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`; + 363| if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`; + 364| return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }); + 365| }, + 366| + 367| populateVaultFilter() { + 368| const select = document.getElementById("dashboard-vault-filter"); + 369| if (!select) return; + 370| + 371| // Keep first option "Tous les vaults" + 372| while (select.options.length > 1) select.remove(1); + 373| + 374| if (typeof allVaults !== "undefined" && Array.isArray(state.allVaults)) { + 375| state.allVaults.forEach((v) => { + 376| const opt = document.createElement("option"); + 377| opt.value = v.name; + 378| opt.textContent = v.name; + 379| select.appendChild(opt); + 380| }); + 381| } + 382| syncVaultSelectors(); + 383| }, + 384| + 385| init() { + 386| const select = document.getElementById("dashboard-vault-filter"); + 387| if (select) { + 388| select.addEventListener("change", async () => { + 389| await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" }); + 390| }); + 391| } + 392| + 393| this.populateVaultFilter(); + 394| }, + 395|}; + 396| + 397|// ── Dashboard Bookmarks Widget ── + 398|// (moved from app.js 3810-3870; logically belongs with dashboard widgets) + 399|const DashboardBookmarkWidget = { + 400| _cache: [], + 401| _currentFilter: "", + 402| + 403| async load(vaultFilter = "") { + 404| const v = vaultFilter || selectedContextVault || "all"; + 405| this._currentFilter = v; + 406| this.showLoading(); + 407| + 408| let url = "/api/bookmarks"; + 409| if (v !== "all") url += `?vault=${encodeURIComponent(v)}`; + 410| + 411| try { + 412| const data = await api(url); + 413| this._cache = data.files || []; + 414| this.render(); + 415| } catch (err) { + 416| console.error("Dashboard: Failed to load bookmarks:", err); + 417| this.showEmpty(); + 418| } + 419| }, + 420| + 421| showLoading() { + 422| const grid = document.getElementById("dashboard-bookmarks-grid"); + 423| const empty = document.getElementById("dashboard-bookmarks-empty"); + 424| const section = document.getElementById("dashboard-bookmarks-section"); + 425| + 426| if (grid) grid.innerHTML = ""; + 427| if (empty) empty.classList.add("hidden"); + 428| }, + 429| + 430| render() { + 431| const grid = document.getElementById("dashboard-bookmarks-grid"); + 432| const empty = document.getElementById("dashboard-bookmarks-empty"); + 433| const section = document.getElementById("dashboard-bookmarks-section"); + 434| + 435| if (!this._cache || this._cache.length === 0) { + 436| if (grid) grid.innerHTML = ""; + 437| if (empty) empty.classList.remove("hidden"); + 438| return; + 439| } + 440| + 441| if (empty) empty.classList.add("hidden"); + 442| if (!grid) return; + 443| grid.innerHTML = ""; + 444| + 445| this._cache.forEach((f, idx) => { + 446| const card = DashboardRecentWidget._createCard(f, idx); + 447| grid.appendChild(card); + 448| }); + 449| + 450| safeCreateIcons(); + 451| }, + 452| + 453| showEmpty() { + 454| const grid = document.getElementById("dashboard-bookmarks-grid"); + 455| const empty = document.getElementById("dashboard-bookmarks-empty"); + 456| if (grid) grid.innerHTML = ""; + 457| if (empty) empty.classList.remove("hidden"); + 458| } + 459|}; + 460| + 461|export { DashboardRecentWidget, DashboardStatsWidget, DashboardBookmarkWidget, DashboardSharedWidget }; + 462| \ No newline at end of file diff --git a/frontend/js/graph.js b/frontend/js/graph.js index 01bdd4d..964fc59 100644 --- a/frontend/js/graph.js +++ b/frontend/js/graph.js @@ -1,735 +1,736 @@ -/* ObsiGate — Graph View: interactive file/folder relationship visualization. - Phase 2: theme colors, tooltips, depth slider, full-vault, search, backlinks. */ -import { api } from './auth.js'; -import { safeCreateIcons } from './utils.js'; -import { openFile } from './viewer.js'; - -// --------------------------------------------------------------------------- -// Theme-aware color helpers -// --------------------------------------------------------------------------- -function _cssVar(name, fallback) { - return getComputedStyle(document.body).getPropertyValue(name).trim() || fallback; -} - -const COLORS = { - get bg() { return _cssVar('--bg-primary', '#1e1e1e'); }, - get text() { return _cssVar('--text-primary', '#ddd'); }, - get accent() { return _cssVar('--accent-color', '#2563eb'); }, - get muted() { return _cssVar('--text-muted', '#888'); }, - get border() { return _cssVar('--border-color', '#333'); }, - dir: '#5b9bd5', - md: '#70ad47', - other: '#999', - vault: '#ffc000', - backlink: '#e74c3c', - highlight: '#ff6b6b', -}; - -// --------------------------------------------------------------------------- -// GraphViewManager -// --------------------------------------------------------------------------- -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, - _scope: 'directory', - _depth: 1, - _searchTerm: '', - _hoveredNode: null, - _tooltipEl: null, - - 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'); - const depthSlider = document.getElementById('graph-depth'); - - if (!modal || !canvas) return; - - this._tooltipEl = document.getElementById('graph-tooltip'); - this._depth = depthSlider ? parseInt(depthSlider.value) : 1; - this._scope = 'directory'; - - title.textContent = `Vue Graphique — ${vault}${path ? '/' + path : ''}`; - info.textContent = 'Chargement...'; - modal.classList.add('active'); - - this._canvas = canvas; - this._ctx = canvas.getContext('2d'); - this._resetView(); - - await this._fetchAndRender(); - safeCreateIcons(); - }, - - _cache: {}, - _cacheKey: '', - - _getCacheKey() { - return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}`; - }, - - async _fetchAndRender() { - const info = document.getElementById('graph-info'); - if (!info) return; - - const cacheKey = this._getCacheKey(); - - // Use cache if same parameters - if (this._cache[cacheKey]) { - const cached = this._cache[cacheKey]; - this._nodes = cached.nodes; - this._edges = cached.edges; - this._scope = cached.scope; - const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; - info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth} (cache)`; - this._initLayout(); - this._startRender(); - return; - } - - const params = new URLSearchParams(); - if (this._path) params.set('path', this._path); - params.set('depth', String(this._depth)); - params.set('scope', this._scope); - if (this._searchTerm) params.set('tag', this._searchTerm); - - try { - const data = await api( - `/api/graph/${encodeURIComponent(this._vault)}?${params.toString()}` - ); - this._nodes = data.nodes || []; - this._edges = data.edges || []; - this._scope = data.scope || 'directory'; - - // Cache the result - this._cache[cacheKey] = { - nodes: this._nodes, - edges: this._edges, - scope: this._scope, - }; - this._cacheKey = cacheKey; - - const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; - info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`; - this._initLayout(); - this._startRender(); - } catch (err) { - info.textContent = 'Erreur de chargement'; - console.error('Graph error:', err); - } - }, - - reload() { - // Called when depth slider or full-vault button changes - this._resetView(); - this._fetchAndRender(); - }, - - setDepth(depth) { - this._depth = depth; - this.reload(); - }, - - toggleScope() { - this._scope = this._scope === 'full' ? 'directory' : 'full'; - const btn = document.getElementById('graph-full-vault'); - if (btn) btn.textContent = this._scope === 'full' ? '📁 Dossier' : '🌐 Tout'; - this.reload(); - }, - - setSearch(term) { - this._searchTerm = term; - // For now, use tag filter on backend; client-side highlighting on draw - if (term && term.length >= 2) { - this.reload(); - } else if (!term && this._searchTerm !== term) { - this.reload(); - } - }, - - close() { - const modal = document.getElementById('graph-modal'); - if (modal) modal.classList.remove('active'); - if (this._animFrame) { - cancelAnimationFrame(this._animFrame); - this._animFrame = null; - } - this._hideTooltip(); - }, - - _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.setTransform(1, 0, 0, 1, 0, 0); - this._ctx.scale(devicePixelRatio, devicePixelRatio); - - 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) / Math.max(this._nodes.length, 1); - 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) — unchanged - 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: Barnes-Hut for >200 nodes, naive O(n²) otherwise - if (this._nodes.length > 200) { - this._barnesHutRepulsion(positions); - } else { - this._naiveRepulsion(positions); - } - - // 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; - } - }, - - _naiveRepulsion(positions) { - 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; - } - } - }, - - // Barnes-Hut quadtree for O(n log n) repulsion - _barnesHutRepulsion(positions) { - // Build quadtree - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const node of this._nodes) { - const p = positions[node.id]; - if (!p) continue; - if (p.x < minX) minX = p.x; - if (p.y < minY) minY = p.y; - if (p.x > maxX) maxX = p.x; - if (p.y > maxY) maxY = p.y; - } - const root = this._buildQuadTree(positions, minX, minY, maxX, maxY); - - // Apply forces from quadtree - const theta = 0.9; // Barnes-Hut opening angle - for (const node of this._nodes) { - const p = positions[node.id]; - if (!p) continue; - this._applyQuadTreeForce(root, p, theta); - } - }, - - _buildQuadTree(positions, x0, y0, x1, y1) { - const cx = (x0 + x1) / 2; - const cy = (y0 + y1) / 2; - const cell = { x0, y0, x1, y1, cx, cy, mass: 0, mx: 0, my: 0, children: null }; - - // Collect nodes in this cell - const contained = []; - for (const node of this._nodes) { - const p = positions[node.id]; - if (p && p.x >= x0 && p.x < x1 && p.y >= y0 && p.y < y1) { - contained.push(p); - } - } - - if (contained.length === 0) return cell; - if (contained.length === 1) { - cell.mass = 1; - cell.mx = contained[0].x; - cell.my = contained[0].y; - return cell; - } - - // Subdivide - const midX = cx; - const midY = cy; - cell.children = [ - this._buildQuadTree(positions, x0, y0, midX, midY), // NW - this._buildQuadTree(positions, midX, y0, x1, midY), // NE - this._buildQuadTree(positions, x0, midY, midX, y1), // SW - this._buildQuadTree(positions, midX, midY, x1, y1), // SE - ]; - - // Compute center of mass - let totalMass = 0; - let sumX = 0, sumY = 0; - for (const child of cell.children) { - if (child.mass > 0) { - totalMass += child.mass; - sumX += child.mx * child.mass; - sumY += child.my * child.mass; - } - } - cell.mass = totalMass; - cell.mx = sumX / totalMass; - cell.my = sumY / totalMass; - - return cell; - }, - - _applyQuadTreeForce(cell, p, theta) { - if (cell.mass === 0) return; - - const dx = cell.mx - p.x; - const dy = cell.my - p.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const size = cell.x1 - cell.x0; - - // If cell is far enough or is a leaf, apply force from center of mass - if (!cell.children || size / dist < theta) { - const force = 2000 * cell.mass / (dist * dist); - const fx = (dx / dist) * force; - const fy = (dy / dist) * force; - p.vx -= fx; - p.vy -= fy; - return; - } - - // Otherwise recurse into children - for (const child of cell.children) { - this._applyQuadTreeForce(child, p, theta); - } - }, - - _draw() { - const ctx = this._ctx; - const w = this._width; - const h = this._height; - - ctx.save(); - ctx.clearRect(0, 0, w, h); - - 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); - - if (edge.relation === 'backlink') { - ctx.strokeStyle = COLORS.backlink; - ctx.lineWidth = 1.5; - ctx.setLineDash([3, 3]); - } else if (edge.relation === 'wikilink') { - ctx.strokeStyle = COLORS.accent; - ctx.lineWidth = 2; - ctx.setLineDash([4, 4]); - } else { - ctx.strokeStyle = COLORS.muted; - ctx.lineWidth = 1; - ctx.setLineDash([]); - } - ctx.stroke(); - } - ctx.setLineDash([]); - - // Draw nodes - const searchLower = this._searchTerm.toLowerCase(); - for (const node of this._nodes) { - if (!this._isNodeVisible(node)) continue; - const p = this._nodePositions[node.id]; - if (!p) continue; - - const links = (node.incoming_count || 0) + (node.outgoing_count || 0); - const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100)); - - // Highlight if search match - const isHighlighted = searchLower && ( - node.name.toLowerCase().includes(searchLower) || - (node.tags || []).some(t => t.toLowerCase().includes(searchLower)) - ); - - ctx.beginPath(); - ctx.arc(p.x, p.y, r, 0, Math.PI * 2); - - switch (node.type) { - case 'directory': - ctx.fillStyle = COLORS.dir; - break; - case 'file': - ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other; - break; - case 'vault': - ctx.fillStyle = COLORS.vault; - break; - default: - ctx.fillStyle = COLORS.other; - } - - if (isHighlighted) { - ctx.shadowColor = COLORS.highlight; - ctx.shadowBlur = 12; - } - - ctx.fill(); - - if (isHighlighted) { - ctx.shadowBlur = 0; - ctx.strokeStyle = COLORS.highlight; - ctx.lineWidth = 2.5; - } else { - ctx.strokeStyle = COLORS.bg; - ctx.lineWidth = 1.5; - } - ctx.stroke(); - - // Node label - const label = node.name.length > 20 ? node.name.slice(0, 18) + '...' : node.name; - ctx.font = `${isHighlighted ? 12 : 11} / ${this._zoom}px -apple-system, sans-serif`; - ctx.fillStyle = isHighlighted ? COLORS.highlight : COLORS.text; - 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 links = (node.incoming_count || 0) + (node.outgoing_count || 0); - const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 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; - }, - - _showTooltip(node, screenX, screenY) { - if (!this._tooltipEl) return; - const tags = (node.tags || []).slice(0, 5).join(', '); - const inc = node.incoming_count || 0; - const out = node.outgoing_count || 0; - this._tooltipEl.innerHTML = ` - ${node.name} - ${node.type === 'file' ? `
${node.path}` : ''} - ${tags ? `
🏷️ ${tags}` : ''} - ${inc + out > 0 ? `
🔗 ${out} sortants · ${inc} entrants` : ''} - `; - this._tooltipEl.style.display = 'block'; - this._tooltipEl.style.left = (screenX + 15) + 'px'; - this._tooltipEl.style.top = (screenY - 10) + 'px'; - }, - - _hideTooltip() { - if (this._tooltipEl) this._tooltipEl.style.display = 'none'; - }, - - // --- Advanced controls --- - - _isNodeVisible(node) { - const showDir = document.getElementById('graph-filter-dir')?.checked ?? true; - const showMd = document.getElementById('graph-filter-md')?.checked ?? true; - const showOther = document.getElementById('graph-filter-other')?.checked ?? true; - if (node.type === 'directory') return showDir; - if (node.type === 'file' && (node.path || '').endsWith('.md')) return showMd; - if (node.type === 'file') return showOther; - return true; - }, - - applyTypeFilter() { - // Filters are applied during _draw() — nodes/edges not in visible set are skipped - }, - - exportPNG() { - const link = document.createElement('a'); - link.download = `obsigate-graph-${this._vault}-${Date.now()}.png`; - link.href = this._canvas.toDataURL('image/png'); - link.click(); - }, - - toggleFullscreen() { - const container = this._canvas?.parentElement?.parentElement; // editor-container - if (!container) return; - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - container.requestFullscreen(); - } - // Re-init layout after fullscreen change - setTimeout(() => this._onResize(), 300); - }, - - focusNode(nodeId) { - const pos = this._nodePositions[nodeId]; - if (!pos) return; - this._offsetX = this._width / 2 - pos.x * this._zoom; - this._offsetY = this._height / 2 - pos.y * this._zoom; - }, - - _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); - if (hit) { - this._canvas.style.cursor = 'pointer'; - this._canvas.title = hit.node.type === 'file' - ? `📄 ${hit.node.name} (cliquer pour ouvrir)` - : `📁 ${hit.node.name} (cliquer pour explorer)`; - this._showTooltip(hit.node, e.clientX, e.clientY); - } else { - this._canvas.style.cursor = 'grab'; - this._canvas.title = ''; - this._hideTooltip(); - } - } - }, - - _onMouseUp(e) { - if (this._dragging && this._dragNode) { - const node = this._dragNode.node; - this._dragging = false; - this._dragNode = null; - this._canvas.style.cursor = 'grab'; - - if (node.type === 'file') { - this.close(); - openFile(this._vault, node.path); - } else if (node.type === 'directory' || node.type === 'vault') { - this.close(); - this._path = node.path || ''; - this.open(this._vault, this._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 -// --------------------------------------------------------------------------- -export function initGraphView() { - const gm = GraphViewManager; - 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 fullVault = document.getElementById('graph-full-vault'); - const depthSlider = document.getElementById('graph-depth'); - const searchInput = document.getElementById('graph-search'); - const modal = document.getElementById('graph-modal'); - const canvas = document.getElementById('graph-canvas'); - - if (closeBtn) closeBtn.addEventListener('click', () => gm.close()); - if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) gm.close(); }); - if (zoomIn) zoomIn.addEventListener('click', () => { gm._zoom = Math.min(3, gm._zoom * 1.2); }); - if (zoomOut) zoomOut.addEventListener('click', () => { gm._zoom = Math.max(0.2, gm._zoom * 0.8); }); - if (reset) reset.addEventListener('click', () => { gm._offsetX = 0; gm._offsetY = 0; gm._zoom = 1; }); - if (fullVault) fullVault.addEventListener('click', () => gm.toggleScope()); - if (depthSlider) depthSlider.addEventListener('input', () => gm.setDepth(parseInt(depthSlider.value))); - if (searchInput) { - let debounce; - searchInput.addEventListener('input', () => { - clearTimeout(debounce); - debounce = setTimeout(() => gm.setSearch(searchInput.value.trim()), 300); - }); - } - - // Export PNG - const exportBtn = document.getElementById('graph-export'); - if (exportBtn) exportBtn.addEventListener('click', () => gm.exportPNG()); - - // Fullscreen - const fullscreenBtn = document.getElementById('graph-fullscreen'); - if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => gm.toggleFullscreen()); - - // Type filters - ['dir', 'md', 'other'].forEach(type => { - const cb = document.getElementById(`graph-filter-${type}`); - if (cb) cb.addEventListener('change', () => gm.applyTypeFilter()); - }); - - if (canvas) { - canvas.addEventListener('mousedown', (e) => gm._onMouseDown(e)); - canvas.addEventListener('mousemove', (e) => gm._onMouseMove(e)); - canvas.addEventListener('mouseup', (e) => gm._onMouseUp(e)); - canvas.addEventListener('mouseleave', () => { - gm._dragging = false; - gm._dragNode = null; - gm._panning = false; - canvas.style.cursor = 'grab'; - gm._hideTooltip(); - }); - canvas.addEventListener('wheel', (e) => gm._onWheel(e), { passive: false }); - window.addEventListener('resize', () => gm._onResize()); - } - - document.addEventListener('keydown', (e) => { - if (modal && modal.classList.contains('active') && e.key === 'Escape') { - gm.close(); - } - }); -} + 1|/* ObsiGate — Graph View: interactive file/folder relationship visualization. + 2| Phase 2: theme colors, tooltips, depth slider, full-vault, search, backlinks. */ + 3|import { api } from './auth.js'; + 4|import { safeCreateIcons } from './utils.js'; + 5|import { openFile } from './viewer.js'; + 6| + 7|// --------------------------------------------------------------------------- + 8|// Theme-aware color helpers + 9|// --------------------------------------------------------------------------- + 10|function _cssVar(name, fallback) { + 11| return getComputedStyle(document.body).getPropertyValue(name).trim() || fallback; + 12|} + 13| + 14|const COLORS = { + 15| get bg() { return _cssVar('--bg-primary', '#1e1e1e'); }, + 16| get text() { return _cssVar('--text-primary', '#ddd'); }, + 17| get accent() { return _cssVar('--accent-color', '#2563eb'); }, + 18| get muted() { return _cssVar('--text-muted', '#888'); }, + 19| get border() { return _cssVar('--border-color', '#333'); }, + 20| dir: '#5b9bd5', + 21| md: '#70ad47', + 22| other: '#999', + 23| vault: '#ffc000', + 24| backlink: '#e74c3c', + 25| highlight: '#ff6b6b', + 26|}; + 27| + 28|// --------------------------------------------------------------------------- + 29|// GraphViewManager + 30|// --------------------------------------------------------------------------- + 31|export const GraphViewManager = { + 32| _canvas: null, + 33| _ctx: null, + 34| _nodes: [], + 35| _edges: [], + 36| _offsetX: 0, + 37| _offsetY: 0, + 38| _zoom: 1, + 39| _dragging: false, + 40| _dragNode: null, + 41| _panning: false, + 42| _panStartX: 0, + 43| _panStartY: 0, + 44| _animFrame: null, + 45| _vault: null, + 46| _path: null, + 47| _nodePositions: {}, + 48| _width: 0, + 49| _height: 0, + 50| _scope: 'directory', + 51| _depth: 1, + 52| _searchTerm: '', + 53| _hoveredNode: null, + 54| _tooltipEl: null, + 55| + 56| async open(vault, path, type) { + 57| this._vault = vault; + 58| this._path = path; + 59| + 60| const modal = document.getElementById('graph-modal'); + 61| const title = document.getElementById('graph-title'); + 62| const info = document.getElementById('graph-info'); + 63| const canvas = document.getElementById('graph-canvas'); + 64| const depthSlider = document.getElementById('graph-depth'); + 65| + 66| if (!modal || !canvas) return; + 67| + 68| this._tooltipEl = document.getElementById('graph-tooltip'); + 69| this._depth = depthSlider ? parseInt(depthSlider.value) : 1; + 70| this._scope = 'directory'; + 71| + 72| title.textContent = `Vue Graphique — ${vault}${path ? '/' + path : ''}`; + 73| info.textContent = 'Chargement...'; + 74| modal.classList.add('active'); + 75| + 76| this._canvas = canvas; + 77| this._ctx = canvas.getContext('2d'); + 78| this._resetView(); + 79| + 80| await this._fetchAndRender(); + 81| safeCreateIcons(); + 82| }, + 83| + 84| _cache: {}, + 85| _cacheKey: '', + 86| + 87| _getCacheKey() { + 88| return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}`; + 89| }, + 90| + 91| async _fetchAndRender() { + 92| const info = document.getElementById('graph-info'); + 93| if (!info) return; + 94| + 95| const cacheKey = this._getCacheKey(); + 96| + 97| // Use cache if same parameters + 98| if (this._cache[cacheKey]) { + 99| const cached = this._cache[cacheKey]; + 100| this._nodes = cached.nodes; + 101| this._edges = cached.edges; + 102| this._scope = cached.scope; + 103| const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; + 104| info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth} (cache)`; + 105| this._initLayout(); + 106| this._startRender(); + 107| return; + 108| } + 109| + 110| const params = new URLSearchParams(); + 111| if (this._path) params.set('path', this._path); + 112| params.set('depth', String(this._depth)); + 113| params.set('scope', this._scope); + 114| if (this._searchTerm) params.set('tag', this._searchTerm); + 115| + 116| try { + 117| const data = await api( + 118| `/api/graph/${encodeURIComponent(this._vault)}?${params.toString()}` + 119| ); + 120| this._nodes = data.nodes || []; + 121| this._edges = data.edges || []; + 122| this._scope = data.scope || 'directory'; + 123| + 124| // Cache the result + 125| this._cache[cacheKey] = { + 126| nodes: this._nodes, + 127| edges: this._edges, + 128| scope: this._scope, + 129| }; + 130| this._cacheKey = cacheKey; + 131| + 132| const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; + 133| info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`; + 134| this._initLayout(); + 135| this._startRender(); + 136| } catch (err) { + 137| info.textContent = 'Erreur de chargement'; + 138| console.error('Graph error:', err); + 139| } + 140| }, + 141| + 142| reload() { + 143| // Called when depth slider or full-vault button changes + 144| this._resetView(); + 145| this._fetchAndRender(); + 146| }, + 147| + 148| setDepth(depth) { + 149| this._depth = depth; + 150| this.reload(); + 151| }, + 152| + 153| toggleScope() { + 154| this._scope = this._scope === 'full' ? 'directory' : 'full'; + 155| const btn = document.getElementById('graph-full-vault'); + 156| if (btn) btn.textContent = this._scope === 'full' ? '📁 Dossier' : '🌐 Tout'; + 157| this.reload(); + 158| }, + 159| + 160| setSearch(term) { + 161| this._searchTerm = term; + 162| // For now, use tag filter on backend; client-side highlighting on draw + 163| if (term && term.length >= 2) { + 164| this.reload(); + 165| } else if (!term && this._searchTerm !== term) { + 166| this.reload(); + 167| } + 168| }, + 169| + 170| close() { + 171| const modal = document.getElementById('graph-modal'); + 172| if (modal) modal.classList.remove('active'); + 173| if (this._animFrame) { + 174| cancelAnimationFrame(this._animFrame); + 175| this._animFrame = null; + 176| } + 177| this._hideTooltip(); + 178| }, + 179| + 180| _resetView() { + 181| this._offsetX = 0; + 182| this._offsetY = 0; + 183| this._zoom = 1; + 184| this._nodePositions = {}; + 185| }, + 186| + 187| _initLayout() { + 188| const w = this._canvas.parentElement.clientWidth; + 189| const h = this._canvas.parentElement.clientHeight; + 190| this._canvas.width = w * devicePixelRatio; + 191| this._canvas.height = h * devicePixelRatio; + 192| this._canvas.style.width = w + 'px'; + 193| this._canvas.style.height = h + 'px'; + 194| this._width = w; + 195| this._height = h; + 196| this._ctx.setTransform(1, 0, 0, 1, 0, 0); + 197| this._ctx.scale(devicePixelRatio, devicePixelRatio); + 198| + 199| const cx = w / 2; + 200| const cy = h / 2; + 201| const radius = Math.min(w, h) * 0.35; + 202| + 203| this._nodes.forEach((node, i) => { + 204| const angle = (2 * Math.PI * i) / Math.max(this._nodes.length, 1); + 205| this._nodePositions[node.id] = { + 206| x: cx + radius * Math.cos(angle), + 207| y: cy + radius * Math.sin(angle), + 208| vx: 0, + 209| vy: 0, + 210| }; + 211| }); + 212| }, + 213| + 214| _startRender() { + 215| const self = this; + 216| let lastTime = 0; + 217| + 218| const loop = (time) => { + 219| const dt = Math.min((time - lastTime) / 1000, 0.1); + 220| lastTime = time; + 221| self._simulate(dt); + 222| self._draw(); + 223| self._animFrame = requestAnimationFrame(loop); + 224| }; + 225| + 226| this._animFrame = requestAnimationFrame(loop); + 227| }, + 228| + 229| _simulate(dt) { + 230| if (this._dragging || this._dragNode) return; + 231| + 232| const positions = this._nodePositions; + 233| const cx = this._width / 2; + 234| const cy = this._height / 2; + 235| + 236| // Spring forces (edges) — unchanged + 237| for (const edge of this._edges) { + 238| const a = positions[edge.source]; + 239| const b = positions[edge.target]; + 240| if (!a || !b) continue; + 241| + 242| const dx = b.x - a.x; + 243| const dy = b.y - a.y; + 244| const dist = Math.sqrt(dx * dx + dy * dy) || 1; + 245| const targetLen = 80; + 246| const force = (dist - targetLen) * 0.01; + 247| const fx = (dx / dist) * force; + 248| const fy = (dy / dist) * force; + 249| + 250| a.vx += fx; + 251| a.vy += fy; + 252| b.vx -= fx; + 253| b.vy -= fy; + 254| } + 255| + 256| // Repulsion: Barnes-Hut for >200 nodes, naive O(n²) otherwise + 257| if (this._nodes.length > 200) { + 258| this._barnesHutRepulsion(positions); + 259| } else { + 260| this._naiveRepulsion(positions); + 261| } + 262| + 263| // Center gravity + 264| for (const node of this._nodes) { + 265| const p = positions[node.id]; + 266| if (!p) continue; + 267| p.vx += (cx - p.x) * 0.001; + 268| p.vy += (cy - p.y) * 0.001; + 269| } + 270| + 271| // Apply velocities with damping + 272| for (const node of this._nodes) { + 273| const p = positions[node.id]; + 274| if (!p) continue; + 275| p.vx *= 0.9; + 276| p.vy *= 0.9; + 277| p.x += p.vx * dt * 60; + 278| p.y += p.vy * dt * 60; + 279| } + 280| }, + 281| + 282| _naiveRepulsion(positions) { + 283| for (const n1 of this._nodes) { + 284| for (const n2 of this._nodes) { + 285| if (n1.id === n2.id) continue; + 286| const a = positions[n1.id]; + 287| const b = positions[n2.id]; + 288| if (!a || !b) continue; + 289| const dx = b.x - a.x; + 290| const dy = b.y - a.y; + 291| const dist = Math.sqrt(dx * dx + dy * dy) || 1; + 292| const force = 2000 / (dist * dist); + 293| const fx = (dx / dist) * force; + 294| const fy = (dy / dist) * force; + 295| a.vx -= fx; + 296| a.vy -= fy; + 297| } + 298| } + 299| }, + 300| + 301| // Barnes-Hut quadtree for O(n log n) repulsion + 302| _barnesHutRepulsion(positions) { + 303| // Build quadtree + 304| let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + 305| for (const node of this._nodes) { + 306| const p = positions[node.id]; + 307| if (!p) continue; + 308| if (p.x < minX) minX = p.x; + 309| if (p.y < minY) minY = p.y; + 310| if (p.x > maxX) maxX = p.x; + 311| if (p.y > maxY) maxY = p.y; + 312| } + 313| const root = this._buildQuadTree(positions, minX, minY, maxX, maxY); + 314| + 315| // Apply forces from quadtree + 316| const theta = 0.9; // Barnes-Hut opening angle + 317| for (const node of this._nodes) { + 318| const p = positions[node.id]; + 319| if (!p) continue; + 320| this._applyQuadTreeForce(root, p, theta); + 321| } + 322| }, + 323| + 324| _buildQuadTree(positions, x0, y0, x1, y1) { + 325| const cx = (x0 + x1) / 2; + 326| const cy = (y0 + y1) / 2; + 327| const cell = { x0, y0, x1, y1, cx, cy, mass: 0, mx: 0, my: 0, children: null }; + 328| + 329| // Collect nodes in this cell + 330| const contained = []; + 331| for (const node of this._nodes) { + 332| const p = positions[node.id]; + 333| if (p && p.x >= x0 && p.x < x1 && p.y >= y0 && p.y < y1) { + 334| contained.push(p); + 335| } + 336| } + 337| + 338| if (contained.length === 0) return cell; + 339| if (contained.length === 1) { + 340| cell.mass = 1; + 341| cell.mx = contained[0].x; + 342| cell.my = contained[0].y; + 343| return cell; + 344| } + 345| + 346| // Subdivide + 347| const midX = cx; + 348| const midY = cy; + 349| cell.children = [ + 350| this._buildQuadTree(positions, x0, y0, midX, midY), // NW + 351| this._buildQuadTree(positions, midX, y0, x1, midY), // NE + 352| this._buildQuadTree(positions, x0, midY, midX, y1), // SW + 353| this._buildQuadTree(positions, midX, midY, x1, y1), // SE + 354| ]; + 355| + 356| // Compute center of mass + 357| let totalMass = 0; + 358| let sumX = 0, sumY = 0; + 359| for (const child of cell.children) { + 360| if (child.mass > 0) { + 361| totalMass += child.mass; + 362| sumX += child.mx * child.mass; + 363| sumY += child.my * child.mass; + 364| } + 365| } + 366| cell.mass = totalMass; + 367| cell.mx = sumX / totalMass; + 368| cell.my = sumY / totalMass; + 369| + 370| return cell; + 371| }, + 372| + 373| _applyQuadTreeForce(cell, p, theta) { + 374| if (cell.mass === 0) return; + 375| + 376| const dx = cell.mx - p.x; + 377| const dy = cell.my - p.y; + 378| const dist = Math.sqrt(dx * dx + dy * dy) || 1; + 379| const size = cell.x1 - cell.x0; + 380| + 381| // If cell is far enough or is a leaf, apply force from center of mass + 382| if (!cell.children || size / dist < theta) { + 383| const force = 2000 * cell.mass / (dist * dist); + 384| const fx = (dx / dist) * force; + 385| const fy = (dy / dist) * force; + 386| p.vx -= fx; + 387| p.vy -= fy; + 388| return; + 389| } + 390| + 391| // Otherwise recurse into children + 392| for (const child of cell.children) { + 393| this._applyQuadTreeForce(child, p, theta); + 394| } + 395| }, + 396| + 397| _draw() { + 398| const ctx = this._ctx; + 399| const w = this._width; + 400| const h = this._height; + 401| + 402| ctx.save(); + 403| ctx.clearRect(0, 0, w, h); + 404| + 405| ctx.translate(this._offsetX, this._offsetY); + 406| ctx.scale(this._zoom, this._zoom); + 407| + 408| // Draw edges + 409| for (const edge of this._edges) { + 410| const a = this._nodePositions[edge.source]; + 411| const b = this._nodePositions[edge.target]; + 412| if (!a || !b) continue; + 413| + 414| ctx.beginPath(); + 415| ctx.moveTo(a.x, a.y); + 416| ctx.lineTo(b.x, b.y); + 417| + 418| if (edge.relation === 'backlink') { + 419| ctx.strokeStyle = COLORS.backlink; + 420| ctx.lineWidth = 1.5; + 421| ctx.setLineDash([3, 3]); + 422| } else if (edge.relation === 'wikilink') { + 423| ctx.strokeStyle = COLORS.accent; + 424| ctx.lineWidth = 2; + 425| ctx.setLineDash([4, 4]); + 426| } else { + 427| ctx.strokeStyle = COLORS.muted; + 428| ctx.lineWidth = 1; + 429| ctx.setLineDash([]); + 430| } + 431| ctx.stroke(); + 432| } + 433| ctx.setLineDash([]); + 434| + 435| // Draw nodes + 436| const searchLower = this._searchTerm.toLowerCase(); + 437| for (const node of this._nodes) { + 438| if (!this._isNodeVisible(node)) continue; + 439| const p = this._nodePositions[node.id]; + 440| if (!p) continue; + 441| + 442| const links = (node.incoming_count || 0) + (node.outgoing_count || 0); + 443| const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100)); + 444| + 445| // Highlight if search match + 446| const isHighlighted = searchLower && ( + 447| node.name.toLowerCase().includes(searchLower) || + 448| (node.tags || []).some(t => t.toLowerCase().includes(searchLower)) + 449| ); + 450| + 451| ctx.beginPath(); + 452| ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + 453| + 454| switch (node.type) { + 455| case 'directory': + 456| ctx.fillStyle = COLORS.dir; + 457| break; + 458| case 'file': + 459| ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other; + 460| break; + 461| case 'vault': + 462| ctx.fillStyle = COLORS.vault; + 463| break; + 464| default: + 465| ctx.fillStyle = COLORS.other; + 466| } + 467| + 468| if (isHighlighted) { + 469| ctx.shadowColor = COLORS.highlight; + 470| ctx.shadowBlur = 12; + 471| } + 472| + 473| ctx.fill(); + 474| + 475| if (isHighlighted) { + 476| ctx.shadowBlur = 0; + 477| ctx.strokeStyle = COLORS.highlight; + 478| ctx.lineWidth = 2.5; + 479| } else { + 480| ctx.strokeStyle = COLORS.bg; + 481| ctx.lineWidth = 1.5; + 482| } + 483| ctx.stroke(); + 484| + 485| // Node label + 486| const label = node.name.length > 20 ? node.name.slice(0, 18) + '...' : node.name; + 487| ctx.font = `${isHighlighted ? 12 : 11} / ${this._zoom}px -apple-system, sans-serif`; + 488| ctx.fillStyle = isHighlighted ? COLORS.highlight : COLORS.text; + 489| ctx.textAlign = 'center'; + 490| ctx.fillText(label, p.x, p.y + r + 12 / this._zoom); + 491| } + 492| + 493| ctx.restore(); + 494| }, + 495| + 496| _getNodeAt(screenX, screenY) { + 497| const x = (screenX - this._offsetX) / this._zoom; + 498| const y = (screenY - this._offsetY) / this._zoom; + 499| + 500| for (const node of this._nodes) { + 501| const p = this._nodePositions[node.id]; + 502| if (!p) continue; + 503| const links = (node.incoming_count || 0) + (node.outgoing_count || 0); + 504| const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100)); + 505| const dx = x - p.x; + 506| const dy = y - p.y; + 507| if (dx * dx + dy * dy <= r * r + 100) { + 508| return { node, pos: p }; + 509| } + 510| } + 511| return null; + 512| }, + 513| + 514| _showTooltip(node, screenX, screenY) { + 515| if (!this._tooltipEl) return; + 516| const tags = (node.tags || []).slice(0, 5).join(', '); + 517| const inc = node.incoming_count || 0; + 518| const out = node.outgoing_count || 0; + 519| this._tooltipEl.innerHTML = ` + 520| ${node.name} + 521| ${node.type === 'file' ? `
${node.path}` : ''} + 522| ${tags ? `
🏷️ ${tags}` : ''} + 523| ${inc + out > 0 ? `
🔗 ${out} sortants · ${inc} entrants` : ''} + 524| `; + 525| this._tooltipEl.style.display = 'block'; + 526| this._tooltipEl.style.left = (screenX + 15) + 'px'; + 527| this._tooltipEl.style.top = (screenY - 10) + 'px'; + 528| }, + 529| + 530| _hideTooltip() { + 531| if (this._tooltipEl) this._tooltipEl.style.display = 'none'; + 532| }, + 533| + 534| // --- Advanced controls --- + 535| + 536| _isNodeVisible(node) { + 537| const showDir = document.getElementById('graph-filter-dir')?.checked ?? true; + 538| const showMd = document.getElementById('graph-filter-md')?.checked ?? true; + 539| const showOther = document.getElementById('graph-filter-other')?.checked ?? true; + 540| if (node.type === 'directory') return showDir; + 541| if (node.type === 'file' && (node.path || '').endsWith('.md')) return showMd; + 542| if (node.type === 'file') return showOther; + 543| return true; + 544| }, + 545| + 546| applyTypeFilter() { + 547| // Filters are applied during _draw() — nodes/edges not in visible set are skipped + 548| }, + 549| + 550| exportPNG() { + 551| const link = document.createElement('a'); + 552| link.download = `obsigate-graph-${this._vault}-${Date.now()}.png`; + 553| link.href = this._canvas.toDataURL('image/png'); + 554| link.click(); + 555| }, + 556| + 557| toggleFullscreen() { + 558| const container = this._canvas?.parentElement?.parentElement; // editor-container + 559| if (!container) return; + 560| if (document.fullscreenElement) { + 561| document.exitFullscreen(); + 562| } else { + 563| container.requestFullscreen(); + 564| } + 565| // Re-init layout after fullscreen change + 566| setTimeout(() => this._onResize(), 300); + 567| }, + 568| + 569| focusNode(nodeId) { + 570| const pos = this._nodePositions[nodeId]; + 571| if (!pos) return; + 572| this._offsetX = this._width / 2 - pos.x * this._zoom; + 573| this._offsetY = this._height / 2 - pos.y * this._zoom; + 574| }, + 575| + 576| _onMouseDown(e) { + 577| const rect = this._canvas.getBoundingClientRect(); + 578| const mx = e.clientX - rect.left; + 579| const my = e.clientY - rect.top; + 580| + 581| const hit = this._getNodeAt(mx, my); + 582| if (hit) { + 583| this._dragging = true; + 584| this._dragNode = hit; + 585| this._canvas.style.cursor = 'grabbing'; + 586| } else { + 587| this._panning = true; + 588| this._panStartX = e.clientX - this._offsetX; + 589| this._panStartY = e.clientY - this._offsetY; + 590| this._canvas.style.cursor = 'grabbing'; + 591| } + 592| }, + 593| + 594| _onMouseMove(e) { + 595| const rect = this._canvas.getBoundingClientRect(); + 596| const mx = e.clientX - rect.left; + 597| const my = e.clientY - rect.top; + 598| + 599| if (this._dragging && this._dragNode) { + 600| this._dragNode.pos.x = (mx - this._offsetX) / this._zoom; + 601| this._dragNode.pos.y = (my - this._offsetY) / this._zoom; + 602| this._dragNode.pos.vx = 0; + 603| this._dragNode.pos.vy = 0; + 604| } else if (this._panning) { + 605| this._offsetX = e.clientX - this._panStartX; + 606| this._offsetY = e.clientY - this._panStartY; + 607| } else { + 608| const hit = this._getNodeAt(mx, my); + 609| if (hit) { + 610| this._canvas.style.cursor = 'pointer'; + 611| this._canvas.title = hit.node.type === 'file' + 612| ? `📄 ${hit.node.name} (cliquer pour ouvrir)` + 613| : `📁 ${hit.node.name} (cliquer pour explorer)`; + 614| this._showTooltip(hit.node, e.clientX, e.clientY); + 615| } else { + 616| this._canvas.style.cursor = 'grab'; + 617| this._canvas.title = ''; + 618| this._hideTooltip(); + 619| } + 620| } + 621| }, + 622| + 623| _onMouseUp(e) { + 624| if (this._dragging && this._dragNode) { + 625| const node = this._dragNode.node; + 626| this._dragging = false; + 627| this._dragNode = null; + 628| this._canvas.style.cursor = 'grab'; + 629| + 630| if (node.type === 'file') { + 631| this.close(); + 632| openFile(this._vault, node.path); + 633| } else if (node.type === 'directory' || node.type === 'vault') { + 634| this.close(); + 635| this._path = node.path || ''; + 636| this.open(this._vault, this._path, node.type); + 637| } + 638| } + 639| this._panning = false; + 640| }, + 641| + 642| _onWheel(e) { + 643| e.preventDefault(); + 644| const rect = this._canvas.getBoundingClientRect(); + 645| const mx = e.clientX - rect.left; + 646| const my = e.clientY - rect.top; + 647| + 648| const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + 649| const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor)); + 650| + 651| this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom); + 652| this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom); + 653| this._zoom = newZoom; + 654| }, + 655| + 656| _onResize() { + 657| if (!this._canvas || !this._nodes.length) return; + 658| const w = this._canvas.parentElement.clientWidth; + 659| const h = this._canvas.parentElement.clientHeight; + 660| this._canvas.width = w * devicePixelRatio; + 661| this._canvas.height = h * devicePixelRatio; + 662| this._canvas.style.width = w + 'px'; + 663| this._canvas.style.height = h + 'px'; + 664| this._width = w; + 665| this._height = h; + 666| this._ctx.setTransform(1, 0, 0, 1, 0, 0); + 667| this._ctx.scale(devicePixelRatio, devicePixelRatio); + 668| }, + 669|}; + 670| + 671|// --------------------------------------------------------------------------- + 672|// Init graph view event listeners + 673|// --------------------------------------------------------------------------- + 674|export function initGraphView() { + 675| const gm = GraphViewManager; + 676| const closeBtn = document.getElementById('graph-close'); + 677| const zoomIn = document.getElementById('graph-zoom-in'); + 678| const zoomOut = document.getElementById('graph-zoom-out'); + 679| const reset = document.getElementById('graph-reset'); + 680| const fullVault = document.getElementById('graph-full-vault'); + 681| const depthSlider = document.getElementById('graph-depth'); + 682| const searchInput = document.getElementById('graph-search'); + 683| const modal = document.getElementById('graph-modal'); + 684| const canvas = document.getElementById('graph-canvas'); + 685| + 686| if (closeBtn) closeBtn.addEventListener('click', () => gm.close()); + 687| if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) gm.close(); }); + 688| if (zoomIn) zoomIn.addEventListener('click', () => { gm._zoom = Math.min(3, gm._zoom * 1.2); }); + 689| if (zoomOut) zoomOut.addEventListener('click', () => { gm._zoom = Math.max(0.2, gm._zoom * 0.8); }); + 690| if (reset) reset.addEventListener('click', () => { gm._offsetX = 0; gm._offsetY = 0; gm._zoom = 1; }); + 691| if (fullVault) fullVault.addEventListener('click', () => gm.toggleScope()); + 692| if (depthSlider) depthSlider.addEventListener('input', () => gm.setDepth(parseInt(depthSlider.value))); + 693| if (searchInput) { + 694| let debounce; + 695| searchInput.addEventListener('input', () => { + 696| clearTimeout(debounce); + 697| debounce = setTimeout(() => gm.setSearch(searchInput.value.trim()), 300); + 698| }); + 699| } + 700| + 701| // Export PNG + 702| const exportBtn = document.getElementById('graph-export'); + 703| if (exportBtn) exportBtn.addEventListener('click', () => gm.exportPNG()); + 704| + 705| // Fullscreen + 706| const fullscreenBtn = document.getElementById('graph-fullscreen'); + 707| if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => gm.toggleFullscreen()); + 708| + 709| // Type filters + 710| ['dir', 'md', 'other'].forEach(type => { + 711| const cb = document.getElementById(`graph-filter-${type}`); + 712| if (cb) cb.addEventListener('change', () => gm.applyTypeFilter()); + 713| }); + 714| + 715| if (canvas) { + 716| canvas.addEventListener('mousedown', (e) => gm._onMouseDown(e)); + 717| canvas.addEventListener('mousemove', (e) => gm._onMouseMove(e)); + 718| canvas.addEventListener('mouseup', (e) => gm._onMouseUp(e)); + 719| canvas.addEventListener('mouseleave', () => { + 720| gm._dragging = false; + 721| gm._dragNode = null; + 722| gm._panning = false; + 723| canvas.style.cursor = 'grab'; + 724| gm._hideTooltip(); + 725| }); + 726| canvas.addEventListener('wheel', (e) => gm._onWheel(e), { passive: false }); + 727| window.addEventListener('resize', () => gm._onResize()); + 728| } + 729| + 730| document.addEventListener('keydown', (e) => { + 731| if (modal && modal.classList.contains('active') && e.key === 'Escape') { + 732| gm.close(); + 733| } + 734| }); + 735|} + 736| \ No newline at end of file diff --git a/frontend/js/legacy.js b/frontend/js/legacy.js index bef31ae..c1f012b 100644 --- a/frontend/js/legacy.js +++ b/frontend/js/legacy.js @@ -1,560 +1,561 @@ -/* 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, -}; + 1|/* ObsiGate — Legacy module: remaining functions for the orchestrator + 2| Extracted from the monolithic frontend/app.js IIFE. + 3| Functions already in other modules are re-exported. */ + 4| + 5|// --- State imports --- + 6|import { + 7| state.currentVault, + 8| state.currentPath, + 9| state.showingSource, + 10| state.cachedRawSource, + 11| state.searchTimeout, + 12| state.searchCaseSensitive, + 13| state.searchWholeWord, + 14| state.searchRegex, + 15| state.searchFilterVisible, + 16| state.advancedSearchOffset, + 17| state.selectedTags, + 18| state.selectedContextVault, + 19| state.vaultSettings, + 20| state.allVaults, + 21| state.MIN_SEARCH_LENGTH, + 22|} from './state.js'; + 23| + 24|// --- Search imports --- + 25|import { + 26| AutocompleteDropdown, + 27| SearchChips, + 28| SearchHistory, + 29| performAdvancedSearch, + 30|} from './search.js'; + 31| + 32|// --- Dashboard imports --- + 33|import { + 34| DashboardRecentWidget, + 35| DashboardStatsWidget, + 36| DashboardBookmarkWidget, + 37| DashboardSharedWidget, + 38|} from './dashboard.js'; + 39| + 40|// --- Re-exports from modules that already have these functions --- + 41|export { initSidebarTabs, initConfigModal, initHelpModal, initRecentTab } from './config.js'; + 42|export { initSyncStatus } from './sync.js'; + 43|export { DashboardRecentWidget } from './dashboard.js'; + 44| + 45|// ========================================================================= + 46|// Progress bar helpers (used by showWelcome) + 47|// ========================================================================= + 48| + 49|function showProgressBar() { + 50| const bar = document.getElementById("search-progress-bar"); + 51| if (bar) bar.classList.add("active"); + 52|} + 53| + 54|function hideProgressBar() { + 55| const bar = document.getElementById("search-progress-bar"); + 56| if (bar) bar.classList.remove("active"); + 57|} + 58| + 59|// ========================================================================= + 60|// loadVaultSettings + 61|// ========================================================================= + 62| + 63|async function loadVaultSettings() { + 64| try { + 65| const settings = await api("/api/vaults/settings/all"); + 66| state.vaultSettings = settings; + 67| } catch (err) { + 68| console.error("Failed to load vault settings:", err); + 69| state.vaultSettings = {}; + 70| } + 71|} + 72| + 73|// ========================================================================= + 74|// Config helpers (needed by initSearch) + 75|// ========================================================================= + 76| + 77|const _FRONTEND_CONFIG_KEY = "obsigate-perf-config"; + 78| + 79|function _getFrontendConfig() { + 80| try { + 81| return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}"); + 82| } catch { + 83| return {}; + 84| } + 85|} + 86| + 87|function _getEffective(key, fallback) { + 88| const cfg = _getFrontendConfig(); + 89| return cfg[key] !== undefined ? cfg[key] : fallback; + 90|} + 91| + 92|/** Check if user is focused on an input/textarea/contenteditable */ + 93|function _isInputFocused() { + 94| const tag = document.activeElement?.tagName; + 95| if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + 96| return document.activeElement?.isContentEditable === true; + 97|} + 98| + 99|// ========================================================================= + 100|// initSearch + 101|// ========================================================================= + 102| + 103|function initSearch() { + 104| const input = document.getElementById("search-input"); + 105| if (!input) return; + 106| const caseBtn = document.getElementById("search-case-btn"); + 107| const wordBtn = document.getElementById("search-word-btn"); + 108| const regexBtn = document.getElementById("search-regex-btn"); + 109| const filterBtn = document.getElementById("search-filter-btn"); + 110| const clearBtn = document.getElementById("search-clear-btn"); + 111| const filterRow = document.getElementById("search-filter-row"); + 112| const prevBtn = document.getElementById("search-prev-btn"); + 113| const nextBtn = document.getElementById("search-next-btn"); + 114| const countEl = document.getElementById("search-match-count"); + 115| + 116| function _updateToggleUI() { + 117| caseBtn.classList.toggle("active", state.searchCaseSensitive); + 118| wordBtn.classList.toggle("active", state.searchWholeWord); + 119| regexBtn.classList.toggle("active", state.searchRegex); + 120| filterBtn.classList.toggle("active", state.searchFilterVisible); + 121| } + 122| + 123| // Toggle buttons + 124| caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); }); + 125| if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); }); + 126| if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); }); + 127| if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); + 128| + 129| // Result navigation (up/down arrows + Enter) + 130| let _searchResultIdx = -1; + 131| let _searchResultItems = []; + 132| + 133| function _updateResultHighlight() { + 134| _searchResultItems.forEach((el, i) => { + 135| el.classList.toggle("search-result-active", i === _searchResultIdx); + 136| }); + 137| if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { + 138| _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); + 139| } + 140| const countEl = document.getElementById("search-match-count"); + 141| if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; + 142| } + 143| + 144| function _refreshResultItems() { + 145| _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + 146| _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; + 147| _updateResultHighlight(); + 148| } + 149| + 150| window.navigateSearchResults = function(delta) { + 151| _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + 152| if (_searchResultItems.length === 0) return; + 153| _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); + 154| _updateResultHighlight(); + 155| }; + 156| + 157| if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); + 158| if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); + 159| + 160| function _research() { + 161| const q = input.value.trim(); + 162| if (q.length >= _getEffective("min_query_length", 2)) { + 163| clearTimeout(state.searchTimeout); + 164| state.searchTimeout = setTimeout(() => { + 165| const vault = document.getElementById("vault-filter").value; + 166| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 167| state.advancedSearchOffset = 0; + 168| performAdvancedSearch(q, vault, tagFilter); + 169| }, _getEffective("debounce_ms", 300)); + 170| } + 171| } + 172| + 173| // Keyboard shortcuts + 174| document.addEventListener("keydown", (e) => { + 175| if (e.altKey && !e.ctrlKey && !e.metaKey) { + 176| if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } + 177| else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } + 178| else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } + 179| else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } + 180| } + 181| }); + 182| + 183| // Initialize sub-controllers + 184| AutocompleteDropdown.init(); + 185| SearchChips.init(); + 186| + 187| // Initially hide clear button + 188| if (clearBtn) clearBtn.style.display = "none"; + 189| + 190| // Input handler: debounced search + autocomplete dropdown + 191| input.addEventListener("input", () => { + 192| const hasText = input.value.length > 0; + 193| clearBtn.style.display = hasText ? "flex" : "none"; + 194| + 195| // Show autocomplete dropdown while typing + 196| AutocompleteDropdown.populate(input.value, input.selectionStart); + 197| + 198| // Debounced search execution + 199| clearTimeout(state.searchTimeout); + 200| state.searchTimeout = setTimeout( + 201| () => { + 202| const q = input.value.trim(); + 203| const vault = document.getElementById("vault-filter").value; + 204| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 205| state.advancedSearchOffset = 0; + 206| if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) { + 207| performAdvancedSearch(q, vault, tagFilter); + 208| } else if (q.length === 0) { + 209| SearchChips.clear(); + 210| showWelcome(); + 211| } + 212| }, + 213| _getEffective("debounce_ms", 300), + 214| ); + 215| }); + 216| + 217| // Focus handler: show history dropdown + 218| input.addEventListener("focus", () => { + 219| if (input.value.length === 0) { + 220| const historyItems = SearchHistory.filter("").slice(0, 5); + 221| if (historyItems.length > 0) { + 222| AutocompleteDropdown.populate("", 0); + 223| } + 224| } + 225| }); + 226| + 227| // Keyboard navigation in dropdown + 228| input.addEventListener("keydown", (e) => { + 229| if (AutocompleteDropdown.isVisible()) { + 230| if (e.key === "ArrowDown") { + 231| e.preventDefault(); + 232| AutocompleteDropdown.navigateDown(); + 233| } else if (e.key === "ArrowUp") { + 234| e.preventDefault(); + 235| AutocompleteDropdown.navigateUp(); + 236| } else if (e.key === "Enter") { + 237| // First: check dropdown suggestions (higher priority than search results) + 238| if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + 239| e.preventDefault(); + 240| return; + 241| } + 242| // Second: navigate search results if visible + 243| const results = document.querySelectorAll(".search-result-item"); + 244| if (results.length > 0 && _searchResultIdx >= 0) { + 245| const el = results[_searchResultIdx]; + 246| if (el) { + 247| const vault = el.dataset.vault; + 248| const path = el.dataset.path; + 249| if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } + 250| } + 251| } + 252| // Third: execute search + 253| AutocompleteDropdown.hide(); + 254| const q = input.value.trim(); + 255| if (q) { + 256| SearchHistory.add(q); + 257| clearTimeout(state.searchTimeout); + 258| state.advancedSearchOffset = 0; + 259| const vault = document.getElementById("vault-filter").value; + 260| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 261| performAdvancedSearch(q, vault, tagFilter); + 262| } + 263| e.preventDefault(); + 264| } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { + 265| // Navigate search results when dropdown is closed + 266| if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } + 267| } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { + 268| if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } + 269| } else if (e.key === "Escape") { + 270| AutocompleteDropdown.hide(); + 271| e.stopPropagation(); + 272| } + 273| } else if (e.key === "Enter") { + 274| if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + 275| e.preventDefault(); + 276| return; + 277| } + 278| const q = input.value.trim(); + 279| if (q) { + 280| SearchHistory.add(q); + 281| clearTimeout(state.searchTimeout); + 282| state.advancedSearchOffset = 0; + 283| const vault = document.getElementById("vault-filter").value; + 284| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 285| performAdvancedSearch(q, vault, tagFilter); + 286| } + 287| e.preventDefault(); + 288| } + 289| }); + 290| + 291| clearBtn.addEventListener("click", () => { + 292| input.value = ""; + 293| if (clearBtn) clearBtn.style.display = "none"; + 294| state.searchCaseSensitive = false; + 295| state.searchWholeWord = false; + 296| state.searchRegex = false; + 297| _updateToggleUI(); + 298| SearchChips.clear(); + 299| AutocompleteDropdown.hide(); + 300| showWelcome(); + 301| }); + 302| + 303| // Global keyboard shortcuts + 304| document.addEventListener("keydown", (e) => { + 305| // Ctrl+K or Cmd+K: focus search + 306| if ((e.ctrlKey || e.metaKey) && e.key === "k") { + 307| e.preventDefault(); + 308| input.focus(); + 309| input.select(); + 310| } + 311| // "/" key: focus search (when not in an input/textarea) + 312| if (e.key === "/" && !_isInputFocused()) { + 313| e.preventDefault(); + 314| input.focus(); + 315| } + 316| // Escape: blur search input and close dropdown + 317| if (e.key === "Escape" && document.activeElement === input) { + 318| AutocompleteDropdown.hide(); + 319| input.blur(); + 320| } + 321| }); + 322|} + 323| + 324|// ========================================================================= + 325|// showWelcome + 326|// ========================================================================= + 327| + 328|function showWelcome() { + 329| hideProgressBar(); + 330| + 331| // Restore or rebuild the dashboard with tabbed sections + 332| const area = document.getElementById("content-area"); + 333| const home = document.getElementById("dashboard-home"); + 334| + 335| if (area && !home) { + 336| area.innerHTML = ` + 337|
+ 338| + 339|
+ 340| + 343| + 346| + 349| + 352|
+ 353| + 354| + 355|
+ 356|
+ 357|
Chargement...
+ 358|
+ 359|
+ 360|
+ 361| + 362| + 363|
+ 364|
+ 365|
+ 366| + 367| Aucun bookmark + 368|

Épinglez des fichiers pour les retrouver ici.

+ 369|
+ 370|
+ 371| + 372| + 373|
+ 374|
+ 375|
+ 376| + 377|
+ 378|
+ 379|
+ 380|
+ 381|
+ 382|
+ 383|
+ 384| + 389|
+ 390| + 391| + 392|
+ 393|
+ 394|
+ 395| + 396| Aucun document partagé + 397|

Partagez un document pour le voir apparaître ici

+ 398|
+ 399|
+ 400|
`; + 401| + 402| // Re-initialize widgets and dashboard tabs + 403| DashboardRecentWidget.init(); + 404| initDashboardTabs(); + 405| safeCreateIcons(); + 406| } else if (home) { + 407| // Dashboard already exists, show it with default tab + 408| home.style.display = ""; + 409| // Reset tabs to default + 410| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + 411| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + 412| const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); + 413| const defaultPanel = document.getElementById("dashboard-panel-stats"); + 414| if (defaultTab) defaultTab.classList.add("active"); + 415| if (defaultPanel) defaultPanel.classList.add("active"); + 416| } + 417| + 418| // Load all widgets (they handle missing elements gracefully) + 419| if (typeof DashboardStatsWidget !== "undefined") { + 420| DashboardStatsWidget.load(); + 421| } + 422| if (typeof DashboardConflictsWidget !== "undefined") { + 423| DashboardConflictsWidget.load(); + 424| } + 425| DashboardRecentWidget.load(state.selectedContextVault); + 426| if (typeof DashboardBookmarkWidget !== "undefined") { + 427| DashboardBookmarkWidget.load(state.selectedContextVault); + 428| } + 429| if (typeof DashboardSharedWidget !== "undefined") { + 430| DashboardSharedWidget.load(); + 431| } + 432| + 433| // Load saved searches sidebar + 434| loadSavedSearches(); + 435|} + 436| + 437|// ========================================================================= + 438|// goHome + 439|// ========================================================================= + 440| + 441|function goHome() { + 442| const searchInput = document.getElementById("search-input"); + 443| if (searchInput) searchInput.value = ""; + 444| + 445| document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + 446| + 447| state.currentVault = null; + 448| state.currentPath = null; + 449| state.showingSource = false; + 450| state.cachedRawSource = null; + 451| + 452| closeMobileSidebar(); + 453| showWelcome(); + 454|} + 455| + 456|// ========================================================================= + 457|// loadSavedSearches (needed by showWelcome) + 458|// ========================================================================= + 459| + 460|async function loadSavedSearches() { + 461| const list = document.getElementById("saved-searches-list"); + 462| const empty = document.getElementById("saved-searches-empty"); + 463| if (!list) return; + 464| try { + 465| const searches = await api("/api/saved-searches"); + 466| if (!searches.length) { + 467| list.innerHTML = ""; + 468| if (empty) empty.style.display = ""; + 469| return; + 470| } + 471| if (empty) empty.style.display = "none"; + 472| list.innerHTML = searches.map(s => { + 473| const badges = []; + 474| if (s.case_sensitive) badges.push('Aa'); + 475| if (s.whole_word) badges.push('wd'); + 476| if (s.regex) badges.push('.*'); + 477| const pathFilters = []; + 478| if (s.include_paths) pathFilters.push(`\u{1F4E5} ${escapeHtml(s.include_paths)}`); + 479| if (s.exclude_paths) pathFilters.push(`\u{1F4E4} ${escapeHtml(s.exclude_paths)}`); + 480| const vaultStr = s.vault && s.vault !== "all" ? `\u{1F4C1} ${escapeHtml(s.vault)}` : ""; + 481| return ` + 482|
+ 483|
${escapeHtml(s.query)}
+ 484|
+ 485| ${badges.join("")} + 486| ${vaultStr} + 487|
+ 488| ${pathFilters.length ? '
' + pathFilters.join(" ") + '
' : ""} + 489| + 490|
+ 491| `}).join(""); + 492| list.querySelectorAll(".saved-search-item").forEach(item => { + 493| item.addEventListener("click", (e) => { + 494| if (e.target.classList.contains("saved-search-delete")) return; + 495| const idx = Array.from(list.children).indexOf(item); + 496| const s = searches[idx]; + 497| if (!s) return; + 498| // Apply the saved search + 499| const input = document.getElementById("search-input"); + 500| if (input) input.value = s.query; + 501| state.searchCaseSensitive = s.case_sensitive || false; + 502| state.searchWholeWord = s.whole_word || false; + 503| state.searchRegex = s.regex || false; + 504| if (typeof _updateToggleUI === "function") _updateToggleUI(); + 505| if (s.include_paths) { + 506| const incl = document.getElementById("search-include-input"); + 507| if (incl) incl.value = s.include_paths; + 508| } + 509| if (s.exclude_paths) { + 510| const excl = document.getElementById("search-exclude-input"); + 511| if (excl) excl.value = s.exclude_paths; + 512| } + 513| // Execute the search + 514| AutocompleteDropdown.hide(); + 515| AutocompleteDropdown._suppressNext = true; + 516| const vault = s.vault || "all"; + 517| if (input) { input.dispatchEvent(new Event("input")); } + 518| clearTimeout(state.searchTimeout); + 519| state.advancedSearchOffset = 0; + 520| performAdvancedSearch(s.query, vault, null); + 521| }); + 522| }); + 523| list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { + 524| e.stopPropagation(); + 525| await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); + 526| loadSavedSearches(); + 527| })); + 528| safeCreateIcons(); + 529| } catch (err) { /* silently ignore */ } + 530|} + 531| + 532|// ========================================================================= + 533|// initDashboardTabs (needed by showWelcome) + 534|// ========================================================================= + 535| + 536|function initDashboardTabs() { + 537| document.querySelectorAll(".dashboard-tab").forEach(tab => { + 538| // Remove existing listeners by cloning + 539| const newTab = tab.cloneNode(true); + 540| tab.parentNode.replaceChild(newTab, tab); + 541| newTab.addEventListener("click", function() { + 542| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + 543| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + 544| this.classList.add("active"); + 545| const panel = document.getElementById("dashboard-panel-" + this.dataset.tab); + 546| if (panel) panel.classList.add("active"); + 547| }); + 548| }); + 549|} + 550| + 551|// ========================================================================= + 552|// Exports + 553|// ========================================================================= + 554| + 555|export { + 556| loadVaultSettings, + 557| initSearch, + 558| showWelcome, + 559| goHome, + 560|}; + 561| \ No newline at end of file diff --git a/frontend/js/search.js b/frontend/js/search.js index 048b920..191a6ab 100644 --- a/frontend/js/search.js +++ b/frontend/js/search.js @@ -1,1106 +1,1107 @@ -/* 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); -} + 1|/* ObsiGate — Search module (extracted from app.js) */ + 2| +import { state } from './state.js'; + 4|import { safeCreateIcons } from './utils.js'; + 5| + 6|// Re-export constants used internally + 7|const state.SEARCH_HISTORY_KEY = state.SEARCH_HISTORY_KEY; + 8|const state.MAX_HISTORY_ENTRIES = state.MAX_HISTORY_ENTRIES; + 9| + 10|// --------------------------------------------------------------------------- + 11|// Search History Service (localStorage, LIFO, max 50, dedup) + 12|// --------------------------------------------------------------------------- + 13|export const SearchHistory = { + 14| _load() { + 15| try { + 16| const raw = localStorage.getItem(state.SEARCH_HISTORY_KEY); + 17| return raw ? JSON.parse(raw) : []; + 18| } catch { + 19| return []; + 20| } + 21| }, + 22| _save(entries) { + 23| try { + 24| localStorage.setItem(state.SEARCH_HISTORY_KEY, JSON.stringify(entries)); + 25| } catch {} + 26| }, + 27| getAll() { + 28| return this._load(); + 29| }, + 30| add(query) { + 31| if (!query || !query.trim()) return; + 32| const q = query.trim(); + 33| let entries = this._load().filter((e) => e !== q); + 34| entries.unshift(q); + 35| if (entries.length > state.MAX_HISTORY_ENTRIES) entries = entries.slice(0, state.MAX_HISTORY_ENTRIES); + 36| this._save(entries); + 37| }, + 38| remove(query) { + 39| const entries = this._load().filter((e) => e !== query); + 40| this._save(entries); + 41| }, + 42| clear() { + 43| this._save([]); + 44| }, + 45| filter(prefix) { + 46| if (!prefix) return this.getAll().slice(0, 8); + 47| const lp = prefix.toLowerCase(); + 48| return this._load() + 49| .filter((e) => e.toLowerCase().includes(lp)) + 50| .slice(0, 8); + 51| }, + 52|}; + 53| + 54|// --------------------------------------------------------------------------- + 55|// Query Parser — extracts operators (tag:, #, vault:, title:, path:, ext:) + 56|// --------------------------------------------------------------------------- + 57|export const QueryParser = { + 58| parse(raw) { + 59| const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" }; + 60| if (!raw) return result; + 61| const tokens = this._tokenize(raw); + 62| const freeTokens = []; + 63| for (const tok of tokens) { + 64| const lower = tok.toLowerCase(); + 65| if (lower.startsWith("tag:")) { + 66| const v = tok.slice(4).replace(/"/g, "").trim().replace(/^#/, ""); + 67| if (v) result.tags.push(v); + 68| } else if (lower.startsWith("#") && tok.length > 1) { + 69| result.tags.push(tok.slice(1)); + 70| } else if (lower.startsWith("vault:")) { + 71| result.vault = tok.slice(6).replace(/"/g, "").trim(); + 72| } else if (lower.startsWith("title:")) { + 73| result.title = tok.slice(6).replace(/"/g, "").trim(); + 74| } else if (lower.startsWith("path:")) { + 75| result.path = tok.slice(5).replace(/"/g, "").trim(); + 76| } else if (lower.startsWith("ext:")) { + 77| result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase(); + 78| } else { + 79| freeTokens.push(tok); + 80| } + 81| } + 82| result.freeText = freeTokens.join(" "); + 83| return result; + 84| }, + 85| _tokenize(raw) { + 86| const tokens = []; + 87| let i = 0; + 88| const n = raw.length; + 89| while (i < n) { + 90| while (i < n && raw[i] === " ") i++; + 91| if (i >= n) break; + 92| if (raw[i] !== '"') { + 93| let j = i; + 94| while (j < n && raw[j] !== " ") { + 95| if (raw[j] === '"') { + 96| j++; + 97| while (j < n && raw[j] !== '"') j++; + 98| if (j < n) j++; + 99| } else j++; + 100| } + 101| tokens.push(raw.slice(i, j).replace(/"/g, "")); + 102| i = j; + 103| } else { + 104| i++; + 105| let j = i; + 106| while (j < n && raw[j] !== '"') j++; + 107| tokens.push(raw.slice(i, j)); + 108| i = j + 1; + 109| } + 110| } + 111| return tokens; + 112| }, + 113| /** Detect the current operator context at cursor for autocomplete */ + 114| getContext(raw, cursorPos) { + 115| const before = raw.slice(0, cursorPos); + 116| // Check if we're typing a tag: or # value + 117| const tagMatch = before.match(/(?:tag:|#)([\w-]*)$/i); + 118| if (tagMatch) return { type: "tag", prefix: tagMatch[1] }; + 119| // Check if typing title: + 120| const titleMatch = before.match(/title:([\w-]*)$/i); + 121| if (titleMatch) return { type: "title", prefix: titleMatch[1] }; + 122| // Default: free text + 123| const words = before.trim().split(/\s+/); + 124| const lastWord = words[words.length - 1] || ""; + 125| return { type: "text", prefix: lastWord }; + 126| }, + 127|}; + 128| + 129|// --------------------------------------------------------------------------- + 130|// Autocomplete Dropdown Controller + 131|// --------------------------------------------------------------------------- + 132|export const AutocompleteDropdown = { + 133| _dropdown: null, + 134| _historySection: null, + 135| _titlesSection: null, + 136| _tagsSection: null, + 137| _historyList: null, + 138| _titlesList: null, + 139| _tagsList: null, + 140| _emptyEl: null, + 141| _suggestTimer: null, + 142| + 143| init() { + 144| this._dropdown = document.getElementById("search-dropdown"); + 145| this._historySection = document.getElementById("search-dropdown-history"); + 146| this._titlesSection = document.getElementById("search-dropdown-titles"); + 147| this._tagsSection = document.getElementById("search-dropdown-tags"); + 148| this._historyList = document.getElementById("search-dropdown-history-list"); + 149| this._titlesList = document.getElementById("search-dropdown-titles-list"); + 150| this._tagsList = document.getElementById("search-dropdown-tags-list"); + 151| this._emptyEl = document.getElementById("search-dropdown-empty"); + 152| + 153| // Clear history button + 154| const clearBtn = document.getElementById("search-dropdown-clear-history"); + 155| if (clearBtn) { + 156| clearBtn.addEventListener("click", (e) => { + 157| e.stopPropagation(); + 158| SearchHistory.clear(); + 159| this.hide(); + 160| }); + 161| } + 162| + 163| // Close dropdown on outside click + 164| document.addEventListener("click", (e) => { + 165| if (this._dropdown && !this._dropdown.contains(e.target) && e.target.id !== "search-input") { + 166| this.hide(); + 167| } + 168| }); + 169| }, + 170| + 171| show() { + 172| if (this._dropdown) this._dropdown.hidden = false; + 173| }, + 174| + 175| hide() { + 176| if (this._dropdown) this._dropdown.hidden = true; + 177| state.dropdownActiveIndex = -1; + 178| state.dropdownItems = []; + 179| }, + 180| + 181| isVisible() { + 182| return this._dropdown && !this._dropdown.hidden; + 183| }, + 184| + 185| /** Populate and show the dropdown with history, title suggestions, and tag suggestions */ + 186| async populate(inputValue, cursorPos) { + 187| if (this._suppressNext) { this._suppressNext = false; return; } + 188| // Cancel previous suggestion request + 189| if (state.suggestAbortController) { + 190| state.suggestAbortController.abort(); + 191| state.suggestAbortController = null; + 192| } + 193| + 194| const ctx = QueryParser.getContext(inputValue, cursorPos); + 195| const vault = document.getElementById("vault-filter").value; + 196| + 197| // History — always show filtered history + 198| const historyItems = SearchHistory.filter(inputValue).slice(0, 5); + 199| this._renderHistory(historyItems, inputValue); + 200| + 201| // Title and tag suggestions from API (debounced) — always fetch both + 202| clearTimeout(this._suggestTimer); + 203| const prefix = ctx.prefix; + 204| if (prefix && prefix.length >= 2) { + 205| // Only show placeholder if lists are empty (avoid flashing on fast typing) + 206| const hasTitles = this._titlesList.children.length > 0 && !this._titlesList.querySelector(".search-dropdown__item--loading"); + 207| const hasTags = this._tagsList.children.length > 0 && !this._tagsList.querySelector(".search-dropdown__item--loading"); + 208| if (!hasTitles) { + 209| this._titlesList.innerHTML = '
  • Recherche...
  • '; + 210| } + 211| if (!hasTags) { + 212| this._tagsList.innerHTML = '
  • Recherche...
  • '; + 213| } + 214| this._titlesSection.hidden = false; + 215| this._tagsSection.hidden = false; + 216| this.show(); + 217| this._suggestTimer = setTimeout(() => this._fetchSuggestions(prefix, vault), 150); + 218| } else { + 219| this._renderTitles([], ""); + 220| this._renderTags([], ""); + 221| this._titlesSection.hidden = true; + 222| this._tagsSection.hidden = true; + 223| } + 224| + 225| // Show/hide sections + 226| this._historySection.hidden = historyItems.length === 0; + 227| const hasContent = historyItems.length > 0; + 228| if (hasContent || (prefix && prefix.length >= 2)) { + 229| this.show(); + 230| } else { + 231| this.hide(); + 232| } + 233| + 234| this._collectItems(); + 235| }, + 236| + 237| async _fetchSuggestions(prefix, vault) { + 238| state.suggestAbortController = new AbortController(); + 239| // Fetch titles + 240| try { + 241| const titlesRes = await api(`/api/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: state.suggestAbortController.signal }); + 242| this._renderTitles(titlesRes.suggestions || [], prefix); + 243| this._titlesSection.hidden = !(titlesRes.suggestions || []).length; + 244| if (titlesRes.suggestions?.length) this.show(); + 245| } catch (err) { + 246| if (err.name === "AbortError") return; + 247| this._titlesSection.hidden = true; + 248| } + 249| // Fetch tags — keep section always visible to confirm it works + 250| try { + 251| const tagsRes = await api(`/api/tags/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: state.suggestAbortController.signal }); + 252| const items = tagsRes.suggestions || []; + 253| if (items.length > 0) { + 254| this._renderTags(items, prefix); + 255| } else { + 256| this._tagsList.innerHTML = '
  • Aucun tag
  • '; + 257| } + 258| this._tagsSection.hidden = false; + 259| this.show(); + 260| } catch (err) { + 261| if (err.name === "AbortError") return; + 262| this._tagsList.innerHTML = '
  • Erreur chargement
  • '; + 263| this._tagsSection.hidden = false; + 264| } + 265| this._collectItems(); + 266| }, + 267| + 268| _renderHistory(items, query) { + 269| this._historyList.innerHTML = ""; + 270| items.forEach((entry) => { + 271| const li = el("li", { class: "search-dropdown__item search-dropdown__item--history", role: "option" }); + 272| const iconEl = el("span", { class: "search-dropdown__icon" }); + 273| iconEl.innerHTML = ''; + 274| const textEl = el("span", { class: "search-dropdown__text" }); + 275| textEl.textContent = entry; + 276| li.appendChild(iconEl); + 277| li.appendChild(textEl); + 278| li.addEventListener("click", () => { + 279| const input = document.getElementById("search-input"); + 280| input.value = entry; + 281| input.dispatchEvent(new Event("input", { bubbles: true })); + 282| this.hide(); + 283| _triggerAdvancedSearch(entry); + 284| }); + 285| this._historyList.appendChild(li); + 286| }); + 287| }, + 288| + 289| _renderTitles(items, prefix) { + 290| this._titlesList.innerHTML = ""; + 291| items.forEach((item) => { + 292| const li = el("li", { class: "search-dropdown__item search-dropdown__item--title", role: "option" }); + 293| const iconEl = el("span", { class: "search-dropdown__icon" }); + 294| iconEl.innerHTML = ''; + 295| const textEl = el("span", { class: "search-dropdown__text" }); + 296| if (prefix) { + 297| this._highlightText(textEl, item.title, prefix); + 298| } else { + 299| textEl.textContent = item.title; + 300| } + 301| const metaEl = el("span", { class: "search-dropdown__meta" }); + 302| metaEl.textContent = item.vault; + 303| li.appendChild(iconEl); + 304| li.appendChild(textEl); + 305| li.appendChild(metaEl); + 306| li.addEventListener("click", () => { + 307| this.hide(); + 308| TabManager.openPreview(item.vault, item.path); + 309| }); + 310| this._titlesList.appendChild(li); + 311| }); + 312| }, + 313| + 314| _renderTags(items, prefix) { + 315| this._tagsList.innerHTML = ""; + 316| items.forEach((item) => { + 317| const li = el("li", { class: "search-dropdown__item search-dropdown__item--tag", role: "option" }); + 318| const iconEl = el("span", { class: "search-dropdown__icon" }); + 319| iconEl.innerHTML = ''; + 320| const textEl = el("span", { class: "search-dropdown__text" }); + 321| if (prefix) { + 322| this._highlightText(textEl, item.tag, prefix); + 323| } else { + 324| textEl.textContent = item.tag; + 325| } + 326| const badge = el("span", { class: "search-dropdown__badge" }); + 327| badge.textContent = item.count; + 328| li.appendChild(iconEl); + 329| li.appendChild(textEl); + 330| li.appendChild(badge); + 331| li.addEventListener("click", () => { + 332| const input = document.getElementById("search-input"); + 333| const current = input.value; + 334| const cursorPos = input.selectionStart; + 335| const ctx = QueryParser.getContext(current, cursorPos); + 336| if (ctx.type === "tag") { + 337| // Replace the partial tag prefix + 338| const before = current.slice(0, cursorPos - ctx.prefix.length); + 339| input.value = before + item.tag + " "; + 340| } else { + 341| // Replace the last word with tag: operator + 342| const words = current.trim().split(/\s+/); + 343| if (words.length > 0 && ctx.prefix && ctx.prefix.length > 0) { + 344| words[words.length - 1] = ""; // remove last partial word + 345| } + 346| const base = words.filter(w => w).join(" "); + 347| input.value = (base ? base + " " : "") + "tag:" + item.tag + " "; + 348| } + 349| input.dispatchEvent(new Event("input", { bubbles: true })); + 350| this.hide(); + 351| input.focus(); + 352| _triggerAdvancedSearch(input.value); + 353| }); + 354| this._tagsList.appendChild(li); + 355| }); + 356| }, + 357| + 358| _highlightText(container, text, query) { + 359| const lower = text.toLowerCase(); + 360| const needle = query.toLowerCase(); + 361| const pos = lower.indexOf(needle); + 362| if (pos === -1) { + 363| container.textContent = text; + 364| return; + 365| } + 366| container.appendChild(document.createTextNode(text.slice(0, pos))); + 367| const markEl = el("mark", {}, [document.createTextNode(text.slice(pos, pos + query.length))]); + 368| container.appendChild(markEl); + 369| container.appendChild(document.createTextNode(text.slice(pos + query.length))); + 370| }, + 371| + 372| _collectItems() { + 373| state.dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item")); + 374| state.dropdownActiveIndex = -1; + 375| state.dropdownItems.forEach((item) => item.classList.remove("active")); + 376| }, + 377| + 378| navigateDown() { + 379| if (!this.isVisible() || state.dropdownItems.length === 0) return; + 380| if (state.dropdownActiveIndex >= 0) state.dropdownItems[state.dropdownActiveIndex].classList.remove("active"); + 381| state.dropdownActiveIndex = (state.dropdownActiveIndex + 1) % state.dropdownItems.length; + 382| state.dropdownItems[state.dropdownActiveIndex].classList.add("active"); + 383| state.dropdownItems[state.dropdownActiveIndex].scrollIntoView({ block: "nearest" }); + 384| }, + 385| + 386| navigateUp() { + 387| if (!this.isVisible() || state.dropdownItems.length === 0) return; + 388| if (state.dropdownActiveIndex >= 0) state.dropdownItems[state.dropdownActiveIndex].classList.remove("active"); + 389| state.dropdownActiveIndex = state.dropdownActiveIndex <= 0 ? state.dropdownItems.length - 1 : state.dropdownActiveIndex - 1; + 390| state.dropdownItems[state.dropdownActiveIndex].classList.add("active"); + 391| state.dropdownItems[state.dropdownActiveIndex].scrollIntoView({ block: "nearest" }); + 392| }, + 393| + 394| selectActive() { + 395| if (state.dropdownActiveIndex >= 0 && state.dropdownActiveIndex < state.dropdownItems.length) { + 396| state.dropdownItems[state.dropdownActiveIndex].click(); + 397| return true; + 398| } + 399| return false; + 400| }, + 401|}; + 402| + 403|// --------------------------------------------------------------------------- + 404|// Search Chips Controller — renders active filter chips from parsed query + 405|// --------------------------------------------------------------------------- + 406|export const SearchChips = { + 407| _container: null, + 408| init() { + 409| this._container = document.getElementById("search-chips"); + 410| }, + 411| update(parsed) { + 412| if (!this._container) return; + 413| this._container.innerHTML = ""; + 414| let hasChips = false; + 415| parsed.tags.forEach((tag) => { + 416| this._addChip("tag", `tag:${tag}`, tag); + 417| hasChips = true; + 418| }); + 419| if (parsed.vault) { + 420| this._addChip("vault", `vault:${parsed.vault}`, parsed.vault); + 421| hasChips = true; + 422| } + 423| if (parsed.title) { + 424| this._addChip("title", `title:${parsed.title}`, parsed.title); + 425| hasChips = true; + 426| } + 427| if (parsed.path) { + 428| this._addChip("path", `path:${parsed.path}`, parsed.path); + 429| hasChips = true; + 430| } + 431| if (parsed.ext) { + 432| this._addChip("ext", `ext:${parsed.ext}`, parsed.ext); + 433| hasChips = true; + 434| } + 435| this._container.hidden = !hasChips; + 436| }, + 437| clear() { + 438| if (!this._container) return; + 439| this._container.innerHTML = ""; + 440| this._container.hidden = true; + 441| }, + 442| _addChip(type, fullOperator, displayText) { + 443| const chip = el("span", { class: `search-chip search-chip--${type}` }); + 444| const label = el("span", { class: "search-chip__label" }); + 445| label.textContent = fullOperator; + 446| const removeBtn = el("button", { class: "search-chip__remove", title: "Retirer ce filtre", type: "button" }); + 447| removeBtn.innerHTML = ''; + 448| removeBtn.addEventListener("click", () => { + 449| // Remove this operator from the input + 450| const input = document.getElementById("search-input"); + 451| const raw = input.value; + 452| // Remove the operator text from the query + 453| const escaped = fullOperator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + 454| input.value = raw.replace(new RegExp("\\s*" + escaped + "\\s*", "i"), " ").trim(); + 455| _triggerAdvancedSearch(input.value); + 456| }); + 457| chip.appendChild(label); + 458| chip.appendChild(removeBtn); + 459| this._container.appendChild(chip); + 460| safeCreateIcons(); + 461| }, + 462|}; + 463| + 464|// --------------------------------------------------------------------------- + 465|// Helper: trigger advanced search from input value + 466|// --------------------------------------------------------------------------- + 467|export function _triggerAdvancedSearch(rawQuery) { + 468| const q = (rawQuery || "").trim(); + 469| const vault = document.getElementById("vault-filter").value; + 470| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 471| state.advancedSearchOffset = 0; + 472| if (q.length > 0 || tagFilter) { + 473| SearchHistory.add(q); + 474| performAdvancedSearch(q, vault, tagFilter); + 475| } else { + 476| SearchChips.clear(); + 477| showWelcome(); + 478| } + 479|} + 480| + 481|// --------------------------------------------------------------------------- + 482|// Search (enhanced with autocomplete, keyboard nav, global shortcuts) + 483|// --------------------------------------------------------------------------- + 484|// ── Search toggle state ── + 485| + 486|function initSearch() { + 487| const input = document.getElementById("search-input"); + 488| if (!input) return; + 489| const caseBtn = document.getElementById("search-case-btn"); + 490| const wordBtn = document.getElementById("search-word-btn"); + 491| const regexBtn = document.getElementById("search-regex-btn"); + 492| const filterBtn = document.getElementById("search-filter-btn"); + 493| const clearBtn = document.getElementById("search-clear-btn"); + 494| const filterRow = document.getElementById("search-filter-row"); + 495| const prevBtn = document.getElementById("search-prev-btn"); + 496| const nextBtn = document.getElementById("search-next-btn"); + 497| const countEl = document.getElementById("search-match-count"); + 498| + 499| function _updateToggleUI() { + 500| caseBtn.classList.toggle("active", state.searchCaseSensitive); + 501| wordBtn.classList.toggle("active", state.searchWholeWord); + 502| regexBtn.classList.toggle("active", state.searchRegex); + 503| filterBtn.classList.toggle("active", state.searchFilterVisible); + 504| } + 505| + 506| // Toggle buttons + 507| caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); }); + 508| if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); }); + 509| if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); }); + 510| if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = state.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); + 511| + 512| // ── Result navigation (up/down arrows + Enter) ── + 513| let _searchResultIdx = -1; + 514| let _searchResultItems = []; + 515| + 516| function _updateResultHighlight() { + 517| _searchResultItems.forEach((el, i) => { + 518| el.classList.toggle("search-result-active", i === _searchResultIdx); + 519| }); + 520| if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { + 521| _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); + 522| } + 523| const countEl = document.getElementById("search-match-count"); + 524| if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; + 525| } + 526| + 527| function _refreshResultItems() { + 528| _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + 529| _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; + 530| _updateResultHighlight(); + 531| } + 532| + 533| window.navigateSearchResults = function(delta) { + 534| _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + 535| if (_searchResultItems.length === 0) return; + 536| _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); + 537| _updateResultHighlight(); + 538| }; + 539| + 540| if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); + 541| if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); + 542| + 543| function _research() { + 544| const q = input.value.trim(); + 545| if (q.length >= _getEffective("min_query_length", 2)) { + 546| clearTimeout(state.searchTimeout); + 547| state.searchTimeout = setTimeout(() => { + 548| const vault = document.getElementById("vault-filter").value; + 549| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 550| state.advancedSearchOffset = 0; + 551| performAdvancedSearch(q, vault, tagFilter); + 552| }, _getEffective("debounce_ms", 300)); + 553| } + 554| } + 555| + 556| // Keyboard shortcuts + 557| document.addEventListener("keydown", (e) => { + 558| if (e.altKey && !e.ctrlKey && !e.metaKey) { + 559| if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } + 560| else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } + 561| else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } + 562| else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } + 563| } + 564| }); + 565| + 566| // Initialize sub-controllers + 567| AutocompleteDropdown.init(); + 568| SearchChips.init(); + 569| + 570| // Initially hide clear button + 571| if (clearBtn) clearBtn.style.display = "none"; + 572| + 573| // --- Input handler: debounced search + autocomplete dropdown --- + 574| input.addEventListener("input", () => { + 575| const hasText = input.value.length > 0; + 576| clearBtn.style.display = hasText ? "flex" : "none"; + 577| + 578| // Show autocomplete dropdown while typing + 579| AutocompleteDropdown.populate(input.value, input.selectionStart); + 580| + 581| // Debounced search execution + 582| clearTimeout(state.searchTimeout); + 583| state.searchTimeout = setTimeout( + 584| () => { + 585| const q = input.value.trim(); + 586| const vault = document.getElementById("vault-filter").value; + 587| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 588| state.advancedSearchOffset = 0; + 589| if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) { + 590| performAdvancedSearch(q, vault, tagFilter); + 591| } else if (q.length === 0) { + 592| SearchChips.clear(); + 593| showWelcome(); + 594| } + 595| }, + 596| _getEffective("debounce_ms", 300), + 597| ); + 598| }); + 599| + 600| // --- Focus handler: show history dropdown --- + 601| input.addEventListener("focus", () => { + 602| if (input.value.length === 0) { + 603| const historyItems = SearchHistory.filter("").slice(0, 5); + 604| if (historyItems.length > 0) { + 605| AutocompleteDropdown.populate("", 0); + 606| } + 607| } + 608| }); + 609| + 610| // --- Keyboard navigation in dropdown --- + 611| input.addEventListener("keydown", (e) => { + 612| if (AutocompleteDropdown.isVisible()) { + 613| if (e.key === "ArrowDown") { + 614| e.preventDefault(); + 615| AutocompleteDropdown.navigateDown(); + 616| } else if (e.key === "ArrowUp") { + 617| e.preventDefault(); + 618| AutocompleteDropdown.navigateUp(); + 619| } else if (e.key === "Enter") { + 620| // First: check dropdown suggestions (higher priority than search results) + 621| if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + 622| e.preventDefault(); + 623| return; + 624| } + 625| // Second: navigate search results if visible + 626| const results = document.querySelectorAll(".search-result-item"); + 627| if (results.length > 0 && _searchResultIdx >= 0) { + 628| const el = results[_searchResultIdx]; + 629| if (el) { + 630| const vault = el.dataset.vault; + 631| const path = el.dataset.path; + 632| if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } + 633| } + 634| } + 635| // Third: execute search + 636| AutocompleteDropdown.hide(); + 637| const q = input.value.trim(); + 638| if (q) { + 639| SearchHistory.add(q); + 640| clearTimeout(state.searchTimeout); + 641| state.advancedSearchOffset = 0; + 642| const vault = document.getElementById("vault-filter").value; + 643| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 644| performAdvancedSearch(q, vault, tagFilter); + 645| } + 646| e.preventDefault(); + 647| } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { + 648| // Navigate search results when dropdown is closed + 649| if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } + 650| } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { + 651| if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } + 652| } else if (e.key === "Escape") { + 653| AutocompleteDropdown.hide(); + 654| e.stopPropagation(); + 655| } + 656| } else if (e.key === "Enter") { + 657| if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + 658| e.preventDefault(); + 659| return; + 660| } + 661| const q = input.value.trim(); + 662| if (q) { + 663| SearchHistory.add(q); + 664| clearTimeout(state.searchTimeout); + 665| state.advancedSearchOffset = 0; + 666| const vault = document.getElementById("vault-filter").value; + 667| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + 668| performAdvancedSearch(q, vault, tagFilter); + 669| } + 670| e.preventDefault(); + 671| } + 672| }); + 673| + 674| clearBtn.addEventListener("click", () => { + 675| input.value = ""; + 676| if (clearBtn) clearBtn.style.display = "none"; + 677| state.searchCaseSensitive = false; + 678| state.searchWholeWord = false; + 679| state.searchRegex = false; + 680| _updateToggleUI(); + 681| SearchChips.clear(); + 682| AutocompleteDropdown.hide(); + 683| showWelcome(); + 684| }); + 685| + 686| // --- Global keyboard shortcuts --- + 687| document.addEventListener("keydown", (e) => { + 688| // Ctrl+K or Cmd+K: focus search + 689| if ((e.ctrlKey || e.metaKey) && e.key === "k") { + 690| e.preventDefault(); + 691| input.focus(); + 692| input.select(); + 693| } + 694| // "/" key: focus search (when not in an input/textarea) + 695| if (e.key === "/" && !_isInputFocused()) { + 696| e.preventDefault(); + 697| input.focus(); + 698| } + 699| // Escape: blur search input and close dropdown + 700| if (e.key === "Escape" && document.activeElement === input) { + 701| AutocompleteDropdown.hide(); + 702| input.blur(); + 703| } + 704| }); + 705|} + 706| + 707|/** Check if user is focused on an input/textarea/contenteditable */ + 708|function _isInputFocused() { + 709| const tag = document.activeElement?.tagName; + 710| if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + 711| return document.activeElement?.isContentEditable === true; + 712|} + 713| + 714|// --- Backward-compatible search (existing /api/search endpoint) --- + 715|export async function performSearch(query, vaultFilter, tagFilter) { + 716| if (state.searchAbortController) state.searchAbortController.abort(); + 717| state.searchAbortController = new AbortController(); + 718| const searchId = ++state.currentSearchId; + 719| showLoading(); + 720| let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; + 721| if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + 722| try { + 723| const data = await api(url, { signal: state.searchAbortController.signal }); + 724| if (searchId !== state.currentSearchId) return; + 725| renderSearchResults(data, query, tagFilter); + 726| } catch (err) { + 727| if (err.name === "AbortError") return; + 728| if (searchId !== state.currentSearchId) return; + 729| showWelcome(); + 730| } finally { + 731| hideProgressBar(); + 732| if (searchId === state.currentSearchId) state.searchAbortController = null; + 733| } + 734|} + 735| + 736|// --- Advanced search with TF-IDF, facets, pagination --- + 737|export async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) { + 738| if (state.searchAbortController) state.searchAbortController.abort(); + 739| state.searchAbortController = new AbortController(); + 740| const searchId = ++state.currentSearchId; + 741| showLoading(); + 742| + 743| const ofs = offset !== undefined ? offset : state.advancedSearchOffset; + 744| const sortBy = sort || state.advancedSearchSort; + 745| state.advancedSearchLastQuery = query; + 746| + 747| // Update chips from parsed query + 748| const parsed = QueryParser.parse(query); + 749| SearchChips.update(parsed); + 750| + 751| const effectiveLimit = _getEffective("results_per_page", state.ADVANCED_SEARCH_LIMIT); + 752| let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${effectiveLimit}&offset=${ofs}&sort=${sortBy}`; + 753| if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + 754| if (state.searchCaseSensitive) url += "&case_sensitive=true"; + 755| if (state.searchWholeWord) url += "&whole_word=true"; + 756| if (state.searchRegex) url += "®ex=true"; + 757| const includeEl = document.getElementById("search-include-input"); + 758| const excludeEl = document.getElementById("search-exclude-input"); + 759| if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; + 760| if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`; + 761| + 762| // Search timeout — abort if server takes too long + 763| const timeoutId = setTimeout( + 764| () => { + 765| if (state.searchAbortController) state.searchAbortController.abort(); + 766| }, + 767| _getEffective("search_timeout_ms", state.SEARCH_TIMEOUT_MS), + 768| ); + 769| + 770| try { + 771| const data = await api(url, { signal: state.searchAbortController.signal }); + 772| clearTimeout(timeoutId); + 773| if (searchId !== state.currentSearchId) return; + 774| state.advancedSearchTotal = data.total; + 775| state.advancedSearchOffset = ofs; + 776| renderAdvancedSearchResults(data, query, tagFilter); + 777| } catch (err) { + 778| clearTimeout(timeoutId); + 779| if (err.name === "AbortError") return; + 780| if (searchId !== state.currentSearchId) return; + 781| showWelcome(); + 782| } finally { + 783| hideProgressBar(); + 784| if (searchId === state.currentSearchId) state.searchAbortController = null; + 785| } + 786|} + 787| + 788|// --- Legacy search results renderer (kept for backward compat) --- + 789|export function renderSearchResults(data, query, tagFilter) { + 790| const area = document.getElementById("content-area"); + 791| area.innerHTML = ""; + 792| const header = buildSearchResultsHeader(data, query, tagFilter); + 793| area.appendChild(header); + 794| if (data.results.length === 0) { + 795| area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); + 796| return; + 797| } + 798| const container = el("div", { class: "search-results" }); + 799| data.results.forEach((r) => { + 800| // Apply client-side filtering for hidden files + 801| if (!shouldDisplayPath(r.path, r.vault)) { + 802| return; // Skip this result + 803| } + 804| + 805| const titleDiv = el("div", { class: "search-result-title" }); + 806| if (query && query.trim()) { + 807| highlightSearchText(titleDiv, r.title, query, state.searchCaseSensitive); + 808| } else { + 809| titleDiv.textContent = r.title; + 810| } + 811| const snippetDiv = el("div", { class: "search-result-snippet" }); + 812| if (r.snippet && r.snippet.includes("")) { + 813| snippetDiv.innerHTML = r.snippet; + 814| } else if (query && query.trim() && r.snippet) { + 815| highlightSearchText(snippetDiv, r.snippet, query, state.searchCaseSensitive); + 816| } else { + 817| snippetDiv.textContent = r.snippet || ""; + 818| } + 819| const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ + 820| el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), + 821| titleDiv, + 822| el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]), + 823| snippetDiv + 824| ]); + 825| if (r.tags && r.tags.length > 0) { + 826| const tagsDiv = el("div", { class: "search-result-tags" }); + 827| r.tags.forEach((tag) => { + 828| if (!TagFilterService.isTagFiltered(tag)) { + 829| const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + 830| tagEl.addEventListener("click", (e) => { + 831| e.stopPropagation(); + 832| addTagFilter(tag); + 833| }); + 834| tagsDiv.appendChild(tagEl); + 835| } + 836| }); + 837| if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); + 838| } + 839| item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + 840| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + 841| container.appendChild(item); + 842| }); + 843| area.appendChild(container); + 844|} + 845| + 846|// --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) --- + 847|export function renderAdvancedSearchResults(data, query, tagFilter) { + 848| const area = document.getElementById("content-area"); + 849| area.innerHTML = ""; + 850| + 851| // Update match counter + 852| const countEl = document.getElementById("search-match-count"); + 853| if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`; + 854| + 855| // Header with result count and sort controls + 856| const header = el("div", { class: "search-results-header" }); + 857| const summaryText = el("span", { class: "search-results-summary-text" }); + 858| const parsed = QueryParser.parse(query); + 859| const freeText = parsed.freeText; + 860| + 861| if (freeText && tagFilter) { + 862| summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`; + 863| } else if (freeText) { + 864| summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`; + 865| } else if (parsed.tags.length > 0 || tagFilter) { + 866| summaryText.textContent = `${data.total} fichier(s) avec filtres`; + 867| } else { + 868| summaryText.textContent = `${data.total} résultat(s)`; + 869| } + 870| if (data.query_time_ms !== undefined && data.query_time_ms > 0) { + 871| const timeBadge = el("span", { class: "search-time-badge" }); + 872| timeBadge.textContent = `(${data.query_time_ms} ms)`; + 873| summaryText.appendChild(timeBadge); + 874| } + 875| header.appendChild(summaryText); + 876| + 877| // Active filter badges + 878| const filtersRow = el("div", { class: "search-filters-row" }); + 879| if (state.searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")])); + 880| if (state.searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")])); + 881| if (state.searchRegex) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode(".*")])); + 882| const inclEl = document.getElementById("search-include-input"); + 883| const exclEl = document.getElementById("search-exclude-input"); + 884| if (inclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("incl: " + inclEl.value.trim())])); + 885| if (exclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("excl: " + exclEl.value.trim())])); + 886| if (filtersRow.children.length > 0) header.appendChild(filtersRow); + 887| + 888| // Sort controls + 889| const sortDiv = el("div", { class: "search-sort" }); + 890| const btnRelevance = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "relevance" ? " active" : ""), type: "button" }); + 891| btnRelevance.textContent = "Pertinence"; + 892| btnRelevance.addEventListener("click", () => { + 893| state.advancedSearchSort = "relevance"; + 894| state.advancedSearchOffset = 0; + 895| const vault = document.getElementById("vault-filter").value; + 896| performAdvancedSearch(query, vault, tagFilter, 0, "relevance"); + 897| }); + 898| const btnDate = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "modified" ? " active" : ""), type: "button" }); + 899| btnDate.textContent = "Date"; + 900| btnDate.addEventListener("click", () => { + 901| state.advancedSearchSort = "modified"; + 902| state.advancedSearchOffset = 0; + 903| const vault = document.getElementById("vault-filter").value; + 904| performAdvancedSearch(query, vault, tagFilter, 0, "modified"); + 905| }); + 906| sortDiv.appendChild(btnRelevance); + 907| sortDiv.appendChild(btnDate); + 908| header.appendChild(sortDiv); + 909| + 910| // Save search button + 911| const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" }); + 912| saveBtn.innerHTML = ' Sauver'; + 913| saveBtn.addEventListener("click", async () => { + 914| const inclEl = document.getElementById("search-include-input"); + 915| const exclEl = document.getElementById("search-exclude-input"); + 916| try { + 917| await api("/api/saved-searches", { + 918| method: "POST", + 919| headers: { "Content-Type": "application/json" }, + 920| body: JSON.stringify({ + 921| query: query, + 922| vault: document.getElementById("vault-filter")?.value || "all", + 923| case_sensitive: state.searchCaseSensitive, + 924| whole_word: state.searchWholeWord, + 925| regex: state.searchRegex, + 926| include_paths: inclEl?.value || "", + 927| exclude_paths: exclEl?.value || "", + 928| }), + 929| }); + 930| showToast("Recherche sauvegardée", "success"); + 931| } catch (err) { showToast("Erreur: " + err.message, "error"); } + 932| }); + 933| header.appendChild(saveBtn); + 934| area.appendChild(header); + 935| + 936| // Active sidebar tag chips + 937| if (state.selectedTags.length > 0) { + 938| const activeTags = el("div", { class: "search-results-active-tags" }); + 939| state.selectedTags.forEach((tag) => { + 940| const removeBtn = el( + 941| "button", + 942| { + 943| class: "search-results-active-tag-remove", + 944| title: `Retirer ${tag} du filtre`, + 945| }, + 946| [document.createTextNode("×")], + 947| ); + 948| removeBtn.addEventListener("click", (e) => { + 949| e.stopPropagation(); + 950| removeTagFilter(tag); + 951| }); + 952| const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + 953| activeTags.appendChild(chip); + 954| }); + 955| area.appendChild(activeTags); + 956| } + 957| + 958| // Facets panel + 959| if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) { + 960| const facetsDiv = el("div", { class: "search-facets" }); + 961| + 962| // Vault facets + 963| const vaultFacets = data.facets.vaults || {}; + 964| if (Object.keys(vaultFacets).length > 1) { + 965| const group = el("div", { class: "search-facets__group" }); + 966| const label = el("span", { class: "search-facets__label" }); + 967| label.textContent = "Vaults"; + 968| group.appendChild(label); + 969| for (const [vaultName, count] of Object.entries(vaultFacets)) { + 970| const item = el("span", { class: "search-facets__item" }); + 971| item.innerHTML = `${vaultName} ${count}`; + 972| item.addEventListener("click", () => { + 973| const input = document.getElementById("search-input"); + 974| // Add vault: operator + 975| const current = input.value.replace(/vault:\S+\s*/gi, "").trim(); + 976| input.value = current + " vault:" + vaultName; + 977| _triggerAdvancedSearch(input.value); + 978| }); + 979| group.appendChild(item); + 980| } + 981| facetsDiv.appendChild(group); + 982| } + 983| + 984| // Tag facets + 985| const tagFacets = data.facets.tags || {}; + 986| if (Object.keys(tagFacets).length > 0) { + 987| const group = el("div", { class: "search-facets__group" }); + 988| const label = el("span", { class: "search-facets__label" }); + 989| label.textContent = "Tags"; + 990| group.appendChild(label); + 991| const entries = Object.entries(tagFacets).slice(0, 12); + 992| for (const [tagName, count] of entries) { + 993| const item = el("span", { class: "search-facets__item" }); + 994| item.innerHTML = `#${tagName} ${count}`; + 995| item.addEventListener("click", () => { + 996| addTagFilter(tagName); + 997| }); + 998| group.appendChild(item); + 999| } + 1000| facetsDiv.appendChild(group); + 1001| } + 1002| + 1003| area.appendChild(facetsDiv); + 1004| } + 1005| + 1006| // Empty state + 1007| if (data.results.length === 0) { + 1008| area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); + 1009| return; + 1010| } + 1011| + 1012| // Results list + 1013| const container = el("div", { class: "search-results" }); + 1014| data.results.forEach((r) => { + 1015| // Apply client-side filtering for hidden files + 1016| if (!shouldDisplayPath(r.path, r.vault)) { + 1017| return; // Skip this result + 1018| } + 1019| + 1020| const titleDiv = el("div", { class: "search-result-title" }); + 1021| if (freeText) { + 1022| highlightSearchText(titleDiv, r.title, freeText, state.searchCaseSensitive); + 1023| } else { + 1024| titleDiv.textContent = r.title; + 1025| } + 1026| + 1027| // Snippet — use HTML from backend (already has tags) + 1028| const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" }); + 1029| if (r.snippet && r.snippet.includes("")) { + 1030| snippetDiv.innerHTML = r.snippet; + 1031| } else if (freeText && r.snippet) { + 1032| highlightSearchText(snippetDiv, r.snippet, freeText, state.searchCaseSensitive); + 1033| } else { + 1034| snippetDiv.textContent = r.snippet || ""; + 1035| } + 1036| + 1037| // Score badge + 1038| const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" }); + 1039| scoreEl.textContent = `score: ${r.score}`; + 1040| + 1041| const vaultPath = el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path), scoreEl]); + 1042| + 1043| const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ + 1044| el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), + 1045| titleDiv, vaultPath, snippetDiv + 1046| ]); + 1047| + 1048| if (r.tags && r.tags.length > 0) { + 1049| const tagsDiv = el("div", { class: "search-result-tags" }); + 1050| r.tags.forEach((tag) => { + 1051| if (!TagFilterService.isTagFiltered(tag)) { + 1052| const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + 1053| tagEl.addEventListener("click", (e) => { + 1054| e.stopPropagation(); + 1055| addTagFilter(tag); + 1056| }); + 1057| tagsDiv.appendChild(tagEl); + 1058| } + 1059| }); + 1060| if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); + 1061| } + 1062| + 1063| item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + 1064| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + 1065| container.appendChild(item); + 1066| }); + 1067| area.appendChild(container); + 1068| + 1069| // Pagination + 1070| if (data.total > state.ADVANCED_SEARCH_LIMIT) { + 1071| const paginationDiv = el("div", { class: "search-pagination" }); + 1072| const prevBtn = el("button", { class: "search-pagination__btn", type: "button" }); + 1073| prevBtn.textContent = "← Précédent"; + 1074| prevBtn.disabled = state.advancedSearchOffset === 0; + 1075| prevBtn.addEventListener("click", () => { + 1076| state.advancedSearchOffset = Math.max(0, state.advancedSearchOffset - state.ADVANCED_SEARCH_LIMIT); + 1077| const vault = document.getElementById("vault-filter").value; + 1078| performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset); + 1079| document.getElementById("content-area").scrollTop = 0; + 1080| }); + 1081| + 1082| const info = el("span", { class: "search-pagination__info" }); + 1083| const from = state.advancedSearchOffset + 1; + 1084| const to = Math.min(state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT, data.total); + 1085| info.textContent = `${from}–${to} sur ${data.total}`; + 1086| + 1087| const nextBtn = el("button", { class: "search-pagination__btn", type: "button" }); + 1088| nextBtn.textContent = "Suivant →"; + 1089| nextBtn.disabled = state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT >= data.total; + 1090| nextBtn.addEventListener("click", () => { + 1091| state.advancedSearchOffset += state.ADVANCED_SEARCH_LIMIT; + 1092| const vault = document.getElementById("vault-filter").value; + 1093| performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset); + 1094| document.getElementById("content-area").scrollTop = 0; + 1095| }); + 1096| + 1097| paginationDiv.appendChild(prevBtn); + 1098| paginationDiv.appendChild(info); + 1099| paginationDiv.appendChild(nextBtn); + 1100| area.appendChild(paginationDiv); + 1101| } + 1102| + 1103| safeCreateIcons(); + 1104| // Initialize result navigation (select first result) + 1105| setTimeout(() => { if (window.navigateSearchResults) window.navigateSearchResults(0); }, 50); + 1106|} + 1107| \ No newline at end of file diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index f2c0c2f..840f1db 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -1,1091 +1,1092 @@ -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 }; +import { state } from './state.js'; + 2| + 3|// --------------------------------------------------------------------------- + 4|// Vault context switching + 5|// --------------------------------------------------------------------------- + 6|function initVaultContext() { + 7| const filter = document.getElementById("vault-filter"); + 8| const quickSelect = document.getElementById("vault-quick-select"); + 9| if (!filter || !quickSelect) return; + 10| + 11| filter.addEventListener("change", async () => { + 12| await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" }); + 13| }); + 14| + 15| quickSelect.addEventListener("change", async () => { + 16| await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" }); + 17| }); + 18|} + 19| + 20|async function setSelectedVaultContext(vaultName, options) { + 21| state.selectedContextVault = vaultName; + 22| state.showingSource = false; + 23| state.cachedRawSource = null; + 24| syncVaultSelectors(); + 25| await refreshSidebarForContext(); + 26| await refreshTagsForContext(); + 27| + 28| // Synchroniser le dashboard et les fichiers récents + 29| if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) { + 30| DashboardRecentWidget.load(vaultName); + 31| } + 32| if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { + 33| DashboardBookmarkWidget.load(vaultName); + 34| } + 35| if (state.activeSidebarTab === "recent") { + 36| loadRecentFiles(vaultName === "all" ? null : vaultName); + 37| } + 38| + 39| showWelcome(); + 40| if (options && options.focusVault && vaultName !== "all") { + 41| await focusVaultInSidebar(vaultName); + 42| } + 43|} + 44| + 45|function syncVaultSelectors() { + 46| const filter = document.getElementById("vault-filter"); + 47| const quickSelect = document.getElementById("vault-quick-select"); + 48| const recentFilter = document.getElementById("recent-vault-filter"); + 49| const dashboardFilter = document.getElementById("dashboard-vault-filter"); + 50| const contextText = document.getElementById("vault-context-text"); + 51| + 52| if (filter) filter.value = state.selectedContextVault; + 53| if (quickSelect) quickSelect.value = state.selectedContextVault; + 54| if (recentFilter) recentFilter.value = state.selectedContextVault === "all" ? "" : state.selectedContextVault; + 55| if (dashboardFilter) dashboardFilter.value = state.selectedContextVault; + 56| + 57| // Mise à jour visuelle des dropdowns personnalisés + 58| updateCustomDropdownVisual("vault-filter-dropdown", state.selectedContextVault); + 59| updateCustomDropdownVisual("vault-quick-select-dropdown", state.selectedContextVault); + 60| + 61| // Update vault context indicator + 62| if (contextText) { + 63| contextText.textContent = state.selectedContextVault === "all" ? "All" : state.selectedContextVault; + 64| } + 65|} + 66| + 67|/** + 68| * Updates the visual state of a custom dropdown based on its current value. + 69| */ + 70|function updateCustomDropdownVisual(dropdownId, value) { + 71| const dropdown = document.getElementById(dropdownId); + 72| if (!dropdown) return; + 73| + 74| const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + 75| const options = dropdown.querySelectorAll(".custom-dropdown-option"); + 76| + 77| options.forEach((opt) => { + 78| const optValue = opt.getAttribute("data-value"); + 79| if (optValue === value) { + 80| opt.classList.add("selected"); + 81| if (selectedText) selectedText.textContent = opt.textContent; + 82| } else { + 83| opt.classList.remove("selected"); + 84| } + 85| }); + 86|} + 87| + 88|function scrollTreeItemIntoView(element, alignToTop) { + 89| if (!element) return; + 90| const scrollContainer = document.getElementById("sidebar-panel-vaults"); + 91| if (!scrollContainer) return; + 92| + 93| const containerRect = scrollContainer.getBoundingClientRect(); + 94| const elementRect = element.getBoundingClientRect(); + 95| const isAbove = elementRect.top < containerRect.top; + 96| const isBelow = elementRect.bottom > containerRect.bottom; + 97| + 98| if (!isAbove && !isBelow && !alignToTop) return; + 99| + 100| const currentTop = scrollContainer.scrollTop; + 101| const offsetTop = element.offsetTop; + 102| const shouldCenter = alignToTop === "center"; + 103| const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2)); + 104| const targetTop = shouldCenter + 105| ? centeredTop + 106| : alignToTop + 107| ? Math.max(0, offsetTop - 60) + 108| : Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35); + 109| + 110| scrollContainer.scrollTo({ + 111| top: targetTop, + 112| behavior: "smooth", + 113| }); + 114|} + 115| + 116|async function refreshSidebarForContext() { + 117| const container = document.getElementById("vault-tree"); + 118| container.innerHTML = ""; + 119| + 120| const vaultsToShow = state.selectedContextVault === "all" ? state.allVaults : state.allVaults.filter((v) => v.name === state.selectedContextVault); + 121| + 122| vaultsToShow.forEach((v) => { + 123| 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)]); + 124| vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); + 125| + 126| vaultItem.addEventListener("contextmenu", (e) => { + 127| e.preventDefault(); + 128| const isReadonly = false; + 129| ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); + 130| }); + 131| attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + 132| attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + 133| + 134| container.appendChild(vaultItem); + 135| + 136| const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); + 137| container.appendChild(childContainer); + 138| }); + 139| + 140| safeCreateIcons(); + 141|} + 142| + 143|async function focusVaultInSidebar(vaultName) { + 144| switchSidebarTab("vaults"); + 145| const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); + 146| if (!vaultItem) return; + 147| document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); + 148| vaultItem.classList.add("focused"); + 149| const childContainer = document.getElementById(`vault-children-${vaultName}`); + 150| if (childContainer && childContainer.classList.contains("collapsed")) { + 151| await toggleVault(vaultItem, vaultName, true); + 152| } + 153| scrollTreeItemIntoView(vaultItem, false); + 154|} + 155| + 156|async function refreshTagsForContext() { + 157| const vaultParam = state.selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(state.selectedContextVault)}`; + 158| const data = await api(`/api/tags${vaultParam}`); + 159| const filteredTags = TagFilterService.filterTags(data.tags); + 160| renderTagCloud(filteredTags); + 161|} + 162| + 163|// --------------------------------------------------------------------------- + 164|// Helper: Check if path should be displayed based on hideHiddenFiles setting + 165|// --------------------------------------------------------------------------- + 166|function shouldDisplayPath(path, vaultName) { + 167| // Get hideHiddenFiles setting for this vault (default: false = show all) + 168| const settings = state.vaultSettings[vaultName] || { hideHiddenFiles: false }; + 169| + 170| if (!settings.hideHiddenFiles) { + 171| // Show all files + 172| return true; + 173| } + 174| + 175| // Check if any segment of the path starts with a dot (hidden) + 176| const segments = path.split("/").filter(Boolean); + 177| for (const segment of segments) { + 178| if (segment.startsWith(".")) { + 179| return false; // Hide this path + 180| } + 181| } + 182| + 183| return true; // Show this path + 184|} + 185| + 186|async function loadVaultSettings() { + 187| try { + 188| const settings = await api("/api/vaults/settings/all"); + 189| state.vaultSettings = settings; + 190| } catch (err) { + 191| console.error("Failed to load vault settings:", err); + 192| state.vaultSettings = {}; + 193| } + 194|} + 195| + 196|// --------------------------------------------------------------------------- + 197|// Sidebar — Vault tree + 198|// --------------------------------------------------------------------------- + 199|async function loadVaults() { + 200| const vaults = await api("/api/vaults"); + 201| state.allVaults = vaults; + 202| const container = document.getElementById("vault-tree"); + 203| container.innerHTML = ""; + 204| + 205| // Prepare dropdown options + 206| const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))]; + 207| + 208| // Populate custom dropdowns + 209| populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all"); + 210| populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all"); + 211| + 212| // Populate standard selects + 213| _populateRecentVaultFilter(); + 214| if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) { + 215| DashboardRecentWidget.populateVaultFilter(); + 216| } + 217| + 218| vaults.forEach((v) => { + 219| // Sidebar tree entry + 220| 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)]); + 221| vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); + 222| + 223| vaultItem.addEventListener("contextmenu", (e) => { + 224| e.preventDefault(); + 225| const isReadonly = false; + 226| ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); + 227| }); + 228| attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + 229| attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + 230| + 231| container.appendChild(vaultItem); + 232| + 233| const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); + 234| container.appendChild(childContainer); + 235| }); + 236| + 237| syncVaultSelectors(); + 238| safeCreateIcons(); + 239|} + 240| + 241|/** + 242| * Refreshes the sidebar tree while preserving the expanded state of vaults and folders. + 243| * Optimized to avoid a full sidebar wipe and minimize visible loading states. + 244| */ + 245|/** + 246| * Incrementally update a directory container without wiping existing DOM. + 247| * Only adds new items, removes deleted ones, and updates changed ones. + 248| */ + 249|async function incrementalLoadDirectory(vaultName, dirPath, container) { + 250| let data; + 251| try { + 252| const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + 253| data = await api(url); + 254| } catch (err) { + 255| // Server unavailable — keep existing content + 256| return; + 257| } + 258| + 259| // Build a map of existing DOM elements by path + 260| const existingItems = {}; + 261| const existingChildren = {}; // path -> child container (for directories) + 262| for (let i = 0; i < container.children.length; i++) { + 263| const child = container.children[i]; + 264| if (child.classList.contains("tree-item") && child.dataset.path) { + 265| existingItems[child.dataset.path] = child; + 266| // The next sibling should be the tree-children container for this directory + 267| if (i + 1 < container.children.length) { + 268| const next = container.children[i + 1]; + 269| if (next.classList.contains("tree-children")) { + 270| existingChildren[child.dataset.path] = next; + 271| } + 272| } + 273| } + 274| } + 275| + 276| const fragment = document.createDocumentFragment(); + 277| + 278| data.items.forEach((item) => { + 279| if (!shouldDisplayPath(item.path, vaultName)) return; + 280| + 281| const existing = existingItems[item.path]; + 282| + 283| if (existing) { + 284| // Item already exists — reuse it, but update text/badge if needed + 285| const textEl = existing.querySelector(".tree-item-text"); + 286| const displayName = item.type === "file" && item.name.match(/\.md$/i) + 287| ? item.name.replace(/\.md$/i, "") + 288| : item.name; + 289| if (textEl && textEl.textContent !== displayName) { + 290| textEl.textContent = displayName; + 291| } + 292| // Update badge for directories + 293| if (item.type === "directory") { + 294| const badge = existing.querySelector(".badge-small"); + 295| const newBadge = `(${item.children_count})`; + 296| if (badge && badge.textContent !== newBadge) { + 297| badge.textContent = newBadge; + 298| } else if (!badge) { + 299| existing.appendChild(smallBadge(item.children_count)); + 300| } + 301| } + 302| fragment.appendChild(existing); + 303| // Also re-add the child container for directories + 304| if (item.type === "directory" && existingChildren[item.path]) { + 305| fragment.appendChild(existingChildren[item.path]); + 306| } else if (item.type === "directory") { + 307| // Directory existed but no child container — create one + 308| const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + 309| fragment.appendChild(subContainer); + 310| } + 311| } else { + 312| // New item — create it + 313| if (item.type === "directory") { + 314| 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)]); + 315| attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + 316| attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + 317| fragment.appendChild(dirItem); + 318| + 319| const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + 320| fragment.appendChild(subContainer); + 321| + 322| dirItem.addEventListener("click", async () => { + 323| scrollTreeItemIntoView(dirItem, false); + 324| if (subContainer.classList.contains("collapsed")) { + 325| if (subContainer.children.length === 0) { + 326| await loadDirectory(vaultName, item.path, subContainer); + 327| } + 328| subContainer.classList.remove("collapsed"); + 329| const chev = dirItem.querySelector("[data-lucide]"); + 330| if (chev) chev.setAttribute("data-lucide", "chevron-down"); + 331| safeCreateIcons(); + 332| } else { + 333| subContainer.classList.add("collapsed"); + 334| const chev = dirItem.querySelector("[data-lucide]"); + 335| if (chev) chev.setAttribute("data-lucide", "chevron-right"); + 336| safeCreateIcons(); + 337| } + 338| }); + 339| + 340| dirItem.addEventListener("contextmenu", (e) => { + 341| e.preventDefault(); + 342| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); + 343| }); + 344| } else { + 345| const fileIconName = getFileIcon(item.name); + 346| const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + 347| 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)])]); + 348| attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + 349| attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + 350| fileItem.addEventListener("click", () => { + 351| scrollTreeItemIntoView(fileItem, false); + 352| TabManager.openPreview(vaultName, item.path); + 353| closeMobileSidebar(); + 354| }); + 355| + 356| fileItem.addEventListener("dblclick", (e) => { + 357| e.preventDefault(); + 358| TabManager.openPersistent(vaultName, item.path); + 359| }); + 360| + 361| fileItem.addEventListener("contextmenu", (e) => { + 362| e.preventDefault(); + 363| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false); + 364| }); + 365| + 366| fragment.appendChild(fileItem); + 367| } + 368| } + 369| }); + 370| + 371| // Replace container content in a single batch operation to avoid flash + 372| container.textContent = ""; + 373| container.appendChild(fragment); + 374|} + 375| + 376|async function refreshSidebarTreePreservingState() { + 377| // 1. Capture expanded states + 378| const expandedVaults = Array.from(document.querySelectorAll(".vault-item")) + 379| .filter((v) => { + 380| const children = document.getElementById(`vault-children-${v.dataset.vault}`); + 381| return children && !children.classList.contains("collapsed"); + 382| }) + 383| .map((v) => v.dataset.vault); + 384| + 385| const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]")) + 386| .filter((item) => { + 387| const vault = item.dataset.vault; + 388| const path = item.dataset.path; + 389| const children = document.getElementById(`dir-${vault}-${path}`); + 390| return children && !children.classList.contains("collapsed"); + 391| }) + 392| .map((item) => ({ vault: item.dataset.vault, path: item.dataset.path })); + 393| + 394| const selectedItem = document.querySelector(".tree-item.path-selected"); + 395| const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null; + 396| + 397| // 2. Soft update: vault names/counts without wiping the tree + 398| try { + 399| const vaults = await api("/api/vaults"); + 400| state.allVaults = vaults; + 401| vaults.forEach((v) => { + 402| const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`); + 403| if (vItem) { + 404| const badge = vItem.querySelector(".badge-small"); + 405| if (badge) badge.textContent = `(${v.file_count})`; + 406| } + 407| }); + 408| } catch (e) { + 409| console.warn("Soft vault refresh failed, falling back to full reload", e); + 410| await loadVaults(); + 411| return; + 412| } + 413| + 414| // 3. Incrementally update expanded vaults (no DOM wipe) + 415| for (const vName of expandedVaults) { + 416| const container = document.getElementById(`vault-children-${vName}`); + 417| if (container) { + 418| await incrementalLoadDirectory(vName, "", container); + 419| } + 420| } + 421| + 422| // 4. Incrementally update expanded directories (parents first, no DOM wipe) + 423| expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length); + 424| for (const dir of expandedDirs) { + 425| const container = document.getElementById(`dir-${dir.vault}-${dir.path}`); + 426| if (container) { + 427| try { + 428| await incrementalLoadDirectory(dir.vault, dir.path, container); + 429| container.classList.remove("collapsed"); + 430| const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`); + 431| if (dItem) { + 432| const chev = dItem.querySelector("[data-lucide]"); + 433| if (chev) chev.setAttribute("data-lucide", "chevron-down"); + 434| } + 435| } catch (e) { + 436| console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e); + 437| } + 438| } + 439| } + 440| + 441| // 5. Restore selection + 442| if (selectedState) { + 443| await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false }); + 444| } + 445| + 446| safeCreateIcons(); + 447|} + 448| + 449|async function toggleVault(itemEl, vaultName, forceExpand) { + 450| const childContainer = document.getElementById(`vault-children-${vaultName}`); + 451| if (!childContainer) return; + 452| + 453| scrollTreeItemIntoView(itemEl, false); + 454| + 455| const shouldExpand = forceExpand || childContainer.classList.contains("collapsed"); + 456| + 457| if (shouldExpand) { + 458| // Expand — load children if empty + 459| if (childContainer.children.length === 0) { + 460| await loadDirectory(vaultName, "", childContainer); + 461| } + 462| childContainer.classList.remove("collapsed"); + 463| // Swap chevron + 464| const chevron = itemEl.querySelector("[data-lucide]"); + 465| if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); + 466| safeCreateIcons(); + 467| } else { + 468| childContainer.classList.add("collapsed"); + 469| const chevron = itemEl.querySelector("[data-lucide]"); + 470| if (chevron) chevron.setAttribute("data-lucide", "chevron-right"); + 471| safeCreateIcons(); + 472| } + 473|} + 474| + 475|async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) { + 476| const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`); + 477| if (!subContainer) return null; + 478| + 479| if (subContainer.children.length === 0) { + 480| await loadDirectory(vaultName, dirPath, subContainer); + 481| } + 482| + 483| subContainer.classList.remove("collapsed"); + 484| if (dirItem) { + 485| const chevron = dirItem.querySelector("[data-lucide]"); + 486| if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); + 487| } + 488| safeCreateIcons(); + 489| return subContainer; + 490|} + 491| + 492|async function focusPathInSidebar(vaultName, targetPath, options) { + 493| switchSidebarTab("vaults"); + 494| + 495| const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); + 496| if (!vaultItem) return; + 497| + 498| document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); + 499| vaultItem.classList.add("focused"); + 500| + 501| const vaultContainer = document.getElementById(`vault-children-${vaultName}`); + 502| if (!vaultContainer) return; + 503| + 504| if (vaultContainer.classList.contains("collapsed")) { + 505| await toggleVault(vaultItem, vaultName, true); + 506| } + 507| + 508| if (!targetPath) { + 509| // Clear any previous path selection + 510| document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); + 511| scrollTreeItemIntoView(vaultItem, options && options.alignToTop); + 512| return; + 513| } + 514| + 515| const segments = targetPath.split("/").filter(Boolean); + 516| let currentContainer = vaultContainer; + 517| let cumulativePath = ""; + 518| let lastTargetItem = null; + 519| + 520| for (let index = 0; index < segments.length; index++) { + 521| cumulativePath += (cumulativePath ? "/" : "") + segments[index]; + 522| + 523| let targetItem = null; + 524| try { + 525| targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`); + 526| } catch (e) { + 527| targetItem = null; + 528| } + 529| + 530| if (!targetItem) { + 531| return; + 532| } + 533| + 534| lastTargetItem = targetItem; + 535| + 536| const isLastSegment = index === segments.length - 1; + 537| if (!isLastSegment) { + 538| const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem); + 539| if (nextContainer) { + 540| currentContainer = nextContainer; + 541| } + 542| } + 543| } + 544| + 545| if (lastTargetItem && options && options.expandTarget) { + 546| await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem); + 547| } + 548| + 549| // Clear previous path selections and highlight the final target + 550| document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); + 551| if (lastTargetItem) { + 552| lastTargetItem.classList.add("path-selected"); + 553| } + 554| + 555| scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop); + 556|} + 557| + 558|function getParentDirectoryPath(filePath) { + 559| if (!filePath) return ""; + 560| const segments = filePath.split("/").filter(Boolean); + 561| if (segments.length <= 1) return ""; + 562| segments.pop(); + 563| return segments.join("/"); + 564|} + 565| + 566|function syncActiveFileTreeItem(vaultName, filePath) { + 567| document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + 568| if (!vaultName || !filePath) return; + 569| const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`; + 570| try { + 571| const active = document.querySelector(selector); + 572| if (active) active.classList.add("active"); + 573| } catch (e) { + 574| /* selector might fail on special chars */ + 575| } + 576|} + 577| + 578|async function loadDirectory(vaultName, dirPath, container) { + 579| // Only show the loading spinner if the container is currently empty + 580| const isEmpty = container.children.length === 0; + 581| if (isEmpty) { + 582| container.innerHTML = '
    '; + 583| } + 584| + 585| var data; + 586| try { + 587| const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + 588| data = await api(url); + 589| } catch (err) { + 590| container.innerHTML = '
    Erreur de chargement
    '; + 591| return; + 592| } + 593| container.innerHTML = ""; + 594| + 595| const fragment = document.createDocumentFragment(); + 596| + 597| data.items.forEach((item) => { + 598| // Apply client-side filtering for hidden files + 599| if (!shouldDisplayPath(item.path, vaultName)) { + 600| return; // Skip this item + 601| } + 602| + 603| if (item.type === "directory") { + 604| 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)]); + 605| attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + 606| attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + 607| fragment.appendChild(dirItem); + 608| + 609| const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + 610| fragment.appendChild(subContainer); + 611| + 612| dirItem.addEventListener("click", async () => { + 613| scrollTreeItemIntoView(dirItem, false); + 614| if (subContainer.classList.contains("collapsed")) { + 615| if (subContainer.children.length === 0) { + 616| await loadDirectory(vaultName, item.path, subContainer); + 617| } + 618| subContainer.classList.remove("collapsed"); + 619| const chev = dirItem.querySelector("[data-lucide]"); + 620| if (chev) chev.setAttribute("data-lucide", "chevron-down"); + 621| safeCreateIcons(); + 622| } else { + 623| subContainer.classList.add("collapsed"); + 624| const chev = dirItem.querySelector("[data-lucide]"); + 625| if (chev) chev.setAttribute("data-lucide", "chevron-right"); + 626| safeCreateIcons(); + 627| } + 628| }); + 629| + 630| dirItem.addEventListener("contextmenu", (e) => { + 631| e.preventDefault(); + 632| const isReadonly = false; + 633| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); + 634| }); + 635| } else { + 636| const fileIconName = getFileIcon(item.name); + 637| const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + 638| 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)])]); + 639| attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + 640| attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + 641| fileItem.addEventListener("click", () => { + 642| scrollTreeItemIntoView(fileItem, false); + 643| TabManager.openPreview(vaultName, item.path); + 644| closeMobileSidebar(); + 645| }); + 646| + 647| fileItem.addEventListener("dblclick", (e) => { + 648| e.preventDefault(); + 649| TabManager.openPersistent(vaultName, item.path); + 650| }); + 651| + 652| fileItem.addEventListener("contextmenu", (e) => { + 653| e.preventDefault(); + 654| const isReadonly = false; + 655| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); + 656| }); + 657| + 658| fragment.appendChild(fileItem); + 659| } + 660| }); + 661| + 662| container.appendChild(fragment); + 663| safeCreateIcons(); + 664|} + 665| + 666|// --------------------------------------------------------------------------- + 667|// Sidebar filter + 668|// --------------------------------------------------------------------------- + 669|function initSidebarFilter() { + 670| const input = document.getElementById("sidebar-filter-input"); + 671| const caseBtn = document.getElementById("sidebar-filter-case-btn"); + 672| const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + 673| + 674| input.addEventListener("input", () => { + 675| const hasText = input.value.length > 0; + 676| clearBtn.style.display = hasText ? "flex" : "none"; + 677| clearTimeout(state.filterDebounce); + 678| state.filterDebounce = setTimeout(async () => { + 679| const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + 680| if (hasText) { + 681| if (state.activeSidebarTab === "vaults") { + 682| await performTreeSearch(q); + 683| } else { + 684| filterTagCloud(q); + 685| } + 686| } else { + 687| if (state.activeSidebarTab === "vaults") { + 688| await restoreSidebarTree(); + 689| } else { + 690| filterTagCloud(""); + 691| } + 692| } + 693| }, 220); + 694| }); + 695| + 696| caseBtn.addEventListener("click", async () => { + 697| state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive; + 698| caseBtn.classList.toggle("active"); + 699| const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + 700| if (input.value.trim()) { + 701| if (state.activeSidebarTab === "vaults") { + 702| await performTreeSearch(q); + 703| } else { + 704| filterTagCloud(q); + 705| } + 706| } + 707| }); + 708| + 709| clearBtn.addEventListener("click", async () => { + 710| input.value = ""; + 711| clearBtn.style.display = "none"; + 712| state.sidebarFilterCaseSensitive = false; + 713| caseBtn.classList.remove("active"); + 714| clearTimeout(state.filterDebounce); + 715| if (state.activeSidebarTab === "vaults") { + 716| await restoreSidebarTree(); + 717| } else { + 718| filterTagCloud(""); + 719| } + 720| }); + 721| + 722| clearBtn.style.display = "none"; + 723|} + 724| + 725|async function performTreeSearch(query) { + 726| if (!query) { + 727| await restoreSidebarTree(); + 728| return; + 729| } + 730| + 731| try { + 732| const vaultParam = state.selectedContextVault === "all" ? "all" : state.selectedContextVault; + 733| const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`; + 734| const data = await api(url); + 735| renderFilteredSidebarResults(query, data.results); + 736| } catch (err) { + 737| console.error("Tree search error:", err); + 738| renderFilteredSidebarResults(query, []); + 739| } + 740|} + 741| + 742|async function restoreSidebarTree() { + 743| await refreshSidebarForContext(); + 744| if (state.currentVault) { + 745| focusPathInSidebar(state.currentVault, currentPath || "", { alignToTop: false }).catch(() => {}); + 746| } + 747|} + 748| + 749|function renderFilteredSidebarResults(query, results) { + 750| const container = document.getElementById("vault-tree"); + 751| container.innerHTML = ""; + 752| + 753| const grouped = new Map(); + 754| results.forEach((result) => { + 755| if (!grouped.has(result.vault)) { + 756| grouped.set(result.vault, []); + 757| } + 758| grouped.get(result.vault).push(result); + 759| }); + 760| + 761| if (grouped.size === 0) { + 762| container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")])); + 763| return; + 764| } + 765| + 766| grouped.forEach((entries, vaultName) => { + 767| entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" })); + 768| + 769| const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]); + 770| container.appendChild(vaultHeader); + 771| + 772| const resultsWrapper = el("div", { class: "filter-results-group" }); + 773| entries.forEach((entry) => { + 774| const resultItem = el( + 775| "div", + 776| { + 777| class: `tree-item filter-result-item filter-result-${entry.type}`, + 778| "data-vault": entry.vault, + 779| "data-path": entry.path, + 780| "data-type": entry.type, + 781| }, + 782| [icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)], + 783| ); + 784| + 785| const textWrap = el("div", { class: "filter-result-text" }); + 786| const primary = el("div", { class: "filter-result-primary" }); + 787| appendHighlightedText(primary, entry.name, query, state.sidebarFilterCaseSensitive); + 788| const secondary = el("div", { class: "filter-result-secondary" }); + 789| appendHighlightedText(secondary, entry.path, query, state.sidebarFilterCaseSensitive); + 790| textWrap.appendChild(primary); + 791| textWrap.appendChild(secondary); + 792| resultItem.appendChild(textWrap); + 793| + 794| resultItem.addEventListener("click", async () => { + 795| const input = document.getElementById("sidebar-filter-input"); + 796| const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + 797| if (input) input.value = ""; + 798| if (clearBtn) clearBtn.style.display = "none"; + 799| await restoreSidebarTree(); + 800| if (entry.type === "directory") { + 801| await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true }); + 802| } else { + 803| await TabManager.openPreview(entry.vault, entry.path); + 804| await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true }); + 805| syncActiveFileTreeItem(entry.vault, entry.path); + 806| } + 807| closeMobileSidebar(); + 808| }); + 809| + 810| resultsWrapper.appendChild(resultItem); + 811| }); + 812| + 813| container.appendChild(resultsWrapper); + 814| }); + 815| + 816| flushIcons(); + 817|} + 818| + 819|function filterSidebarTree(query) { + 820| const tree = document.getElementById("vault-tree"); + 821| const items = tree.querySelectorAll(".tree-item"); + 822| const containers = tree.querySelectorAll(".tree-children"); + 823| + 824| if (!query) { + 825| items.forEach((item) => item.classList.remove("filtered-out")); + 826| containers.forEach((c) => { + 827| c.classList.remove("filtered-out"); + 828| // Keep current collapsed state when clearing filter + 829| }); + 830| return; + 831| } + 832| + 833| // First pass: mark all as filtered out + 834| items.forEach((item) => item.classList.add("filtered-out")); + 835| containers.forEach((c) => c.classList.add("filtered-out")); + 836| + 837| // Second pass: find matching items and mark them + ancestors + descendants + 838| const matchingItems = new Set(); + 839| + 840| items.forEach((item) => { + 841| const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase(); + 842| const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); + 843| if (text.includes(searchQuery)) { + 844| matchingItems.add(item); + 845| item.classList.remove("filtered-out"); + 846| + 847| // Show all ancestor containers + 848| let parent = item.parentElement; + 849| while (parent && parent !== tree) { + 850| parent.classList.remove("filtered-out"); + 851| if (parent.classList.contains("tree-children")) { + 852| parent.classList.remove("collapsed"); + 853| } + 854| parent = parent.parentElement; + 855| } + 856| + 857| // If this is a directory (has a children container after it), show all descendants + 858| const nextEl = item.nextElementSibling; + 859| if (nextEl && nextEl.classList.contains("tree-children")) { + 860| nextEl.classList.remove("filtered-out"); + 861| nextEl.classList.remove("collapsed"); + 862| // Recursively show all children in this container + 863| showAllDescendants(nextEl); + 864| } + 865| } + 866| }); + 867| + 868| // Third pass: show items that are descendants of matching directories + 869| // and ensure their containers are visible + 870| matchingItems.forEach((item) => { + 871| const nextEl = item.nextElementSibling; + 872| if (nextEl && nextEl.classList.contains("tree-children")) { + 873| const children = nextEl.querySelectorAll(".tree-item"); + 874| children.forEach((child) => child.classList.remove("filtered-out")); + 875| } + 876| }); + 877|} + 878| + 879|function showAllDescendants(container) { + 880| const items = container.querySelectorAll(".tree-item"); + 881| items.forEach((item) => { + 882| item.classList.remove("filtered-out"); + 883| // If this item has children, also show them + 884| const nextEl = item.nextElementSibling; + 885| if (nextEl && nextEl.classList.contains("tree-children")) { + 886| nextEl.classList.remove("filtered-out"); + 887| nextEl.classList.remove("collapsed"); + 888| } + 889| }); + 890| // Also ensure all nested containers are visible + 891| const nestedContainers = container.querySelectorAll(".tree-children"); + 892| nestedContainers.forEach((c) => { + 893| c.classList.remove("filtered-out"); + 894| c.classList.remove("collapsed"); + 895| }); + 896|} + 897| + 898|function filterTagCloud(query) { + 899| const tags = document.querySelectorAll("#tag-cloud .tag-item"); + 900| tags.forEach((tag) => { + 901| const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase(); + 902| const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); + 903| if (!query || text.includes(searchQuery)) { + 904| tag.classList.remove("filtered-out"); + 905| } else { + 906| tag.classList.add("filtered-out"); + 907| } + 908| }); + 909|} + 910| + 911|// --------------------------------------------------------------------------- + 912|// Tag Filter Service + 913|// --------------------------------------------------------------------------- + 914|const TagFilterService = { + 915| defaultFilters: [ + 916| { pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true }, + 917| { pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true }, + 918| { pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true }, + 919| ], + 920| + 921| getConfig() { + 922| const stored = localStorage.getItem("obsigate-tag-filters"); + 923| if (stored) { + 924| try { + 925| return JSON.parse(stored); + 926| } catch (e) { + 927| return { tagFilters: this.defaultFilters }; + 928| } + 929| } + 930| return { tagFilters: this.defaultFilters }; + 931| }, + 932| + 933| saveConfig(config) { + 934| localStorage.setItem("obsigate-tag-filters", JSON.stringify(config)); + 935| }, + 936| + 937| patternToRegex(pattern) { + 938| // 1. Escape ALL special regex characters + 939| // We use a broader set including * and . + 940| let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + 941| + 942| // 2. Convert escaped '*' to '.*' (wildcard) + 943| regex = regex.replace(/\\\*/g, ".*"); + 944| + 945| // 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*' + 946| // We also handle optional whitespace around it to make it more user-friendly + 947| regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*"); + 948| + 949| return regex; + 950| }, + 951| + 952| isTagFiltered(tag) { + 953| const config = this.getConfig(); + 954| const filters = config.tagFilters || this.defaultFilters; + 955| const tagWithHash = `#${tag}`; + 956| + 957| for (const filter of filters) { + 958| if (!filter.enabled) continue; + 959| try { + 960| // Robustly handle regex with or without ^/$ + 961| let patternStr = filter.regex; + 962| if (!patternStr.startsWith("^")) patternStr = "^" + patternStr; + 963| if (!patternStr.endsWith("$")) patternStr = patternStr + "$"; + 964| + 965| const regex = new RegExp(patternStr); + 966| if (regex.test(tagWithHash)) { + 967| return true; + 968| } + 969| } catch (e) { + 970| console.warn("Invalid regex:", filter.regex, e); + 971| } + 972| } + 973| return false; + 974| }, + 975| + 976| filterTags(tags) { + 977| const filtered = {}; + 978| Object.entries(tags).forEach(([tag, count]) => { + 979| if (!this.isTagFiltered(tag)) { + 980| filtered[tag] = count; + 981| } + 982| }); + 983| return filtered; + 984| }, + 985|}; + 986| + 987|// --------------------------------------------------------------------------- + 988|// Tags + 989|// --------------------------------------------------------------------------- + 990|async function loadTags() { + 991| const data = await api("/api/tags"); + 992| const filteredTags = TagFilterService.filterTags(data.tags); + 993| renderTagCloud(filteredTags); + 994|} + 995| + 996|function renderTagCloud(tags) { + 997| const cloud = document.getElementById("tag-cloud"); + 998| cloud.innerHTML = ""; + 999| + 1000| const counts = Object.values(tags); + 1001| if (counts.length === 0) return; + 1002| + 1003| const maxCount = Math.max(...counts); + 1004| const minSize = 0.7; + 1005| const maxSize = 1.25; + 1006| + 1007| Object.entries(tags).forEach(([tag, count]) => { + 1008| const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; + 1009| const size = minSize + ratio * (maxSize - minSize); + 1010| const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [document.createTextNode(`#${tag}`)]); + 1011| tagEl.addEventListener("click", () => searchByTag(tag)); + 1012| cloud.appendChild(tagEl); + 1013| }); + 1014|} + 1015| + 1016|function addTagFilter(tag) { + 1017| if (!state.selectedTags.includes(tag)) { + 1018| state.selectedTags.push(tag); + 1019| performTagSearch(); + 1020| } + 1021|} + 1022| + 1023|function removeTagFilter(tag) { + 1024| state.selectedTags = state.selectedTags.filter((t) => t !== tag); + 1025| if (state.selectedTags.length > 0) { + 1026| performTagSearch(); + 1027| } else { + 1028| const input = document.getElementById("search-input"); + 1029| if (input.value.trim()) { + 1030| performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null); + 1031| } else { + 1032| showWelcome(); + 1033| } + 1034| } + 1035|} + 1036| + 1037|function performTagSearch() { + 1038| const input = document.getElementById("search-input"); + 1039| const query = input.value.trim(); + 1040| const vault = document.getElementById("vault-filter").value; + 1041| performAdvancedSearch(query, vault, state.selectedTags.length > 0 ? state.selectedTags.join(",") : null); + 1042|} + 1043| + 1044|function buildSearchResultsHeader(data, query, tagFilter) { + 1045| const header = el("div", { class: "search-results-header" }); + 1046| const summaryText = el("span", { class: "search-results-summary-text" }); + 1047| + 1048| if (query && tagFilter) { + 1049| summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`; + 1050| } else if (query) { + 1051| summaryText.textContent = `${data.count} résultat(s) pour "${query}"`; + 1052| } else if (tagFilter) { + 1053| summaryText.textContent = `${data.count} fichier(s) avec les tags`; + 1054| } else { + 1055| summaryText.textContent = `${data.count} résultat(s)`; + 1056| } + 1057| + 1058| header.appendChild(summaryText); + 1059| + 1060| if (state.selectedTags.length > 0) { + 1061| const activeTags = el("div", { class: "search-results-active-tags" }); + 1062| state.selectedTags.forEach((tag) => { + 1063| const removeBtn = el( + 1064| "button", + 1065| { + 1066| class: "search-results-active-tag-remove", + 1067| title: `Retirer ${tag} du filtre`, + 1068| "aria-label": `Retirer ${tag} du filtre`, + 1069| }, + 1070| [document.createTextNode("×")], + 1071| ); + 1072| removeBtn.addEventListener("click", (e) => { + 1073| e.stopPropagation(); + 1074| removeTagFilter(tag); + 1075| }); + 1076| + 1077| const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + 1078| activeTags.appendChild(chip); + 1079| }); + 1080| header.appendChild(activeTags); + 1081| } + 1082| + 1083| return header; + 1084|} + 1085| + 1086|function searchByTag(tag) { + 1087| addTagFilter(tag); + 1088|} + 1089| + 1090| + 1091|export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext }; + 1092| \ No newline at end of file diff --git a/frontend/js/state.js b/frontend/js/state.js index 1b8cf06..32d3672 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -1,55 +1,59 @@ -/* ObsiGate — Shared application state */ -export const APP_VERSION = "1.5.0"; +/* ObsiGate — Shared mutable application state. + Use state.xxx to read/write any value. ES module imports are read-only, + so we export a single mutable object instead of individual let bindings. */ +export const state = { + APP_VERSION: "1.5.0", -// Core navigation state -export let currentVault = null; -export let currentPath = null; -export let allVaults = []; -export let selectedContextVault = "all"; + // Core navigation + currentVault: null, + currentPath: null, + allVaults: [], + 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 + searchTimeout: null, + searchAbortController: null, + advancedSearchOffset: 0, + advancedSearchTotal: 0, + advancedSearchSort: "relevance", + advancedSearchLastQuery: "", + suggestAbortController: null, + dropdownActiveIndex: -1, + dropdownItems: [], + currentSearchId: 0, + selectedTags: [], + searchCaseSensitive: false, + searchWholeWord: false, + searchRegex: false, + 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; + // Search constants + SEARCH_HISTORY_KEY: "obsigate_search_history", + MAX_HISTORY_ENTRIES: 50, + SUGGEST_DEBOUNCE_MS: 150, + ADVANCED_SEARCH_LIMIT: 50, + MIN_SEARCH_LENGTH: 2, + 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; + // Viewer state + showingSource: false, + cachedRawSource: null, + editorView: null, + editorVault: null, + editorPath: null, + fallbackEditorEl: null, + _iconDebounceTimer: null, -// Outline/TOC state -export let outlineObserver = null; -export let activeHeadingId = null; -export let headingsCache = []; -export let rightSidebarVisible = true; -export let rightSidebarWidth = 280; + // Outline/TOC + outlineObserver: null, + activeHeadingId: null, + headingsCache: [], + rightSidebarVisible: true, + rightSidebarWidth: 280, -// Sidebar state -export let sidebarFilterCaseSensitive = false; -export let activeSidebarTab = "vaults"; -export let filterDebounce = null; -export let vaultSettings = {}; + // Sidebar + sidebarFilterCaseSensitive: false, + activeSidebarTab: "vaults", + filterDebounce: null, + vaultSettings: {}, +}; diff --git a/frontend/js/sync.js b/frontend/js/sync.js index 81159b8..956cf79 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -1,437 +1,438 @@ -/* 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(); -} + 1|/* ObsiGate — Sync: SSE client + PWA registration */ + 2|import { + 3| state.currentVault, + 4| state.currentPath, + 5| state.activeSidebarTab, + 6| selectedContextVault + 7|} from './state.js'; + 8|import { showToast } from './ui.js'; + 9|import { loadVaults, loadTags, refreshTagsForContext } from './sidebar.js'; + 10| + 11|// --------------------------------------------------------------------------- + 12|// SSE Client — IndexUpdateManager + 13|// --------------------------------------------------------------------------- + 14|export const IndexUpdateManager = (() => { + 15| let eventSource = null; + 16| let reconnectTimer = null; + 17| let reconnectDelay = 1000; + 18| const MAX_RECONNECT_DELAY = 30000; + 19| let recentEvents = []; + 20| const MAX_RECENT_EVENTS = 20; + 21| let connectionState = "disconnected"; // disconnected | connecting | connected + 22| + 23| function connect() { + 24| if (eventSource) { + 25| eventSource.close(); + 26| } + 27| connectionState = "connecting"; + 28| _updateBadge(); + 29| + 30| eventSource = new EventSource("/api/events"); + 31| + 32| eventSource.addEventListener("connected", (e) => { + 33| connectionState = "connected"; + 34| reconnectDelay = 1000; + 35| _updateBadge(); + 36| }); + 37| + 38| eventSource.addEventListener("index_updated", (e) => { + 39| try { + 40| const data = JSON.parse(e.data); + 41| _addEvent("index_updated", data); + 42| _onIndexUpdated(data); + 43| } catch (err) { + 44| console.error("SSE parse error:", err); + 45| } + 46| }); + 47| + 48| eventSource.addEventListener("index_reloaded", (e) => { + 49| try { + 50| const data = JSON.parse(e.data); + 51| _addEvent("index_reloaded", data); + 52| _onIndexReloaded(data); + 53| } catch (err) { + 54| console.error("SSE parse error:", err); + 55| } + 56| }); + 57| + 58| eventSource.addEventListener("vault_added", (e) => { + 59| try { + 60| const data = JSON.parse(e.data); + 61| _addEvent("vault_added", data); + 62| showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info"); + 63| loadVaults(); + 64| loadTags(); + 65| } catch (err) { + 66| console.error("SSE parse error:", err); + 67| } + 68| }); + 69| + 70| eventSource.addEventListener("vault_removed", (e) => { + 71| try { + 72| const data = JSON.parse(e.data); + 73| _addEvent("vault_removed", data); + 74| showToast(`Vault "${data.vault}" supprimé`, "info"); + 75| loadVaults(); + 76| loadTags(); + 77| } catch (err) { + 78| console.error("SSE parse error:", err); + 79| } + 80| }); + 81| + 82| eventSource.addEventListener("index_start", (e) => { + 83| try { + 84| const data = JSON.parse(e.data); + 85| _addEvent("index_start", data); + 86| connectionState = "syncing"; + 87| _updateBadge(); + 88| showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info"); + 89| } catch (err) { + 90| console.error("SSE parse error:", err); + 91| } + 92| }); + 93| + 94| eventSource.addEventListener("index_progress", (e) => { + 95| try { + 96| const data = JSON.parse(e.data); + 97| _addEvent("index_progress", data); + 98| connectionState = "syncing"; + 99| _updateBadge(); + 100| loadVaults(); + 101| loadTags(); + 102| } catch (err) { + 103| console.error("SSE parse error:", err); + 104| } + 105| }); + 106| + 107| eventSource.addEventListener("index_complete", (e) => { + 108| try { + 109| const data = JSON.parse(e.data); + 110| _addEvent("index_complete", data); + 111| connectionState = "connected"; + 112| _updateBadge(); + 113| showToast(`Indexation terminée (${data.total_files} fichiers)`, "success"); + 114| loadVaults(); + 115| loadTags(); + 116| } catch (err) { + 117| console.error("SSE parse error:", err); + 118| } + 119| }); + 120| + 121| eventSource.onerror = () => { + 122| connectionState = "disconnected"; + 123| _updateBadge(); + 124| eventSource.close(); + 125| eventSource = null; + 126| _scheduleReconnect(); + 127| }; + 128| } + 129| + 130| function _scheduleReconnect() { + 131| if (reconnectTimer) clearTimeout(reconnectTimer); + 132| reconnectTimer = setTimeout(() => { + 133| reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + 134| connect(); + 135| }, reconnectDelay); + 136| } + 137| + 138| function _addEvent(type, data) { + 139| recentEvents.unshift({ + 140| type, + 141| data, + 142| timestamp: new Date().toISOString(), + 143| }); + 144| if (recentEvents.length > MAX_RECENT_EVENTS) { + 145| recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS); + 146| } + 147| } + 148| + 149| async function _onIndexUpdated(data) { + 150| // Brief syncing state + 151| connectionState = "syncing"; + 152| _updateBadge(); + 153| + 154| const n = data.total_changes || 0; + 155| const vaults = (data.vaults || []).join(", "); + 156| // Toast removed: silent auto-indexing — no notification needed + 157| + 158| // Refresh sidebar and tags if affected vault matches current context + 159| const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault); + 160| if (affectsCurrentVault) { + 161| try { + 162| await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + 163| // Refresh current file if it was updated + 164| if (currentVault && state.currentPath) { + 165| const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath); + 166| if (changed) { + 167| openFile(state.currentVault, state.currentPath); + 168| } + 169| } + 170| } catch (err) { + 171| console.error("Error refreshing after index update:", err); + 172| } + 173| } + 174| + 175| // Refresh recent tab if it is active + 176| if (state.activeSidebarTab === "recent") { + 177| const vaultFilter = document.getElementById("recent-vault-filter"); + 178| loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + 179| } + 180| + 181| setTimeout(() => { + 182| connectionState = "connected"; + 183| _updateBadge(); + 184| }, 1500); + 185| } + 186| + 187| async function _onIndexReloaded(data) { + 188| connectionState = "syncing"; + 189| _updateBadge(); + 190| showToast("Index complet rechargé", "info"); + 191| try { + 192| await Promise.all([loadVaults(), loadTags()]); + 193| } catch (err) { + 194| console.error("Error refreshing after full reload:", err); + 195| } + 196| setTimeout(() => { + 197| connectionState = "connected"; + 198| _updateBadge(); + 199| }, 1500); + 200| } + 201| + 202| function _updateBadge() { + 203| const badge = document.getElementById("sync-badge"); + 204| if (!badge) return; + 205| badge.className = "sync-badge sync-badge--" + connectionState; + 206| const labels = { + 207| disconnected: "Déconnecté", + 208| connecting: "Connexion...", + 209| connected: "Synchronisé", + 210| syncing: "Mise à jour...", + 211| }; + 212| badge.title = labels[connectionState] || connectionState; + 213| } + 214| + 215| function disconnect() { + 216| if (eventSource) { + 217| eventSource.close(); + 218| eventSource = null; + 219| } + 220| if (reconnectTimer) { + 221| clearTimeout(reconnectTimer); + 222| reconnectTimer = null; + 223| } + 224| connectionState = "disconnected"; + 225| _updateBadge(); + 226| } + 227| + 228| function getState() { + 229| return connectionState; + 230| } + 231| + 232| function getRecentEvents() { + 233| return recentEvents; + 234| } + 235| + 236| return { connect, disconnect, getState, getRecentEvents }; + 237|})(); + 238| + 239|// --------------------------------------------------------------------------- + 240|// Sync status badge and panel + 241|// --------------------------------------------------------------------------- + 242| + 243|function initSyncStatus() { + 244| const badge = document.getElementById("sync-badge"); + 245| if (!badge) return; + 246| + 247| badge.addEventListener("click", (e) => { + 248| e.stopPropagation(); + 249| toggleSyncPanel(); + 250| }); + 251| + 252| IndexUpdateManager.connect(); + 253|} + 254| + 255|function toggleSyncPanel() { + 256| let panel = document.getElementById("sync-panel"); + 257| if (panel) { + 258| panel.remove(); + 259| return; + 260| } + 261| // Auto reconnect if disconnected when user opens the panel + 262| if (IndexUpdateManager.getState() === "disconnected") { + 263| IndexUpdateManager.connect(); + 264| } + 265| panel = document.createElement("div"); + 266| panel.id = "sync-panel"; + 267| panel.className = "sync-panel"; + 268| _renderSyncPanel(panel); + 269| document.body.appendChild(panel); + 270| + 271| // Close on outside click + 272| setTimeout(() => { + 273| document.addEventListener("click", _closeSyncPanelOutside, { once: true }); + 274| }, 0); + 275|} + 276| + 277|function _closeSyncPanelOutside(e) { + 278| const panel = document.getElementById("sync-panel"); + 279| if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") { + 280| panel.remove(); + 281| } + 282|} + 283| + 284|function _renderSyncPanel(panel) { + 285| const state = IndexUpdateManager.getState(); + 286| const events = IndexUpdateManager.getRecentEvents(); + 287| + 288| const stateLabels = { + 289| disconnected: "Déconnecté", + 290| connecting: "Connexion...", + 291| connected: "Connecté", + 292| syncing: "Synchronisation...", + 293| }; + 294| + 295| let html = `
    + 296| Synchronisation + 297| ${stateLabels[state] || state} + 298|
    `; + 299| + 300| if (events.length === 0) { + 301| html += `
    Aucun événement récent
    `; + 302| } else { + 303| html += `
    `; + 304| events.slice(0, 10).forEach((ev) => { + 305| const time = new Date(ev.timestamp).toLocaleTimeString(); + 306| const typeLabels = { + 307| index_updated: "Mise à jour", + 308| index_reloaded: "Rechargement", + 309| vault_added: "Vault ajouté", + 310| vault_removed: "Vault supprimé", + 311| index_start: "Démarrage index.", + 312| index_progress: "Vault indexé", + 313| index_complete: "Indexation tech.", + 314| }; + 315| const label = typeLabels[ev.type] || ev.type; + 316| let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || ""; + 317| if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`; + 318| if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`; + 319| if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`; + 320| html += `
    + 321| ${label} + 322| ${detail} + 323| ${time} + 324|
    `; + 325| }); + 326| html += `
    `; + 327| } + 328| + 329| panel.innerHTML = html; + 330|} + 331| + 332|// --------------------------------------------------------------------------- + 333|// PWA Service Worker Registration + 334|// --------------------------------------------------------------------------- + 335|function registerServiceWorker() { + 336| if ("serviceWorker" in navigator) { + 337| window.addEventListener("load", () => { + 338| navigator.serviceWorker + 339| .register("/sw.js") + 340| .then((registration) => { + 341| console.log("[PWA] Service Worker registered successfully:", registration.scope); + 342| + 343| // Check for updates periodically + 344| setInterval(() => { + 345| registration.update(); + 346| }, 60000); // Check every minute + 347| + 348| // Handle service worker updates + 349| registration.addEventListener("updatefound", () => { + 350| const newWorker = registration.installing; + 351| newWorker.addEventListener("statechange", () => { + 352| if (newWorker.state === "installed" && navigator.serviceWorker.controller) { + 353| // New service worker available + 354| showUpdateNotification(); + 355| } + 356| }); + 357| }); + 358| }) + 359| .catch((error) => { + 360| console.log("[PWA] Service Worker registration failed:", error); + 361| }); + 362| }); + 363| } + 364|} + 365| + 366|function showUpdateNotification() { + 367| const message = document.createElement("div"); + 368| message.className = "pwa-update-notification"; + 369| message.innerHTML = ` + 370|
    + 371| Une nouvelle version d'ObsiGate est disponible ! + 372| + 373| + 374|
    + 375| `; + 376| document.body.appendChild(message); + 377| + 378| // Auto-dismiss after 30 seconds + 379| setTimeout(() => { + 380| if (message.parentElement) { + 381| message.remove(); + 382| } + 383| }, 30000); + 384|} + 385| + 386|// Handle install prompt + 387|let deferredPrompt; + 388|window.addEventListener("beforeinstallprompt", (e) => { + 389| e.preventDefault(); + 390| deferredPrompt = e; + 391| + 392| // Show install button if desired + 393| const installBtn = document.getElementById("pwa-install-btn"); + 394| if (installBtn) { + 395| installBtn.style.display = "block"; + 396| installBtn.addEventListener("click", async () => { + 397| if (deferredPrompt) { + 398| deferredPrompt.prompt(); + 399| const { outcome } = await deferredPrompt.userChoice; + 400| console.log(`[PWA] User response to install prompt: ${outcome}`); + 401| deferredPrompt = null; + 402| installBtn.style.display = "none"; + 403| } + 404| }); + 405| } + 406|}); + 407| + 408|// Log when app is installed + 409|window.addEventListener("appinstalled", () => { + 410| console.log("[PWA] ObsiGate has been installed"); + 411| showToast("ObsiGate installé avec succès !", "success"); + 412|}); + 413| + 414|// ── Dashboard tab switching (runs on page load and after rebuild) ── + 415|function initDashboardTabs() { + 416| document.querySelectorAll(".dashboard-tab").forEach(tab => { + 417| // Remove existing listeners by cloning + 418| const newTab = tab.cloneNode(true); + 419| tab.parentNode.replaceChild(newTab, tab); + 420| newTab.addEventListener("click", function() { + 421| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + 422| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + 423| this.classList.add("active"); + 424| const panel = document.getElementById("dashboard-panel-" + this.dataset.tab); + 425| if (panel) panel.classList.add("active"); + 426| }); + 427| }); + 428|} + 429| + 430|// --------------------------------------------------------------------------- + 431|// Init — called by app.js orchestrator + 432|// --------------------------------------------------------------------------- + 433|export { initSyncStatus }; + 434|export function init() { + 435| registerServiceWorker(); + 436| initDashboardTabs(); + 437|} + 438| \ No newline at end of file diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 2b54ea2..b46d84e 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1,2246 +1,2001 @@ -/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */ -import { rightSidebarVisible, rightSidebarWidth, currentVault, currentPath, selectedContextVault } from './state.js'; -import { openFile } from './viewer.js'; -import { safeCreateIcons } from './utils.js'; - -// --------------------------------------------------------------------------- -// Right Sidebar Manager -// --------------------------------------------------------------------------- - -export 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(); -} - -// ---- 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(); -}; + 1|/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */ +import { state } from './state.js'; + 3|import { openFile } from './viewer.js'; + 4|import { safeCreateIcons } from './utils.js'; + 5| + 6|// --------------------------------------------------------------------------- + 7|// Right Sidebar Manager + 8|// --------------------------------------------------------------------------- + 9| + 10|export const RightSidebarManager = { + 11| init() { + 12| this.loadState(); + 13| this.initToggle(); + 14| this.initResize(); + 15| }, + 16| + 17| loadState() { + 18| const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible"); + 19| const savedWidth = localStorage.getItem("obsigate-right-sidebar-width"); + 20| + 21| if (savedVisible !== null) { + 22| state.rightSidebarVisible = savedVisible === "true"; + 23| } + 24| + 25| if (savedWidth) { + 26| state.rightSidebarWidth = parseInt(savedWidth) || 280; + 27| } + 28| + 29| this.applyState(); + 30| }, + 31| + 32| applyState() { + 33| const sidebar = document.getElementById("right-sidebar"); + 34| const handle = document.getElementById("right-sidebar-resize-handle"); + 35| const tocBtn = document.getElementById("toc-toggle-btn"); + 36| const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn"); + 37| + 38| if (!sidebar) return; + 39| + 40| if (state.rightSidebarVisible) { + 41| sidebar.classList.remove("hidden"); + 42| sidebar.style.width = `${state.rightSidebarWidth}px`; + 43| if (handle) handle.classList.remove("hidden"); + 44| if (tocBtn) { + 45| tocBtn.classList.add("active"); + 46| tocBtn.title = "Masquer le sommaire"; + 47| } + 48| if (headerToggleBtn) { + 49| headerToggleBtn.title = "Masquer le panneau"; + 50| headerToggleBtn.setAttribute("aria-label", "Masquer le panneau"); + 51| } + 52| } else { + 53| sidebar.classList.add("hidden"); + 54| if (handle) handle.classList.add("hidden"); + 55| if (tocBtn) { + 56| tocBtn.classList.remove("active"); + 57| tocBtn.title = "Afficher le sommaire"; + 58| } + 59| if (headerToggleBtn) { + 60| headerToggleBtn.title = "Afficher le panneau"; + 61| headerToggleBtn.setAttribute("aria-label", "Afficher le panneau"); + 62| } + 63| } + 64| + 65| // Update icons + 66| safeCreateIcons(); + 67| }, + 68| + 69| toggle() { + 70| state.rightSidebarVisible = !state.rightSidebarVisible; + 71| localStorage.setItem("obsigate-right-sidebar-visible", state.rightSidebarVisible); + 72| this.applyState(); + 73| }, + 74| + 75| initToggle() { + 76| const toggleBtn = document.getElementById("right-sidebar-toggle-btn"); + 77| if (toggleBtn) { + 78| toggleBtn.addEventListener("click", () => this.toggle()); + 79| } + 80| }, + 81| + 82| initResize() { + 83| const handle = document.getElementById("right-sidebar-resize-handle"); + 84| const sidebar = document.getElementById("right-sidebar"); + 85| + 86| if (!handle || !sidebar) return; + 87| + 88| let isResizing = false; + 89| let startX = 0; + 90| let startWidth = 0; + 91| + 92| const onMouseDown = (e) => { + 93| isResizing = true; + 94| startX = e.clientX; + 95| startWidth = sidebar.offsetWidth; + 96| handle.classList.add("active"); + 97| document.body.style.cursor = "ew-resize"; + 98| document.body.style.userSelect = "none"; + 99| }; + 100| + 101| const onMouseMove = (e) => { + 102| if (!isResizing) return; + 103| + 104| const delta = startX - e.clientX; + 105| let newWidth = startWidth + delta; + 106| + 107| // Constrain width + 108| newWidth = Math.max(200, Math.min(400, newWidth)); + 109| + 110| sidebar.style.width = `${newWidth}px`; + 111| state.rightSidebarWidth = newWidth; + 112| }; + 113| + 114| const onMouseUp = () => { + 115| if (!isResizing) return; + 116| + 117| isResizing = false; + 118| handle.classList.remove("active"); + 119| document.body.style.cursor = ""; + 120| document.body.style.userSelect = ""; + 121| + 122| localStorage.setItem("obsigate-right-sidebar-width", state.rightSidebarWidth); + 123| }; + 124| + 125| handle.addEventListener("mousedown", onMouseDown); + 126| document.addEventListener("mousemove", onMouseMove); + 127| document.addEventListener("mouseup", onMouseUp); + 128| }, + 129|}; + 130| + 131|// --------------------------------------------------------------------------- + 132|// Theme + 133|// --------------------------------------------------------------------------- + 134|export function initTheme() { + 135| const saved = localStorage.getItem("obsigate-theme") || "dark"; + 136| applyTheme(saved); + 137|} + 138| + 139|export function applyTheme(theme) { + 140| document.documentElement.setAttribute("data-theme", theme); + 141| localStorage.setItem("obsigate-theme", theme); + 142| + 143| // Update theme button icon and label + 144| const themeBtn = document.getElementById("theme-toggle"); + 145| const themeLabel = document.getElementById("theme-label"); + 146| if (themeBtn && themeLabel) { + 147| const icon = themeBtn.querySelector("i"); + 148| if (icon) { + 149| icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun"); + 150| } + 151| themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair"; + 152| safeCreateIcons(); + 153| } + 154| + 155| // Swap highlight.js theme + 156| const darkSheet = document.getElementById("hljs-theme-dark"); + 157| const lightSheet = document.getElementById("hljs-theme-light"); + 158| if (darkSheet && lightSheet) { + 159| darkSheet.disabled = theme !== "dark"; + 160| lightSheet.disabled = theme !== "light"; + 161| } + 162|} + 163| + 164|export function toggleTheme() { + 165| const current = document.documentElement.getAttribute("data-theme"); + 166| applyTheme(current === "dark" ? "light" : "dark"); + 167|} + 168| + 169|export function initHeaderMenu() { + 170| const menuBtn = document.getElementById("header-menu-btn"); + 171| const menuDropdown = document.getElementById("header-menu-dropdown"); + 172| + 173| if (!menuBtn || !menuDropdown) return; + 174| + 175| menuBtn.addEventListener("click", (e) => { + 176| e.stopPropagation(); + 177| menuBtn.classList.toggle("active"); + 178| menuDropdown.classList.toggle("active"); + 179| }); + 180| + 181| // Close menu when clicking outside + 182| document.addEventListener("click", (e) => { + 183| if (!menuDropdown.contains(e.target) && e.target !== menuBtn) { + 184| menuBtn.classList.remove("active"); + 185| menuDropdown.classList.remove("active"); + 186| } + 187| }); + 188| + 189| // Prevent menu from closing when clicking inside + 190| menuDropdown.addEventListener("click", (e) => { + 191| e.stopPropagation(); + 192| }); + 193|} + 194| + 195|function closeHeaderMenu() { + 196| const menuBtn = document.getElementById("header-menu-btn"); + 197| const menuDropdown = document.getElementById("header-menu-dropdown"); + 198| if (!menuBtn || !menuDropdown) return; + 199| menuBtn.classList.remove("active"); + 200| menuDropdown.classList.remove("active"); + 201|} + 202| + 203|// --------------------------------------------------------------------------- + 204|// Custom Dropdowns + 205|// --------------------------------------------------------------------------- + 206|export function initCustomDropdowns() { + 207| document.querySelectorAll(".custom-dropdown").forEach((dropdown) => { + 208| const trigger = dropdown.querySelector(".custom-dropdown-trigger"); + 209| const options = dropdown.querySelectorAll(".custom-dropdown-option"); + 210| const hiddenInput = dropdown.querySelector('input[type="hidden"]'); + 211| const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + 212| const menu = dropdown.querySelector(".custom-dropdown-menu"); + 213| + 214| if (!trigger) return; + 215| + 216| // Toggle dropdown + 217| trigger.addEventListener("click", (e) => { + 218| e.stopPropagation(); + 219| const isOpen = dropdown.classList.contains("open"); + 220| + 221| // Close all other dropdowns + 222| document.querySelectorAll(".custom-dropdown.open").forEach((d) => { + 223| if (d !== dropdown) d.classList.remove("open"); + 224| }); + 225| + 226| dropdown.classList.toggle("open", !isOpen); + 227| trigger.setAttribute("aria-expanded", !isOpen); + 228| + 229| // Position fixed menu for sidebar dropdowns + 230| if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) { + 231| const rect = trigger.getBoundingClientRect(); + 232| menu.style.top = `${rect.bottom + 4}px`; + 233| menu.style.left = `${rect.left}px`; + 234| menu.style.width = `${rect.width}px`; + 235| } + 236| }); + 237| + 238| // Handle option selection + 239| options.forEach((option) => { + 240| option.addEventListener("click", (e) => { + 241| e.stopPropagation(); + 242| const value = option.getAttribute("data-value"); + 243| const text = option.textContent; + 244| + 245| // Update hidden input + 246| if (hiddenInput) { + 247| hiddenInput.value = value; + 248| // Trigger change event + 249| hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); + 250| } + 251| + 252| // Update selected text + 253| if (selectedText) { + 254| selectedText.textContent = text; + 255| } + 256| + 257| // Update visual selection + 258| options.forEach((opt) => opt.classList.remove("selected")); + 259| option.classList.add("selected"); + 260| + 261| // Close dropdown + 262| dropdown.classList.remove("open"); + 263| trigger.setAttribute("aria-expanded", "false"); + 264| }); + 265| }); + 266| }); + 267| + 268| // Close dropdowns when clicking outside + 269| document.addEventListener("click", () => { + 270| document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => { + 271| dropdown.classList.remove("open"); + 272| const trigger = dropdown.querySelector(".custom-dropdown-trigger"); + 273| if (trigger) trigger.setAttribute("aria-expanded", "false"); + 274| }); + 275| }); + 276|} + 277| + 278|// Helper to populate custom dropdown options + 279|function populateCustomDropdown(dropdownId, optionsList, defaultValue) { + 280| const dropdown = document.getElementById(dropdownId); + 281| if (!dropdown) return; + 282| + 283| const optionsContainer = dropdown.querySelector(".custom-dropdown-menu"); + 284| const hiddenInput = dropdown.querySelector('input[type="hidden"]'); + 285| const selectedText = dropdown.querySelector(".custom-dropdown-selected"); + 286| + 287| if (!optionsContainer) return; + 288| + 289| // Clear existing options (keep the first one if it's the default) + 290| optionsContainer.innerHTML = ""; + 291| + 292| // Add new options + 293| optionsList.forEach((opt) => { + 294| const li = document.createElement("li"); + 295| li.className = "custom-dropdown-option"; + 296| li.setAttribute("role", "option"); + 297| li.setAttribute("data-value", opt.value); + 298| li.textContent = opt.text; + 299| if (opt.value === defaultValue) { + 300| li.classList.add("selected"); + 301| if (selectedText) selectedText.textContent = opt.text; + 302| if (hiddenInput) hiddenInput.value = opt.value; + 303| } + 304| optionsContainer.appendChild(li); + 305| }); + 306| + 307| // Re-initialize click handlers + 308| optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => { + 309| option.addEventListener("click", (e) => { + 310| e.stopPropagation(); + 311| const value = option.getAttribute("data-value"); + 312| const text = option.textContent; + 313| + 314| if (hiddenInput) { + 315| hiddenInput.value = value; + 316| hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); + 317| } + 318| + 319| if (selectedText) { + 320| selectedText.textContent = text; + 321| } + 322| + 323| optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected")); + 324| option.classList.add("selected"); + 325| + 326| dropdown.classList.remove("open"); + 327| const trigger = dropdown.querySelector(".custom-dropdown-trigger"); + 328| if (trigger) trigger.setAttribute("aria-expanded", "false"); + 329| }); + 330| }); + 331|} + 332| + 333|// --------------------------------------------------------------------------- + 334|// Toast notifications + 335|// --------------------------------------------------------------------------- + 336| + 337|/** Display a brief toast message at the bottom of the viewport. */ + 338|export function showToast(message, type) { + 339| console.log("showToast called with:", message, type); + 340| type = type || "info"; + 341| let container = document.getElementById("toast-container"); + 342| if (!container) { + 343| container = document.createElement("div"); + 344| container.id = "toast-container"; + 345| container.className = "toast-container"; + 346| container.setAttribute("aria-live", "polite"); + 347| document.body.appendChild(container); + 348| } + 349| var toast = document.createElement("div"); + 350| toast.className = "toast toast-" + type; + 351| toast.textContent = message; + 352| container.appendChild(toast); + 353| // Trigger entrance animation + 354| requestAnimationFrame(function () { + 355| toast.classList.add("show"); + 356| }); + 357| setTimeout(function () { + 358| toast.classList.remove("show"); + 359| toast.addEventListener("transitionend", function () { + 360| toast.remove(); + 361| }); + 362| }, 3500); + 363|} + 364| + 365|// --------------------------------------------------------------------------- + 366|// Sidebar toggle (desktop) + 367|// --------------------------------------------------------------------------- + 368|export function initSidebarToggle() { + 369| const toggleBtn = document.getElementById("sidebar-toggle-btn"); + 370| const sidebar = document.getElementById("sidebar"); + 371| const resizeHandle = document.getElementById("sidebar-resize-handle"); + 372| + 373| if (!toggleBtn || !sidebar || !resizeHandle) return; + 374| + 375| // Restore saved state + 376| const savedState = localStorage.getItem("obsigate-sidebar-hidden"); + 377| if (savedState === "true") { + 378| sidebar.classList.add("hidden"); + 379| resizeHandle.classList.add("hidden"); + 380| toggleBtn.classList.add("active"); + 381| } + 382| + 383| toggleBtn.addEventListener("click", () => { + 384| const isHidden = sidebar.classList.toggle("hidden"); + 385| resizeHandle.classList.toggle("hidden", isHidden); + 386| toggleBtn.classList.toggle("active", isHidden); + 387| localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false"); + 388| }); + 389|} + 390| + 391|// --------------------------------------------------------------------------- + 392|// Mobile sidebar + 393|// --------------------------------------------------------------------------- + 394|export function initMobile() { + 395| const hamburger = document.getElementById("hamburger-btn"); + 396| const overlay = document.getElementById("sidebar-overlay"); + 397| const sidebar = document.getElementById("sidebar"); + 398| + 399| hamburger.addEventListener("click", () => { + 400| sidebar.classList.toggle("mobile-open"); + 401| overlay.classList.toggle("active"); + 402| }); + 403| + 404| overlay.addEventListener("click", () => { + 405| sidebar.classList.remove("mobile-open"); + 406| overlay.classList.remove("active"); + 407| }); + 408|} + 409| + 410|function closeMobileSidebar() { + 411| const sidebar = document.getElementById("sidebar"); + 412| const overlay = document.getElementById("sidebar-overlay"); + 413| if (sidebar) sidebar.classList.remove("mobile-open"); + 414| if (overlay) overlay.classList.remove("active"); + 415|} + 416| + 417|// --------------------------------------------------------------------------- + 418|// Resizable sidebar (horizontal) + 419|// --------------------------------------------------------------------------- + 420|export function initSidebarResize() { + 421| const handle = document.getElementById("sidebar-resize-handle"); + 422| const sidebar = document.getElementById("sidebar"); + 423| if (!handle || !sidebar) return; + 424| + 425| // Restore saved width + 426| const savedWidth = localStorage.getItem("obsigate-sidebar-width"); + 427| if (savedWidth) { + 428| sidebar.style.width = savedWidth + "px"; + 429| } + 430| + 431| let startX = 0; + 432| let startWidth = 0; + 433| + 434| function onMouseMove(e) { + 435| const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX))); + 436| sidebar.style.width = newWidth + "px"; + 437| } + 438| + 439| function onMouseUp() { + 440| document.body.classList.remove("resizing"); + 441| handle.classList.remove("active"); + 442| document.removeEventListener("mousemove", onMouseMove); + 443| document.removeEventListener("mouseup", onMouseUp); + 444| localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width)); + 445| } + 446| + 447| handle.addEventListener("mousedown", (e) => { + 448| e.preventDefault(); + 449| startX = e.clientX; + 450| startWidth = sidebar.getBoundingClientRect().width; + 451| document.body.classList.add("resizing"); + 452| handle.classList.add("active"); + 453| document.addEventListener("mousemove", onMouseMove); + 454| document.addEventListener("mouseup", onMouseUp); + 455| }); + 456|} + 457| + 458|// --------------------------------------------------------------------------- + 459|// Resizable tag section (vertical) + 460|// --------------------------------------------------------------------------- + 461|export function initTagResize() { + 462| const handle = document.getElementById("tag-resize-handle"); + 463| const tagSection = document.getElementById("tag-cloud-section"); + 464| if (!handle || !tagSection) return; + 465| + 466| // Restore saved height + 467| const savedHeight = localStorage.getItem("obsigate-tag-height"); + 468| if (savedHeight) { + 469| tagSection.style.height = savedHeight + "px"; + 470| } + 471| + 472| let startY = 0; + 473| let startHeight = 0; + 474| + 475| function onMouseMove(e) { + 476| // Dragging up increases height, dragging down decreases + 477| const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY))); + 478| tagSection.style.height = newHeight + "px"; + 479| } + 480| + 481| function onMouseUp() { + 482| document.body.classList.remove("resizing-v"); + 483| handle.classList.remove("active"); + 484| document.removeEventListener("mousemove", onMouseMove); + 485| document.removeEventListener("mouseup", onMouseUp); + 486| localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height)); + 487| } + 488| + 489| handle.addEventListener("mousedown", (e) => { + 490| e.preventDefault(); + 491| startY = e.clientY; + 492| startHeight = tagSection.getBoundingClientRect().height; + 493| document.body.classList.add("resizing-v"); + 494| handle.classList.add("active"); + 495| document.addEventListener("mousemove", onMouseMove); + 496| document.addEventListener("mouseup", onMouseUp); + 497| }); + 498|} + 499| + 500|// --------------------------------------------------------------------------- + 501|// Frontmatter Accent Card Builder + 502|// --------------------------------------------------------------------------- + 503| + 504|function buildFrontmatterCard(frontmatter) { + 505| // Helper: format date + 506| function formatDate(iso) { + 507| if (!iso) return "—"; + 508| const d = new Date(iso); + 509| const date = d.toISOString().slice(0, 10); + 510| const time = d.toTimeString().slice(0, 5); + 511| return `${date} · ${time}`; + 512| } + 513| + 514| // Extract boolean flags + 515| const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); + 516| + 517| // Toggle state + 518| let isOpen = true; + 519| + 520| // Build header with chevron + 521| const chevron = el("span", { class: "fm-chevron open" }); + 522| chevron.innerHTML = ''; + 523| + 524| const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); + 525| + 526| // ZONE 1: Top strip + 527| const topBadges = []; + 528| + 529| // Title badge + 530| const title = frontmatter.titre || frontmatter.title || ""; + 531| if (title) { + 532| topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); + 533| } + 534| + 535| // Status badge + 536| if (frontmatter.statut) { + 537| const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); + 538| topBadges.push(statusBadge); + 539| } + 540| + 541| // Category badge + 542| if (frontmatter.catégorie || frontmatter.categorie) { + 543| const cat = frontmatter.catégorie || frontmatter.categorie; + 544| const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); + 545| topBadges.push(catBadge); + 546| } + 547| + 548| // Publish badge + 549| if (frontmatter.publish) { + 550| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); + 551| } + 552| + 553| // Favoris badge + 554| if (frontmatter.favoris) { + 555| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); + 556| } + 557| + 558| const acTop = el("div", { class: "ac-top" }, topBadges); + 559| + 560| // ZONE 2: Body 2 columns + 561| const leftCol = el("div", { class: "ac-col" }, [ + 562| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), + 563| 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 || "—")])]), + 564| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), + 565| 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(", ") : "[]")])]), + 566| ]); + 567| + 568| const rightCol = el("div", { class: "ac-col" }, [ + 569| 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))])]), + 570| 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))])]), + 571| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), + 572| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), + 573| ]); + 574| + 575| const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); + 576| + 577| // ZONE 3: Tags row + 578| const tagPills = []; + 579| if (frontmatter.tags && frontmatter.tags.length > 0) { + 580| frontmatter.tags.forEach((tag) => { + 581| tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); + 582| }); + 583| } + 584| + 585| const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); + 586| + 587| // ZONE 4: Flags row + 588| const flagChips = []; + 589| booleanFlags.forEach((flag) => { + 590| const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; + 591| flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); + 592| }); + 593| + 594| const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); + 595| + 596| // Assemble the card + 597| const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); + 598| + 599| // Toggle functionality + 600| fmHeader.addEventListener("click", () => { + 601| isOpen = !isOpen; + 602| if (isOpen) { + 603| acCard.style.display = "block"; + 604| chevron.classList.remove("closed"); + 605| chevron.classList.add("open"); + 606| } else { + 607| acCard.style.display = "none"; + 608| chevron.classList.remove("open"); + 609| chevron.classList.add("closed"); + 610| } + 611| safeCreateIcons(); + 612| }); + 613| + 614| // Wrap in section + 615| const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); + 616| + 617| return fmSection; + 618|} + 619| + 620|// --------------------------------------------------------------------------- + 621|// Context Menu Manager + 622|// --------------------------------------------------------------------------- + 623|export const ContextMenuManager = { + 624| _menu: null, + 625| _targetElement: null, + 626| _targetVault: null, + 627| _targetPath: null, + 628| _targetType: null, + 629| + 630| init() { + 631| this._menu = document.createElement('div'); + 632| this._menu.className = 'context-menu'; + 633| this._menu.id = 'context-menu'; + 634| document.body.appendChild(this._menu); + 635| + 636| document.addEventListener('click', () => this.hide()); + 637| document.addEventListener('contextmenu', (e) => { + 638| if (!e.target.closest('.tree-item')) { + 639| this.hide(); + 640| } + 641| }); + 642| + 643| document.addEventListener('keydown', (e) => { + 644| if (e.key === 'Escape') this.hide(); + 645| }); + 646| + 647| document.addEventListener('scroll', () => this.hide(), true); + 648| }, + 649| + 650| show(x, y, vault, path, type, isReadonly) { + 651| this._targetVault = vault; + 652| this._targetPath = path; + 653| this._targetType = type; + 654| + 655| this._menu.innerHTML = ''; + 656| + 657| // Copy path — available for all types + 658| const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; + 659| this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); + 660| + 661| // Graph view — available for all types + 662| const graphPath = type === 'vault' ? '' : path; + 663| this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); + 664| + 665| this._addSeparator(); + 666| + 667| if (type === 'vault') { + 668| this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); + 669| this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); + 670| } else if (type === 'directory') { + 671| this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); + 672| this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); + 673| this._addSeparator(); + 674| this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + 675| this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); + 676| } else if (type === 'file') { + 677| this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + 678| this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); + 679| this._addSeparator(); + 680| this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false); + 681| } + 682| + 683| this._menu.classList.add('active'); + 684| + 685| const rect = this._menu.getBoundingClientRect(); + 686| const viewportWidth = window.innerWidth; + 687| const viewportHeight = window.innerHeight; + 688| + 689| let finalX = x; + 690| let finalY = y; + 691| + 692| if (x + rect.width > viewportWidth) { + 693| finalX = viewportWidth - rect.width - 10; + 694| } + 695| + 696| if (y + rect.height > viewportHeight) { + 697| finalY = viewportHeight - rect.height - 10; + 698| } + 699| + 700| this._menu.style.left = `${finalX}px`; + 701| this._menu.style.top = `${finalY}px`; + 702| + 703| safeCreateIcons(); + 704| }, + 705| + 706| hide() { + 707| if (this._menu) { + 708| this._menu.classList.remove('active'); + 709| } + 710| }, + 711| + 712| _addItem(icon, label, callback, disabled) { + 713| const item = document.createElement('div'); + 714| item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); + 715| item.innerHTML = ` + 716| + 717| ${label} + 718| `; + 719| + 720| if (!disabled) { + 721| item.addEventListener('click', (e) => { + 722| e.stopPropagation(); + 723| this.hide(); + 724| callback(); + 725| }); + 726| } else { + 727| item.title = 'Vault en lecture seule'; + 728| } + 729| + 730| this._menu.appendChild(item); + 731| }, + 732| + 733| _addSeparator() { + 734| const sep = document.createElement('div'); + 735| sep.className = 'context-menu-separator'; + 736| this._menu.appendChild(sep); + 737| }, + 738| + 739| _createDirectory() { + 740| FileOperationsManager.showCreateDirectoryModal(this._targetVault, this._targetPath); + 741| }, + 742| + 743| _createFile() { + 744| FileOperationsManager.showCreateFileModal(this._targetVault, this._targetPath); + 745| }, + 746| + 747| _renameItem() { + 748| FileOperationsManager.startInlineRename(this._targetVault, this._targetPath, this._targetType); + 749| }, + 750| + 751| _deleteDirectory() { + 752| FileOperationsManager.confirmDeleteDirectory(this._targetVault, this._targetPath); + 753| }, + 754| + 755| _deleteFile() { + 756| FileOperationsManager.confirmDeleteFile(this._targetVault, this._targetPath); + 757| }, + 758| + 759| _copyPath(path) { + 760| // Try modern clipboard API first, fall back to execCommand for non-secure contexts + 761| if (navigator.clipboard && navigator.clipboard.writeText) { + 762| navigator.clipboard.writeText(path).then(() => { + 763| showToast(`Chemin copié : ${path}`, 'success'); + 764| }).catch(() => { + 765| this._copyPathFallback(path); + 766| }); + 767| } else { + 768| this._copyPathFallback(path); + 769| } + 770| }, + 771| + 772| _copyPathFallback(path) { + 773| const textarea = document.createElement('textarea'); + 774| textarea.value = path; + 775| textarea.style.position = 'fixed'; + 776| textarea.style.left = '-9999px'; + 777| textarea.style.top = '-9999px'; + 778| document.body.appendChild(textarea); + 779| textarea.focus(); + 780| textarea.select(); + 781| try { + 782| const success = document.execCommand('copy'); + 783| if (success) { + 784| showToast(`Chemin copié : ${path}`, 'success'); + 785| } else { + 786| showToast('Erreur lors de la copie', 'error'); + 787| } + 788| } catch (e) { + 789| showToast('Erreur lors de la copie', 'error'); + 790| } + 791| document.body.removeChild(textarea); + 792| }, + 793| + 794| async _toggleBookmark() { + 795| try { + 796| const data = await api("/api/bookmarks/toggle", { + 797| method: "POST", + 798| headers: { "Content-Type": "application/json" }, + 799| body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }), + 800| }); + 801| showToast(data.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); + 802| if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { + 803| DashboardBookmarkWidget.load(); + 804| } + 805| } catch (err) { showToast("Erreur: " + err.message, "error"); } + 806| } + 807|}; + 808| + 809|// --------------------------------------------------------------------------- + 810|// File Operations Manager + 811|// --------------------------------------------------------------------------- + 812| + 813|export const FileOperationsManager = { + 814| showCreateDirectoryModal(vault, parentPath) { + 815| const overlay = this._createModalOverlay(); + 816| const modal = document.createElement('div'); + 817| modal.className = 'obsigate-modal'; + 818| modal.innerHTML = ` + 819|
    + 820|

    Créer un dossier

    + 821|
    + 822|
    + 823| + 829|
    + 830| + 834| `; + 835| + 836| overlay.appendChild(modal); + 837| document.body.appendChild(overlay); + 838| + 839| setTimeout(() => overlay.classList.add('active'), 10); + 840| + 841| const input = modal.querySelector('#dir-name-input'); + 842| const errorDiv = modal.querySelector('#dir-error'); + 843| const createBtn = modal.querySelector('#dir-create-btn'); + 844| const cancelBtn = modal.querySelector('#dir-cancel-btn'); + 845| + 846| input.focus(); + 847| + 848| const validateName = (name) => { + 849| if (!name.trim()) return 'Le nom ne peut pas être vide'; + 850| if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + 851| return null; + 852| }; + 853| + 854| input.addEventListener('input', () => { + 855| const error = validateName(input.value); + 856| if (error) { + 857| errorDiv.textContent = error; + 858| errorDiv.style.display = 'block'; + 859| input.classList.add('error'); + 860| } else { + 861| errorDiv.style.display = 'none'; + 862| input.classList.remove('error'); + 863| } + 864| }); + 865| + 866| const create = async () => { + 867| const name = input.value.trim(); + 868| const error = validateName(name); + 869| if (error) { + 870| errorDiv.textContent = error; + 871| errorDiv.style.display = 'block'; + 872| return; + 873| } + 874| + 875| const path = parentPath ? `${parentPath}/${name}` : name; + 876| createBtn.disabled = true; + 877| createBtn.textContent = 'Création...'; + 878| + 879| try { + 880| await api(`/api/directory/${encodeURIComponent(vault)}`, { + 881| method: 'POST', + 882| headers: { 'Content-Type': 'application/json' }, + 883| body: JSON.stringify({ path }), + 884| }); + 885| + 886| showToast(`Dossier "${name}" créé`, 'success'); + 887| this._closeModal(overlay); + 888| await refreshSidebarTreePreservingState(); + 889| } catch (err) { + 890| showToast(err.message || 'Erreur lors de la création', 'error'); + 891| createBtn.disabled = false; + 892| createBtn.textContent = 'Créer'; + 893| } + 894| }; + 895| + 896| createBtn.addEventListener('click', create); + 897| cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + 898| + 899| input.addEventListener('keydown', (e) => { + 900| if (e.key === 'Enter') create(); + 901| if (e.key === 'Escape') this._closeModal(overlay); + 902| }); + 903| }, + 904| + 905| showCreateFileModal(vault, parentPath) { + 906| const overlay = this._createModalOverlay(); + 907| const modal = document.createElement('div'); + 908| modal.className = 'obsigate-modal'; + 909| modal.innerHTML = ` + 910|
    + 911|

    Créer un fichier

    + 912|
    + 913|
    + 914| + 920| + 933|
    + 934| + 938| `; + 939| + 940| overlay.appendChild(modal); + 941| document.body.appendChild(overlay); + 942| + 943| setTimeout(() => overlay.classList.add('active'), 10); + 944| + 945| const input = modal.querySelector('#file-name-input'); + 946| const extSelect = modal.querySelector('#file-ext-select'); + 947| const errorDiv = modal.querySelector('#file-error'); + 948| const createBtn = modal.querySelector('#file-create-btn'); + 949| const cancelBtn = modal.querySelector('#file-cancel-btn'); + 950| + 951| input.focus(); + 952| + 953| const validateName = (name) => { + 954| if (!name.trim()) return 'Le nom ne peut pas être vide'; + 955| if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + 956| return null; + 957| }; + 958| + 959| input.addEventListener('input', () => { + 960| const error = validateName(input.value); + 961| if (error) { + 962| errorDiv.textContent = error; + 963| errorDiv.style.display = 'block'; + 964| input.classList.add('error'); + 965| } else { + 966| errorDiv.style.display = 'none'; + 967| input.classList.remove('error'); + 968| } + 969| }); + 970| + 971| const create = async () => { + 972| let name = input.value.trim(); + 973| const error = validateName(name); + 974| if (error) { + 975| errorDiv.textContent = error; + 976| errorDiv.style.display = 'block'; + 977| return; + 978| } + 979| + 980| const ext = extSelect.value; + 981| if (!name.endsWith(ext)) { + 982| name += ext; + 983| } + 984| + 985| const path = parentPath ? `${parentPath}/${name}` : name; + 986| createBtn.disabled = true; + 987| createBtn.textContent = 'Création...'; + 988| + 989| try { + 990| await api(`/api/file/${encodeURIComponent(vault)}`, { + 991| method: 'POST', + 992| headers: { 'Content-Type': 'application/json' }, + 993| body: JSON.stringify({ path, content: '' }), + 994| }); + 995| + 996| showToast(`Fichier "${name}" créé`, 'success'); + 997| this._closeModal(overlay); + 998| await refreshSidebarTreePreservingState(); + 999| openFile(vault, path); + 1000| } catch (err) { + 1001| showToast(err.message || 'Erreur lors de la création', 'error'); + 1002| createBtn.disabled = false; + 1003| createBtn.textContent = 'Créer'; + 1004| } + 1005| }; + 1006| + 1007| createBtn.addEventListener('click', create); + 1008| cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + 1009| + 1010| input.addEventListener('keydown', (e) => { + 1011| if (e.key === 'Enter') create(); + 1012| if (e.key === 'Escape') this._closeModal(overlay); + 1013| }); + 1014| }, + 1015| + 1016| async startInlineRename(vault, path, type) { + 1017| const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`); + 1018| if (!item) { + 1019| showToast('Élément introuvable dans l’arborescence', 'error'); + 1020| return; + 1021| } + 1022| + 1023| const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); + 1024| if (!textNode) { + 1025| showToast('Impossible de renommer cet élément', 'error'); + 1026| return; + 1027| } + 1028| + 1029| const originalText = textNode.textContent; + 1030| const trimmedOriginal = originalText.trim(); + 1031| const currentName = path.split('/').pop() || trimmedOriginal; + 1032| const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName; + 1033| const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : ''; + 1034| + 1035| const input = document.createElement('input'); + 1036| input.type = 'text'; + 1037| input.className = 'sidebar-item-input'; + 1038| input.value = baseName; + 1039| + 1040| textNode.textContent = ' '; + 1041| const badge = item.querySelector('.badge-small'); + 1042| if (badge) { + 1043| item.insertBefore(input, badge); + 1044| } else { + 1045| item.appendChild(input); + 1046| } + 1047| + 1048| const restore = () => { + 1049| input.remove(); + 1050| textNode.textContent = originalText; + 1051| }; + 1052| + 1053| const validateName = (name) => { + 1054| if (!name.trim()) return 'Le nom ne peut pas être vide'; + 1055| if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; + 1056| return null; + 1057| }; + 1058| + 1059| const submit = async () => { + 1060| const name = input.value.trim(); + 1061| const error = validateName(name); + 1062| if (error) { + 1063| showToast(error, 'error'); + 1064| input.focus(); + 1065| input.select(); + 1066| return; + 1067| } + 1068| + 1069| const newName = `${name}${extension}`; + 1070| if (newName === currentName) { + 1071| restore(); + 1072| return; + 1073| } + 1074| + 1075| input.disabled = true; + 1076| try { + 1077| const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`; + 1078| const result = await api(endpoint, { + 1079| method: 'PATCH', + 1080| headers: { 'Content-Type': 'application/json' }, + 1081| body: JSON.stringify({ path, new_name: newName }), + 1082| }); + 1083| + 1084| const nextPath = result.new_path; + 1085| await refreshSidebarTreePreservingState(); + 1086| + 1087| if (type === 'file' && state.currentVault === vault && state.currentPath === path) { + 1088| await openFile(vault, nextPath); + 1089| } else if (type === 'directory' && state.currentVault === vault && currentPath && (state.currentPath === path || state.currentPath.startsWith(`${path}/`))) { + 1090| const suffix = state.currentPath === path ? '' : state.currentPath.slice(path.length); + 1091| state.currentPath = `${nextPath}${suffix}`; + 1092| await focusPathInSidebar(vault, state.currentPath, { alignToTop: false }); + 1093| } + 1094| + 1095| showToast(type === 'directory' ? 'Dossier renommé' : 'Fichier renommé', 'success'); + 1096| } catch (err) { + 1097| input.disabled = false; + 1098| showToast(err.message || 'Erreur lors du renommage', 'error'); + 1099| input.focus(); + 1100| input.select(); + 1101| return; + 1102| } + 1103| }; + 1104| + 1105| input.addEventListener('click', (e) => e.stopPropagation()); + 1106| input.addEventListener('keydown', async (e) => { + 1107| e.stopPropagation(); + 1108| if (e.key === 'Enter') { + 1109| e.preventDefault(); + 1110| await submit(); + 1111| } + 1112| if (e.key === 'Escape') { + 1113| e.preventDefault(); + 1114| restore(); + 1115| } + 1116| }); + 1117| input.addEventListener('blur', async () => { + 1118| if (!input.disabled) { + 1119| await submit(); + 1120| } + 1121| }); + 1122| + 1123| input.focus(); + 1124| input.setSelectionRange(0, input.value.length); + 1125| }, + 1126| + 1127| confirmDeleteDirectory(vault, path) { + 1128| const overlay = this._createModalOverlay(); + 1129| const modal = document.createElement('div'); + 1130| modal.className = 'obsigate-modal'; + 1131| modal.innerHTML = ` + 1132|
    + 1133|

    Supprimer le dossier

    + 1134|
    + 1135|
    + 1136| + 1143| + 1147|
    + 1148| + 1152| `; + 1153| + 1154| overlay.appendChild(modal); + 1155| document.body.appendChild(overlay); + 1156| + 1157| setTimeout(() => overlay.classList.add('active'), 10); + 1158| safeCreateIcons(); + 1159| + 1160| const confirmBtn = modal.querySelector('#del-confirm-btn'); + 1161| const cancelBtn = modal.querySelector('#del-cancel-btn'); + 1162| + 1163| const deleteDir = async () => { + 1164| confirmBtn.disabled = true; + 1165| confirmBtn.textContent = 'Suppression...'; + 1166| + 1167| try { + 1168| const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { + 1169| method: 'DELETE', + 1170| }); + 1171| + 1172| showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success'); + 1173| this._closeModal(overlay); + 1174| await refreshSidebarTreePreservingState(); + 1175| + 1176| if (state.currentVault === vault && currentPath && state.currentPath.startsWith(path)) { + 1177| showWelcome(); + 1178| } + 1179| } catch (err) { + 1180| showToast(err.message || 'Erreur lors de la suppression', 'error'); + 1181| confirmBtn.disabled = false; + 1182| confirmBtn.textContent = 'Supprimer définitivement'; + 1183| } + 1184| }; + 1185| + 1186| confirmBtn.addEventListener('click', deleteDir); + 1187| cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + 1188| }, + 1189| + 1190| confirmDeleteFile(vault, path) { + 1191| const overlay = this._createModalOverlay(); + 1192| const modal = document.createElement('div'); + 1193| modal.className = 'obsigate-modal'; + 1194| modal.innerHTML = ` + 1195|
    + 1196|

    Supprimer le fichier

    + 1197|
    + 1198|
    + 1199| + 1205| + 1209|
    + 1210| + 1214| `; + 1215| + 1216| overlay.appendChild(modal); + 1217| document.body.appendChild(overlay); + 1218| + 1219| setTimeout(() => overlay.classList.add('active'), 10); + 1220| safeCreateIcons(); + 1221| + 1222| const confirmBtn = modal.querySelector('#del-confirm-btn'); + 1223| const cancelBtn = modal.querySelector('#del-cancel-btn'); + 1224| + 1225| const deleteFile = async () => { + 1226| confirmBtn.disabled = true; + 1227| confirmBtn.textContent = 'Suppression...'; + 1228| + 1229| try { + 1230| await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { + 1231| method: 'DELETE', + 1232| }); + 1233| + 1234| showToast('Fichier supprimé', 'success'); + 1235| this._closeModal(overlay); + 1236| await refreshSidebarTreePreservingState(); + 1237| + 1238| if (state.currentVault === vault && state.currentPath === path) { + 1239| showWelcome(); + 1240| } + 1241| } catch (err) { + 1242| showToast(err.message || 'Erreur lors de la suppression', 'error'); + 1243| confirmBtn.disabled = false; + 1244| confirmBtn.textContent = 'Supprimer définitivement'; + 1245| } + 1246| }; + 1247| + 1248| confirmBtn.addEventListener('click', deleteFile); + 1249| cancelBtn.addEventListener('click', () => this._closeModal(overlay)); + 1250| }, + 1251| + 1252| _createModalOverlay() { + 1253| const overlay = document.createElement('div'); + 1254| overlay.className = 'obsigate-modal-overlay'; + 1255| overlay.addEventListener('click', (e) => { + 1256| if (e.target === overlay) { + 1257| this._closeModal(overlay); + 1258| } + 1259| }); + 1260| return overlay; + 1261| }, + 1262| + 1263| _closeModal(overlay) { + 1264| overlay.classList.remove('active'); + 1265| setTimeout(() => overlay.remove(), 200); + 1266| } + 1267|}; + 1268| + 1269|// --------------------------------------------------------------------------- + 1270|// Find in Page Manager + 1271|// --------------------------------------------------------------------------- + 1272|export const FindInPageManager = { + 1273| isOpen: false, + 1274| searchTerm: "", + 1275| matches: [], + 1276| currentIndex: -1, + 1277| options: { + 1278| caseSensitive: false, + 1279| wholeWord: false, + 1280| useRegex: false, + 1281| }, + 1282| debounceTimer: null, + 1283| previousFocus: null, + 1284| + 1285| init() { + 1286| const bar = document.getElementById("find-in-page-bar"); + 1287| const input = document.getElementById("find-input"); + 1288| const prevBtn = document.getElementById("find-prev"); + 1289| const nextBtn = document.getElementById("find-next"); + 1290| const closeBtn = document.getElementById("find-close"); + 1291| const caseSensitiveBtn = document.getElementById("find-case-sensitive"); + 1292| const wholeWordBtn = document.getElementById("find-whole-word"); + 1293| const regexBtn = document.getElementById("find-regex"); + 1294| + 1295| if (!bar || !input) return; + 1296| + 1297| // Keyboard shortcuts + 1298| document.addEventListener("keydown", (e) => { + 1299| // Ctrl+F or Cmd+F to open + 1300| if ((e.ctrlKey || e.metaKey) && e.key === "f") { + 1301| e.preventDefault(); + 1302| this.open(); + 1303| } + 1304| // Escape to close + 1305| if (e.key === "Escape" && this.isOpen) { + 1306| e.preventDefault(); + 1307| this.close(); + 1308| } + 1309| // Enter to go to next + 1310| if (e.key === "Enter" && this.isOpen && document.activeElement === input) { + 1311| e.preventDefault(); + 1312| if (e.shiftKey) { + 1313| this.goToPrevious(); + 1314| } else { + 1315| this.goToNext(); + 1316| } + 1317| } + 1318| // F3 for next/previous + 1319| if (e.key === "F3" && this.isOpen) { + 1320| e.preventDefault(); + 1321| if (e.shiftKey) { + 1322| this.goToPrevious(); + 1323| } else { + 1324| this.goToNext(); + 1325| } + 1326| } + 1327| }); + 1328| + 1329| // Input event with debounce + 1330| input.addEventListener("input", (e) => { + 1331| clearTimeout(this.debounceTimer); + 1332| this.debounceTimer = setTimeout(() => { + 1333| this.search(e.target.value); + 1334| }, 250); + 1335| }); + 1336| + 1337| // Navigation buttons + 1338| prevBtn.addEventListener("click", () => this.goToPrevious()); + 1339| nextBtn.addEventListener("click", () => this.goToNext()); + 1340| + 1341| // Close button + 1342| closeBtn.addEventListener("click", () => this.close()); + 1343| + 1344| // Option toggles + 1345| caseSensitiveBtn.addEventListener("click", () => { + 1346| this.options.caseSensitive = !this.options.caseSensitive; + 1347| caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); + 1348| this.saveState(); + 1349| if (this.searchTerm) this.search(this.searchTerm); + 1350| }); + 1351| + 1352| wholeWordBtn.addEventListener("click", () => { + 1353| this.options.wholeWord = !this.options.wholeWord; + 1354| wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); + 1355| this.saveState(); + 1356| if (this.searchTerm) this.search(this.searchTerm); + 1357| }); + 1358| + 1359| regexBtn.addEventListener("click", () => { + 1360| this.options.useRegex = !this.options.useRegex; + 1361| regexBtn.setAttribute("aria-pressed", this.options.useRegex); + 1362| this.saveState(); + 1363| if (this.searchTerm) this.search(this.searchTerm); + 1364| }); + 1365| + 1366| // Load saved state + 1367| this.loadState(); + 1368| }, + 1369| + 1370| open() { + 1371| const bar = document.getElementById("find-in-page-bar"); + 1372| const input = document.getElementById("find-input"); + 1373| if (!bar || !input) return; + 1374| + 1375| this.previousFocus = document.activeElement; + 1376| this.isOpen = true; + 1377| bar.hidden = false; + 1378| input.focus(); + 1379| input.select(); + 1380| safeCreateIcons(); + 1381| }, + 1382| + 1383| close() { + 1384| const bar = document.getElementById("find-in-page-bar"); + 1385| if (!bar) return; + 1386| + 1387| this.isOpen = false; + 1388| bar.hidden = true; + 1389| this.clearHighlights(); + 1390| this.matches = []; + 1391| this.currentIndex = -1; + 1392| this.searchTerm = ""; + 1393| + 1394| // Restore previous focus + 1395| if (this.previousFocus && this.previousFocus.focus) { + 1396| this.previousFocus.focus(); + 1397| } + 1398| }, + 1399| + 1400| search(term) { + 1401| this.searchTerm = term; + 1402| this.clearHighlights(); + 1403| this.hideError(); + 1404| + 1405| if (!term || term.trim().length === 0) { + 1406| this.updateCounter(); + 1407| this.updateNavButtons(); + 1408| return; + 1409| } + 1410| + 1411| const contentArea = document.querySelector(".md-content"); + 1412| if (!contentArea) { + 1413| this.updateCounter(); + 1414| this.updateNavButtons(); + 1415| return; + 1416| } + 1417| + 1418| try { + 1419| const regex = this.createRegex(term); + 1420| this.matches = []; + 1421| this.findMatches(contentArea, regex); + 1422| this.currentIndex = this.matches.length > 0 ? 0 : -1; + 1423| this.highlightMatches(); + 1424| this.updateCounter(); + 1425| this.updateNavButtons(); + 1426| + 1427| if (this.matches.length > 0) { + 1428| this.scrollToMatch(0); + 1429| } + 1430| } catch (err) { + 1431| this.showError(err.message); + 1432| this.matches = []; + 1433| this.currentIndex = -1; + 1434| this.updateCounter(); + 1435| this.updateNavButtons(); + 1436| } + 1437| }, + 1438| + 1439| createRegex(term) { + 1440| let pattern = term; + 1441| + 1442| if (!this.options.useRegex) { + 1443| // Escape special regex characters + 1444| pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + 1445| } + 1446| + 1447| if (this.options.wholeWord) { + 1448| pattern = "\\b" + pattern + "\\b"; + 1449| } + 1450| + 1451| const flags = this.options.caseSensitive ? "g" : "gi"; + 1452| return new RegExp(pattern, flags); + 1453| }, + 1454| + 1455| findMatches(container, regex) { + 1456| const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + 1457| acceptNode: (node) => { + 1458| // Skip code blocks, scripts, styles + 1459| const parent = node.parentElement; + 1460| if (!parent) return NodeFilter.FILTER_REJECT; + 1461| const tagName = parent.tagName.toLowerCase(); + 1462| if (["code", "pre", "script", "style"].includes(tagName)) { + 1463| return NodeFilter.FILTER_REJECT; + 1464| } + 1465| // Skip empty text nodes + 1466| if (!node.textContent || node.textContent.trim().length === 0) { + 1467| return NodeFilter.FILTER_REJECT; + 1468| } + 1469| return NodeFilter.FILTER_ACCEPT; + 1470| }, + 1471| }); + 1472| + 1473| let node; + 1474| while ((node = walker.nextNode())) { + 1475| const text = node.textContent; + 1476| let match; + 1477| regex.lastIndex = 0; // Reset regex + 1478| + 1479| while ((match = regex.exec(text)) !== null) { + 1480| this.matches.push({ + 1481| node: node, + 1482| index: match.index, + 1483| length: match[0].length, + 1484| text: match[0], + 1485| }); + 1486| + 1487| // Prevent infinite loop with zero-width matches + 1488| if (match.index === regex.lastIndex) { + 1489| regex.lastIndex++; + 1490| } + 1491| } + 1492| } + 1493| }, + 1494| + 1495| highlightMatches() { + 1496| const matchesByNode = new Map(); + 1497| + 1498| this.matches.forEach((match, idx) => { + 1499| if (!matchesByNode.has(match.node)) { + 1500| matchesByNode.set(match.node, []); + 1501| } + 1502| matchesByNode.get(match.node).push({ match, idx }); + 1503| }); + 1504| + 1505| matchesByNode.forEach((entries, node) => { + 1506| if (!node || !node.parentNode) return; + 1507| + 1508| const text = node.textContent || ""; + 1509| let cursor = 0; + 1510| const fragment = document.createDocumentFragment(); + 1511| + 1512| entries.sort((a, b) => a.match.index - b.match.index); + 1513| + 1514| entries.forEach(({ match, idx }) => { + 1515| if (match.index > cursor) { + 1516| fragment.appendChild(document.createTextNode(text.substring(cursor, match.index))); + 1517| } + 1518| + 1519| const matchText = text.substring(match.index, match.index + match.length); + 1520| const mark = document.createElement("mark"); + 1521| mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight"; + 1522| mark.textContent = matchText; + 1523| mark.setAttribute("data-find-index", idx); + 1524| fragment.appendChild(mark); + 1525| + 1526| match.element = mark; + 1527| cursor = match.index + match.length; + 1528| }); + 1529| + 1530| if (cursor < text.length) { + 1531| fragment.appendChild(document.createTextNode(text.substring(cursor))); + 1532| } + 1533| + 1534| node.parentNode.replaceChild(fragment, node); + 1535| }); + 1536| }, + 1537| + 1538| clearHighlights() { + 1539| const contentArea = document.querySelector(".md-content"); + 1540| if (!contentArea) return; + 1541| + 1542| const marks = contentArea.querySelectorAll("mark.find-highlight"); + 1543| marks.forEach((mark) => { + 1544| if (!mark.parentNode) return; + 1545| const text = mark.textContent; + 1546| const textNode = document.createTextNode(text); + 1547| mark.parentNode.replaceChild(textNode, mark); + 1548| }); + 1549| + 1550| // Normalize text nodes to merge adjacent text nodes + 1551| contentArea.normalize(); + 1552| }, + 1553| + 1554| goToNext() { + 1555| if (this.matches.length === 0) return; + 1556| + 1557| // Remove active class from current + 1558| if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + 1559| this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); + 1560| } + 1561| + 1562| // Move to next (with wrapping) + 1563| this.currentIndex = (this.currentIndex + 1) % this.matches.length; + 1564| + 1565| // Add active class to new current + 1566| if (this.matches[this.currentIndex].element) { + 1567| this.matches[this.currentIndex].element.classList.add("find-highlight-active"); + 1568| } + 1569| + 1570| this.scrollToMatch(this.currentIndex); + 1571| this.updateCounter(); + 1572| }, + 1573| + 1574| goToPrevious() { + 1575| if (this.matches.length === 0) return; + 1576| + 1577| // Remove active class from current + 1578| if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + 1579| this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); + 1580| } + 1581| + 1582| // Move to previous (with wrapping) + 1583| this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; + 1584| + 1585| // Add active class to new current + 1586| if (this.matches[this.currentIndex].element) { + 1587| this.matches[this.currentIndex].element.classList.add("find-highlight-active"); + 1588| } + 1589| + 1590| this.scrollToMatch(this.currentIndex); + 1591| this.updateCounter(); + 1592| }, + 1593| + 1594| scrollToMatch(index) { + 1595| if (index < 0 || index >= this.matches.length) return; + 1596| + 1597| const match = this.matches[index]; + 1598| if (!match.element) return; + 1599| + 1600| const contentArea = document.getElementById("content-area"); + 1601| if (!contentArea) { + 1602| match.element.scrollIntoView({ behavior: "smooth", block: "center" }); + 1603| return; + 1604| } + 1605| + 1606| // Calculate position with offset for header + 1607| const elementTop = match.element.offsetTop; + 1608| const offset = 100; // Offset for header + 1609| + 1610| contentArea.scrollTo({ + 1611| top: elementTop - offset, + 1612| behavior: "smooth", + 1613| }); + 1614| }, + 1615| + 1616| updateCounter() { + 1617| const counter = document.getElementById("find-counter"); + 1618| if (!counter) return; + 1619| + 1620| const count = this.matches.length; + 1621| if (count === 0) { + 1622| counter.textContent = "0 occurrence"; + 1623| } else if (count === 1) { + 1624| counter.textContent = "1 occurrence"; + 1625| } else { + 1626| counter.textContent = `${count} occurrences`; + 1627| } + 1628| }, + 1629| + 1630| updateNavButtons() { + 1631| const prevBtn = document.getElementById("find-prev"); + 1632| const nextBtn = document.getElementById("find-next"); + 1633| if (!prevBtn || !nextBtn) return; + 1634| + 1635| const hasMatches = this.matches.length > 0; + 1636| prevBtn.disabled = !hasMatches; + 1637| nextBtn.disabled = !hasMatches; + 1638| }, + 1639| + 1640| showError(message) { + 1641| const errorEl = document.getElementById("find-error"); + 1642| if (!errorEl) return; + 1643| + 1644| errorEl.textContent = message; + 1645| errorEl.hidden = false; + 1646| }, + 1647| + 1648| hideError() { + 1649| const errorEl = document.getElementById("find-error"); + 1650| if (!errorEl) return; + 1651| + 1652| errorEl.hidden = true; + 1653| }, + 1654| + 1655| saveState() { + 1656| try { + 1657| const state = { + 1658| options: this.options, + 1659| }; + 1660| localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state)); + 1661| } catch (e) { + 1662| // Ignore localStorage errors + 1663| } + 1664| }, + 1665| + 1666| loadState() { + 1667| try { + 1668| const saved = localStorage.getItem("obsigate-find-in-page-state"); + 1669| if (saved) { + 1670| const state = JSON.parse(saved); + 1671| if (state.options) { + 1672| this.options = { ...this.options, ...state.options }; + 1673| + 1674| // Update button states + 1675| const caseSensitiveBtn = document.getElementById("find-case-sensitive"); + 1676| const wholeWordBtn = document.getElementById("find-whole-word"); + 1677| const regexBtn = document.getElementById("find-regex"); + 1678| + 1679| if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); + 1680| if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); + 1681| if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex); + 1682| } + 1683| } + 1684| } catch (e) { + 1685| // Ignore localStorage errors + 1686| } + 1687| }, + 1688|}; + 1689| + 1690|// --------------------------------------------------------------------------- + 1691|// Tab Manager + 1692|// --------------------------------------------------------------------------- + 1693|export const TabManager = { + 1694| _tabs: [], + 1695| _activeTabId: null, + 1696| _previewTabId: null, // single-click preview tab (temporary, replaced on next preview) + 1697| _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } + 1698| _tabBar: null, + 1699| _tabList: null, + 1700| _dirtyTabs: new Set(), + 1701| + 1702| init() { + 1703| this._tabBar = document.getElementById("tab-bar"); + 1704| this._tabList = document.getElementById("tab-list"); + 1705| }, + 1706| + 1707| /** Open a file as a preview tab (single-click). + 1708| * Replaces any existing preview tab. If the file is already + 1709| * open as a persistent tab, just activates it. */ + 1710| async openPreview(vault, path) { + 1711| const tabId = `${vault}::${path}`; + 1712| + 1713| // If already open as persistent tab, just activate it + 1714| const existing = this._tabs.find(t => t.id === tabId && !t.preview); + 1715| if (existing) { + 1716| this.activate(tabId); + 1717| return; + 1718| } + 1719| + 1720| // Close existing preview tab + 1721| if (this._previewTabId && this._previewTabId !== tabId) { + 1722| this.close(this._previewTabId); + 1723| } + 1724| + 1725| // If already open as preview, just focus it + 1726| const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); + 1727| if (previewExisting) { + 1728| this.activate(tabId); + 1729| return; + 1730| } + 1731| + 1732| // Create preview tab + 1733| const name = path.split("/").pop().replace(/\.md$/i, ""); + 1734| const icon = getFileIcon(name + ".md"); + 1735| + 1736| this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); + 1737| this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + 1738| this._previewTabId = tabId; + 1739| + 1740| this._renderTabs(); + 1741| this.activate(tabId); + 1742| }, + 1743| + 1744| /** Convert a preview tab to a persistent tab (double-click). + 1745| * If already persistent, opens a new duplicate (same file, different tab). */ + 1746| async openPersistent(vault, path) { + 1747| const tabId = `${vault}::${path}`; + 1748| + 1749| // If it's already a preview tab, convert it to persistent + 1750| const previewTab = this._tabs.find(t => t.id === tabId && t.preview); + 1751| if (previewTab) { + 1752| previewTab.preview = false; + 1753| if (this._previewTabId === tabId) { + 1754| this._previewTabId = null; + 1755| } + 1756| this._renderTabs(); + 1757| this.activate(tabId); + 1758| return; + 1759| } + 1760| + 1761| // If already persistent, just focus it + 1762| const existing = this._tabs.find(t => t.id === tabId && !t.preview); + 1763| if (existing) { + 1764| this.activate(tabId); + 1765| return; + 1766| } + 1767| + 1768| // Create a new persistent tab + 1769| this.open(vault, path); + 1770| }, + 1771| + 1772| /** Open a file in a tab (or focus existing) */ + 1773| async open(vault, path, options = {}) { + 1774| const tabId = `${vault}::${path}`; + 1775| + 1776| // If already open, just focus it + 1777| const existing = this._tabs.find(t => t.id === tabId); + 1778| if (existing) { + 1779| // Convert preview to persistent if needed + 1780| if (existing.preview) { + 1781| existing.preview = false; + 1782| if (this._previewTabId === tabId) this._previewTabId = null; + 1783| this._renderTabs(); + 1784| } + 1785| this.activate(tabId); + 1786| return; + 1787| } + 1788| + 1789| // Create new tab + 1790| const name = path.split("/").pop().replace(/\.md$/i, ""); + 1791| const icon = getFileIcon(name + ".md"); + 1792| + 1793| this._tabs.push({ id: tabId, vault, path, name, icon }); + 1794| this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + 1795| + 1796| this._renderTabs(); + 1797| this.activate(tabId); + 1798| }, + 1799| + 1800| /** Activate a specific tab */ + 1801| async activate(tabId) { + 1802| if (this._activeTabId === tabId && this._tabs.length > 0) return; + 1803| + 1804| // Save current tab state + 1805| if (this._activeTabId && this._tabCache[this._activeTabId]) { + 1806| this._saveCurrentTabState(); + 1807| } + 1808| + 1809| this._activeTabId = tabId; + 1810| this._renderTabs(); + 1811| + 1812| // Load tab content + 1813| const cache = this._tabCache[tabId]; + 1814| if (!cache) return; + 1815| + 1816| // Update global state + 1817| state.currentVault = cache.vault; + 1818| state.currentPath = cache.path; + 1819| syncActiveFileTreeItem(cache.vault, cache.path); + 1820| + 1821| const area = document.getElementById("content-area"); + 1822| + 1823| if (cache.data) { + 1824| // Use cached data + 1825| this._restoreTabContent(cache, area); + 1826| } else { + 1827| // Fetch file content + 1828| area.innerHTML = '
    Chargement...
    '; + 1829| try { + 1830| const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`); + 1831| cache.data = data; + 1832| cache.title = data.title; + 1833| renderFile(cache.data); + 1834| + 1835| // Restore source view if needed + 1836| if (cache.sourceView) { + 1837| await this._toggleSourceView(cache, area); + 1838| } + 1839| if (cache.scrollTop) { + 1840| area.scrollTop = cache.scrollTop; + 1841| } + 1842| } catch (err) { + 1843| area.innerHTML = `
    Erreur: ${escapeHtml(err.message)}
    `; + 1844| } + 1845| } + 1846| + 1847| // Update URL hash + 1848| if (history.pushState) { + 1849| history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); + 1850| } + 1851| + 1852| // Hide dashboard + 1853| const dashboard = document.getElementById("dashboard-home"); + 1854| if (dashboard) dashboard.style.display = "none"; + 1855| }, + 1856| + 1857| /** Close a tab */ + 1858| close(tabId) { + 1859| const idx = this._tabs.findIndex(t => t.id === tabId); + 1860| if (idx === -1) return; + 1861| + 1862| this._tabs.splice(idx, 1); + 1863| delete this._tabCache[tabId]; + 1864| this._dirtyTabs.delete(tabId); + 1865| + 1866| if (this._tabs.length === 0) { + 1867| this._activeTabId = null; + 1868| this._showDashboard(); + 1869| this._tabBar.hidden = true; + 1870| } else if (this._activeTabId === tabId) { + 1871| // Activate adjacent tab + 1872| const newIdx = Math.min(idx, this._tabs.length - 1); + 1873| this.activate(this._tabs[newIdx].id); + 1874| } + 1875| + 1876| this._renderTabs(); + 1877| }, + 1878| + 1879| /** Close all tabs */ + 1880| closeAll() { + 1881| this._tabs = []; + 1882| this._tabCache = {}; + 1883| this._dirtyTabs.clear(); + 1884| this._activeTabId = null; + 1885| this._showDashboard(); + 1886| this._tabBar.hidden = true; + 1887| }, + 1888| + 1889| /** Close tabs to the right */ + 1890| closeRight(tabId) { + 1891| const idx = this._tabs.findIndex(t => t.id === tabId); + 1892| if (idx === -1) return; + 1893| const toClose = this._tabs.slice(idx + 1); + 1894| for (const tab of toClose) { + 1895| delete this._tabCache[tab.id]; + 1896| this._dirtyTabs.delete(tab.id); + 1897| } + 1898| this._tabs = this._tabs.slice(0, idx + 1); + 1899| if (!this._tabs.find(t => t.id === this._activeTabId)) { + 1900| this.activate(tabId); + 1901| } + 1902| this._renderTabs(); + 1903| }, + 1904| + 1905| /** Close other tabs */ + 1906| closeOthers(tabId) { + 1907| const tab = this._tabs.find(t => t.id === tabId); + 1908| if (!tab) return; + 1909| for (const t of this._tabs) { + 1910| if (t.id !== tabId) { + 1911| delete this._tabCache[t.id]; + 1912| this._dirtyTabs.delete(t.id); + 1913| } + 1914| } + 1915| this._tabs = [tab]; + 1916| this.activate(tabId); + 1917| this._renderTabs(); + 1918| }, + 1919| + 1920| /** Reorder tabs by drag and drop */ + 1921| moveTab(fromIdx, toIdx) { + 1922| if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return; + 1923| const tab = this._tabs.splice(fromIdx, 1)[0]; + 1924| this._tabs.splice(toIdx, 0, tab); + 1925| this._renderTabs(); + 1926| }, + 1927| + 1928| /** Save current tab state before switching */ + 1929| _saveCurrentTabState() { + 1930| const cache = this._tabCache[this._activeTabId]; + 1931| if (!cache) return; + 1932| + 1933| const area = document.getElementById("content-area"); + 1934| const rendered = document.getElementById("file-rendered-content"); + 1935| + 1936| cache.scrollTop = area.scrollTop; + 1937| cache.sourceView = rendered ? rendered.style.display === "none" : false; + 1938| }, + 1939| + 1940| /** Restore tab content from cache */ + 1941| _restoreTabContent(cache, area) { + 1942| renderFile(cache.data); + 1943| if (cache.sourceView) { + 1944| this._restoreSourceView(cache, area); + 1945| } + 1946| if (cache.scrollTop) { + 1947| area.scrollTop = cache.scrollTop; + 1948| } + 1949| }, + 1950| + 1951| async _toggleSourceView(cache, area) { + 1952| const rendered = document.getElementById("file-rendered-content"); + 1953| const raw = document.getElementById("file-raw-content"); + 1954| if (!rendered || !raw) return; + 1955| + 1956| if (!cache.rawSource) { + 1957| const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`); + 1958| cache.rawSource = rawData.raw; + 1959| } + 1960| raw.textContent = cache.rawSource; + 1961| rendered.style.display = "none"; + 1962| raw.style.display = "block"; + 1963| }, + 1964| + 1965| _restoreSourceView(cache, area) { + 1966| requestAnimationFrame(() => { + 1967| const rendered = document.getElementById("file-rendered-content"); + 1968| const raw = document.getElementById("file-raw-content"); + 1969| if (rendered && raw && cache.rawSource) { + 1970| raw.textContent = cache.rawSource; + 1971| rendered.style.display = "none"; + 1972| raw.style.display = "block"; + 1973| } + 1974| }); + 1975| }, + 1976| + 1977| _showDashboard() { + 1978| const area = document.getElementById("content-area"); + 1979| // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) + 1980| let dashboard = document.getElementById("dashboard-home"); + 1981| if (!dashboard) { + 1982| // Dashboard was destroyed — rebuild via showWelcome + 1983| area.innerHTML = ""; + 1984| showWelcome(); + 1985| return; + 1986| } + 1987| area.innerHTML = ""; + 1988| dashboard.style.display = ""; + 1989| area.appendChild(dashboard); + 1990| // Refresh widgets after restoring + 1991| if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load(); + 1992| if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load(); + 1993| if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(state.selectedContextVault); + 1994| if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(state.selectedContextVault); + 1995| if (history.pushState) { + 1996| history.pushState(null, "", "#"); + 1997| } + 1998| }, + 1999| + 2000| /** Render the tab bar */ + 2001| \ No newline at end of file diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 5687d32..bd0f053 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -1,510 +1,511 @@ -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 }; +import { state } from './state.js'; + 2| + 3|// --------------------------------------------------------------------------- + 4|// File extension → Lucide icon mapping + 5|// --------------------------------------------------------------------------- + 6|const EXT_ICONS = { + 7| // Text files + 8| ".md": "file-text", + 9| ".txt": "file-text", + 10| ".log": "file-text", + 11| ".readme": "file-text", + 12| ".rst": "file-text", + 13| ".adoc": "file-text", + 14| + 15| // Web development + 16| ".html": "file-code", + 17| ".htm": "file-code", + 18| ".css": "file-code", + 19| ".scss": "file-code", + 20| ".sass": "file-code", + 21| ".less": "file-code", + 22| ".js": "file-code", + 23| ".jsx": "file-code", + 24| ".ts": "file-code", + 25| ".tsx": "file-code", + 26| ".vue": "file-code", + 27| ".svelte": "file-code", + 28| + 29| // Programming languages + 30| ".py": "file-code", + 31| ".java": "file-code", + 32| ".c": "file-code", + 33| ".cpp": "file-code", + 34| ".cc": "file-code", + 35| ".cxx": "file-code", + 36| ".h": "file-code", + 37| ".hpp": "file-code", + 38| ".cs": "file-code", + 39| ".go": "file-code", + 40| ".rs": "file-code", + 41| ".rb": "file-code", + 42| ".php": "file-code", + 43| ".swift": "file-code", + 44| ".kt": "file-code", + 45| ".scala": "file-code", + 46| ".r": "file-code", + 47| ".m": "file-code", + 48| ".pl": "file-code", + 49| ".lua": "file-code", + 50| ".dart": "file-code", + 51| ".nim": "file-code", + 52| ".zig": "file-code", + 53| ".odin": "file-code", + 54| ".v": "file-code", + 55| ".cr": "file-code", + 56| ".ex": "file-code", + 57| ".exs": "file-code", + 58| ".elm": "file-code", + 59| ".purs": "file-code", + 60| ".hs": "file-code", + 61| ".ml": "file-code", + 62| ".ocaml": "file-code", + 63| ".fs": "file-code", + 64| ".fsx": "file-code", + 65| ".vb": "file-code", + 66| ".pas": "file-code", + 67| ".pp": "file-code", + 68| ".inc": "file-code", + 69| + 70| // Data formats + 71| ".json": "file-json", + 72| ".yaml": "file-cog", + 73| ".yml": "file-cog", + 74| ".toml": "file-cog", + 75| ".xml": "file-code", + 76| ".csv": "table", + 77| ".tsv": "table", + 78| ".sql": "database", + 79| ".db": "database", + 80| ".sqlite": "database", + 81| ".sqlite3": "database", + 82| ".parquet": "database", + 83| ".avro": "database", + 84| + 85| // Configuration files + 86| ".ini": "file-cog", + 87| ".cfg": "file-cog", + 88| ".conf": "file-cog", + 89| ".env": "file-cog", + 90| ".dockerfile": "file-cog", + 91| ".gitignore": "file-cog", + 92| ".gitattributes": "file-cog", + 93| ".editorconfig": "file-cog", + 94| ".eslintrc": "file-cog", + 95| ".prettierrc": "file-cog", + 96| ".babelrc": "file-cog", + 97| ".tsconfig": "file-cog", + 98| "package.json": "file-cog", + 99| "package-lock.json": "file-cog", + 100| "yarn.lock": "file-cog", + 101| "composer.json": "file-cog", + 102| "requirements.txt": "file-cog", + 103| "pipfile": "file-cog", + 104| "gemfile": "file-cog", + 105| "cargo.toml": "file-cog", + 106| "go.mod": "file-cog", + 107| "go.sum": "file-cog", + 108| "pom.xml": "file-cog", + 109| "build.gradle": "file-cog", + 110| "cmakelists.txt": "file-cog", + 111| "makefile": "file-cog", + 112| + 113| // Shell scripts + 114| ".sh": "terminal", + 115| ".bash": "terminal", + 116| ".zsh": "terminal", + 117| ".fish": "terminal", + 118| ".bat": "terminal", + 119| ".cmd": "terminal", + 120| ".ps1": "terminal", + 121| ".psm1": "terminal", + 122| ".psd1": "terminal", + 123| + 124| // Document formats + 125| ".pdf": "file-text", + 126| ".doc": "file-text", + 127| ".docx": "file-text", + 128| ".rtf": "file-text", + 129| ".odt": "file-text", + 130| ".tex": "file-text", + 131| ".latex": "file-text", + 132| + 133| // Image files + 134| ".png": "file-image", + 135| ".jpg": "file-image", + 136| ".jpeg": "file-image", + 137| ".gif": "file-image", + 138| ".svg": "file-image", + 139| ".webp": "file-image", + 140| ".bmp": "file-image", + 141| ".ico": "file-image", + 142| ".tiff": "file-image", + 143| ".tif": "file-image", + 144| + 145| // Audio files + 146| ".mp3": "file-music", + 147| ".wav": "file-music", + 148| ".flac": "file-music", + 149| ".aac": "file-music", + 150| ".ogg": "file-music", + 151| ".m4a": "file-music", + 152| ".wma": "file-music", + 153| + 154| // Video files + 155| ".mp4": "play", + 156| ".avi": "play", + 157| ".mov": "play", + 158| ".wmv": "play", + 159| ".flv": "play", + 160| ".webm": "play", + 161| ".mkv": "play", + 162| ".m4v": "play", + 163| ".3gp": "play", + 164| + 165| // Archive files + 166| ".zip": "file-archive", + 167| ".rar": "file-archive", + 168| ".7z": "file-archive", + 169| ".tar": "file-archive", + 170| ".gz": "file-archive", + 171| ".tgz": "file-archive", + 172| ".bz2": "file-archive", + 173| ".xz": "file-archive", + 174| ".deb": "file-archive", + 175| ".rpm": "file-archive", + 176| ".dmg": "file-archive", + 177| ".pkg": "file-archive", + 178| ".msi": "file-archive", + 179| ".exe": "file-archive", + 180| + 181| // Font files + 182| ".ttf": "file-type", + 183| ".otf": "file-type", + 184| ".woff": "file-type", + 185| ".woff2": "file-type", + 186| ".eot": "file-type", + 187| + 188| // Other common files + 189| ".key": "file-cog", + 190| ".pem": "file-cog", + 191| ".crt": "file-cog", + 192| ".cert": "file-cog", + 193| ".p12": "file-cog", + 194| ".pfx": "file-cog", + 195| ".lock": "file-cog", + 196| ".tmp": "file", + 197| ".bak": "file", + 198| ".old": "file", + 199| ".orig": "file", + 200| ".save": "file", + 201|}; + 202| + 203|function getFileIcon(name) { + 204| const ext = "." + name.split(".").pop().toLowerCase(); + 205| return EXT_ICONS[ext] || "file"; + 206|} + 207| + 208|// --------------------------------------------------------------------------- + 209|// Safe CDN helpers + 210|// --------------------------------------------------------------------------- + 211| + 212|let state._iconDebounceTimer = null; + 213| + 214|/** + 215| * Debounced icon creation — batches multiple rapid calls into one + 216| * DOM scan to avoid excessive reflows when building large trees. + 217| */ + 218|function safeCreateIcons() { + 219| if (typeof lucide === "undefined" || !lucide.createIcons) return; + 220| if (state._iconDebounceTimer) return; // already scheduled + 221| state._iconDebounceTimer = requestAnimationFrame(() => { + 222| state._iconDebounceTimer = null; + 223| try { + 224| lucide.createIcons(); + 225| } catch (e) { + 226| /* CDN not loaded */ + 227| } + 228| }); + 229|} + 230| + 231|/** Force-flush icon creation immediately (use sparingly). */ + 232|function flushIcons() { + 233| if (state._iconDebounceTimer) { + 234| cancelAnimationFrame(state._iconDebounceTimer); + 235| state._iconDebounceTimer = null; + 236| } + 237| if (typeof lucide !== "undefined" && lucide.createIcons) { + 238| try { + 239| lucide.createIcons(); + 240| } catch (e) { + 241| /* CDN not loaded */ + 242| } + 243| } + 244|} + 245| + 246|function safeHighlight(block) { + 247| if (typeof hljs !== "undefined" && hljs.highlightElement) { + 248| try { + 249| hljs.highlightElement(block); + 250| } catch (e) { + 251| /* CDN not loaded */ + 252| } + 253| } + 254|} + 255| + 256|// --------------------------------------------------------------------------- + 257|// Helpers + 258|// --------------------------------------------------------------------------- + 259|function escapeHtml(str) { + 260| if (!str) return ""; + 261| return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + 262|} + 263| + 264|// --------------------------------------------------------------------------- + 265|// Editor (CodeMirror) + 266|// --------------------------------------------------------------------------- + 267|async function openEditor(vaultName, filePath) { + 268| state.editorVault = vaultName; + 269| state.editorPath = filePath; + 270| + 271| const modal = document.getElementById("editor-modal"); + 272| const titleEl = document.getElementById("editor-title"); + 273| const bodyEl = document.getElementById("editor-body"); + 274| + 275| titleEl.textContent = `Édition: ${filePath.split("/").pop()}`; + 276| + 277| // Fetch raw content + 278| const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; + 279| const rawData = await api(rawUrl); + 280| + 281| // Clear previous editor + 282| bodyEl.innerHTML = ""; + 283| if (state.editorView) { + 284| state.editorView.destroy(); + 285| state.editorView = null; + 286| } + 287| state.fallbackEditorEl = null; + 288| + 289| try { + 290| await waitForCodeMirror(); + 291| + 292| const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror; + 293| + 294| const currentTheme = document.documentElement.getAttribute("data-theme"); + 295| const fileExt = filePath.split(".").pop().toLowerCase(); + 296| + 297| const extensions = [ + 298| basicSetup, + 299| keymap.of([ + 300| { + 301| key: "Mod-s", + 302| run: () => { + 303| saveFile(); + 304| return true; + 305| }, + 306| }, + 307| ]), + 308| EditorView.lineWrapping, + 309| ]; + 310| + 311| // Add language support based on file extension + 312| const langMap = { + 313| md: markdown, + 314| markdown: markdown, + 315| py: python, + 316| js: javascript, + 317| jsx: javascript, + 318| ts: javascript, + 319| tsx: javascript, + 320| mjs: javascript, + 321| cjs: javascript, + 322| html: html, + 323| htm: html, + 324| css: css, + 325| scss: css, + 326| less: css, + 327| json: json, + 328| xml: xml, + 329| svg: xml, + 330| sql: sql, + 331| php: php, + 332| cpp: cpp, + 333| cc: cpp, + 334| cxx: cpp, + 335| c: cpp, + 336| h: cpp, + 337| hpp: cpp, + 338| java: java, + 339| rs: rust, + 340| sh: javascript, // Using javascript for shell scripts as fallback + 341| bash: javascript, + 342| zsh: javascript, + 343| }; + 344| + 345| const langMode = langMap[fileExt]; + 346| if (langMode) { + 347| extensions.push(langMode()); + 348| } + 349| + 350| if (currentTheme === "dark") { + 351| extensions.push(oneDark); + 352| } + 353| + 354| const state = EditorState.create({ + 355| doc: rawData.raw, + 356| extensions: extensions, + 357| }); + 358| + 359| state.editorView = new EditorView({ + 360| state: state, + 361| parent: bodyEl, + 362| }); + 363| } catch (err) { + 364| console.error("CodeMirror init failed, falling back to textarea:", err); + 365| state.fallbackEditorEl = document.createElement("textarea"); + 366| state.fallbackEditorEl.className = "fallback-editor"; + 367| state.fallbackEditorEl.value = rawData.raw; + 368| bodyEl.appendChild(state.fallbackEditorEl); + 369| } + 370| + 371| modal.classList.add("active"); + 372| safeCreateIcons(); + 373|} + 374| + 375|async function waitForCodeMirror() { + 376| let attempts = 0; + 377| while (!window.CodeMirror && attempts < 50) { + 378| await new Promise((resolve) => setTimeout(resolve, 100)); + 379| attempts++; + 380| } + 381| if (!window.CodeMirror) { + 382| throw new Error("CodeMirror failed to load"); + 383| } + 384|} + 385| + 386|function closeEditor() { + 387| const modal = document.getElementById("editor-modal"); + 388| modal.classList.remove("active"); + 389| if (state.editorView) { + 390| state.editorView.destroy(); + 391| state.editorView = null; + 392| } + 393| state.fallbackEditorEl = null; + 394| state.editorVault = null; + 395| state.editorPath = null; + 396|} + 397| + 398|async function saveFile() { + 399| if ((!editorView && !state.fallbackEditorEl) || !editorVault || !state.editorPath) return; + 400| + 401| const content = editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value; + 402| const saveBtn = document.getElementById("editor-save"); + 403| const originalHTML = saveBtn.innerHTML; + 404| + 405| try { + 406| saveBtn.disabled = true; + 407| saveBtn.innerHTML = ''; + 408| safeCreateIcons(); + 409| + 410| const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}/save?path=${encodeURIComponent(state.editorPath)}`, { + 411| method: "PUT", + 412| headers: { "Content-Type": "application/json" }, + 413| body: JSON.stringify({ content }), + 414| }); + 415| + 416| if (!response.ok) { + 417| const error = await response.json(); + 418| throw new Error(error.detail || "Erreur de sauvegarde"); + 419| } + 420| + 421| saveBtn.innerHTML = ''; + 422| safeCreateIcons(); + 423| + 424| setTimeout(() => { + 425| closeEditor(); + 426| if (state.currentVault === editorVault && state.currentPath === state.editorPath) { + 427| openFile(state.currentVault, state.currentPath); + 428| } + 429| }, 800); + 430| } catch (err) { + 431| console.error("Save error:", err); + 432| alert(`Erreur: ${err.message}`); + 433| saveBtn.innerHTML = originalHTML; + 434| saveBtn.disabled = false; + 435| safeCreateIcons(); + 436| } + 437|} + 438| + 439|async function deleteFile() { + 440| if (!editorVault || !state.editorPath) return; + 441| + 442| const deleteBtn = document.getElementById("editor-delete"); + 443| const originalHTML = deleteBtn.innerHTML; + 444| + 445| try { + 446| deleteBtn.disabled = true; + 447| deleteBtn.innerHTML = ''; + 448| safeCreateIcons(); + 449| + 450| const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}?path=${encodeURIComponent(state.editorPath)}`, { method: "DELETE" }); + 451| + 452| if (!response.ok) { + 453| const error = await response.json(); + 454| throw new Error(error.detail || "Erreur de suppression"); + 455| } + 456| + 457| closeEditor(); + 458| showWelcome(); + 459| await refreshSidebarForContext(); + 460| await refreshTagsForContext(); + 461| } catch (err) { + 462| console.error("Delete error:", err); + 463| alert(`Erreur: ${err.message}`); + 464| deleteBtn.innerHTML = originalHTML; + 465| deleteBtn.disabled = false; + 466| safeCreateIcons(); + 467| } + 468|} + 469| + 470|function initEditor() { + 471| const cancelBtn = document.getElementById("editor-cancel"); + 472| const deleteBtn = document.getElementById("editor-delete"); + 473| const saveBtn = document.getElementById("editor-save"); + 474| const modal = document.getElementById("editor-modal"); + 475| + 476| cancelBtn.addEventListener("click", closeEditor); + 477| deleteBtn.addEventListener("click", deleteFile); + 478| saveBtn.addEventListener("click", saveFile); + 479| + 480| // Close on overlay click + 481| modal.addEventListener("click", (e) => { + 482| if (e.target === modal) { + 483| closeEditor(); + 484| } + 485| }); + 486| + 487| // ESC to close + 488| document.addEventListener("keydown", (e) => { + 489| if (e.key === "Escape" && modal.classList.contains("active")) { + 490| closeEditor(); + 491| } + 492| }); + 493| + 494| // Fix mouse wheel scrolling in editor + 495| modal.addEventListener( + 496| "wheel", + 497| (e) => { + 498| const editorBody = document.getElementById("editor-body"); + 499| if (editorBody && editorBody.contains(e.target)) { + 500| // Let the editor handle the scroll + 501| return; + 502| } + 503| // Prevent modal from scrolling if not in editor area + 504| e.preventDefault(); + 505| }, + 506| { passive: false }, + 507| ); + 508|} + 509| + 510|export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor }; + 511| \ No newline at end of file diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 0bf741b..cc69d7e 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1,1554 +1,1555 @@ -/* 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 }; - + 1|/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */ +import { state } from './state.js'; + 3|import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from "./utils.js"; + 4| + 5|// initEditor is defined in utils.js — re-exported below. + 6| + 7|// --------------------------------------------------------------------------- + 8|// Outline/TOC Manager + 9|// --------------------------------------------------------------------------- + 10| + 11|const OutlineManager = { + 12| /** + 13| * Slugify text to create valid IDs + 14| */ + 15| slugify(text) { + 16| return ( + 17| text + 18| .toLowerCase() + 19| .normalize("NFD") + 20| .replace(/[\u0300-\u036f]/g, "") + 21| .replace(/[^\p{L}\p{N}\s-]/gu, "") + 22| .replace(/\s+/g, "-") + 23| .replace(/-+/g, "-") + 24| .trim() || "heading" + 25| ); + 26| }, + 27| + 28| /** + 29| * Parse headings from markdown content + 30| */ + 31| parseHeadings() { + 32| const contentArea = document.querySelector(".md-content"); + 33| if (!contentArea) return []; + 34| + 35| const headings = []; + 36| const h2s = contentArea.querySelectorAll("h2"); + 37| const h3s = contentArea.querySelectorAll("h3"); + 38| const allHeadings = [...h2s, ...h3s].sort((a, b) => { + 39| return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + 40| }); + 41| + 42| const usedIds = new Map(); + 43| + 44| allHeadings.forEach((heading) => { + 45| const text = heading.textContent.trim(); + 46| if (!text) return; + 47| + 48| const level = parseInt(heading.tagName[1]); + 49| let id = this.slugify(text); + 50| + 51| // Handle duplicate IDs + 52| if (usedIds.has(id)) { + 53| const count = usedIds.get(id) + 1; + 54| usedIds.set(id, count); + 55| id = `${id}-${count}`; + 56| } else { + 57| usedIds.set(id, 1); + 58| } + 59| + 60| // Inject ID into heading if not present + 61| if (!heading.id) { + 62| heading.id = id; + 63| } else { + 64| id = heading.id; + 65| } + 66| + 67| headings.push({ + 68| id, + 69| level, + 70| text, + 71| element: heading, + 72| }); + 73| }); + 74| + 75| return headings; + 76| }, + 77| + 78| /** + 79| * Render outline list + 80| */ + 81| renderOutline(headings) { + 82| const outlineList = document.getElementById("outline-list"); + 83| const outlineEmpty = document.getElementById("outline-empty"); + 84| + 85| if (!outlineList) return; + 86| + 87| outlineList.innerHTML = ""; + 88| + 89| if (!headings || headings.length === 0) { + 90| outlineList.hidden = true; + 91| if (outlineEmpty) { + 92| outlineEmpty.hidden = false; + 93| safeCreateIcons(); + 94| } + 95| return; + 96| } + 97| + 98| outlineList.hidden = false; + 99| if (outlineEmpty) outlineEmpty.hidden = true; + 100| + 101| headings.forEach((heading) => { + 102| const item = el( + 103| "a", + 104| { + 105| class: `outline-item level-${heading.level}`, + 106| href: `#${heading.id}`, + 107| "data-heading-id": heading.id, + 108| role: "link", + 109| }, + 110| [document.createTextNode(heading.text)], + 111| ); + 112| + 113| item.addEventListener("click", (e) => { + 114| e.preventDefault(); + 115| this.scrollToHeading(heading.id); + 116| }); + 117| + 118| outlineList.appendChild(item); + 119| }); + 120| + 121| state.headingsCache = headings; + 122| }, + 123| + 124| /** + 125| * Scroll to heading with smooth behavior + 126| */ + 127| scrollToHeading(headingId) { + 128| const heading = document.getElementById(headingId); + 129| if (!heading) return; + 130| + 131| const contentArea = document.getElementById("content-area"); + 132| if (!contentArea) return; + 133| + 134| // Calculate offset for fixed header (if any) + 135| const headerHeight = 80; + 136| const headingTop = heading.offsetTop; + 137| + 138| contentArea.scrollTo({ + 139| top: headingTop - headerHeight, + 140| behavior: "smooth", + 141| }); + 142| + 143| // Update active state immediately + 144| this.setActiveHeading(headingId); + 145| }, + 146| + 147| /** + 148| * Set active heading in outline + 149| */ + 150| setActiveHeading(headingId) { + 151| if (state.activeHeadingId === headingId) return; + 152| + 153| state.activeHeadingId = headingId; + 154| + 155| const items = document.querySelectorAll(".outline-item"); + 156| items.forEach((item) => { + 157| if (item.getAttribute("data-heading-id") === headingId) { + 158| item.classList.add("active"); + 159| item.setAttribute("aria-current", "location"); + 160| // Scroll outline item into view + 161| item.scrollIntoView({ block: "nearest", behavior: "smooth" }); + 162| } else { + 163| item.classList.remove("active"); + 164| item.removeAttribute("aria-current"); + 165| } + 166| }); + 167| }, + 168| + 169| /** + 170| * Initialize outline for current document + 171| */ + 172| init() { + 173| const headings = this.parseHeadings(); + 174| this.renderOutline(headings); + 175| ScrollSpyManager.init(headings); + 176| ReadingProgressManager.init(); + 177| }, + 178| + 179| /** + 180| * Cleanup + 181| */ + 182| destroy() { + 183| ScrollSpyManager.destroy(); + 184| ReadingProgressManager.destroy(); + 185| state.headingsCache = []; + 186| state.activeHeadingId = null; + 187| }, + 188|}; + 189| + 190| + 191|// --------------------------------------------------------------------------- + 192|// Scroll Spy Manager + 193|// --------------------------------------------------------------------------- + 194| + 195|const ScrollSpyManager = { + 196| observer: null, + 197| headings: [], + 198| + 199| init(headings) { + 200| this.destroy(); + 201| this.headings = headings; + 202| + 203| if (!headings || headings.length === 0) return; + 204| + 205| const contentArea = document.getElementById("content-area"); + 206| if (!contentArea) return; + 207| + 208| const options = { + 209| root: contentArea, + 210| rootMargin: "-20% 0px -70% 0px", + 211| threshold: [0, 0.3, 0.5, 1.0], + 212| }; + 213| + 214| this.observer = new IntersectionObserver((entries) => { + 215| // Find the most visible heading + 216| let mostVisible = null; + 217| let maxRatio = 0; + 218| + 219| entries.forEach((entry) => { + 220| if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { + 221| maxRatio = entry.intersectionRatio; + 222| mostVisible = entry.target; + 223| } + 224| }); + 225| + 226| if (mostVisible && mostVisible.id) { + 227| OutlineManager.setActiveHeading(mostVisible.id); + 228| } + 229| }, options); + 230| + 231| // Observe all headings + 232| headings.forEach((heading) => { + 233| if (heading.element) { + 234| this.observer.observe(heading.element); + 235| } + 236| }); + 237| }, + 238| + 239| destroy() { + 240| if (this.observer) { + 241| this.observer.disconnect(); + 242| this.observer = null; + 243| } + 244| this.headings = []; + 245| }, + 246|}; + 247| + 248| + 249|// --------------------------------------------------------------------------- + 250|// Reading Progress Manager + 251|// --------------------------------------------------------------------------- + 252| + 253|const ReadingProgressManager = { + 254| scrollHandler: null, + 255| + 256| init() { + 257| this.destroy(); + 258| + 259| const contentArea = document.getElementById("content-area"); + 260| if (!contentArea) return; + 261| + 262| this.scrollHandler = this.throttle(() => { + 263| this.updateProgress(); + 264| }, 100); + 265| + 266| contentArea.addEventListener("scroll", this.scrollHandler); + 267| this.updateProgress(); + 268| }, + 269| + 270| updateProgress() { + 271| const contentArea = document.getElementById("content-area"); + 272| const progressFill = document.getElementById("reading-progress-fill"); + 273| const progressText = document.getElementById("reading-progress-text"); + 274| + 275| if (!contentArea || !progressFill || !progressText) return; + 276| + 277| const scrollTop = contentArea.scrollTop; + 278| const scrollHeight = contentArea.scrollHeight; + 279| const clientHeight = contentArea.clientHeight; + 280| + 281| const maxScroll = scrollHeight - clientHeight; + 282| const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; + 283| + 284| progressFill.style.width = `${percentage}%`; + 285| progressText.textContent = `${percentage}%`; + 286| }, + 287| + 288| throttle(func, delay) { + 289| let lastCall = 0; + 290| return function (...args) { + 291| const now = Date.now(); + 292| if (now - lastCall >= delay) { + 293| lastCall = now; + 294| func.apply(this, args); + 295| } + 296| }; + 297| }, + 298| + 299| destroy() { + 300| const contentArea = document.getElementById("content-area"); + 301| if (contentArea && this.scrollHandler) { + 302| contentArea.removeEventListener("scroll", this.scrollHandler); + 303| } + 304| this.scrollHandler = null; + 305| + 306| // Reset progress + 307| const progressFill = document.getElementById("reading-progress-fill"); + 308| const progressText = document.getElementById("reading-progress-text"); + 309| if (progressFill) progressFill.style.width = "0%"; + 310| if (progressText) progressText.textContent = "0%"; + 311| }, + 312|}; + 313| + 314| + 315|// --------------------------------------------------------------------------- + 316|// File viewer + 317|// --------------------------------------------------------------------------- + 318|async function openFile(vaultName, filePath) { + 319| state.currentVault = vaultName; + 320| state.currentPath = filePath; + 321| state.showingSource = false; + 322| state.cachedRawSource = null; + 323| + 324| // Highlight active + 325| syncActiveFileTreeItem(vaultName, filePath); + 326| + 327| // Show loading state while fetching + 328| const area = document.getElementById("content-area"); + 329| area.innerHTML = '
    Chargement...
    '; + 330| + 331| try { + 332| const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; + 333| const data = await api(url); + 334| renderFile(data); + 335| } catch (err) { + 336| area.innerHTML = '

    Impossible de charger le fichier.

    '; + 337| } + 338|} + 339| + 340|async function renderBacklinksPanel(vault, path, container) { + 341| try { + 342| const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`); + 343| if (!data.backlinks || data.backlinks.length === 0) return; + 344| + 345| const panel = el("div", { class: "backlinks-panel" }); + 346| const header = el("div", { class: "backlinks-header" }, [ + 347| icon("link", 14), + 348| document.createTextNode(` ${data.total} lien(s) entrant(s)`), + 349| ]); + 350| panel.appendChild(header); + 351| + 352| const list = el("div", { class: "backlinks-list" }); + 353| data.backlinks.forEach((bl) => { + 354| const item = el("div", { class: "backlink-item" }); + 355| const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]); + 356| const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]); + 357| item.appendChild(icon(getFileIcon(bl.path), 12)); + 358| item.appendChild(vaultBadge); + 359| item.appendChild(titleEl); + 360| item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path)); + 361| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); }); + 362| list.appendChild(item); + 363| }); + 364| panel.appendChild(list); + 365| container.appendChild(panel); + 366| } catch (err) { + 367| // Silently ignore — backlinks are optional + 368| console.debug("Backlinks fetch failed:", err); + 369| } + 370|} + 371| + 372|function renderFile(data) { + 373| const area = document.getElementById("content-area"); + 374| + 375| // Handle unsupported (binary) files + 376| if (data.unsupported) { + 377| const sizeStr = data.size_bytes + 378| ? data.size_bytes < 1024 ? `${data.size_bytes} o` + 379| : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko` + 380| : `${(data.size_bytes / 1048576).toFixed(1)} Mo` + 381| : ""; + 382| area.innerHTML = ` + 383|
    + 384| + 385|
    ${escapeHtml(data.path.split("/").pop())}
    + 386|
    Ce fichier est binaire et ne peut pas être affiché.
    + 387| ${sizeStr ? `
    Taille : ${sizeStr}
    ` : ""} + 388| + 391|
    `; + 392| lucide.createIcons(); + 393| document.getElementById("unsupported-download-btn").addEventListener("click", () => { + 394| const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; + 395| window.open(dlUrl, "_blank"); + 396| }); + 397| return; + 398| } + 399| + 400| // Breadcrumb + 401| const parts = data.path.split("/"); + 402| const breadcrumbEls = []; + 403| breadcrumbEls.push( + 404| makeBreadcrumbSpan(data.vault, () => { + 405| focusPathInSidebar(data.vault, "", { alignToTop: "center" }); + 406| }), + 407| ); + 408| let accumulated = ""; + 409| parts.forEach((part, i) => { + 410| breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); + 411| accumulated += (accumulated ? "/" : "") + part; + 412| const p = accumulated; + 413| if (i < parts.length - 1) { + 414| breadcrumbEls.push( + 415| makeBreadcrumbSpan(part, () => { + 416| focusPathInSidebar(data.vault, p, { alignToTop: "center" }); + 417| }), + 418| ); + 419| } else { + 420| breadcrumbEls.push( + 421| makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { + 422| focusPathInSidebar(data.vault, data.path, { alignToTop: "center" }); + 423| }), + 424| ); + 425| } + 426| }); + 427| + 428| const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); + 429| + 430| // Tags + 431| const tagsDiv = el("div", { class: "file-tags" }); + 432| (data.tags || []).forEach((tag) => { + 433| if (!TagFilterService.isTagFiltered(tag)) { + 434| const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + 435| t.addEventListener("click", () => searchByTag(tag)); + 436| tagsDiv.appendChild(t); + 437| } + 438| }); + 439| + 440| // Action buttons + 441| const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]); + 442| copyBtn.addEventListener("click", async () => { + 443| try { + 444| // Fetch raw content if not already cached + 445| if (!state.cachedRawSource) { + 446| const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; + 447| const rawData = await api(rawUrl); + 448| state.cachedRawSource = rawData.raw; + 449| } + 450| await navigator.clipboard.writeText(state.cachedRawSource); + 451| copyBtn.lastChild.textContent = "Copié !"; + 452| setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500); + 453| } catch (err) { + 454| console.error("Copy error:", err); + 455| showToast("Erreur lors de la copie", "error"); + 456| } + 457| }); + 458| + 459| const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]); + 460| + 461| // MD download button + 462| const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]); + 463| mdBtn.addEventListener("click", () => { + 464| const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; + 465| const a = document.createElement("a"); + 466| a.href = dlUrl; + 467| a.download = data.path.split("/").pop(); + 468| document.body.appendChild(a); + 469| a.click(); + 470| document.body.removeChild(a); + 471| }); + 472| + 473| // PDF download button + 474| const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]); + 475| pdfBtn.addEventListener("click", () => { + 476| const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; + 477| window.open(pdfUrl, "_blank"); + 478| }); + 479| + 480| const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]); + 481| editBtn.addEventListener("click", () => { + 482| openEditor(data.vault, data.path); + 483| }); + 484| + 485| const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]); + 486| openNewWindowBtn.addEventListener("click", () => { + 487| const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`; + 488| 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"); + 489| }); + 490| + 491| const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]); + 492| tocBtn.addEventListener("click", () => { + 493| RightSidebarManager.toggle(); + 494| }); + 495| + 496| // Share button — check if already shared + 497| const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); + 498| // Check if already shared and color the button + 499| (async () => { + 500| try { + 501| const shares = await api("/api/shares"); + 502| if (shares.some(s => s.vault === data.vault && s.path === data.path)) { + 503| shareBtn.classList.add("shared"); + 504| shareBtn.title = "Document partagé — cliquer pour gérer"; + 505| } + 506| } catch (e) { /* ignore */ } + 507| })(); + 508| shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); + 509| + 510| // Bookmark button — check if already bookmarked + 511| const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); + 512| // Check bookmark status and color the button + 513| (async () => { + 514| try { + 515| const bms = await api("/api/bookmarks"); + 516| if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) { + 517| bookmarkBtn.classList.add("active"); + 518| bookmarkBtn.title = "Retirer des bookmarks"; + 519| } + 520| } catch (e) { /* ignore */ } + 521| })(); + 522| bookmarkBtn.addEventListener("click", async () => { + 523| try { + 524| 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 }) }); + 525| bookmarkBtn.classList.toggle("active", res.bookmarked); + 526| bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; + 527| showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); + 528| if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(); + 529| } catch (err) { showToast("Erreur: " + err.message, "error"); } + 530| }); + 531| + 532| // Frontmatter — Accent Card + 533| let fmSection = null; + 534| if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { + 535| fmSection = buildFrontmatterCard(data.frontmatter); + 536| } + 537| + 538| // Content container (rendered HTML) + 539| const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); + 540| mdDiv.innerHTML = data.html; + 541| + 542| // Raw source container (hidden initially) + 543| const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); + 544| + 545| // Source button toggle logic + 546| sourceBtn.addEventListener("click", async () => { + 547| const rendered = document.getElementById("file-rendered-content"); + 548| const raw = document.getElementById("file-raw-content"); + 549| if (!rendered || !raw) return; + 550| + 551| state.showingSource = !state.showingSource; + 552| if (state.showingSource) { + 553| sourceBtn.classList.add("active"); + 554| if (!state.cachedRawSource) { + 555| const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; + 556| const rawData = await api(rawUrl); + 557| state.cachedRawSource = rawData.raw; + 558| } + 559| raw.textContent = state.cachedRawSource; + 560| rendered.style.display = "none"; + 561| raw.style.display = "block"; + 562| } else { + 563| sourceBtn.classList.remove("active"); + 564| rendered.style.display = "block"; + 565| raw.style.display = "none"; + 566| } + 567| }); + 568| + 569| // Assemble + 570| area.innerHTML = ""; + 571| area.appendChild(breadcrumb); + 572| 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])])); + 573| if (fmSection) area.appendChild(fmSection); + 574| area.appendChild(mdDiv); + 575| area.appendChild(rawDiv); + 576| + 577| // Backlinks panel + 578| if (data.is_markdown) { + 579| renderBacklinksPanel(data.vault, data.path, area); + 580| } + 581| + 582| // Highlight code blocks + 583| area.querySelectorAll("pre code").forEach((block) => { + 584| safeHighlight(block); + 585| }); + 586| + 587| // Wire up wikilinks + 588| area.querySelectorAll(".wikilink").forEach((link) => { + 589| link.addEventListener("click", (e) => { + 590| e.preventDefault(); + 591| const v = link.getAttribute("data-vault"); + 592| const p = link.getAttribute("data-path"); + 593| if (v && p) openFile(v, p); + 594| }); + 595| }); + 596| + 597| safeCreateIcons(); + 598| area.scrollTop = 0; + 599| + 600| // Initialize outline/TOC for this document + 601| OutlineManager.init(); + 602|} + 603| + 604| + 605|function buildFrontmatterCard(frontmatter) { + 606| // Helper: format date + 607| function formatDate(iso) { + 608| if (!iso) return "—"; + 609| const d = new Date(iso); + 610| const date = d.toISOString().slice(0, 10); + 611| const time = d.toTimeString().slice(0, 5); + 612| return `${date} · ${time}`; + 613| } + 614| + 615| // Extract boolean flags + 616| const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); + 617| + 618| // Toggle state + 619| let isOpen = true; + 620| + 621| // Build header with chevron + 622| const chevron = el("span", { class: "fm-chevron open" }); + 623| chevron.innerHTML = ''; + 624| + 625| const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); + 626| + 627| // ZONE 1: Top strip + 628| const topBadges = []; + 629| + 630| // Title badge + 631| const title = frontmatter.titre || frontmatter.title || ""; + 632| if (title) { + 633| topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); + 634| } + 635| + 636| // Status badge + 637| if (frontmatter.statut) { + 638| const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); + 639| topBadges.push(statusBadge); + 640| } + 641| + 642| // Category badge + 643| if (frontmatter.catégorie || frontmatter.categorie) { + 644| const cat = frontmatter.catégorie || frontmatter.categorie; + 645| const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); + 646| topBadges.push(catBadge); + 647| } + 648| + 649| // Publish badge + 650| if (frontmatter.publish) { + 651| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); + 652| } + 653| + 654| // Favoris badge + 655| if (frontmatter.favoris) { + 656| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); + 657| } + 658| + 659| const acTop = el("div", { class: "ac-top" }, topBadges); + 660| + 661| // ZONE 2: Body 2 columns + 662| const leftCol = el("div", { class: "ac-col" }, [ + 663| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), + 664| 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 || "—")])]), + 665| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), + 666| 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(", ") : "[]")])]), + 667| ]); + 668| + 669| const rightCol = el("div", { class: "ac-col" }, [ + 670| 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))])]), + 671| 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))])]), + 672| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), + 673| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), + 674| ]); + 675| + 676| const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); + 677| + 678| // ZONE 3: Tags row + 679| const tagPills = []; + 680| if (frontmatter.tags && frontmatter.tags.length > 0) { + 681| frontmatter.tags.forEach((tag) => { + 682| tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); + 683| }); + 684| } + 685| + 686| const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); + 687| + 688| // ZONE 4: Flags row + 689| const flagChips = []; + 690| booleanFlags.forEach((flag) => { + 691| const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; + 692| flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); + 693| }); + 694| + 695| const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); + 696| + 697| // Assemble the card + 698| const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); + 699| + 700| // Toggle functionality + 701| fmHeader.addEventListener("click", () => { + 702| isOpen = !isOpen; + 703| if (isOpen) { + 704| acCard.style.display = "block"; + 705| chevron.classList.remove("closed"); + 706| chevron.classList.add("open"); + 707| } else { + 708| acCard.style.display = "none"; + 709| chevron.classList.remove("open"); + 710| chevron.classList.add("closed"); + 711| } + 712| safeCreateIcons(); + 713| }); + 714| + 715| // Wrap in section + 716| const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); + 717| + 718| return fmSection; + 719|} + 720| + 721| + 722|// --------------------------------------------------------------------------- + 723|// Helpers + 724|// --------------------------------------------------------------------------- + 725|// escapeHtml imported from utils.js above + 726| + 727|function el(tag, attrs, children) { + 728| const e = document.createElement(tag); + 729| if (attrs) { + 730| Object.entries(attrs).forEach(([k, v]) => { + 731| // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug + 732| if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) { + 733| return; + 734| } + 735| e.setAttribute(k, v); + 736| }); + 737| } + 738| if (children) { + 739| children.forEach((c) => { + 740| if (c) e.appendChild(c); + 741| }); + 742| } + 743| return e; + 744|} + 745| + 746|function icon(name, size) { + 747| const i = document.createElement("i"); + 748| i.setAttribute("data-lucide", name); + 749| i.style.width = size + "px"; + 750| i.style.height = size + "px"; + 751| i.classList.add("icon"); + 752| return i; + 753|} + 754| + 755|function smallBadge(count) { + 756| const s = document.createElement("span"); + 757| s.className = "badge-small"; + 758| s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; + 759| s.textContent = `(${count})`; + 760| return s; + 761|} + 762| + 763|function getContextMenuPositionFromElement(target) { + 764| const rect = target.getBoundingClientRect(); + 765| return { + 766| x: Math.min(rect.right - 8, window.innerWidth - 16), + 767| y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16), + 768| }; + 769|} + 770| + 771|function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) { + 772| const button = document.createElement("button"); + 773| button.type = "button"; + 774| button.className = "tree-item-action-btn"; + 775| button.setAttribute("aria-label", "Afficher le menu d’actions"); + 776| button.setAttribute("title", "Actions"); + 777| const iconEl = icon("more-vertical", 16); + 778| button.appendChild(iconEl); + 779| button.addEventListener("click", (e) => { + 780| e.preventDefault(); + 781| e.stopPropagation(); + 782| const pos = getContextMenuPositionFromElement(button); + 783| ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly); + 784| }); + 785| itemEl.appendChild(button); + 786| // Ensure Lucide icons are rendered for the button + 787| setTimeout(() => { + 788| safeCreateIcons(); + 789| }, 0); + 790|} + 791| + 792|function attachTreeItemLongPress(itemEl, getMenuData) { + 793| let pressTimer = null; + 794| let pressHandled = false; + 795| let startX = 0; + 796| let startY = 0; + 797| const longPressDelay = 550; + 798| const moveThreshold = 10; + 799| + 800| const clearPressTimer = () => { + 801| if (pressTimer) { + 802| clearTimeout(pressTimer); + 803| pressTimer = null; + 804| } + 805| }; + 806| + 807| itemEl.addEventListener("touchstart", (e) => { + 808| if (!e.touches || e.touches.length !== 1) return; + 809| pressHandled = false; + 810| startX = e.touches[0].clientX; + 811| startY = e.touches[0].clientY; + 812| clearPressTimer(); + 813| pressTimer = setTimeout(() => { + 814| const data = getMenuData(); + 815| if (!data) return; + 816| pressHandled = true; + 817| ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly); + 818| }, longPressDelay); + 819| }, { passive: true }); + 820| + 821| itemEl.addEventListener("touchmove", (e) => { + 822| if (!e.touches || e.touches.length !== 1) return; + 823| const dx = Math.abs(e.touches[0].clientX - startX); + 824| const dy = Math.abs(e.touches[0].clientY - startY); + 825| if (dx > moveThreshold || dy > moveThreshold) { + 826| clearPressTimer(); + 827| } + 828| }, { passive: true }); + 829| + 830| itemEl.addEventListener("touchend", () => { + 831| clearPressTimer(); + 832| }, { passive: true }); + 833| + 834| itemEl.addEventListener("touchcancel", () => { + 835| clearPressTimer(); + 836| }, { passive: true }); + 837| + 838| itemEl.addEventListener("click", (e) => { + 839| if (pressHandled) { + 840| e.preventDefault(); + 841| e.stopPropagation(); + 842| setTimeout(() => { + 843| pressHandled = false; + 844| }, 0); + 845| } + 846| }, true); + 847|} + 848| + 849|function getVaultIcon(vaultName, size = 16) { + 850| const v = state.allVaults.find((val) => val.name === vaultName); + 851| const type = v ? v.type : "VAULT"; + 852| + 853| if (type === "DIR") { + 854| const i = icon("folder", size); + 855| i.style.color = "#eab308"; // yellow tint + 856| return i; + 857| } else { + 858| const purple = "#8b5cf6"; + 859| const svgNS = "http://www.w3.org/2000/svg"; + 860| const svg = document.createElementNS(svgNS, "svg"); + 861| svg.setAttribute("xmlns", svgNS); + 862| svg.setAttribute("width", size); + 863| svg.setAttribute("height", size); + 864| svg.setAttribute("viewBox", "0 0 24 24"); + 865| svg.setAttribute("fill", "none"); + 866| svg.setAttribute("stroke", purple); + 867| svg.setAttribute("stroke-width", "2"); + 868| svg.setAttribute("stroke-linecap", "round"); + 869| svg.setAttribute("stroke-linejoin", "round"); + 870| svg.classList.add("icon"); + 871| + 872| const path1 = document.createElementNS(svgNS, "path"); + 873| path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z"); + 874| const path2 = document.createElementNS(svgNS, "path"); + 875| path2.setAttribute("d", "M11 3 8 9l4 12"); + 876| const path3 = document.createElementNS(svgNS, "path"); + 877| path3.setAttribute("d", "M12 21l4-12-3-6"); + 878| const path4 = document.createElementNS(svgNS, "path"); + 879| path4.setAttribute("d", "M2 9h20"); + 880| + 881| svg.appendChild(path1); + 882| svg.appendChild(path2); + 883| svg.appendChild(path3); + 884| svg.appendChild(path4); + 885| return svg; + 886| } + 887|} + 888| + 889|function makeBreadcrumbSpan(text, onClick) { + 890| const s = document.createElement("span"); + 891| s.textContent = text; + 892| if (onClick) { + 893| s.addEventListener("click", async (event) => { + 894| event.preventDefault(); + 895| if (s.dataset.busy === "true") return; + 896| s.dataset.busy = "true"; + 897| s.style.pointerEvents = "none"; + 898| try { + 899| await onClick(event); + 900| } finally { + 901| s.dataset.busy = "false"; + 902| s.style.pointerEvents = ""; + 903| } + 904| }); + 905| } + 906| return s; + 907|} + 908| + 909|function appendHighlightedText(container, text, query, caseSensitive) { + 910| container.textContent = ""; + 911| if (!query) { + 912| container.appendChild(document.createTextNode(text)); + 913| return; + 914| } + 915| + 916| const source = caseSensitive ? text : text.toLowerCase(); + 917| const needle = caseSensitive ? query : query.toLowerCase(); + 918| let start = 0; + 919| let index = source.indexOf(needle, start); + 920| + 921| if (index === -1) { + 922| container.appendChild(document.createTextNode(text)); + 923| return; + 924| } + 925| + 926| while (index !== -1) { + 927| if (index > start) { + 928| container.appendChild(document.createTextNode(text.slice(start, index))); + 929| } + 930| const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); + 931| container.appendChild(mark); + 932| start = index + query.length; + 933| index = source.indexOf(needle, start); + 934| } + 935| + 936| if (start < text.length) { + 937| container.appendChild(document.createTextNode(text.slice(start))); + 938| } + 939|} + 940| + 941|function highlightSearchText(container, text, query, caseSensitive) { + 942| container.textContent = ""; + 943| if (!query || !text) { + 944| container.appendChild(document.createTextNode(text || "")); + 945| return; + 946| } + 947| + 948| const source = caseSensitive ? text : text.toLowerCase(); + 949| const needle = caseSensitive ? query : query.toLowerCase(); + 950| let start = 0; + 951| let index = source.indexOf(needle, start); + 952| + 953| if (index === -1) { + 954| container.appendChild(document.createTextNode(text)); + 955| return; + 956| } + 957| + 958| while (index !== -1) { + 959| if (index > start) { + 960| container.appendChild(document.createTextNode(text.slice(start, index))); + 961| } + 962| const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); + 963| container.appendChild(mark); + 964| start = index + query.length; + 965| index = source.indexOf(needle, start); + 966| } + 967| + 968| if (start < text.length) { + 969| container.appendChild(document.createTextNode(text.slice(start))); + 970| } + 971|} + 972| + 973|function showWelcome() { + 974| hideProgressBar(); + 975| + 976| // Restore or rebuild the dashboard with tabbed sections + 977| const area = document.getElementById("content-area"); + 978| const home = document.getElementById("dashboard-home"); + 979| + 980| if (area && !home) { + 981| area.innerHTML = ` + 982|
    + 983| + 984|
    + 985| + 988| + 991| + 994| + 997|
    + 998| + 999| + 1000|
    + 1001|
    + 1002|
    Chargement...
    + 1003|
    + 1004|
    + 1005|
    + 1006| + 1007| + 1008|
    + 1009|
    + 1010|
    + 1011| + 1012| Aucun bookmark + 1013|

    Épinglez des fichiers pour les retrouver ici.

    + 1014|
    + 1015|
    + 1016| + 1017| + 1018|
    + 1019|
    + 1020|
    + 1021| + 1022|
    + 1023|
    + 1024|
    + 1025|
    + 1026|
    + 1027|
    + 1028|
    + 1029| + 1034|
    + 1035| + 1036| + 1037|
    + 1038|
    + 1039|
    + 1040| + 1041| Aucun document partagé + 1042|

    Partagez un document pour le voir apparaître ici

    + 1043|
    + 1044|
    + 1045|
    `; + 1046| + 1047| // Re-initialize widgets and dashboard tabs + 1048| if (typeof DashboardRecentWidget !== "undefined") { + 1049| DashboardRecentWidget.init(); + 1050| } + 1051| initDashboardTabs(); + 1052| safeCreateIcons(); + 1053| } else if (home) { + 1054| // Dashboard already exists, show it with default tab + 1055| home.style.display = ""; + 1056| // Reset tabs to default + 1057| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + 1058| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + 1059| const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); + 1060| const defaultPanel = document.getElementById("dashboard-panel-stats"); + 1061| if (defaultTab) defaultTab.classList.add("active"); + 1062| if (defaultPanel) defaultPanel.classList.add("active"); + 1063| } + 1064| + 1065| // Load all widgets (they handle missing elements gracefully) + 1066| if (typeof DashboardStatsWidget !== "undefined") { + 1067| DashboardStatsWidget.load(); + 1068| } + 1069| if (typeof DashboardConflictsWidget !== "undefined") { + 1070| DashboardConflictsWidget.load(); + 1071| } + 1072| if (typeof DashboardRecentWidget !== "undefined") { + 1073| DashboardRecentWidget.load(state.selectedContextVault); + 1074| } + 1075| if (typeof DashboardBookmarkWidget !== "undefined") { + 1076| DashboardBookmarkWidget.load(state.selectedContextVault); + 1077| } + 1078| if (typeof DashboardSharedWidget !== "undefined") { + 1079| DashboardSharedWidget.load(); + 1080| } + 1081| + 1082| // Load saved searches sidebar + 1083| loadSavedSearches(); + 1084|} + 1085| + 1086|async function loadSavedSearches() { + 1087| const list = document.getElementById("saved-searches-list"); + 1088| const empty = document.getElementById("saved-searches-empty"); + 1089| if (!list) return; + 1090| try { + 1091| const searches = await api("/api/saved-searches"); + 1092| if (!searches.length) { + 1093| list.innerHTML = ""; + 1094| if (empty) empty.style.display = ""; + 1095| return; + 1096| } + 1097| if (empty) empty.style.display = "none"; + 1098| list.innerHTML = searches.map(s => { + 1099| const badges = []; + 1100| if (s.case_sensitive) badges.push('Aa'); + 1101| if (s.whole_word) badges.push('wd'); + 1102| if (s.regex) badges.push('.*'); + 1103| const pathFilters = []; + 1104| if (s.include_paths) pathFilters.push(`📥 ${escapeHtml(s.include_paths)}`); + 1105| if (s.exclude_paths) pathFilters.push(`📤 ${escapeHtml(s.exclude_paths)}`); + 1106| const vaultStr = s.vault && s.vault !== "all" ? `📁 ${escapeHtml(s.vault)}` : ""; + 1107| return ` + 1108|
    + 1109|
    ${escapeHtml(s.query)}
    + 1110|
    + 1111| ${badges.join("")} + 1112| ${vaultStr} + 1113|
    + 1114| ${pathFilters.length ? '
    ' + pathFilters.join(" ") + '
    ' : ""} + 1115| + 1116|
    + 1117| `}).join(""); + 1118| list.querySelectorAll(".saved-search-item").forEach(item => { + 1119| item.addEventListener("click", (e) => { + 1120| if (e.target.classList.contains("saved-search-delete")) return; + 1121| const idx = Array.from(list.children).indexOf(item); + 1122| const s = searches[idx]; + 1123| if (!s) return; + 1124| // Apply the saved search + 1125| const input = document.getElementById("search-input"); + 1126| if (input) input.value = s.query; + 1127| state.searchCaseSensitive = s.case_sensitive || false; + 1128| state.searchWholeWord = s.whole_word || false; + 1129| state.searchRegex = s.regex || false; + 1130| if (typeof _updateToggleUI === "function") _updateToggleUI(); + 1131| if (s.include_paths) { + 1132| const incl = document.getElementById("search-include-input"); + 1133| if (incl) incl.value = s.include_paths; + 1134| } + 1135| if (s.exclude_paths) { + 1136| const excl = document.getElementById("search-exclude-input"); + 1137| if (excl) excl.value = s.exclude_paths; + 1138| } + 1139| // Execute the search — suppress dropdown from appearing + 1140| AutocompleteDropdown.hide(); + 1141| AutocompleteDropdown._suppressNext = true; + 1142| const vault = s.vault || "all"; + 1143| if (input) { input.dispatchEvent(new Event("input")); } + 1144| clearTimeout(state.searchTimeout); + 1145| state.advancedSearchOffset = 0; + 1146| performAdvancedSearch(s.query, vault, null); + 1147| }); + 1148| }); + 1149| list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { + 1150| e.stopPropagation(); + 1151| await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); + 1152| loadSavedSearches(); + 1153| })); + 1154| safeCreateIcons(); + 1155| } catch (err) { /* silently ignore */ } + 1156|} + 1157| + 1158|function showLoading() { + 1159| const area = document.getElementById("content-area"); + 1160| area.innerHTML = ` + 1161|
    + 1162|
    + 1163|
    Recherche en cours...
    + 1164|
    `; + 1165| showProgressBar(); + 1166|} + 1167| + 1168|function showProgressBar() { + 1169| const bar = document.getElementById("search-progress-bar"); + 1170| if (bar) bar.classList.add("active"); + 1171|} + 1172| + 1173|function hideProgressBar() { + 1174| const bar = document.getElementById("search-progress-bar"); + 1175| if (bar) bar.classList.remove("active"); + 1176|} + 1177| + 1178|function goHome() { + 1179| const searchInput = document.getElementById("search-input"); + 1180| if (searchInput) searchInput.value = ""; + 1181| + 1182| document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); + 1183| + 1184| state.currentVault = null; + 1185| state.currentPath = null; + 1186| state.showingSource = false; + 1187| state.cachedRawSource = null; + 1188| + 1189| closeMobileSidebar(); + 1190| showWelcome(); + 1191|} + 1192| + 1193| + 1194|// initEditor wires up the editor modal — editor functions (openEditor, closeEditor, saveFile, deleteFile) are in utils.js + 1195|function initEditor() { + 1196| const cancelBtn = document.getElementById("editor-cancel"); + 1197| const deleteBtn = document.getElementById("editor-delete"); + 1198| const saveBtn = document.getElementById("editor-save"); + 1199| const modal = document.getElementById("editor-modal"); + 1200| + 1201| cancelBtn.addEventListener("click", closeEditor); + 1202| deleteBtn.addEventListener("click", deleteFile); + 1203| saveBtn.addEventListener("click", saveFile); + 1204| + 1205| // Close on overlay click + 1206| modal.addEventListener("click", (e) => { + 1207| if (e.target === modal) { + 1208| closeEditor(); + 1209| } + 1210| }); + 1211| + 1212| // ESC to close + 1213| document.addEventListener("keydown", (e) => { + 1214| if (e.key === "Escape" && modal.classList.contains("active")) { + 1215| closeEditor(); + 1216| } + 1217| }); + 1218| + 1219| // Fix mouse wheel scrolling in editor + 1220| modal.addEventListener( + 1221| "wheel", + 1222| (e) => { + 1223| const editorBody = document.getElementById("editor-body"); + 1224| if (editorBody && editorBody.contains(e.target)) { + 1225| // Let the editor handle the scroll + 1226| return; + 1227| } + 1228| // Prevent modal from scrolling if not in editor area + 1229| e.preventDefault(); + 1230| }, + 1231| { passive: false }, + 1232| ); + 1233|} + 1234| + 1235| + 1236|// --------------------------------------------------------------------------- + 1237|// SSE Client — IndexUpdateManager + 1238|// --------------------------------------------------------------------------- + 1239|const IndexUpdateManager = (() => { + 1240| let eventSource = null; + 1241| let reconnectTimer = null; + 1242| let reconnectDelay = 1000; + 1243| const MAX_RECONNECT_DELAY = 30000; + 1244| let recentEvents = []; + 1245| const MAX_RECENT_EVENTS = 20; + 1246| let connectionState = "disconnected"; // disconnected | connecting | connected + 1247| + 1248| function connect() { + 1249| if (eventSource) { + 1250| eventSource.close(); + 1251| } + 1252| connectionState = "connecting"; + 1253| _updateBadge(); + 1254| + 1255| eventSource = new EventSource("/api/events"); + 1256| + 1257| eventSource.addEventListener("connected", (e) => { + 1258| connectionState = "connected"; + 1259| reconnectDelay = 1000; + 1260| _updateBadge(); + 1261| }); + 1262| + 1263| eventSource.addEventListener("index_updated", (e) => { + 1264| try { + 1265| const data = JSON.parse(e.data); + 1266| _addEvent("index_updated", data); + 1267| _onIndexUpdated(data); + 1268| } catch (err) { + 1269| console.error("SSE parse error:", err); + 1270| } + 1271| }); + 1272| + 1273| eventSource.addEventListener("index_reloaded", (e) => { + 1274| try { + 1275| const data = JSON.parse(e.data); + 1276| _addEvent("index_reloaded", data); + 1277| _onIndexReloaded(data); + 1278| } catch (err) { + 1279| console.error("SSE parse error:", err); + 1280| } + 1281| }); + 1282| + 1283| eventSource.addEventListener("vault_added", (e) => { + 1284| try { + 1285| const data = JSON.parse(e.data); + 1286| _addEvent("vault_added", data); + 1287| showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info"); + 1288| loadVaults(); + 1289| loadTags(); + 1290| } catch (err) { + 1291| console.error("SSE parse error:", err); + 1292| } + 1293| }); + 1294| + 1295| eventSource.addEventListener("vault_removed", (e) => { + 1296| try { + 1297| const data = JSON.parse(e.data); + 1298| _addEvent("vault_removed", data); + 1299| showToast(`Vault "${data.vault}" supprimé`, "info"); + 1300| loadVaults(); + 1301| loadTags(); + 1302| } catch (err) { + 1303| console.error("SSE parse error:", err); + 1304| } + 1305| }); + 1306| + 1307| eventSource.addEventListener("index_start", (e) => { + 1308| try { + 1309| const data = JSON.parse(e.data); + 1310| _addEvent("index_start", data); + 1311| connectionState = "syncing"; + 1312| _updateBadge(); + 1313| showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info"); + 1314| } catch (err) { + 1315| console.error("SSE parse error:", err); + 1316| } + 1317| }); + 1318| + 1319| eventSource.addEventListener("index_progress", (e) => { + 1320| try { + 1321| const data = JSON.parse(e.data); + 1322| _addEvent("index_progress", data); + 1323| connectionState = "syncing"; + 1324| _updateBadge(); + 1325| loadVaults(); + 1326| loadTags(); + 1327| } catch (err) { + 1328| console.error("SSE parse error:", err); + 1329| } + 1330| }); + 1331| + 1332| eventSource.addEventListener("index_complete", (e) => { + 1333| try { + 1334| const data = JSON.parse(e.data); + 1335| _addEvent("index_complete", data); + 1336| connectionState = "connected"; + 1337| _updateBadge(); + 1338| showToast(`Indexation terminée (${data.total_files} fichiers)`, "success"); + 1339| loadVaults(); + 1340| loadTags(); + 1341| } catch (err) { + 1342| console.error("SSE parse error:", err); + 1343| } + 1344| }); + 1345| + 1346| eventSource.onerror = () => { + 1347| connectionState = "disconnected"; + 1348| _updateBadge(); + 1349| eventSource.close(); + 1350| eventSource = null; + 1351| _scheduleReconnect(); + 1352| }; + 1353| } + 1354| + 1355| function _scheduleReconnect() { + 1356| if (reconnectTimer) clearTimeout(reconnectTimer); + 1357| reconnectTimer = setTimeout(() => { + 1358| reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + 1359| connect(); + 1360| }, reconnectDelay); + 1361| } + 1362| + 1363| function _addEvent(type, data) { + 1364| recentEvents.unshift({ + 1365| type, + 1366| data, + 1367| timestamp: new Date().toISOString(), + 1368| }); + 1369| if (recentEvents.length > MAX_RECENT_EVENTS) { + 1370| recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS); + 1371| } + 1372| } + 1373| + 1374| async function _onIndexUpdated(data) { + 1375| // Brief syncing state + 1376| connectionState = "syncing"; + 1377| _updateBadge(); + 1378| + 1379| const n = data.total_changes || 0; + 1380| const vaults = (data.vaults || []).join(", "); + 1381| // Toast removed: silent auto-indexing — no notification needed + 1382| + 1383| // Refresh sidebar and tags if affected vault matches current context + 1384| const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault); + 1385| if (affectsCurrentVault) { + 1386| try { + 1387| await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + 1388| // Refresh current file if it was updated + 1389| if (currentVault && state.currentPath) { + 1390| const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath); + 1391| if (changed) { + 1392| openFile(state.currentVault, state.currentPath); + 1393| } + 1394| } + 1395| } catch (err) { + 1396| console.error("Error refreshing after index update:", err); + 1397| } + 1398| } + 1399| + 1400| // Refresh recent tab if it is active + 1401| if (state.activeSidebarTab === "recent") { + 1402| const vaultFilter = document.getElementById("recent-vault-filter"); + 1403| loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + 1404| } + 1405| + 1406| setTimeout(() => { + 1407| connectionState = "connected"; + 1408| _updateBadge(); + 1409| }, 1500); + 1410| } + 1411| + 1412| async function _onIndexReloaded(data) { + 1413| connectionState = "syncing"; + 1414| _updateBadge(); + 1415| showToast("Index complet rechargé", "info"); + 1416| try { + 1417| await Promise.all([loadVaults(), loadTags()]); + 1418| } catch (err) { + 1419| console.error("Error refreshing after full reload:", err); + 1420| } + 1421| setTimeout(() => { + 1422| connectionState = "connected"; + 1423| _updateBadge(); + 1424| }, 1500); + 1425| } + 1426| + 1427| function _updateBadge() { + 1428| const badge = document.getElementById("sync-badge"); + 1429| if (!badge) return; + 1430| badge.className = "sync-badge sync-badge--" + connectionState; + 1431| const labels = { + 1432| disconnected: "Déconnecté", + 1433| connecting: "Connexion...", + 1434| connected: "Synchronisé", + 1435| syncing: "Mise à jour...", + 1436| }; + 1437| badge.title = labels[connectionState] || connectionState; + 1438| } + 1439| + 1440| function disconnect() { + 1441| if (eventSource) { + 1442| eventSource.close(); + 1443| eventSource = null; + 1444| } + 1445| if (reconnectTimer) { + 1446| clearTimeout(reconnectTimer); + 1447| reconnectTimer = null; + 1448| } + 1449| connectionState = "disconnected"; + 1450| _updateBadge(); + 1451| } + 1452| + 1453| function getState() { + 1454| return connectionState; + 1455| } + 1456| + 1457| function getRecentEvents() { + 1458| return recentEvents; + 1459| } + 1460| + 1461| return { connect, disconnect, getState, getRecentEvents }; + 1462|})(); + 1463| + 1464|function initSyncStatus() { + 1465| const badge = document.getElementById("sync-badge"); + 1466| if (!badge) return; + 1467| + 1468| badge.addEventListener("click", (e) => { + 1469| e.stopPropagation(); + 1470| toggleSyncPanel(); + 1471| }); + 1472| + 1473| IndexUpdateManager.connect(); + 1474|} + 1475| + 1476|function toggleSyncPanel() { + 1477| let panel = document.getElementById("sync-panel"); + 1478| if (panel) { + 1479| panel.remove(); + 1480| return; + 1481| } + 1482| // Auto reconnect if disconnected when user opens the panel + 1483| if (IndexUpdateManager.getState() === "disconnected") { + 1484| IndexUpdateManager.connect(); + 1485| } + 1486| panel = document.createElement("div"); + 1487| panel.id = "sync-panel"; + 1488| panel.className = "sync-panel"; + 1489| _renderSyncPanel(panel); + 1490| document.body.appendChild(panel); + 1491| + 1492| // Close on outside click + 1493| setTimeout(() => { + 1494| document.addEventListener("click", _closeSyncPanelOutside, { once: true }); + 1495| }, 0); + 1496|} + 1497| + 1498|function _closeSyncPanelOutside(e) { + 1499| const panel = document.getElementById("sync-panel"); + 1500| if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") { + 1501| panel.remove(); + 1502| } + 1503|} + 1504| + 1505|function _renderSyncPanel(panel) { + 1506| const state = IndexUpdateManager.getState(); + 1507| const events = IndexUpdateManager.getRecentEvents(); + 1508| + 1509| const stateLabels = { + 1510| disconnected: "Déconnecté", + 1511| connecting: "Connexion...", + 1512| connected: "Connecté", + 1513| syncing: "Synchronisation...", + 1514| }; + 1515| + 1516| let html = `
    + 1517| Synchronisation + 1518| ${stateLabels[state] || state} + 1519|
    `; + 1520| + 1521| if (events.length === 0) { + 1522| html += `
    Aucun événement récent
    `; + 1523| } else { + 1524| html += `
    `; + 1525| events.slice(0, 10).forEach((ev) => { + 1526| const time = new Date(ev.timestamp).toLocaleTimeString(); + 1527| const typeLabels = { + 1528| index_updated: "Mise à jour", + 1529| index_reloaded: "Rechargement", + 1530| vault_added: "Vault ajouté", + 1531| vault_removed: "Vault supprimé", + 1532| index_start: "Démarrage index.", + 1533| index_progress: "Vault indexé", + 1534| index_complete: "Indexation tech.", + 1535| }; + 1536| const label = typeLabels[ev.type] || ev.type; + 1537| let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || ""; + 1538| if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`; + 1539| if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`; + 1540| if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`; + 1541| html += `
    + 1542| ${label} + 1543| ${detail} + 1544| ${time} + 1545|
    `; + 1546| }); + 1547| html += `
    `; + 1548| } + 1549| + 1550| panel.innerHTML = html; + 1551|} + 1552| + 1553|export { OutlineManager, ScrollSpyManager, ReadingProgressManager, openFile, buildFrontmatterCard, initEditor }; + 1554| + 1555| \ No newline at end of file