From 643a73e0f59df3601e17c66dcf3e9ba5d9cb382d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 28 May 2026 16:40:14 -0400 Subject: [PATCH] fix: strip read_file line numbers accidentally injected into JS files --- frontend/js/auth.js | 1093 +++++++------ frontend/js/config.js | 2023 ++++++++++++----------- frontend/js/dashboard.js | 921 ++++++----- frontend/js/graph.js | 1471 +++++++++-------- frontend/js/legacy.js | 1121 +++++++------ frontend/js/search.js | 2154 ++++++++++++------------- frontend/js/sidebar.js | 2181 +++++++++++++------------ frontend/js/state.js | 2 +- frontend/js/sync.js | 875 +++++----- frontend/js/ui.js | 3296 +++++++++++++++----------------------- frontend/js/utils.js | 1019 ++++++------ frontend/js/viewer.js | 2808 +++++++++++++++----------------- 12 files changed, 8947 insertions(+), 10017 deletions(-) diff --git a/frontend/js/auth.js b/frontend/js/auth.js index f7ef6da..16a6cfc 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -1,548 +1,547 @@ - 1|/* ObsiGate — Authentication: API helper, AuthManager, login form, 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 + +// --------------------------------------------------------------------------- +// 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 state.allVaults = document.getElementById("admin-all-vaults").checked; + const selectedVaults = allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value); + + try { + if (isEdit) { + const updates = { + display_name: form.display_name.value || null, + role: form.role.value, + vaults: selectedVaults, + }; + if (form.password.value) updates.password = form.password.value; + const activeCheckbox = form.querySelector('input[name="active"]'); + if (activeCheckbox) updates.active = activeCheckbox.checked; + await api("/api/auth/admin/users/" + user.username, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + } else { + await api("/api/auth/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: form.username.value, + password: form.password.value, + display_name: form.display_name.value || null, + role: form.role.value, + vaults: selectedVaults, + }), + }); + } + overlay.remove(); + this._loadUsers(); + showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success"); + } catch (err) { + showToast(err.message, "error"); + } + }); + }, + + async _deleteUser(username) { + const currentUser = AuthManager.getUser(); + if (currentUser && currentUser.username === username) { + showToast("Impossible de supprimer son propre compte", "error"); + return; + } + if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return; + try { + await api("/api/auth/admin/users/" + username, { method: "DELETE" }); + this._loadUsers(); + showToast("Utilisateur supprimé", "success"); + } catch (err) { + showToast(err.message, "error"); + } + }, +}; + + +export { api, AuthManager, initLoginForm, AdminPanel }; diff --git a/frontend/js/config.js b/frontend/js/config.js index 646c6d5..5dc72a1 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1,1013 +1,1012 @@ - 1|// config.js — extracted from app.js (3872-4865) +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 + +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); + state.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) { + state.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", state.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 || state.allVaults.length === 0) { + container.innerHTML = '
Aucun vault configuré
'; + return; + } + + state.allVaults.forEach((vault) => { + const settings = allSettings[vault.name] || { hideHiddenFiles: false }; + + const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name }); + + // Vault header + const header = el("div", { class: "hidden-files-vault-header" }, [el("h3", {}, [document.createTextNode(vault.name)]), el("span", { class: "hidden-files-vault-type" }, [document.createTextNode(vault.type || "VAULT")])]); + + // Hide hidden files toggle + const toggleRow = el("div", { class: "config-row" }, [ + el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]), + el("label", { class: "config-toggle" }, [ + el("input", { + type: "checkbox", + id: `hide-hidden-${vault.name}`, + "data-vault": vault.name, + checked: settings.hideHiddenFiles ? "true" : false, + }), + el("span", { class: "config-toggle-slider" }), + ]), + el("span", { class: "config-hint" }, [document.createTextNode("Masquer les fichiers/dossiers commençant par un point dans l'interface (ils restent indexés et cherchables)")]), + ]); + + vaultCard.appendChild(header); + vaultCard.appendChild(toggleRow); + + container.appendChild(vaultCard); + }); +} + +async function saveHiddenFilesSettings() { + const btn = document.getElementById("cfg-save-hidden-files"); + if (btn) { + btn.disabled = true; + btn.textContent = "Sauvegarde..."; + } + + try { + const vaultCards = document.querySelectorAll(".hidden-files-vault-card"); + const promises = []; + + vaultCards.forEach((card) => { + const vaultName = card.dataset.vault; + const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false; + + const settings = { + hideHiddenFiles, + }; + + promises.push( + api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }), + ); + }); + + await Promise.all(promises); + + // Reload vault settings to update the cache + await loadVaultSettings(); + + showToast("✓ Paramètres sauvegardés", "success"); + + // Refresh the UI to apply the filter + await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + } catch (err) { + console.error("Failed to save hidden files settings:", err); + const errorMsg = err.message || "Erreur inconnue"; + showToast(`Erreur: ${errorMsg}`, "error"); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = "💾 Sauvegarder"; + } + } +} + +// ── Webhooks UI ── +async function loadWebhooksUI() { + const list = document.getElementById("webhooks-list"); + if (!list) return; + try { + const webhooks = await api("/api/webhooks"); + renderWebhooksUI(webhooks); + } catch { list.innerHTML = '
Admin uniquement
'; } +} +function renderWebhooksUI(webhooks) { + const list = document.getElementById("webhooks-list"); + if (!list) return; + if (!webhooks.length) { list.innerHTML = '
Aucun webhook configuré.
'; return; } + list.innerHTML = webhooks.map(w => ` +
+ ${escapeHtml(w.name)} + ${escapeHtml(w.url)} + ${(w.events||[]).join(", ")} + +
+ `).join(""); + list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => { + await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" }); + loadWebhooksUI(); + })); +} +document.addEventListener("click", function(e) { + if (e.target.id === "webhook-add-btn") { + const name = document.getElementById("webhook-name-input").value.trim(); + const url = document.getElementById("webhook-url-input").value.trim(); + if (!url) { showToast("URL requise", "error"); return; } + api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error")); + } +}); + +// ── Shares UI ── +async function loadSharesUI() { + const list = document.getElementById("shares-list"); + if (!list) return; + try { + const shares = await api("/api/shares"); + renderSharesUI(shares); + } catch { list.innerHTML = '
Chargement...
'; } +} +function renderSharesUI(shares) { + const list = document.getElementById("shares-list"); + if (!list) return; + if (!shares.length) { list.innerHTML = '
Aucun partage actif.
'; return; } + list.innerHTML = shares.map(s => ` + + `).join(""); + list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => { + await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); + loadSharesUI(); + })); +} + +// ── Share Dialog (professional) ── +async function openShareDialog(vault, path) { + // First check if already shared + let existingShare = null; + try { + const shares = await api("/api/shares"); + existingShare = shares.find(s => s.vault === vault && s.path === path); + } catch (e) { /* ignore */ } + + const div = document.createElement("div"); + div.className = "share-dialog-overlay"; + + const renderContent = () => { + if (existingShare) { + const url = window.location.origin + existingShare.url; + const expiresInfo = existingShare.expires_at + ? `

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

` + : '

Sans expiration

'; + div.innerHTML = ` + `; + div.querySelector(".share-copy-btn").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(url); + } catch (e) { + // Fallback for non-HTTPS contexts + const ta = document.createElement("textarea"); + ta.value = url; ta.style.position = "fixed"; ta.style.left = "-9999px"; + document.body.appendChild(ta); ta.select(); document.execCommand("copy"); + document.body.removeChild(ta); + } + showToast("Lien copié !", "success"); + div.remove(); + }); + div.querySelector(".share-revoke-btn").addEventListener("click", async () => { + try { + await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); + showToast("Partage révoqué", "success"); + existingShare = null; + renderContent(); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + } else { + div.innerHTML = ` + `; + div.querySelector(".share-create-btn").addEventListener("click", async () => { + try { + const expiry = document.getElementById("share-expiry")?.value; + const share = await api(`/api/share/${encodeURIComponent(vault)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), + }); + existingShare = share; + renderContent(); + showToast("Lien créé !", "success"); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + } + div.querySelector(".share-close-btn").addEventListener("click", () => div.remove()); + div.addEventListener("click", (e) => { if (e.target === div) div.remove(); }); + }; + + renderContent(); + document.body.appendChild(div); +} + +function renderConfigFilters() { + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + const container = document.getElementById("config-filters-list"); + + container.innerHTML = ""; + + filters.forEach((filter, index) => { + const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [ + el("span", {}, [document.createTextNode(filter.pattern)]), + el( + "button", + { + class: "config-filter-toggle", + title: filter.enabled ? "Désactiver" : "Activer", + type: "button", + }, + [document.createTextNode(filter.enabled ? "✓" : "○")], + ), + el( + "button", + { + class: "config-filter-remove", + title: "Supprimer", + type: "button", + }, + [document.createTextNode("×")], + ), + ]); + + const toggleBtn = badge.querySelector(".config-filter-toggle"); + const removeBtn = badge.querySelector(".config-filter-remove"); + + toggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + toggleConfigFilter(index); + }); + + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeConfigFilter(index); + }); + + container.appendChild(badge); + }); +} + +function toggleConfigFilter(index) { + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + if (filters[index]) { + filters[index].enabled = !filters[index].enabled; + config.tagFilters = filters; + TagFilterService.saveConfig(config); + renderConfigFilters(); + refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + } +} + +function removeConfigFilter(index) { + const config = TagFilterService.getConfig(); + let filters = config.tagFilters || TagFilterService.defaultFilters; + filters = filters.filter((_, i) => i !== index); + config.tagFilters = filters; + TagFilterService.saveConfig(config); + renderConfigFilters(); + refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); +} + +function addConfigFilter() { + const input = document.getElementById("config-pattern-input"); + const pattern = input.value.trim(); + + if (!pattern) return; + + const regex = TagFilterService.patternToRegex(pattern); + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + + const newFilter = { pattern, regex, enabled: true }; + filters.push(newFilter); + config.tagFilters = filters; + TagFilterService.saveConfig(config); + + input.value = ""; + renderConfigFilters(); + refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); + updateRegexPreview(); +} + +function updateRegexPreview() { + const input = document.getElementById("config-pattern-input"); + const preview = document.getElementById("config-regex-preview"); + const code = document.getElementById("config-regex-code"); + const pattern = input.value.trim(); + + if (pattern) { + const regex = TagFilterService.patternToRegex(pattern); + code.textContent = `^${regex}$`; + preview.style.display = "block"; + } else { + preview.style.display = "none"; + } +} + + +export { + initSidebarTabs, + initConfigModal, + initConfigModal as initConfigPanel, + initHelpModal, + closeHelpModal, + initRecentTab, + loadAbout as initAboutSection, + loadHiddenFilesSettings as initHiddenFilesConfig, +}; diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js index da19c0f..6b97f75 100644 --- a/frontend/js/dashboard.js +++ b/frontend/js/dashboard.js @@ -1,462 +1,461 @@ - 1|// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870) +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 + +// --------------------------------------------------------------------------- +// 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(state.allVaults)) { + state.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 }; diff --git a/frontend/js/graph.js b/frontend/js/graph.js index 964fc59..eb62782 100644 --- a/frontend/js/graph.js +++ b/frontend/js/graph.js @@ -1,736 +1,735 @@ - 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 +1|/* 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(); + } + }); +} diff --git a/frontend/js/legacy.js b/frontend/js/legacy.js index c1f012b..18f7611 100644 --- a/frontend/js/legacy.js +++ b/frontend/js/legacy.js @@ -1,561 +1,560 @@ - 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 +1|/* 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 { + state.currentVault, + state.currentPath, + state.showingSource, + state.cachedRawSource, + state.searchTimeout, + state.searchCaseSensitive, + state.searchWholeWord, + state.searchRegex, + state.searchFilterVisible, + state.advancedSearchOffset, + state.selectedTags, + state.selectedContextVault, + state.vaultSettings, + state.allVaults, + state.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"); + state.vaultSettings = settings; + } catch (err) { + console.error("Failed to load vault settings:", err); + state.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", 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 = 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(); + } + }); +} + +// ========================================================================= +// 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(state.selectedContextVault); + if (typeof DashboardBookmarkWidget !== "undefined") { + DashboardBookmarkWidget.load(state.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")); + + state.currentVault = null; + state.currentPath = null; + state.showingSource = false; + state.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; + state.searchCaseSensitive = s.case_sensitive || false; + state.searchWholeWord = s.whole_word || false; + state.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(state.searchTimeout); + state.advancedSearchOffset = 0; + performAdvancedSearch(s.query, vault, null); + }); + }); + list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { + e.stopPropagation(); + await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); + loadSavedSearches(); + })); + safeCreateIcons(); + } catch (err) { /* silently ignore */ } +} + +// ========================================================================= +// initDashboardTabs (needed by showWelcome) +// ========================================================================= + +function initDashboardTabs() { + document.querySelectorAll(".dashboard-tab").forEach(tab => { + // Remove existing listeners by cloning + const newTab = tab.cloneNode(true); + tab.parentNode.replaceChild(newTab, tab); + newTab.addEventListener("click", function() { + document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + this.classList.add("active"); + const panel = document.getElementById("dashboard-panel-" + this.dataset.tab); + if (panel) panel.classList.add("active"); + }); + }); +} + +// ========================================================================= +// Exports +// ========================================================================= + +export { + loadVaultSettings, + initSearch, + showWelcome, + goHome, +}; diff --git a/frontend/js/search.js b/frontend/js/search.js index 191a6ab..032103c 100644 --- a/frontend/js/search.js +++ b/frontend/js/search.js @@ -1,1107 +1,1049 @@ - 1|/* ObsiGate — Search module (extracted from app.js) */ - 2| +1|/* ObsiGate — Search module (extracted from app.js) */ + 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 +import { safeCreateIcons } from './utils.js'; + +// Re-export constants used internally +const state.SEARCH_HISTORY_KEY = state.SEARCH_HISTORY_KEY; +const state.MAX_HISTORY_ENTRIES = state.MAX_HISTORY_ENTRIES; + +// --------------------------------------------------------------------------- +// Search History Service (localStorage, LIFO, max 50, dedup) +// --------------------------------------------------------------------------- +export const SearchHistory = { + _load() { + try { + const raw = localStorage.getItem(state.SEARCH_HISTORY_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } + }, + _save(entries) { + try { + localStorage.setItem(state.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 > state.MAX_HISTORY_ENTRIES) entries = entries.slice(0, state.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-chi + +... [OUTPUT TRUNCATED - 3180 chars omitted out of 53180 total] ... + +search(); }); + if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); }); + if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); }); + if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = state.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); + + // ── Result navigation (up/down arrows + Enter) ── + let _searchResultIdx = -1; + let _searchResultItems = []; + + function _updateResultHighlight() { + _searchResultItems.forEach((el, i) => { + el.classList.toggle("search-result-active", i === _searchResultIdx); + }); + if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { + _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + const countEl = document.getElementById("search-match-count"); + if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; + } + + function _refreshResultItems() { + _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; + _updateResultHighlight(); + } + + window.navigateSearchResults = function(delta) { + _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); + if (_searchResultItems.length === 0) return; + _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); + _updateResultHighlight(); + }; + + if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); + if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); + + function _research() { + const q = input.value.trim(); + if (q.length >= _getEffective("min_query_length", 2)) { + clearTimeout(state.searchTimeout); + state.searchTimeout = setTimeout(() => { + const vault = document.getElementById("vault-filter").value; + const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + state.advancedSearchOffset = 0; + performAdvancedSearch(q, vault, tagFilter); + }, _getEffective("debounce_ms", 300)); + } + } + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } + else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } + else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } + else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } + } + }); + + // Initialize sub-controllers + AutocompleteDropdown.init(); + SearchChips.init(); + + // Initially hide clear button + if (clearBtn) clearBtn.style.display = "none"; + + // --- Input handler: debounced search + autocomplete dropdown --- + input.addEventListener("input", () => { + const hasText = input.value.length > 0; + clearBtn.style.display = hasText ? "flex" : "none"; + + // Show autocomplete dropdown while typing + AutocompleteDropdown.populate(input.value, input.selectionStart); + + // Debounced search execution + clearTimeout(state.searchTimeout); + state.searchTimeout = setTimeout( + () => { + const q = input.value.trim(); + const vault = document.getElementById("vault-filter").value; + const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + state.advancedSearchOffset = 0; + if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) { + performAdvancedSearch(q, vault, tagFilter); + } else if (q.length === 0) { + SearchChips.clear(); + showWelcome(); + } + }, + _getEffective("debounce_ms", 300), + ); + }); + + // --- Focus handler: show history dropdown --- + input.addEventListener("focus", () => { + if (input.value.length === 0) { + const historyItems = SearchHistory.filter("").slice(0, 5); + if (historyItems.length > 0) { + AutocompleteDropdown.populate("", 0); + } + } + }); + + // --- Keyboard navigation in dropdown --- + input.addEventListener("keydown", (e) => { + if (AutocompleteDropdown.isVisible()) { + if (e.key === "ArrowDown") { + e.preventDefault(); + AutocompleteDropdown.navigateDown(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + AutocompleteDropdown.navigateUp(); + } else if (e.key === "Enter") { + // First: check dropdown suggestions (higher priority than search results) + if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + e.preventDefault(); + return; + } + // Second: navigate search results if visible + const results = document.querySelectorAll(".search-result-item"); + if (results.length > 0 && _searchResultIdx >= 0) { + const el = results[_searchResultIdx]; + if (el) { + const vault = el.dataset.vault; + const path = el.dataset.path; + if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } + } + } + // Third: execute search + AutocompleteDropdown.hide(); + const q = input.value.trim(); + if (q) { + SearchHistory.add(q); + clearTimeout(state.searchTimeout); + state.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + performAdvancedSearch(q, vault, tagFilter); + } + e.preventDefault(); + } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { + // Navigate search results when dropdown is closed + if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } + } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { + if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } + } else if (e.key === "Escape") { + AutocompleteDropdown.hide(); + e.stopPropagation(); + } + } else if (e.key === "Enter") { + if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { + e.preventDefault(); + return; + } + const q = input.value.trim(); + if (q) { + SearchHistory.add(q); + clearTimeout(state.searchTimeout); + state.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; + performAdvancedSearch(q, vault, tagFilter); + } + e.preventDefault(); + } + }); + + clearBtn.addEventListener("click", () => { + input.value = ""; + if (clearBtn) clearBtn.style.display = "none"; + state.searchCaseSensitive = false; + state.searchWholeWord = false; + state.searchRegex = false; + _updateToggleUI(); + SearchChips.clear(); + AutocompleteDropdown.hide(); + showWelcome(); + }); + + // --- Global keyboard shortcuts --- + document.addEventListener("keydown", (e) => { + // Ctrl+K or Cmd+K: focus search + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + input.focus(); + input.select(); + } + // "/" key: focus search (when not in an input/textarea) + if (e.key === "/" && !_isInputFocused()) { + e.preventDefault(); + input.focus(); + } + // Escape: blur search input and close dropdown + if (e.key === "Escape" && document.activeElement === input) { + AutocompleteDropdown.hide(); + input.blur(); + } + }); +} + +/** Check if user is focused on an input/textarea/contenteditable */ +function _isInputFocused() { + const tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return document.activeElement?.isContentEditable === true; +} + +// --- Backward-compatible search (existing /api/search endpoint) --- +export async function performSearch(query, vaultFilter, tagFilter) { + if (state.searchAbortController) state.searchAbortController.abort(); + state.searchAbortController = new AbortController(); + const searchId = ++state.currentSearchId; + showLoading(); + let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; + if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + try { + const data = await api(url, { signal: state.searchAbortController.signal }); + if (searchId !== state.currentSearchId) return; + renderSearchResults(data, query, tagFilter); + } catch (err) { + if (err.name === "AbortError") return; + if (searchId !== state.currentSearchId) return; + showWelcome(); + } finally { + hideProgressBar(); + if (searchId === state.currentSearchId) state.searchAbortController = null; + } +} + +// --- Advanced search with TF-IDF, facets, pagination --- +export async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) { + if (state.searchAbortController) state.searchAbortController.abort(); + state.searchAbortController = new AbortController(); + const searchId = ++state.currentSearchId; + showLoading(); + + const ofs = offset !== undefined ? offset : state.advancedSearchOffset; + const sortBy = sort || state.advancedSearchSort; + state.advancedSearchLastQuery = query; + + // Update chips from parsed query + const parsed = QueryParser.parse(query); + SearchChips.update(parsed); + + const effectiveLimit = _getEffective("results_per_page", state.ADVANCED_SEARCH_LIMIT); + let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${effectiveLimit}&offset=${ofs}&sort=${sortBy}`; + if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + if (state.searchCaseSensitive) url += "&case_sensitive=true"; + if (state.searchWholeWord) url += "&whole_word=true"; + if (state.searchRegex) url += "®ex=true"; + const includeEl = document.getElementById("search-include-input"); + const excludeEl = document.getElementById("search-exclude-input"); + if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; + if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`; + + // Search timeout — abort if server takes too long + const timeoutId = setTimeout( + () => { + if (state.searchAbortController) state.searchAbortController.abort(); + }, + _getEffective("search_timeout_ms", state.SEARCH_TIMEOUT_MS), + ); + + try { + const data = await api(url, { signal: state.searchAbortController.signal }); + clearTimeout(timeoutId); + if (searchId !== state.currentSearchId) return; + state.advancedSearchTotal = data.total; + state.advancedSearchOffset = ofs; + renderAdvancedSearchResults(data, query, tagFilter); + } catch (err) { + clearTimeout(timeoutId); + if (err.name === "AbortError") return; + if (searchId !== state.currentSearchId) return; + showWelcome(); + } finally { + hideProgressBar(); + if (searchId === state.currentSearchId) state.searchAbortController = null; + } +} + +// --- Legacy search results renderer (kept for backward compat) --- +export function renderSearchResults(data, query, tagFilter) { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const header = buildSearchResultsHeader(data, query, tagFilter); + area.appendChild(header); + if (data.results.length === 0) { + area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); + return; + } + const container = el("div", { class: "search-results" }); + data.results.forEach((r) => { + // Apply client-side filtering for hidden files + if (!shouldDisplayPath(r.path, r.vault)) { + return; // Skip this result + } + + const titleDiv = el("div", { class: "search-result-title" }); + if (query && query.trim()) { + highlightSearchText(titleDiv, r.title, query, state.searchCaseSensitive); + } else { + titleDiv.textContent = r.title; + } + const snippetDiv = el("div", { class: "search-result-snippet" }); + if (r.snippet && r.snippet.includes("")) { + snippetDiv.innerHTML = r.snippet; + } else if (query && query.trim() && r.snippet) { + highlightSearchText(snippetDiv, r.snippet, query, state.searchCaseSensitive); + } else { + snippetDiv.textContent = r.snippet || ""; + } + const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ + el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), + titleDiv, + el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]), + snippetDiv + ]); + if (r.tags && r.tags.length > 0) { + const tagsDiv = el("div", { class: "search-result-tags" }); + r.tags.forEach((tag) => { + if (!TagFilterService.isTagFiltered(tag)) { + const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + addTagFilter(tag); + }); + tagsDiv.appendChild(tagEl); + } + }); + if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); + } + item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + container.appendChild(item); + }); + area.appendChild(container); +} + +// --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) --- +export function renderAdvancedSearchResults(data, query, tagFilter) { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + + // Update match counter + const countEl = document.getElementById("search-match-count"); + if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`; + + // Header with result count and sort controls + const header = el("div", { class: "search-results-header" }); + const summaryText = el("span", { class: "search-results-summary-text" }); + const parsed = QueryParser.parse(query); + const freeText = parsed.freeText; + + if (freeText && tagFilter) { + summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`; + } else if (freeText) { + summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`; + } else if (parsed.tags.length > 0 || tagFilter) { + summaryText.textContent = `${data.total} fichier(s) avec filtres`; + } else { + summaryText.textContent = `${data.total} résultat(s)`; + } + if (data.query_time_ms !== undefined && data.query_time_ms > 0) { + const timeBadge = el("span", { class: "search-time-badge" }); + timeBadge.textContent = `(${data.query_time_ms} ms)`; + summaryText.appendChild(timeBadge); + } + header.appendChild(summaryText); + + // Active filter badges + const filtersRow = el("div", { class: "search-filters-row" }); + if (state.searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")])); + if (state.searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")])); + if (state.searchRegex) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode(".*")])); + const inclEl = document.getElementById("search-include-input"); + const exclEl = document.getElementById("search-exclude-input"); + if (inclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("incl: " + inclEl.value.trim())])); + if (exclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("excl: " + exclEl.value.trim())])); + if (filtersRow.children.length > 0) header.appendChild(filtersRow); + + // Sort controls + const sortDiv = el("div", { class: "search-sort" }); + const btnRelevance = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "relevance" ? " active" : ""), type: "button" }); + btnRelevance.textContent = "Pertinence"; + btnRelevance.addEventListener("click", () => { + state.advancedSearchSort = "relevance"; + state.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, 0, "relevance"); + }); + const btnDate = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "modified" ? " active" : ""), type: "button" }); + btnDate.textContent = "Date"; + btnDate.addEventListener("click", () => { + state.advancedSearchSort = "modified"; + state.advancedSearchOffset = 0; + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, 0, "modified"); + }); + sortDiv.appendChild(btnRelevance); + sortDiv.appendChild(btnDate); + header.appendChild(sortDiv); + + // Save search button + const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" }); + saveBtn.innerHTML = ' Sauver'; + saveBtn.addEventListener("click", async () => { + const inclEl = document.getElementById("search-include-input"); + const exclEl = document.getElementById("search-exclude-input"); + try { + await api("/api/saved-searches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: query, + vault: document.getElementById("vault-filter")?.value || "all", + case_sensitive: state.searchCaseSensitive, + whole_word: state.searchWholeWord, + regex: state.searchRegex, + include_paths: inclEl?.value || "", + exclude_paths: exclEl?.value || "", + }), + }); + showToast("Recherche sauvegardée", "success"); + } catch (err) { showToast("Erreur: " + err.message, "error"); } + }); + header.appendChild(saveBtn); + area.appendChild(header); + + // Active sidebar tag chips + if (state.selectedTags.length > 0) { + const activeTags = el("div", { class: "search-results-active-tags" }); + state.selectedTags.forEach((tag) => { + const removeBtn = el( + "button", + { + class: "search-results-active-tag-remove", + title: `Retirer ${tag} du filtre`, + }, + [document.createTextNode("×")], + ); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeTagFilter(tag); + }); + const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + activeTags.appendChild(chip); + }); + area.appendChild(activeTags); + } + + // Facets panel + if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) { + const facetsDiv = el("div", { class: "search-facets" }); + + // Vault facets + const vaultFacets = data.facets.vaults || {}; + if (Object.keys(vaultFacets).length > 1) { + const group = el("div", { class: "search-facets__group" }); + const label = el("span", { class: "search-facets__label" }); + label.textContent = "Vaults"; + group.appendChild(label); + for (const [vaultName, count] of Object.entries(vaultFacets)) { + const item = el("span", { class: "search-facets__item" }); + item.innerHTML = `${vaultName} ${count}`; + item.addEventListener("click", () => { + const input = document.getElementById("search-input"); + // Add vault: operator + const current = input.value.replace(/vault:\S+\s*/gi, "").trim(); + input.value = current + " vault:" + vaultName; + _triggerAdvancedSearch(input.value); + }); + group.appendChild(item); + } + facetsDiv.appendChild(group); + } + + // Tag facets + const tagFacets = data.facets.tags || {}; + if (Object.keys(tagFacets).length > 0) { + const group = el("div", { class: "search-facets__group" }); + const label = el("span", { class: "search-facets__label" }); + label.textContent = "Tags"; + group.appendChild(label); + const entries = Object.entries(tagFacets).slice(0, 12); + for (const [tagName, count] of entries) { + const item = el("span", { class: "search-facets__item" }); + item.innerHTML = `#${tagName} ${count}`; + item.addEventListener("click", () => { + addTagFilter(tagName); + }); + group.appendChild(item); + } + facetsDiv.appendChild(group); + } + + area.appendChild(facetsDiv); + } + + // Empty state + if (data.results.length === 0) { + area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); + return; + } + + // Results list + const container = el("div", { class: "search-results" }); + data.results.forEach((r) => { + // Apply client-side filtering for hidden files + if (!shouldDisplayPath(r.path, r.vault)) { + return; // Skip this result + } + + const titleDiv = el("div", { class: "search-result-title" }); + if (freeText) { + highlightSearchText(titleDiv, r.title, freeText, state.searchCaseSensitive); + } else { + titleDiv.textContent = r.title; + } + + // Snippet — use HTML from backend (already has tags) + const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" }); + if (r.snippet && r.snippet.includes("")) { + snippetDiv.innerHTML = r.snippet; + } else if (freeText && r.snippet) { + highlightSearchText(snippetDiv, r.snippet, freeText, state.searchCaseSensitive); + } else { + snippetDiv.textContent = r.snippet || ""; + } + + // Score badge + const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" }); + scoreEl.textContent = `score: ${r.score}`; + + const vaultPath = el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path), scoreEl]); + + const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ + el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), + titleDiv, vaultPath, snippetDiv + ]); + + if (r.tags && r.tags.length > 0) { + const tagsDiv = el("div", { class: "search-result-tags" }); + r.tags.forEach((tag) => { + if (!TagFilterService.isTagFiltered(tag)) { + const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + addTagFilter(tag); + }); + tagsDiv.appendChild(tagEl); + } + }); + if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); + } + + item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + container.appendChild(item); + }); + area.appendChild(container); + + // Pagination + if (data.total > state.ADVANCED_SEARCH_LIMIT) { + const paginationDiv = el("div", { class: "search-pagination" }); + const prevBtn = el("button", { class: "search-pagination__btn", type: "button" }); + prevBtn.textContent = "← Précédent"; + prevBtn.disabled = state.advancedSearchOffset === 0; + prevBtn.addEventListener("click", () => { + state.advancedSearchOffset = Math.max(0, state.advancedSearchOffset - state.ADVANCED_SEARCH_LIMIT); + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset); + document.getElementById("content-area").scrollTop = 0; + }); + + const info = el("span", { class: "search-pagination__info" }); + const from = state.advancedSearchOffset + 1; + const to = Math.min(state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT, data.total); + info.textContent = `${from}–${to} sur ${data.total}`; + + const nextBtn = el("button", { class: "search-pagination__btn", type: "button" }); + nextBtn.textContent = "Suivant →"; + nextBtn.disabled = state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT >= data.total; + nextBtn.addEventListener("click", () => { + state.advancedSearchOffset += state.ADVANCED_SEARCH_LIMIT; + const vault = document.getElementById("vault-filter").value; + performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset); + document.getElementById("content-area").scrollTop = 0; + }); + + paginationDiv.appendChild(prevBtn); + paginationDiv.appendChild(info); + paginationDiv.appendChild(nextBtn); + area.appendChild(paginationDiv); + } + + safeCreateIcons(); + // Initialize result navigation (select first result) + setTimeout(() => { if (window.navigateSearchResults) window.navigateSearchResults(0); }, 50); +} diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 840f1db..08289c7 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -1,1092 +1,1091 @@ 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 + +// --------------------------------------------------------------------------- +// 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) { + state.selectedContextVault = vaultName; + state.showingSource = false; + state.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 (state.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 = state.selectedContextVault; + if (quickSelect) quickSelect.value = state.selectedContextVault; + if (recentFilter) recentFilter.value = state.selectedContextVault === "all" ? "" : state.selectedContextVault; + if (dashboardFilter) dashboardFilter.value = state.selectedContextVault; + + // Mise à jour visuelle des dropdowns personnalisés + updateCustomDropdownVisual("vault-filter-dropdown", state.selectedContextVault); + updateCustomDropdownVisual("vault-quick-select-dropdown", state.selectedContextVault); + + // Update vault context indicator + if (contextText) { + contextText.textContent = state.selectedContextVault === "all" ? "All" : state.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 = state.selectedContextVault === "all" ? state.allVaults : state.allVaults.filter((v) => v.name === state.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 = state.selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(state.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 = state.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"); + state.vaultSettings = settings; + } catch (err) { + console.error("Failed to load vault settings:", err); + state.vaultSettings = {}; + } +} + +// --------------------------------------------------------------------------- +// Sidebar — Vault tree +// --------------------------------------------------------------------------- +async function loadVaults() { + const vaults = await api("/api/vaults"); + state.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"); + state.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(state.filterDebounce); + state.filterDebounce = setTimeout(async () => { + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (hasText) { + if (state.activeSidebarTab === "vaults") { + await performTreeSearch(q); + } else { + filterTagCloud(q); + } + } else { + if (state.activeSidebarTab === "vaults") { + await restoreSidebarTree(); + } else { + filterTagCloud(""); + } + } + }, 220); + }); + + caseBtn.addEventListener("click", async () => { + state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive; + caseBtn.classList.toggle("active"); + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (input.value.trim()) { + if (state.activeSidebarTab === "vaults") { + await performTreeSearch(q); + } else { + filterTagCloud(q); + } + } + }); + + clearBtn.addEventListener("click", async () => { + input.value = ""; + clearBtn.style.display = "none"; + state.sidebarFilterCaseSensitive = false; + caseBtn.classList.remove("active"); + clearTimeout(state.filterDebounce); + if (state.activeSidebarTab === "vaults") { + await restoreSidebarTree(); + } else { + filterTagCloud(""); + } + }); + + clearBtn.style.display = "none"; +} + +async function performTreeSearch(query) { + if (!query) { + await restoreSidebarTree(); + return; + } + + try { + const vaultParam = state.selectedContextVault === "all" ? "all" : state.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 (state.currentVault) { + focusPathInSidebar(state.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, state.sidebarFilterCaseSensitive); + const secondary = el("div", { class: "filter-result-secondary" }); + appendHighlightedText(secondary, entry.path, query, state.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 (!state.selectedTags.includes(tag)) { + state.selectedTags.push(tag); + performTagSearch(); + } +} + +function removeTagFilter(tag) { + state.selectedTags = state.selectedTags.filter((t) => t !== tag); + if (state.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, state.selectedTags.length > 0 ? state.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 (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`, + "aria-label": `Retirer ${tag} du filtre`, + }, + [document.createTextNode("×")], + ); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeTagFilter(tag); + }); + + const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); + activeTags.appendChild(chip); + }); + header.appendChild(activeTags); + } + + return header; +} + +function searchByTag(tag) { + addTagFilter(tag); +} + + +export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext }; diff --git a/frontend/js/state.js b/frontend/js/state.js index 32d3672..e5d290c 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -56,4 +56,4 @@ export const state = { activeSidebarTab: "vaults", filterDebounce: null, vaultSettings: {}, -}; +}; \ No newline at end of file diff --git a/frontend/js/sync.js b/frontend/js/sync.js index 956cf79..67c9c82 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -1,438 +1,437 @@ - 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 +1|/* ObsiGate — Sync: SSE client + PWA registration */ +import { + state.currentVault, + state.currentPath, + state.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 = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault); + if (affectsCurrentVault) { + try { + await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + // Refresh current file if it was updated + if (currentVault && state.currentPath) { + const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath); + if (changed) { + openFile(state.currentVault, state.currentPath); + } + } + } catch (err) { + console.error("Error refreshing after index update:", err); + } + } + + // Refresh recent tab if it is active + if (state.activeSidebarTab === "recent") { + const vaultFilter = document.getElementById("recent-vault-filter"); + loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + } + + setTimeout(() => { + connectionState = "connected"; + _updateBadge(); + }, 1500); + } + + async function _onIndexReloaded(data) { + connectionState = "syncing"; + _updateBadge(); + showToast("Index complet rechargé", "info"); + try { + await Promise.all([loadVaults(), loadTags()]); + } catch (err) { + console.error("Error refreshing after full reload:", err); + } + setTimeout(() => { + connectionState = "connected"; + _updateBadge(); + }, 1500); + } + + function _updateBadge() { + const badge = document.getElementById("sync-badge"); + if (!badge) return; + badge.className = "sync-badge sync-badge--" + connectionState; + const labels = { + disconnected: "Déconnecté", + connecting: "Connexion...", + connected: "Synchronisé", + syncing: "Mise à jour...", + }; + badge.title = labels[connectionState] || connectionState; + } + + function disconnect() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + connectionState = "disconnected"; + _updateBadge(); + } + + function getState() { + return connectionState; + } + + function getRecentEvents() { + return recentEvents; + } + + return { connect, disconnect, getState, getRecentEvents }; +})(); + +// --------------------------------------------------------------------------- +// Sync status badge and panel +// --------------------------------------------------------------------------- + +function initSyncStatus() { + const badge = document.getElementById("sync-badge"); + if (!badge) return; + + badge.addEventListener("click", (e) => { + e.stopPropagation(); + toggleSyncPanel(); + }); + + IndexUpdateManager.connect(); +} + +function toggleSyncPanel() { + let panel = document.getElementById("sync-panel"); + if (panel) { + panel.remove(); + return; + } + // Auto reconnect if disconnected when user opens the panel + if (IndexUpdateManager.getState() === "disconnected") { + IndexUpdateManager.connect(); + } + panel = document.createElement("div"); + panel.id = "sync-panel"; + panel.className = "sync-panel"; + _renderSyncPanel(panel); + document.body.appendChild(panel); + + // Close on outside click + setTimeout(() => { + document.addEventListener("click", _closeSyncPanelOutside, { once: true }); + }, 0); +} + +function _closeSyncPanelOutside(e) { + const panel = document.getElementById("sync-panel"); + if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") { + panel.remove(); + } +} + +function _renderSyncPanel(panel) { + const state = IndexUpdateManager.getState(); + const events = IndexUpdateManager.getRecentEvents(); + + const stateLabels = { + disconnected: "Déconnecté", + connecting: "Connexion...", + connected: "Connecté", + syncing: "Synchronisation...", + }; + + let html = `
    + Synchronisation + ${stateLabels[state] || state} +
    `; + + if (events.length === 0) { + html += `
    Aucun événement récent
    `; + } else { + html += `
    `; + events.slice(0, 10).forEach((ev) => { + const time = new Date(ev.timestamp).toLocaleTimeString(); + const typeLabels = { + index_updated: "Mise à jour", + index_reloaded: "Rechargement", + vault_added: "Vault ajouté", + vault_removed: "Vault supprimé", + index_start: "Démarrage index.", + index_progress: "Vault indexé", + index_complete: "Indexation tech.", + }; + const label = typeLabels[ev.type] || ev.type; + let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || ""; + if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`; + if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`; + if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`; + html += `
    + ${label} + ${detail} + ${time} +
    `; + }); + html += `
    `; + } + + panel.innerHTML = html; +} + +// --------------------------------------------------------------------------- +// PWA Service Worker Registration +// --------------------------------------------------------------------------- +function registerServiceWorker() { + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/sw.js") + .then((registration) => { + console.log("[PWA] Service Worker registered successfully:", registration.scope); + + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60000); // Check every minute + + // Handle service worker updates + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing; + newWorker.addEventListener("statechange", () => { + if (newWorker.state === "installed" && navigator.serviceWorker.controller) { + // New service worker available + showUpdateNotification(); + } + }); + }); + }) + .catch((error) => { + console.log("[PWA] Service Worker registration failed:", error); + }); + }); + } +} + +function showUpdateNotification() { + const message = document.createElement("div"); + message.className = "pwa-update-notification"; + message.innerHTML = ` +
    + Une nouvelle version d'ObsiGate est disponible ! + + +
    + `; + document.body.appendChild(message); + + // Auto-dismiss after 30 seconds + setTimeout(() => { + if (message.parentElement) { + message.remove(); + } + }, 30000); +} + +// Handle install prompt +let deferredPrompt; +window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + deferredPrompt = e; + + // Show install button if desired + const installBtn = document.getElementById("pwa-install-btn"); + if (installBtn) { + installBtn.style.display = "block"; + installBtn.addEventListener("click", async () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + console.log(`[PWA] User response to install prompt: ${outcome}`); + deferredPrompt = null; + installBtn.style.display = "none"; + } + }); + } +}); + +// Log when app is installed +window.addEventListener("appinstalled", () => { + console.log("[PWA] ObsiGate has been installed"); + showToast("ObsiGate installé avec succès !", "success"); +}); + +// ── Dashboard tab switching (runs on page load and after rebuild) ── +function initDashboardTabs() { + document.querySelectorAll(".dashboard-tab").forEach(tab => { + // Remove existing listeners by cloning + const newTab = tab.cloneNode(true); + tab.parentNode.replaceChild(newTab, tab); + newTab.addEventListener("click", function() { + document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); + document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); + this.classList.add("active"); + const panel = document.getElementById("dashboard-panel-" + this.dataset.tab); + if (panel) panel.classList.add("active"); + }); + }); +} + +// --------------------------------------------------------------------------- +// Init — called by app.js orchestrator +// --------------------------------------------------------------------------- +export { initSyncStatus }; +export function init() { + registerServiceWorker(); + initDashboardTabs(); +} diff --git a/frontend/js/ui.js b/frontend/js/ui.js index b46d84e..967431e 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1,2001 +1,1297 @@ - 1|/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */ +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 +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) { + state.rightSidebarVisible = savedVisible === "true"; + } + + if (savedWidth) { + state.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 (state.rightSidebarVisible) { + sidebar.classList.remove("hidden"); + sidebar.style.width = `${state.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() { + state.rightSidebarVisible = !state.rightSidebarVisible; + localStorage.setItem("obsigate-right-sidebar-visible", state.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`; + state.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", state.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; + 4 + +... [OUTPUT TRUNCATED - 30698 chars omitted out of 80698 total] ... + + + + + + `; + + 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 (state.currentVault === vault && state.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 + state.currentVault = cache.vault; + state.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(state.selectedContextVault); + if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(state.selectedContextVault); + if (history.pushState) { + history.pushState(null, "", "#"); + } + }, + + /** Render the tab bar */ diff --git a/frontend/js/utils.js b/frontend/js/utils.js index bd0f053..f5e46cf 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -1,511 +1,510 @@ 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 + +// --------------------------------------------------------------------------- +// 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 state._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 (state._iconDebounceTimer) return; // already scheduled + state._iconDebounceTimer = requestAnimationFrame(() => { + state._iconDebounceTimer = null; + try { + lucide.createIcons(); + } catch (e) { + /* CDN not loaded */ + } + }); +} + +/** Force-flush icon creation immediately (use sparingly). */ +function flushIcons() { + if (state._iconDebounceTimer) { + cancelAnimationFrame(state._iconDebounceTimer); + state._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) { + state.editorVault = vaultName; + state.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 (state.editorView) { + state.editorView.destroy(); + state.editorView = null; + } + state.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, + }); + + state.editorView = new EditorView({ + state: state, + parent: bodyEl, + }); + } catch (err) { + console.error("CodeMirror init failed, falling back to textarea:", err); + state.fallbackEditorEl = document.createElement("textarea"); + state.fallbackEditorEl.className = "fallback-editor"; + state.fallbackEditorEl.value = rawData.raw; + bodyEl.appendChild(state.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 (state.editorView) { + state.editorView.destroy(); + state.editorView = null; + } + state.fallbackEditorEl = null; + state.editorVault = null; + state.editorPath = null; +} + +async function saveFile() { + if ((!editorView && !state.fallbackEditorEl) || !editorVault || !state.editorPath) return; + + const content = editorView ? state.editorView.state.doc.toString() : state.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(state.editorVault)}/save?path=${encodeURIComponent(state.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 (state.currentVault === editorVault && state.currentPath === state.editorPath) { + openFile(state.currentVault, state.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 || !state.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(state.editorVault)}?path=${encodeURIComponent(state.editorPath)}`, { method: "DELETE" }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || "Erreur de suppression"); + } + + closeEditor(); + showWelcome(); + await refreshSidebarForContext(); + await refreshTagsForContext(); + } catch (err) { + console.error("Delete error:", err); + alert(`Erreur: ${err.message}`); + deleteBtn.innerHTML = originalHTML; + deleteBtn.disabled = false; + safeCreateIcons(); + } +} + +function initEditor() { + const cancelBtn = document.getElementById("editor-cancel"); + const deleteBtn = document.getElementById("editor-delete"); + const saveBtn = document.getElementById("editor-save"); + const modal = document.getElementById("editor-modal"); + + cancelBtn.addEventListener("click", closeEditor); + deleteBtn.addEventListener("click", deleteFile); + saveBtn.addEventListener("click", saveFile); + + // Close on overlay click + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeEditor(); + } + }); + + // ESC to close + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeEditor(); + } + }); + + // Fix mouse wheel scrolling in editor + modal.addEventListener( + "wheel", + (e) => { + const editorBody = document.getElementById("editor-body"); + if (editorBody && editorBody.contains(e.target)) { + // Let the editor handle the scroll + return; + } + // Prevent modal from scrolling if not in editor area + e.preventDefault(); + }, + { passive: false }, + ); +} + +export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor }; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index cc69d7e..ec650f3 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1,1555 +1,1255 @@ - 1|/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */ +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 +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); + }); + + state.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 (state.activeHeadingId === headingId) return; + + state.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(); + state.headingsCache = []; + state.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) { + state.currentVault = vaultName; + state.currentPath = filePath; + state.showingSource = false; + state.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 (!state.cachedRawSource) { + const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; + const rawData = await api(rawUrl); + state.cachedRawSource = rawData.raw; + } + await navigator.clipboard.writeText(state.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", + +... [OUTPUT TRUNCATED - 13907 chars omitted out of 63907 total] ... + +1| 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 = state.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(state.selectedContextVault); + } + if (typeof DashboardBookmarkWidget !== "undefined") { + DashboardBookmarkWidget.load(state.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; + state.searchCaseSensitive = s.case_sensitive || false; + state.searchWholeWord = s.whole_word || false; + state.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(state.searchTimeout); + state.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")); + + state.currentVault = null; + state.currentPath = null; + state.showingSource = false; + state.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 = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault); + if (affectsCurrentVault) { + try { + await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); + // Refresh current file if it was updated + if (currentVault && state.currentPath) { + const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath); + if (changed) { + openFile(state.currentVault, state.currentPath); + } + } + } catch (err) { + console.error("Error refreshing after index update:", err); + } + } + + // Refresh recent tab if it is active + if (state.activeSidebarTab === "recent") { + const vaultFilter = document.getElementById("recent-vault-filter"); + loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + } + + setTimeout(() => { + connectionState = "connected"; + _updateBadge(); + }, 1500); + } + + async function _onIndexReloaded(data) { + connectionState = "syncing"; + _updateBadge(); + showToast("Index complet rechargé", "info"); + try { + await Promise.all([loadVaults(), loadTags()]); + } catch (err) { + console.error("Error refreshing after full reload:", err); + } + setTimeout(() => { + connectionState = "connected"; + _updateBadge(); + }, 1500); + } + + function _updateBadge() { + const badge = document.getElementById("sync-badge"); + if (!badge) return; + badge.className = "sync-badge sync-badge--" + connectionState; + const labels = { + disconnected: "Déconnecté", + connecting: "Connexion...", + connected: "Synchronisé", + syncing: "Mise à jour...", + }; + badge.title = labels[connectionState] || connectionState; + } + + function disconnect() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + connectionState = "disconnected"; + _updateBadge(); + } + + function getState() { + return connectionState; + } + + function getRecentEvents() { + return recentEvents; + } + + return { connect, disconnect, getState, getRecentEvents }; +})(); + +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 }; +