/* ObsiGate — Vanilla JS SPA */ (function () { "use strict"; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let currentVault = null; let currentPath = null; let searchTimeout = null; let searchAbortController = null; let showingSource = false; let cachedRawSource = null; let allVaults = []; let selectedContextVault = "all"; let selectedTags = []; let editorView = null; let editorVault = null; let editorPath = null; let fallbackEditorEl = null; let sidebarFilterCaseSensitive = false; let searchCaseSensitive = false; let _iconDebounceTimer = null; let activeSidebarTab = "vaults"; let filterDebounce = null; // Advanced search state let advancedSearchOffset = 0; let advancedSearchTotal = 0; let advancedSearchSort = "relevance"; let advancedSearchLastQuery = ""; let suggestAbortController = null; let dropdownActiveIndex = -1; let dropdownItems = []; let currentSearchId = 0; // Advanced search constants const SEARCH_HISTORY_KEY = "obsigate_search_history"; const MAX_HISTORY_ENTRIES = 50; const SUGGEST_DEBOUNCE_MS = 150; const ADVANCED_SEARCH_LIMIT = 50; const MIN_SEARCH_LENGTH = 2; const SEARCH_TIMEOUT_MS = 30000; // --------------------------------------------------------------------------- // File extension → Lucide icon mapping // --------------------------------------------------------------------------- const EXT_ICONS = { ".md": "file-text", ".txt": "file-text", ".log": "file-text", ".py": "file-code", ".js": "file-code", ".ts": "file-code", ".jsx": "file-code", ".tsx": "file-code", ".html": "file-code", ".css": "file-code", ".scss": "file-code", ".less": "file-code", ".json": "file-json", ".yaml": "file-cog", ".yml": "file-cog", ".toml": "file-cog", ".xml": "file-code", ".sh": "terminal", ".bash": "terminal", ".zsh": "terminal", ".bat": "terminal", ".cmd": "terminal", ".ps1": "terminal", ".java": "file-code", ".c": "file-code", ".cpp": "file-code", ".h": "file-code", ".hpp": "file-code", ".cs": "file-code", ".go": "file-code", ".rs": "file-code", ".rb": "file-code", ".php": "file-code", ".sql": "database", ".csv": "table", ".ini": "file-cog", ".cfg": "file-cog", ".conf": "file-cog", ".env": "file-cog", }; function getFileIcon(name) { const ext = "." + name.split(".").pop().toLowerCase(); return EXT_ICONS[ext] || "file"; } // --------------------------------------------------------------------------- // Search History Service (localStorage, LIFO, max 50, dedup) // --------------------------------------------------------------------------- const SearchHistory = { _load() { try { const raw = localStorage.getItem(SEARCH_HISTORY_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } }, _save(entries) { try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(entries)); } catch {} }, getAll() { return this._load(); }, add(query) { if (!query || !query.trim()) return; const q = query.trim(); let entries = this._load().filter(e => e !== q); entries.unshift(q); if (entries.length > MAX_HISTORY_ENTRIES) entries = entries.slice(0, MAX_HISTORY_ENTRIES); this._save(entries); }, remove(query) { const entries = this._load().filter(e => e !== query); this._save(entries); }, clear() { this._save([]); }, filter(prefix) { if (!prefix) return this.getAll().slice(0, 8); const lp = prefix.toLowerCase(); return this._load().filter(e => e.toLowerCase().includes(lp)).slice(0, 8); }, }; // --------------------------------------------------------------------------- // Query Parser — extracts operators (tag:, #, vault:, title:, path:) // --------------------------------------------------------------------------- const QueryParser = { parse(raw) { const result = { tags: [], vault: null, title: null, path: 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 { 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 // --------------------------------------------------------------------------- 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; dropdownActiveIndex = -1; dropdownItems = []; }, isVisible() { return this._dropdown && !this._dropdown.hidden; }, /** Populate and show the dropdown with history, title suggestions, and tag suggestions */ async populate(inputValue, cursorPos) { // Cancel previous suggestion request if (suggestAbortController) { suggestAbortController.abort(); 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); this._renderHistory(historyItems, inputValue); // Title and tag suggestions from API (debounced) clearTimeout(this._suggestTimer); if (ctx.prefix && ctx.prefix.length >= 2) { this._suggestTimer = setTimeout(() => this._fetchSuggestions(ctx, vault, inputValue), SUGGEST_DEBOUNCE_MS); } else { this._renderTitles([], ""); this._renderTags([], ""); } // Show/hide sections const hasContent = historyItems.length > 0; this._historySection.hidden = historyItems.length === 0; this._emptyEl.hidden = hasContent; if (hasContent || (ctx.prefix && ctx.prefix.length >= 2)) { this.show(); } else if (!hasContent) { this.hide(); } this._collectItems(); }, async _fetchSuggestions(ctx, vault, inputValue) { suggestAbortController = new AbortController(); try { const [titlesRes, tagsRes] = await Promise.all([ ctx.type !== "tag" ? api(`/api/suggest?q=${encodeURIComponent(ctx.prefix)}&vault=${encodeURIComponent(vault)}&limit=8`, { signal: suggestAbortController.signal }) : Promise.resolve({ suggestions: [] }), (ctx.type === "tag" || ctx.type === "text") ? api(`/api/tags/suggest?q=${encodeURIComponent(ctx.prefix)}&vault=${encodeURIComponent(vault)}&limit=6`, { signal: suggestAbortController.signal }) : Promise.resolve({ suggestions: [] }), ]); this._renderTitles(titlesRes.suggestions || [], ctx.prefix); this._renderTags(tagsRes.suggestions || [], ctx.prefix); // Update visibility const hasTitles = (titlesRes.suggestions || []).length > 0; const hasTags = (tagsRes.suggestions || []).length > 0; this._titlesSection.hidden = !hasTitles; this._tagsSection.hidden = !hasTags; const historyVisible = !this._historySection.hidden; const hasAny = historyVisible || hasTitles || hasTags; this._emptyEl.hidden = hasAny; if (hasAny) this.show(); else if (!historyVisible) this.hide(); this._collectItems(); } catch (err) { if (err.name !== "AbortError") console.error("Suggestion fetch error:", err); } }, _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; 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(); openFile(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"); // Append tag: operator if not already typing one const current = input.value; const ctx = QueryParser.getContext(current, input.selectionStart); if (ctx.type === "tag") { // Replace the partial tag prefix const before = current.slice(0, input.selectionStart - ctx.prefix.length); input.value = before + item.tag + " "; } else { input.value = (current ? current + " " : "") + "tag:" + item.tag + " "; } 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() { dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item")); dropdownActiveIndex = -1; dropdownItems.forEach(item => item.classList.remove("active")); }, navigateDown() { if (!this.isVisible() || dropdownItems.length === 0) return; if (dropdownActiveIndex >= 0) dropdownItems[dropdownActiveIndex].classList.remove("active"); dropdownActiveIndex = (dropdownActiveIndex + 1) % dropdownItems.length; dropdownItems[dropdownActiveIndex].classList.add("active"); dropdownItems[dropdownActiveIndex].scrollIntoView({ block: "nearest" }); }, navigateUp() { if (!this.isVisible() || dropdownItems.length === 0) return; if (dropdownActiveIndex >= 0) dropdownItems[dropdownActiveIndex].classList.remove("active"); dropdownActiveIndex = dropdownActiveIndex <= 0 ? dropdownItems.length - 1 : dropdownActiveIndex - 1; dropdownItems[dropdownActiveIndex].classList.add("active"); dropdownItems[dropdownActiveIndex].scrollIntoView({ block: "nearest" }); }, selectActive() { if (dropdownActiveIndex >= 0 && dropdownActiveIndex < dropdownItems.length) { dropdownItems[dropdownActiveIndex].click(); return true; } return false; }, }; // --------------------------------------------------------------------------- // Search Chips Controller — renders active filter chips from parsed query // --------------------------------------------------------------------------- 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; } this._container.hidden = !hasChips; }, clear() { if (!this._container) return; this._container.innerHTML = ""; this._container.hidden = true; }, _addChip(type, fullOperator, displayText) { const chip = el("span", { class: `search-chip search-chip--${type}` }); const label = el("span", { class: "search-chip__label" }); label.textContent = fullOperator; const removeBtn = el("button", { class: "search-chip__remove", title: "Retirer ce filtre", type: "button" }); removeBtn.innerHTML = ''; removeBtn.addEventListener("click", () => { // Remove this operator from the input const input = document.getElementById("search-input"); const raw = input.value; // Remove the operator text from the query const escaped = fullOperator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); input.value = raw.replace(new RegExp("\\s*" + escaped + "\\s*", "i"), " ").trim(); _triggerAdvancedSearch(input.value); }); chip.appendChild(label); chip.appendChild(removeBtn); this._container.appendChild(chip); safeCreateIcons(); }, }; // --------------------------------------------------------------------------- // Helper: trigger advanced search from input value // --------------------------------------------------------------------------- function _triggerAdvancedSearch(rawQuery) { const q = (rawQuery || "").trim(); const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; advancedSearchOffset = 0; if (q.length > 0 || tagFilter) { SearchHistory.add(q); performAdvancedSearch(q, vault, tagFilter); } else { SearchChips.clear(); showWelcome(); } } // --------------------------------------------------------------------------- // Safe CDN helpers // --------------------------------------------------------------------------- /** * Debounced icon creation — batches multiple rapid calls into one * DOM scan to avoid excessive reflows when building large trees. */ function safeCreateIcons() { if (typeof lucide === "undefined" || !lucide.createIcons) return; if (_iconDebounceTimer) return; // already scheduled _iconDebounceTimer = requestAnimationFrame(() => { _iconDebounceTimer = null; try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } }); } /** Force-flush icon creation immediately (use sparingly). */ function flushIcons() { if (_iconDebounceTimer) { cancelAnimationFrame(_iconDebounceTimer); _iconDebounceTimer = null; } if (typeof lucide !== "undefined" && lucide.createIcons) { try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } } } function safeHighlight(block) { if (typeof hljs !== "undefined" && hljs.highlightElement) { try { hljs.highlightElement(block); } catch (e) { /* CDN not loaded */ } } } // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- function initTheme() { const saved = localStorage.getItem("obsigate-theme") || "dark"; applyTheme(saved); } 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"; } } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme"); applyTheme(current === "dark" ? "light" : "dark"); } 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 // --------------------------------------------------------------------------- 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. */ function showToast(message, type) { type = type || "error"; 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); } // --------------------------------------------------------------------------- // 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 { res = await fetch(path, opts || {}); } catch (err) { if (err.name === "AbortError") throw err; // let callers handle abort showToast("Erreur réseau — vérifiez votre connexion"); throw err; } if (!res.ok) { var detail = ""; try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ } showToast(detail || "Erreur API : " + res.status); throw new Error(detail || "API error: " + res.status); } return res.json(); } // --------------------------------------------------------------------------- // Sidebar toggle (desktop) // --------------------------------------------------------------------------- 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 // --------------------------------------------------------------------------- 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"); } // --------------------------------------------------------------------------- // Vault context switching // --------------------------------------------------------------------------- function initVaultContext() { const filter = document.getElementById("vault-filter"); const quickSelect = document.getElementById("vault-quick-select"); if (!filter || !quickSelect) return; filter.addEventListener("change", async () => { await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" }); }); quickSelect.addEventListener("change", async () => { await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" }); }); } async function setSelectedVaultContext(vaultName, options) { selectedContextVault = vaultName; showingSource = false; cachedRawSource = null; syncVaultSelectors(); await refreshSidebarForContext(); await refreshTagsForContext(); 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"); if (filter) filter.value = selectedContextVault; if (quickSelect) quickSelect.value = selectedContextVault; } 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 targetTop = alignToTop ? Math.max(0, offsetTop - 60) : Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height * 0.35)); scrollContainer.scrollTo({ top: targetTop, behavior: "smooth", }); } async function refreshSidebarForContext() { const container = document.getElementById("vault-tree"); container.innerHTML = ""; const vaultsToShow = selectedContextVault === "all" ? allVaults : allVaults.filter((v) => v.name === selectedContextVault); vaultsToShow.forEach((v) => { const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [ icon("chevron-right", 14), icon("database", 16), document.createTextNode(` ${v.name} `), smallBadge(v.file_count), ]); vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); container.appendChild(vaultItem); const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); container.appendChild(childContainer); }); safeCreateIcons(); } async function focusVaultInSidebar(vaultName) { switchSidebarTab("vaults"); const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); if (!vaultItem) return; document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); vaultItem.classList.add("focused"); const childContainer = document.getElementById(`vault-children-${vaultName}`); if (childContainer && childContainer.classList.contains("collapsed")) { await toggleVault(vaultItem, vaultName, true); } scrollTreeItemIntoView(vaultItem, false); } async function refreshTagsForContext() { const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`; const data = await api(`/api/tags${vaultParam}`); const filteredTags = TagFilterService.filterTags(data.tags); renderTagCloud(filteredTags); } // --------------------------------------------------------------------------- // Sidebar — Vault tree // --------------------------------------------------------------------------- async function loadVaults() { const vaults = await api("/api/vaults"); allVaults = vaults; const container = document.getElementById("vault-tree"); container.innerHTML = ""; // Prepare dropdown options const dropdownOptions = [ { value: "all", text: "Tous les vaults" }, ...vaults.map(v => ({ value: v.name, text: v.name })) ]; // Populate custom dropdowns populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all"); populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all"); vaults.forEach((v) => { // Sidebar tree entry const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [ icon("chevron-right", 14), icon("database", 16), document.createTextNode(` ${v.name} `), smallBadge(v.file_count), ]); vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); container.appendChild(vaultItem); const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); container.appendChild(childContainer); }); syncVaultSelectors(); 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 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 = document.getElementById(`dir-${vaultName}-${cumulativePath}`); if (nextContainer && nextContainer.classList.contains("collapsed")) { targetItem.click(); await new Promise((resolve) => setTimeout(resolve, 0)); } if (nextContainer) { currentContainer = nextContainer; } } } // 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); } async function loadDirectory(vaultName, dirPath, container) { // Show inline loading indicator while fetching directory contents 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) => { 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), document.createTextNode(` ${item.name} `), smallBadge(item.children_count), ]); 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(); } }); } 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), document.createTextNode(` ${displayName}`), ]); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); openFile(vaultName, item.path); closeMobileSidebar(); }); fragment.appendChild(fileItem); } }); container.appendChild(fragment); safeCreateIcons(); } // --------------------------------------------------------------------------- // Sidebar filter // --------------------------------------------------------------------------- function initSidebarFilter() { const input = document.getElementById("sidebar-filter-input"); const caseBtn = document.getElementById("sidebar-filter-case-btn"); const clearBtn = document.getElementById("sidebar-filter-clear-btn"); input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; clearTimeout(filterDebounce); filterDebounce = setTimeout(async () => { const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); if (hasText) { if (activeSidebarTab === "vaults") { await performTreeSearch(q); } else { filterTagCloud(q); } } else { if (activeSidebarTab === "vaults") { await restoreSidebarTree(); } else { filterTagCloud(""); } } }, 220); }); caseBtn.addEventListener("click", async () => { sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive; caseBtn.classList.toggle("active"); const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); if (input.value.trim()) { if (activeSidebarTab === "vaults") { await performTreeSearch(q); } else { filterTagCloud(q); } } }); clearBtn.addEventListener("click", async () => { input.value = ""; clearBtn.style.display = "none"; sidebarFilterCaseSensitive = false; caseBtn.classList.remove("active"); clearTimeout(filterDebounce); if (activeSidebarTab === "vaults") { await restoreSidebarTree(); } else { filterTagCloud(""); } }); clearBtn.style.display = "none"; } async function performTreeSearch(query) { if (!query) { await restoreSidebarTree(); return; } try { const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault; const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`; const data = await api(url); renderFilteredSidebarResults(query, data.results); } catch (err) { console.error("Tree search error:", err); renderFilteredSidebarResults(query, []); } } async function restoreSidebarTree() { await refreshSidebarForContext(); if (currentVault) { focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {}); } } function renderFilteredSidebarResults(query, results) { const container = document.getElementById("vault-tree"); container.innerHTML = ""; const grouped = new Map(); results.forEach((result) => { if (!grouped.has(result.vault)) { grouped.set(result.vault, []); } grouped.get(result.vault).push(result); }); if (grouped.size === 0) { container.appendChild(el("div", { class: "sidebar-filter-empty" }, [ document.createTextNode("Aucun répertoire ou fichier correspondant."), ])); return; } grouped.forEach((entries, vaultName) => { entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" })); const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [ icon("database", 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length), ]); container.appendChild(vaultHeader); const resultsWrapper = el("div", { class: "filter-results-group" }); entries.forEach((entry) => { const resultItem = el("div", { class: `tree-item filter-result-item filter-result-${entry.type}`, "data-vault": entry.vault, "data-path": entry.path, "data-type": entry.type, }, [ icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16), ]); const textWrap = el("div", { class: "filter-result-text" }); const primary = el("div", { class: "filter-result-primary" }); appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive); const secondary = el("div", { class: "filter-result-secondary" }); appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive); textWrap.appendChild(primary); textWrap.appendChild(secondary); resultItem.appendChild(textWrap); resultItem.addEventListener("click", async () => { const input = document.getElementById("sidebar-filter-input"); const clearBtn = document.getElementById("sidebar-filter-clear-btn"); if (input) input.value = ""; if (clearBtn) clearBtn.style.display = "none"; await restoreSidebarTree(); if (entry.type === "directory") { await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true }); } else { await openFile(entry.vault, entry.path); await focusPathInSidebar(entry.vault, entry.path, { alignToTop: false }); } 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) { let regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); regex = regex.replace(/\\\.\\\.\\\./g, '.*'); return regex; }, isTagFiltered(tag) { const config = this.getConfig(); const filters = config.tagFilters || this.defaultFilters; for (const filter of filters) { if (!filter.enabled) continue; try { const regex = new RegExp(`^${filter.regex}$`); if (regex.test(`#${tag}`)) { return true; } } catch (e) { console.warn("Invalid regex:", filter.regex, e); } } return false; }, filterTags(tags) { const filtered = {}; Object.entries(tags).forEach(([tag, count]) => { if (!this.isTagFiltered(tag)) { filtered[tag] = count; } }); return filtered; } }; // --------------------------------------------------------------------------- // Tags // --------------------------------------------------------------------------- async function loadTags() { const data = await api("/api/tags"); const filteredTags = TagFilterService.filterTags(data.tags); renderTagCloud(filteredTags); } function renderTagCloud(tags) { const cloud = document.getElementById("tag-cloud"); cloud.innerHTML = ""; const counts = Object.values(tags); if (counts.length === 0) return; const maxCount = Math.max(...counts); const minSize = 0.7; const maxSize = 1.25; Object.entries(tags).forEach(([tag, count]) => { const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; const size = minSize + ratio * (maxSize - minSize); const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [ document.createTextNode(`#${tag}`), ]); tagEl.addEventListener("click", () => searchByTag(tag)); cloud.appendChild(tagEl); }); } function addTagFilter(tag) { if (!selectedTags.includes(tag)) { selectedTags.push(tag); performTagSearch(); } } function removeTagFilter(tag) { selectedTags = selectedTags.filter(t => t !== tag); if (selectedTags.length > 0) { performTagSearch(); } else { const input = document.getElementById("search-input"); if (input.value.trim()) { performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null); } else { showWelcome(); } } } function performTagSearch() { const input = document.getElementById("search-input"); const query = input.value.trim(); const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null); } function buildSearchResultsHeader(data, query, tagFilter) { const header = el("div", { class: "search-results-header" }); const summaryText = el("span", { class: "search-results-summary-text" }); if (query && tagFilter) { summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`; } else if (query) { summaryText.textContent = `${data.count} résultat(s) pour "${query}"`; } else if (tagFilter) { summaryText.textContent = `${data.count} fichier(s) avec les tags`; } else { summaryText.textContent = `${data.count} résultat(s)`; } header.appendChild(summaryText); if (selectedTags.length > 0) { const activeTags = el("div", { class: "search-results-active-tags" }); selectedTags.forEach((tag) => { const removeBtn = el("button", { class: "search-results-active-tag-remove", title: `Retirer ${tag} du filtre`, "aria-label": `Retirer ${tag} du filtre` }, [document.createTextNode("×")]); removeBtn.addEventListener("click", (e) => { e.stopPropagation(); removeTagFilter(tag); }); const chip = el("span", { class: "search-results-active-tag" }, [ document.createTextNode(`#${tag}`), removeBtn, ]); activeTags.appendChild(chip); }); header.appendChild(activeTags); } return header; } function searchByTag(tag) { addTagFilter(tag); } // --------------------------------------------------------------------------- // File viewer // --------------------------------------------------------------------------- async function openFile(vaultName, filePath) { currentVault = vaultName; currentPath = filePath; showingSource = false; cachedRawSource = null; // Highlight active document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); const selector = `.tree-item[data-vault="${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 */ } // 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.

'; } } function renderFile(data) { const area = document.getElementById("content-area"); // Breadcrumb const parts = data.path.split("/"); const breadcrumbEls = []; breadcrumbEls.push(makeBreadcrumbSpan(data.vault, () => { focusPathInSidebar(data.vault, "", { alignToTop: true }); })); 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: true }); })); } else { breadcrumbEls.push(makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { focusPathInSidebar(data.vault, data.path, { alignToTop: false }); })); } }); const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); t.addEventListener("click", () => searchByTag(tag)); tagsDiv.appendChild(t); } }); // Action buttons const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [ icon("copy", 14), document.createTextNode("Copier"), ]); copyBtn.addEventListener("click", async () => { try { // Fetch raw content if not already cached if (!cachedRawSource) { const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); cachedRawSource = rawData.raw; } await navigator.clipboard.writeText(cachedRawSource); copyBtn.lastChild.textContent = "Copié !"; setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500); } catch (err) { console.error("Copy error:", err); showToast("Erreur lors de la copie"); } }); const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [ icon("code", 14), document.createTextNode("Source"), ]); const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [ icon("download", 14), document.createTextNode("Télécharger"), ]); downloadBtn.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); }); const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [ icon("edit", 14), document.createTextNode("Éditer"), ]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); }); // Frontmatter let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { const fmToggle = el("div", { class: "frontmatter-toggle" }, [ document.createTextNode("▶ Frontmatter"), ]); const fmContent = el("div", { class: "frontmatter-content" }, [ document.createTextNode(JSON.stringify(data.frontmatter, null, 2)), ]); fmToggle.addEventListener("click", () => { fmContent.classList.toggle("open"); fmToggle.textContent = fmContent.classList.contains("open") ? "▼ Frontmatter" : "▶ Frontmatter"; }); fmSection = el("div", {}, [fmToggle, fmContent]); } // Content container (rendered HTML) const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); mdDiv.innerHTML = data.html; // Raw source container (hidden initially) const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); // Source button toggle logic sourceBtn.addEventListener("click", async () => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; showingSource = !showingSource; if (showingSource) { sourceBtn.classList.add("active"); if (!cachedRawSource) { const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); cachedRawSource = rawData.raw; } raw.textContent = cachedRawSource; rendered.style.display = "none"; raw.style.display = "block"; } else { sourceBtn.classList.remove("active"); rendered.style.display = "block"; raw.style.display = "none"; } }); // Assemble area.innerHTML = ""; area.appendChild(breadcrumb); area.appendChild(el("div", { class: "file-header" }, [ el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn]), ])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv); // Highlight code blocks area.querySelectorAll("pre code").forEach((block) => { safeHighlight(block); }); // Wire up wikilinks area.querySelectorAll(".wikilink").forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const v = link.getAttribute("data-vault"); const p = link.getAttribute("data-path"); if (v && p) openFile(v, p); }); }); safeCreateIcons(); area.scrollTop = 0; } // --------------------------------------------------------------------------- // Sidebar tabs // --------------------------------------------------------------------------- function initSidebarTabs() { document.querySelectorAll(".sidebar-tab").forEach((tab) => { tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab)); }); } function switchSidebarTab(tab) { activeSidebarTab = tab; document.querySelectorAll(".sidebar-tab").forEach((btn) => { const isActive = btn.dataset.tab === tab; btn.classList.toggle("active", isActive); btn.setAttribute("aria-selected", isActive ? "true" : "false"); }); document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => { const isActive = panel.id === `sidebar-panel-${tab}`; panel.classList.toggle("active", isActive); }); const filterInput = document.getElementById("sidebar-filter-input"); if (filterInput) { filterInput.placeholder = tab === "vaults" ? "Filtrer fichiers..." : "Filtrer tags..."; } const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); else filterTagCloud(query); } } 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(); }); 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 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(); 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); 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); } 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 _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"); } 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), }; try { await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); showToast("Configuration backend sauvegardée"); } catch (err) { console.error("Failed to save backend config:", err); showToast("Erreur de sauvegarde"); } } 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"); loadDiagnostics(); await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Reindex error:", err); showToast("Erreur de réindexation"); } 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"); } 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); }); } 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"; } } // --------------------------------------------------------------------------- // Search (enhanced with autocomplete, keyboard nav, global shortcuts) // --------------------------------------------------------------------------- function initSearch() { const input = document.getElementById("search-input"); const caseBtn = document.getElementById("search-case-btn"); const clearBtn = document.getElementById("search-clear-btn"); // Initialize sub-controllers AutocompleteDropdown.init(); SearchChips.init(); // Initially hide clear button clearBtn.style.display = "none"; // --- Input handler: debounced search + autocomplete dropdown --- input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; // Show autocomplete dropdown while typing AutocompleteDropdown.populate(input.value, input.selectionStart); // Debounced search execution clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { const q = input.value.trim(); const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; advancedSearchOffset = 0; if ((q.length >= _getEffective("min_query_length", MIN_SEARCH_LENGTH)) || tagFilter) { performAdvancedSearch(q, vault, tagFilter); } else if (q.length === 0) { SearchChips.clear(); showWelcome(); } }, _getEffective("debounce_ms", 300)); }); // --- Focus handler: show history dropdown --- input.addEventListener("focus", () => { if (input.value.length === 0) { const historyItems = SearchHistory.filter(""); 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") { if (AutocompleteDropdown.selectActive()) { e.preventDefault(); return; } // No active item — execute search normally AutocompleteDropdown.hide(); const q = input.value.trim(); if (q) { SearchHistory.add(q); clearTimeout(searchTimeout); advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; performAdvancedSearch(q, vault, tagFilter); } e.preventDefault(); } else if (e.key === "Escape") { AutocompleteDropdown.hide(); e.stopPropagation(); } } else if (e.key === "Enter") { const q = input.value.trim(); if (q) { SearchHistory.add(q); clearTimeout(searchTimeout); advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; performAdvancedSearch(q, vault, tagFilter); } e.preventDefault(); } }); caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; caseBtn.classList.toggle("active"); }); clearBtn.addEventListener("click", () => { input.value = ""; clearBtn.style.display = "none"; searchCaseSensitive = false; caseBtn.classList.remove("active"); 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) --- async function performSearch(query, vaultFilter, tagFilter) { if (searchAbortController) searchAbortController.abort(); searchAbortController = new AbortController(); const searchId = ++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: searchAbortController.signal }); if (searchId !== currentSearchId) return; renderSearchResults(data, query, tagFilter); } catch (err) { if (err.name === "AbortError") return; if (searchId !== currentSearchId) return; showWelcome(); } finally { hideProgressBar(); if (searchId === currentSearchId) searchAbortController = null; } } // --- Advanced search with TF-IDF, facets, pagination --- async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) { if (searchAbortController) searchAbortController.abort(); searchAbortController = new AbortController(); const searchId = ++currentSearchId; showLoading(); const ofs = offset !== undefined ? offset : advancedSearchOffset; const sortBy = sort || advancedSearchSort; advancedSearchLastQuery = query; // Update chips from parsed query const parsed = QueryParser.parse(query); SearchChips.update(parsed); const effectiveLimit = _getEffective("results_per_page", 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)}`; // Search timeout — abort if server takes too long const timeoutId = setTimeout(() => { if (searchAbortController) searchAbortController.abort(); }, _getEffective("search_timeout_ms", SEARCH_TIMEOUT_MS)); try { const data = await api(url, { signal: searchAbortController.signal }); clearTimeout(timeoutId); if (searchId !== currentSearchId) return; advancedSearchTotal = data.total; advancedSearchOffset = ofs; renderAdvancedSearchResults(data, query, tagFilter); } catch (err) { clearTimeout(timeoutId); if (err.name === "AbortError") return; if (searchId !== currentSearchId) return; showWelcome(); } finally { hideProgressBar(); if (searchId === currentSearchId) searchAbortController = null; } } // --- Legacy search results renderer (kept for backward compat) --- 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) => { const titleDiv = el("div", { class: "search-result-title" }); if (query && query.trim()) { highlightSearchText(titleDiv, r.title, query, searchCaseSensitive); } else { titleDiv.textContent = r.title; } const snippetDiv = el("div", { class: "search-result-snippet" }); if (query && query.trim() && r.snippet) { highlightSearchText(snippetDiv, r.snippet, query, searchCaseSensitive); } else { snippetDiv.textContent = r.snippet || ""; } const item = el("div", { class: "search-result-item" }, [ 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", () => openFile(r.vault, r.path)); container.appendChild(item); }); area.appendChild(container); } // --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) --- function renderAdvancedSearchResults(data, query, tagFilter) { const area = document.getElementById("content-area"); area.innerHTML = ""; // 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); // Sort controls const sortDiv = el("div", { class: "search-sort" }); const btnRelevance = el("button", { class: "search-sort__btn" + (advancedSearchSort === "relevance" ? " active" : ""), type: "button" }); btnRelevance.textContent = "Pertinence"; btnRelevance.addEventListener("click", () => { advancedSearchSort = "relevance"; advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, 0, "relevance"); }); const btnDate = el("button", { class: "search-sort__btn" + (advancedSearchSort === "modified" ? " active" : ""), type: "button" }); btnDate.textContent = "Date"; btnDate.addEventListener("click", () => { advancedSearchSort = "modified"; advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, 0, "modified"); }); sortDiv.appendChild(btnRelevance); sortDiv.appendChild(btnDate); header.appendChild(sortDiv); area.appendChild(header); // Active sidebar tag chips if (selectedTags.length > 0) { const activeTags = el("div", { class: "search-results-active-tags" }); selectedTags.forEach((tag) => { const removeBtn = el("button", { class: "search-results-active-tag-remove", title: `Retirer ${tag} du filtre`, }, [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) => { const titleDiv = el("div", { class: "search-result-title" }); if (freeText) { highlightSearchText(titleDiv, r.title, freeText, 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, 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" }, [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", () => openFile(r.vault, r.path)); container.appendChild(item); }); area.appendChild(container); // Pagination if (data.total > 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 = advancedSearchOffset === 0; prevBtn.addEventListener("click", () => { advancedSearchOffset = Math.max(0, advancedSearchOffset - ADVANCED_SEARCH_LIMIT); const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, advancedSearchOffset); document.getElementById("content-area").scrollTop = 0; }); const info = el("span", { class: "search-pagination__info" }); const from = advancedSearchOffset + 1; const to = Math.min(advancedSearchOffset + 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 = advancedSearchOffset + ADVANCED_SEARCH_LIMIT >= data.total; nextBtn.addEventListener("click", () => { advancedSearchOffset += ADVANCED_SEARCH_LIMIT; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, advancedSearchOffset); document.getElementById("content-area").scrollTop = 0; }); paginationDiv.appendChild(prevBtn); paginationDiv.appendChild(info); paginationDiv.appendChild(nextBtn); area.appendChild(paginationDiv); } safeCreateIcons(); } // --------------------------------------------------------------------------- // Resizable sidebar (horizontal) // --------------------------------------------------------------------------- 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) // --------------------------------------------------------------------------- function initTagResize() { const handle = document.getElementById("tag-resize-handle"); const tagSection = document.getElementById("tag-cloud-section"); if (!handle || !tagSection) return; // Restore saved height const savedHeight = localStorage.getItem("obsigate-tag-height"); if (savedHeight) { tagSection.style.height = savedHeight + "px"; } let startY = 0; let startHeight = 0; function onMouseMove(e) { // Dragging up increases height, dragging down decreases const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY))); tagSection.style.height = newHeight + "px"; } function onMouseUp() { document.body.classList.remove("resizing-v"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startY = e.clientY; startHeight = tagSection.getBoundingClientRect().height; document.body.classList.add("resizing-v"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function el(tag, attrs, children) { const e = document.createElement(tag); if (attrs) { Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v)); } if (children) { children.forEach((c) => { if (c) e.appendChild(c); }); } return e; } function icon(name, size) { const i = document.createElement("i"); i.setAttribute("data-lucide", name); i.style.width = size + "px"; i.style.height = size + "px"; i.classList.add("icon"); return i; } function smallBadge(count) { const s = document.createElement("span"); s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; s.textContent = `(${count})`; return s; } function makeBreadcrumbSpan(text, onClick) { const s = document.createElement("span"); s.textContent = text; if (onClick) s.addEventListener("click", onClick); 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(); const area = document.getElementById("content-area"); area.innerHTML = `

ObsiGate

Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.

`; safeCreateIcons(); } function showLoading() { const area = document.getElementById("content-area"); area.innerHTML = `
Recherche en cours...
`; showProgressBar(); } function showProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.add("active"); } function hideProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.remove("active"); } function goHome() { const searchInput = document.getElementById("search-input"); if (searchInput) searchInput.value = ""; document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); currentVault = null; currentPath = null; showingSource = false; cachedRawSource = null; closeMobileSidebar(); showWelcome(); } // --------------------------------------------------------------------------- // Editor (CodeMirror) // --------------------------------------------------------------------------- async function openEditor(vaultName, filePath) { editorVault = vaultName; editorPath = filePath; const modal = document.getElementById("editor-modal"); const titleEl = document.getElementById("editor-title"); const bodyEl = document.getElementById("editor-body"); titleEl.textContent = `Édition: ${filePath.split("/").pop()}`; // Fetch raw content const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; const rawData = await api(rawUrl); // Clear previous editor bodyEl.innerHTML = ""; if (editorView) { editorView.destroy(); editorView = null; } fallbackEditorEl = null; try { await waitForCodeMirror(); const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror; const currentTheme = document.documentElement.getAttribute("data-theme"); const fileExt = filePath.split(".").pop().toLowerCase(); const extensions = [ basicSetup, keymap.of([{ key: "Mod-s", run: () => { saveFile(); return true; } }]), EditorView.lineWrapping, ]; // Add language support based on file extension const langMap = { "md": markdown, "markdown": markdown, "py": python, "js": javascript, "jsx": javascript, "ts": javascript, "tsx": javascript, "mjs": javascript, "cjs": javascript, "html": html, "htm": html, "css": css, "scss": css, "less": css, "json": json, "xml": xml, "svg": xml, "sql": sql, "php": php, "cpp": cpp, "cc": cpp, "cxx": cpp, "c": cpp, "h": cpp, "hpp": cpp, "java": java, "rs": rust, "sh": javascript, // Using javascript for shell scripts as fallback "bash": javascript, "zsh": javascript, }; const langMode = langMap[fileExt]; if (langMode) { extensions.push(langMode()); } if (currentTheme === "dark") { extensions.push(oneDark); } const state = EditorState.create({ doc: rawData.raw, extensions: extensions, }); editorView = new EditorView({ state: state, parent: bodyEl, }); } catch (err) { console.error("CodeMirror init failed, falling back to textarea:", err); fallbackEditorEl = document.createElement("textarea"); fallbackEditorEl.className = "fallback-editor"; fallbackEditorEl.value = rawData.raw; bodyEl.appendChild(fallbackEditorEl); } modal.classList.add("active"); safeCreateIcons(); } async function waitForCodeMirror() { let attempts = 0; while (!window.CodeMirror && attempts < 50) { await new Promise(resolve => setTimeout(resolve, 100)); attempts++; } if (!window.CodeMirror) { throw new Error("CodeMirror failed to load"); } } function closeEditor() { const modal = document.getElementById("editor-modal"); modal.classList.remove("active"); if (editorView) { editorView.destroy(); editorView = null; } fallbackEditorEl = null; editorVault = null; editorPath = null; } async function saveFile() { if ((!editorView && !fallbackEditorEl) || !editorVault || !editorPath) return; const content = editorView ? editorView.state.doc.toString() : fallbackEditorEl.value; const saveBtn = document.getElementById("editor-save"); const originalHTML = saveBtn.innerHTML; try { saveBtn.disabled = true; saveBtn.innerHTML = ''; safeCreateIcons(); const response = await fetch( `/api/file/${encodeURIComponent(editorVault)}/save?path=${encodeURIComponent(editorPath)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), } ); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Erreur de sauvegarde"); } saveBtn.innerHTML = ''; safeCreateIcons(); setTimeout(() => { closeEditor(); if (currentVault === editorVault && currentPath === editorPath) { openFile(currentVault, currentPath); } }, 800); } catch (err) { console.error("Save error:", err); alert(`Erreur: ${err.message}`); saveBtn.innerHTML = originalHTML; saveBtn.disabled = false; safeCreateIcons(); } } async function deleteFile() { if (!editorVault || !editorPath) return; const deleteBtn = document.getElementById("editor-delete"); const originalHTML = deleteBtn.innerHTML; try { deleteBtn.disabled = true; deleteBtn.innerHTML = ''; safeCreateIcons(); const response = await fetch( `/api/file/${encodeURIComponent(editorVault)}?path=${encodeURIComponent(editorPath)}`, { method: "DELETE" } ); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Erreur de suppression"); } closeEditor(); showWelcome(); await refreshSidebarForContext(); await refreshTagsForContext(); } catch (err) { console.error("Delete error:", err); alert(`Erreur: ${err.message}`); deleteBtn.innerHTML = originalHTML; deleteBtn.disabled = false; safeCreateIcons(); } } function initEditor() { const cancelBtn = document.getElementById("editor-cancel"); const deleteBtn = document.getElementById("editor-delete"); const saveBtn = document.getElementById("editor-save"); const modal = document.getElementById("editor-modal"); cancelBtn.addEventListener("click", closeEditor); deleteBtn.addEventListener("click", deleteFile); saveBtn.addEventListener("click", saveFile); // Close on overlay click modal.addEventListener("click", (e) => { if (e.target === modal) { closeEditor(); } }); // ESC to close document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { closeEditor(); } }); // Fix mouse wheel scrolling in editor modal.addEventListener("wheel", (e) => { const editorBody = document.getElementById("editor-body"); if (editorBody && editorBody.contains(e.target)) { // Let the editor handle the scroll return; } // Prevent modal from scrolling if not in editor area e.preventDefault(); }, { passive: false }); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { initTheme(); initHeaderMenu(); initCustomDropdowns(); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("header-logo").addEventListener("click", goHome); initSearch(); initSidebarToggle(); initMobile(); initVaultContext(); initSidebarTabs(); initHelpModal(); initConfigModal(); initSidebarFilter(); initSidebarResize(); initEditor(); try { await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Failed to initialize ObsiGate:", err); showToast("Erreur lors de l'initialisation"); } safeCreateIcons(); } document.addEventListener("DOMContentLoaded", init); })();