/* ObsiGate — Search module */ import { api } from './auth.js'; import { state } from './state.js'; import { safeCreateIcons } from './utils.js'; import { showLoading, el, hideProgressBar, showWelcome, highlightSearchText } from './viewer.js'; import { _getEffective } from './config.js'; import { TabManager, showToast } from './ui.js'; import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter } from './sidebar.js'; // --------------------------------------------------------------------------- // Search History Service (localStorage, LIFO, max 50, dedup) // --------------------------------------------------------------------------- export const SearchHistory = { _load() { try { const raw = localStorage.getItem(state.SEARCH_HISTORY_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } }, _save(entries) { try { localStorage.setItem(state.SEARCH_HISTORY_KEY, JSON.stringify(entries)); } catch {} }, getAll() { return this._load(); }, add(query) { if (!query || !query.trim()) return; const q = query.trim(); let entries = this._load().filter((e) => e !== q); entries.unshift(q); if (entries.length > state.MAX_HISTORY_ENTRIES) entries = entries.slice(0, state.MAX_HISTORY_ENTRIES); this._save(entries); }, remove(query) { const entries = this._load().filter((e) => e !== query); this._save(entries); }, clear() { this._save([]); }, filter(prefix) { if (!prefix) return this.getAll().slice(0, 8); const lp = prefix.toLowerCase(); return this._load() .filter((e) => e.toLowerCase().includes(lp)) .slice(0, 8); }, }; // --------------------------------------------------------------------------- // Query Parser — extracts operators (tag:, #, vault:, title:, path:, ext:) // --------------------------------------------------------------------------- export const QueryParser = { parse(raw) { const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" }; if (!raw) return result; const tokens = this._tokenize(raw); const freeTokens = []; for (const tok of tokens) { const lower = tok.toLowerCase(); if (lower.startsWith("tag:")) { const v = tok.slice(4).replace(/"/g, "").trim().replace(/^#/, ""); if (v) result.tags.push(v); } else if (lower.startsWith("#") && tok.length > 1) { result.tags.push(tok.slice(1)); } else if (lower.startsWith("vault:")) { result.vault = tok.slice(6).replace(/"/g, "").trim(); } else if (lower.startsWith("title:")) { result.title = tok.slice(6).replace(/"/g, "").trim(); } else if (lower.startsWith("path:")) { result.path = tok.slice(5).replace(/"/g, "").trim(); } else if (lower.startsWith("ext:")) { result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase(); } else { freeTokens.push(tok); } } result.freeText = freeTokens.join(" "); return result; }, _tokenize(raw) { const tokens = []; let i = 0; const n = raw.length; while (i < n) { while (i < n && raw[i] === " ") i++; if (i >= n) break; if (raw[i] !== '"') { let j = i; while (j < n && raw[j] !== " ") { if (raw[j] === '"') { j++; while (j < n && raw[j] !== '"') j++; if (j < n) j++; } else j++; } tokens.push(raw.slice(i, j).replace(/"/g, "")); i = j; } else { i++; let j = i; while (j < n && raw[j] !== '"') j++; tokens.push(raw.slice(i, j)); i = j + 1; } } return tokens; }, /** Detect the current operator context at cursor for autocomplete */ getContext(raw, cursorPos) { const before = raw.slice(0, cursorPos); // Check if we're typing a tag: or # value const tagMatch = before.match(/(?:tag:|#)([\w-]*)$/i); if (tagMatch) return { type: "tag", prefix: tagMatch[1] }; // Check if typing title: const titleMatch = before.match(/title:([\w-]*)$/i); if (titleMatch) return { type: "title", prefix: titleMatch[1] }; // Default: free text const words = before.trim().split(/\s+/); const lastWord = words[words.length - 1] || ""; return { type: "text", prefix: lastWord }; }, }; // --------------------------------------------------------------------------- // Autocomplete Dropdown Controller // --------------------------------------------------------------------------- export const AutocompleteDropdown = { _dropdown: null, _historySection: null, _titlesSection: null, _tagsSection: null, _historyList: null, _titlesList: null, _tagsList: null, _emptyEl: null, _suggestTimer: null, init() { this._dropdown = document.getElementById("search-dropdown"); this._historySection = document.getElementById("search-dropdown-history"); this._titlesSection = document.getElementById("search-dropdown-titles"); this._tagsSection = document.getElementById("search-dropdown-tags"); this._historyList = document.getElementById("search-dropdown-history-list"); this._titlesList = document.getElementById("search-dropdown-titles-list"); this._tagsList = document.getElementById("search-dropdown-tags-list"); this._emptyEl = document.getElementById("search-dropdown-empty"); // Clear history button const clearBtn = document.getElementById("search-dropdown-clear-history"); if (clearBtn) { clearBtn.addEventListener("click", (e) => { e.stopPropagation(); SearchHistory.clear(); this.hide(); }); } // Close dropdown on outside click document.addEventListener("click", (e) => { if (this._dropdown && !this._dropdown.contains(e.target) && e.target.id !== "search-input") { this.hide(); } }); }, show() { if (this._dropdown) this._dropdown.hidden = false; }, hide() { if (this._dropdown) this._dropdown.hidden = true; state.dropdownActiveIndex = -1; state.dropdownItems = []; }, isVisible() { return this._dropdown && !this._dropdown.hidden; }, /** Populate and show the dropdown with history, title suggestions, and tag suggestions */ async populate(inputValue, cursorPos) { if (this._suppressNext) { this._suppressNext = false; return; } // Cancel previous suggestion request if (state.suggestAbortController) { state.suggestAbortController.abort(); state.suggestAbortController = null; } const ctx = QueryParser.getContext(inputValue, cursorPos); const vault = document.getElementById("vault-filter").value; // History — always show filtered history const historyItems = SearchHistory.filter(inputValue).slice(0, 5); this._renderHistory(historyItems, inputValue); // Title and tag suggestions from API (debounced) — always fetch both clearTimeout(this._suggestTimer); const prefix = ctx.prefix; if (prefix && prefix.length >= 2) { // Only show placeholder if lists are empty (avoid flashing on fast typing) const hasTitles = this._titlesList.children.length > 0 && !this._titlesList.querySelector(".search-dropdown__item--loading"); const hasTags = this._tagsList.children.length > 0 && !this._tagsList.querySelector(".search-dropdown__item--loading"); if (!hasTitles) { this._titlesList.innerHTML = '
  • Recherche...
  • '; } if (!hasTags) { this._tagsList.innerHTML = '
  • Recherche...
  • '; } this._titlesSection.hidden = false; this._tagsSection.hidden = false; this.show(); this._suggestTimer = setTimeout(() => this._fetchSuggestions(prefix, vault), 150); } else { this._renderTitles([], ""); this._renderTags([], ""); this._titlesSection.hidden = true; this._tagsSection.hidden = true; } // Show/hide sections this._historySection.hidden = historyItems.length === 0; const hasContent = historyItems.length > 0; if (hasContent || (prefix && prefix.length >= 2)) { this.show(); } else { this.hide(); } this._collectItems(); }, async _fetchSuggestions(prefix, vault) { state.suggestAbortController = new AbortController(); // Fetch titles try { const titlesRes = await api(`/api/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: state.suggestAbortController.signal }); this._renderTitles(titlesRes.suggestions || [], prefix); this._titlesSection.hidden = !(titlesRes.suggestions || []).length; if (titlesRes.suggestions?.length) this.show(); } catch (err) { if (err.name === "AbortError") return; this._titlesSection.hidden = true; } // Fetch tags — keep section always visible to confirm it works try { const tagsRes = await api(`/api/tags/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: state.suggestAbortController.signal }); const items = tagsRes.suggestions || []; if (items.length > 0) { this._renderTags(items, prefix); } else { this._tagsList.innerHTML = '
  • Aucun tag
  • '; } this._tagsSection.hidden = false; this.show(); } catch (err) { if (err.name === "AbortError") return; this._tagsList.innerHTML = '
  • Erreur chargement
  • '; this._tagsSection.hidden = false; } this._collectItems(); }, _renderHistory(items, query) { this._historyList.innerHTML = ""; items.forEach((entry) => { const li = el("li", { class: "search-dropdown__item search-dropdown__item--history", role: "option" }); const iconEl = el("span", { class: "search-dropdown__icon" }); iconEl.innerHTML = ''; const textEl = el("span", { class: "search-dropdown__text" }); textEl.textContent = entry; li.appendChild(iconEl); li.appendChild(textEl); li.addEventListener("click", () => { const input = document.getElementById("search-input"); input.value = entry; input.dispatchEvent(new Event("input", { bubbles: true })); this.hide(); _triggerAdvancedSearch(entry); }); this._historyList.appendChild(li); }); }, _renderTitles(items, prefix) { this._titlesList.innerHTML = ""; items.forEach((item) => { const li = el("li", { class: "search-dropdown__item search-dropdown__item--title", role: "option" }); const iconEl = el("span", { class: "search-dropdown__icon" }); iconEl.innerHTML = ''; const textEl = el("span", { class: "search-dropdown__text" }); if (prefix) { this._highlightText(textEl, item.title, prefix); } else { textEl.textContent = item.title; } const metaEl = el("span", { class: "search-dropdown__meta" }); metaEl.textContent = item.vault; li.appendChild(iconEl); li.appendChild(textEl); li.appendChild(metaEl); li.addEventListener("click", () => { this.hide(); TabManager.openPreview(item.vault, item.path); }); this._titlesList.appendChild(li); }); }, _renderTags(items, prefix) { this._tagsList.innerHTML = ""; items.forEach((item) => { const li = el("li", { class: "search-dropdown__item search-dropdown__item--tag", role: "option" }); const iconEl = el("span", { class: "search-dropdown__icon" }); iconEl.innerHTML = ''; const textEl = el("span", { class: "search-dropdown__text" }); if (prefix) { this._highlightText(textEl, item.tag, prefix); } else { textEl.textContent = item.tag; } const badge = el("span", { class: "search-dropdown__badge" }); badge.textContent = item.count; li.appendChild(iconEl); li.appendChild(textEl); li.appendChild(badge); li.addEventListener("click", () => { const input = document.getElementById("search-input"); const current = input.value; const cursorPos = input.selectionStart; const ctx = QueryParser.getContext(current, cursorPos); if (ctx.type === "tag") { // Replace the partial tag prefix const before = current.slice(0, cursorPos - ctx.prefix.length); input.value = before + item.tag + " "; } else { // Replace the last word with tag: operator const words = current.trim().split(/\s+/); if (words.length > 0 && ctx.prefix && ctx.prefix.length > 0) { words[words.length - 1] = ""; // remove last partial word } const base = words.filter(w => w).join(" "); input.value = (base ? base + " " : "") + "tag:" + item.tag + " "; } input.dispatchEvent(new Event("input", { bubbles: true })); this.hide(); input.focus(); _triggerAdvancedSearch(input.value); }); this._tagsList.appendChild(li); }); }, _highlightText(container, text, query) { const lower = text.toLowerCase(); const needle = query.toLowerCase(); const pos = lower.indexOf(needle); if (pos === -1) { container.textContent = text; return; } container.appendChild(document.createTextNode(text.slice(0, pos))); const markEl = el("mark", {}, [document.createTextNode(text.slice(pos, pos + query.length))]); container.appendChild(markEl); container.appendChild(document.createTextNode(text.slice(pos + query.length))); }, _collectItems() { state.dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item")); state.dropdownActiveIndex = -1; state.dropdownItems.forEach((item) => item.classList.remove("active")); }, navigateDown() { if (!this.isVisible() || state.dropdownItems.length === 0) return; if (state.dropdownActiveIndex >= 0) state.dropdownItems[state.dropdownActiveIndex].classList.remove("active"); state.dropdownActiveIndex = (state.dropdownActiveIndex + 1) % state.dropdownItems.length; state.dropdownItems[state.dropdownActiveIndex].classList.add("active"); state.dropdownItems[state.dropdownActiveIndex].scrollIntoView({ block: "nearest" }); }, navigateUp() { if (!this.isVisible() || state.dropdownItems.length === 0) return; if (state.dropdownActiveIndex >= 0) state.dropdownItems[state.dropdownActiveIndex].classList.remove("active"); state.dropdownActiveIndex = state.dropdownActiveIndex <= 0 ? state.dropdownItems.length - 1 : state.dropdownActiveIndex - 1; state.dropdownItems[state.dropdownActiveIndex].classList.add("active"); state.dropdownItems[state.dropdownActiveIndex].scrollIntoView({ block: "nearest" }); }, selectActive() { if (state.dropdownActiveIndex >= 0 && state.dropdownActiveIndex < state.dropdownItems.length) { state.dropdownItems[state.dropdownActiveIndex].click(); return true; } return false; }, }; // --------------------------------------------------------------------------- // Search Chips Controller — renders active filter chips from parsed query // --------------------------------------------------------------------------- export const SearchChips = { _container: null, init() { this._container = document.getElementById("search-chips"); }, update(parsed) { if (!this._container) return; this._container.innerHTML = ""; let hasChips = false; parsed.tags.forEach((tag) => { this._addChip("tag", `tag:${tag}`, tag); hasChips = true; }); if (parsed.vault) { this._addChip("vault", `vault:${parsed.vault}`, parsed.vault); hasChips = true; } if (parsed.title) { this._addChip("title", `title:${parsed.title}`, parsed.title); hasChips = true; } if (parsed.path) { this._addChip("path", `path:${parsed.path}`, parsed.path); hasChips = true; } if (parsed.ext) { this._addChip("ext", `ext:${parsed.ext}`, parsed.ext); hasChips = true; } this._container.hidden = !hasChips; }, clear() { if (!this._container) return; this._container.innerHTML = ""; this._container.hidden = true; }, _addChip(type, fullOperator, displayText) { const chip = el("span", { class: `search-chip search-chip--${type}` }); const label = el("span", { class: "search-chip__label" }); label.textContent = fullOperator; const removeBtn = el("button", { class: "search-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 = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; state.advancedSearchOffset = 0; if (q.length > 0 || tagFilter) { SearchHistory.add(q); performAdvancedSearch(q, vault, tagFilter); } else { SearchChips.clear(); showWelcome(); } } // --------------------------------------------------------------------------- // Search (enhanced with autocomplete, keyboard nav, global shortcuts) // --------------------------------------------------------------------------- // ── Search toggle state ── export function initSearch() { const input = document.getElementById("search-input"); if (!input) return; const caseBtn = document.getElementById("search-case-btn"); const wordBtn = document.getElementById("search-word-btn"); const regexBtn = document.getElementById("search-regex-btn"); const filterBtn = document.getElementById("search-filter-btn"); const clearBtn = document.getElementById("search-clear-btn"); const filterRow = document.getElementById("search-filter-row"); const prevBtn = document.getElementById("search-prev-btn"); const nextBtn = document.getElementById("search-next-btn"); const countEl = document.getElementById("search-match-count"); function _updateToggleUI() { caseBtn.classList.toggle("active", state.searchCaseSensitive); wordBtn.classList.toggle("active", state.searchWholeWord); regexBtn.classList.toggle("active", state.searchRegex); filterBtn.classList.toggle("active", state.searchFilterVisible); } // Toggle buttons caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); }); if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); }); if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); }); if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = state.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); // ── Result navigation (up/down arrows + Enter) ── let _searchResultIdx = -1; let _searchResultItems = []; function _updateResultHighlight() { _searchResultItems.forEach((el, i) => { el.classList.toggle("search-result-active", i === _searchResultIdx); }); if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); } const countEl = document.getElementById("search-match-count"); if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; } function _refreshResultItems() { _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; _updateResultHighlight(); } window.navigateSearchResults = function(delta) { _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); if (_searchResultItems.length === 0) return; _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); _updateResultHighlight(); }; if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); function _research() { const q = input.value.trim(); if (q.length >= _getEffective("min_query_length", 2)) { clearTimeout(state.searchTimeout); state.searchTimeout = setTimeout(() => { const vault = document.getElementById("vault-filter").value; const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; state.advancedSearchOffset = 0; performAdvancedSearch(q, vault, tagFilter); }, _getEffective("debounce_ms", 300)); } } // Keyboard shortcuts document.addEventListener("keydown", (e) => { if (e.altKey && !e.ctrlKey && !e.metaKey) { if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } } }); // Initialize sub-controllers AutocompleteDropdown.init(); SearchChips.init(); // Initially hide clear button if (clearBtn) clearBtn.style.display = "none"; // --- Input handler: debounced search + autocomplete dropdown --- input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; // Show autocomplete dropdown while typing AutocompleteDropdown.populate(input.value, input.selectionStart); // Debounced search execution clearTimeout(state.searchTimeout); state.searchTimeout = setTimeout( () => { const q = input.value.trim(); const vault = document.getElementById("vault-filter").value; const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; state.advancedSearchOffset = 0; if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) { performAdvancedSearch(q, vault, tagFilter); } else if (q.length === 0) { SearchChips.clear(); showWelcome(); } }, _getEffective("debounce_ms", 300), ); }); // --- Focus handler: show history dropdown --- input.addEventListener("focus", () => { if (input.value.length === 0) { const historyItems = SearchHistory.filter("").slice(0, 5); if (historyItems.length > 0) { AutocompleteDropdown.populate("", 0); } } }); // --- Keyboard navigation in dropdown --- input.addEventListener("keydown", (e) => { if (AutocompleteDropdown.isVisible()) { if (e.key === "ArrowDown") { e.preventDefault(); AutocompleteDropdown.navigateDown(); } else if (e.key === "ArrowUp") { e.preventDefault(); AutocompleteDropdown.navigateUp(); } else if (e.key === "Enter") { // First: check dropdown suggestions (higher priority than search results) if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { e.preventDefault(); return; } // Second: navigate search results if visible const results = document.querySelectorAll(".search-result-item"); if (results.length > 0 && _searchResultIdx >= 0) { const el = results[_searchResultIdx]; if (el) { const vault = el.dataset.vault; const path = el.dataset.path; if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } } } // Third: execute search AutocompleteDropdown.hide(); const q = input.value.trim(); if (q) { SearchHistory.add(q); clearTimeout(state.searchTimeout); state.advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; performAdvancedSearch(q, vault, tagFilter); } e.preventDefault(); } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { // Navigate search results when dropdown is closed if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } } else if (e.key === "Escape") { AutocompleteDropdown.hide(); e.stopPropagation(); } } else if (e.key === "Enter") { if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { e.preventDefault(); return; } const q = input.value.trim(); if (q) { SearchHistory.add(q); clearTimeout(state.searchTimeout); state.advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null; performAdvancedSearch(q, vault, tagFilter); } e.preventDefault(); } }); clearBtn.addEventListener("click", () => { input.value = ""; if (clearBtn) clearBtn.style.display = "none"; state.searchCaseSensitive = false; state.searchWholeWord = false; state.searchRegex = false; _updateToggleUI(); SearchChips.clear(); AutocompleteDropdown.hide(); showWelcome(); }); // --- Global keyboard shortcuts --- document.addEventListener("keydown", (e) => { // Ctrl+K or Cmd+K: focus search if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault(); input.focus(); input.select(); } // "/" key: focus search (when not in an input/textarea) if (e.key === "/" && !_isInputFocused()) { e.preventDefault(); input.focus(); } // Escape: blur search input and close dropdown if (e.key === "Escape" && document.activeElement === input) { AutocompleteDropdown.hide(); input.blur(); } }); } /** Check if user is focused on an input/textarea/contenteditable */ function _isInputFocused() { const tag = document.activeElement?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; return document.activeElement?.isContentEditable === true; } // --- Backward-compatible search (existing /api/search endpoint) --- export async function performSearch(query, vaultFilter, tagFilter) { if (state.searchAbortController) state.searchAbortController.abort(); state.searchAbortController = new AbortController(); const searchId = ++state.currentSearchId; showLoading(); let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; try { const data = await api(url, { signal: state.searchAbortController.signal }); if (searchId !== state.currentSearchId) return; renderSearchResults(data, query, tagFilter); } catch (err) { if (err.name === "AbortError") return; if (searchId !== state.currentSearchId) return; showWelcome(); } finally { hideProgressBar(); if (searchId === state.currentSearchId) state.searchAbortController = null; } } // --- Advanced search with TF-IDF, facets, pagination --- export async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) { if (state.searchAbortController) state.searchAbortController.abort(); state.searchAbortController = new AbortController(); const searchId = ++state.currentSearchId; showLoading(); const ofs = offset !== undefined ? offset : state.advancedSearchOffset; const sortBy = sort || state.advancedSearchSort; state.advancedSearchLastQuery = query; // Update chips from parsed query const parsed = QueryParser.parse(query); SearchChips.update(parsed); const effectiveLimit = _getEffective("results_per_page", state.ADVANCED_SEARCH_LIMIT); let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${effectiveLimit}&offset=${ofs}&sort=${sortBy}`; if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; if (state.searchCaseSensitive) url += "&case_sensitive=true"; if (state.searchWholeWord) url += "&whole_word=true"; if (state.searchRegex) url += "®ex=true"; const includeEl = document.getElementById("search-include-input"); const excludeEl = document.getElementById("search-exclude-input"); if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`; // Search timeout — abort if server takes too long const timeoutId = setTimeout( () => { if (state.searchAbortController) state.searchAbortController.abort(); }, _getEffective("search_timeout_ms", state.SEARCH_TIMEOUT_MS), ); try { const data = await api(url, { signal: state.searchAbortController.signal }); clearTimeout(timeoutId); if (searchId !== state.currentSearchId) return; state.advancedSearchTotal = data.total; state.advancedSearchOffset = ofs; renderAdvancedSearchResults(data, query, tagFilter); } catch (err) { clearTimeout(timeoutId); if (err.name === "AbortError") return; if (searchId !== state.currentSearchId) return; showWelcome(); } finally { hideProgressBar(); if (searchId === state.currentSearchId) state.searchAbortController = null; } } // --- Legacy search results renderer (kept for backward compat) --- export function renderSearchResults(data, query, tagFilter) { const area = document.getElementById("content-area"); area.innerHTML = ""; const header = buildSearchResultsHeader(data, query, tagFilter); area.appendChild(header); if (data.results.length === 0) { area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); return; } const container = el("div", { class: "search-results" }); data.results.forEach((r) => { // Apply client-side filtering for hidden files if (!shouldDisplayPath(r.path, r.vault)) { return; // Skip this result } const titleDiv = el("div", { class: "search-result-title" }); if (query && query.trim()) { highlightSearchText(titleDiv, r.title, query, state.searchCaseSensitive); } else { titleDiv.textContent = r.title; } const snippetDiv = el("div", { class: "search-result-snippet" }); if (r.snippet && r.snippet.includes("")) { snippetDiv.innerHTML = r.snippet; } else if (query && query.trim() && r.snippet) { highlightSearchText(snippetDiv, r.snippet, query, state.searchCaseSensitive); } else { snippetDiv.textContent = r.snippet || ""; } const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), titleDiv, el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]), snippetDiv ]); if (r.tags && r.tags.length > 0) { const tagsDiv = el("div", { class: "search-result-tags" }); r.tags.forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); }); tagsDiv.appendChild(tagEl); } }); if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); } item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); container.appendChild(item); }); area.appendChild(container); } // --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) --- export function renderAdvancedSearchResults(data, query, tagFilter) { const area = document.getElementById("content-area"); area.innerHTML = ""; // Update match counter const countEl = document.getElementById("search-match-count"); if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`; // Header with result count and sort controls const header = el("div", { class: "search-results-header" }); const summaryText = el("span", { class: "search-results-summary-text" }); const parsed = QueryParser.parse(query); const freeText = parsed.freeText; if (freeText && tagFilter) { summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`; } else if (freeText) { summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`; } else if (parsed.tags.length > 0 || tagFilter) { summaryText.textContent = `${data.total} fichier(s) avec filtres`; } else { summaryText.textContent = `${data.total} résultat(s)`; } if (data.query_time_ms !== undefined && data.query_time_ms > 0) { const timeBadge = el("span", { class: "search-time-badge" }); timeBadge.textContent = `(${data.query_time_ms} ms)`; summaryText.appendChild(timeBadge); } header.appendChild(summaryText); // Active filter badges const filtersRow = el("div", { class: "search-filters-row" }); if (state.searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")])); if (state.searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")])); if (state.searchRegex) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode(".*")])); const inclEl = document.getElementById("search-include-input"); const exclEl = document.getElementById("search-exclude-input"); if (inclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("incl: " + inclEl.value.trim())])); if (exclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("excl: " + exclEl.value.trim())])); if (filtersRow.children.length > 0) header.appendChild(filtersRow); // Sort controls const sortDiv = el("div", { class: "search-sort" }); const btnRelevance = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "relevance" ? " active" : ""), type: "button" }); btnRelevance.textContent = "Pertinence"; btnRelevance.addEventListener("click", () => { state.advancedSearchSort = "relevance"; state.advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, 0, "relevance"); }); const btnDate = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "modified" ? " active" : ""), type: "button" }); btnDate.textContent = "Date"; btnDate.addEventListener("click", () => { state.advancedSearchSort = "modified"; state.advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, 0, "modified"); }); sortDiv.appendChild(btnRelevance); sortDiv.appendChild(btnDate); header.appendChild(sortDiv); // Save search button const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" }); saveBtn.innerHTML = ' Sauver'; saveBtn.addEventListener("click", async () => { const inclEl = document.getElementById("search-include-input"); const exclEl = document.getElementById("search-exclude-input"); try { await api("/api/saved-searches", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: query, vault: document.getElementById("vault-filter")?.value || "all", case_sensitive: state.searchCaseSensitive, whole_word: state.searchWholeWord, regex: state.searchRegex, include_paths: inclEl?.value || "", exclude_paths: exclEl?.value || "", }), }); showToast("Recherche sauvegardée", "success"); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); header.appendChild(saveBtn); area.appendChild(header); // Active sidebar tag chips if (state.selectedTags.length > 0) { const activeTags = el("div", { class: "search-results-active-tags" }); state.selectedTags.forEach((tag) => { const removeBtn = el( "button", { class: "search-results-active-tag-remove", title: `Retirer ${tag} du filtre`, }, [document.createTextNode("×")], ); removeBtn.addEventListener("click", (e) => { e.stopPropagation(); removeTagFilter(tag); }); const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]); activeTags.appendChild(chip); }); area.appendChild(activeTags); } // Facets panel if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) { const facetsDiv = el("div", { class: "search-facets" }); // Vault facets const vaultFacets = data.facets.vaults || {}; if (Object.keys(vaultFacets).length > 1) { const group = el("div", { class: "search-facets__group" }); const label = el("span", { class: "search-facets__label" }); label.textContent = "Vaults"; group.appendChild(label); for (const [vaultName, count] of Object.entries(vaultFacets)) { const item = el("span", { class: "search-facets__item" }); item.innerHTML = `${vaultName} ${count}`; item.addEventListener("click", () => { const input = document.getElementById("search-input"); // Add vault: operator const current = input.value.replace(/vault:\S+\s*/gi, "").trim(); input.value = current + " vault:" + vaultName; _triggerAdvancedSearch(input.value); }); group.appendChild(item); } facetsDiv.appendChild(group); } // Tag facets const tagFacets = data.facets.tags || {}; if (Object.keys(tagFacets).length > 0) { const group = el("div", { class: "search-facets__group" }); const label = el("span", { class: "search-facets__label" }); label.textContent = "Tags"; group.appendChild(label); const entries = Object.entries(tagFacets).slice(0, 12); for (const [tagName, count] of entries) { const item = el("span", { class: "search-facets__item" }); item.innerHTML = `#${tagName} ${count}`; item.addEventListener("click", () => { addTagFilter(tagName); }); group.appendChild(item); } facetsDiv.appendChild(group); } area.appendChild(facetsDiv); } // Empty state if (data.results.length === 0) { area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); return; } // Results list const container = el("div", { class: "search-results" }); data.results.forEach((r) => { // Apply client-side filtering for hidden files if (!shouldDisplayPath(r.path, r.vault)) { return; // Skip this result } const titleDiv = el("div", { class: "search-result-title" }); if (freeText) { highlightSearchText(titleDiv, r.title, freeText, state.searchCaseSensitive); } else { titleDiv.textContent = r.title; } // Snippet — use HTML from backend (already has tags) const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" }); if (r.snippet && r.snippet.includes("")) { snippetDiv.innerHTML = r.snippet; } else if (freeText && r.snippet) { highlightSearchText(snippetDiv, r.snippet, freeText, state.searchCaseSensitive); } else { snippetDiv.textContent = r.snippet || ""; } // Score badge const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" }); scoreEl.textContent = `score: ${r.score}`; const vaultPath = el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path), scoreEl]); const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), titleDiv, vaultPath, snippetDiv ]); if (r.tags && r.tags.length > 0) { const tagsDiv = el("div", { class: "search-result-tags" }); r.tags.forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); }); tagsDiv.appendChild(tagEl); } }); if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); } item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); container.appendChild(item); }); area.appendChild(container); // Pagination if (data.total > state.ADVANCED_SEARCH_LIMIT) { const paginationDiv = el("div", { class: "search-pagination" }); const prevBtn = el("button", { class: "search-pagination__btn", type: "button" }); prevBtn.textContent = "← Précédent"; prevBtn.disabled = state.advancedSearchOffset === 0; prevBtn.addEventListener("click", () => { state.advancedSearchOffset = Math.max(0, state.advancedSearchOffset - state.ADVANCED_SEARCH_LIMIT); const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset); document.getElementById("content-area").scrollTop = 0; }); const info = el("span", { class: "search-pagination__info" }); const from = state.advancedSearchOffset + 1; const to = Math.min(state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT, data.total); info.textContent = `${from}–${to} sur ${data.total}`; const nextBtn = el("button", { class: "search-pagination__btn", type: "button" }); nextBtn.textContent = "Suivant →"; nextBtn.disabled = state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT >= data.total; nextBtn.addEventListener("click", () => { state.advancedSearchOffset += state.ADVANCED_SEARCH_LIMIT; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset); document.getElementById("content-area").scrollTop = 0; }); paginationDiv.appendChild(prevBtn); paginationDiv.appendChild(info); paginationDiv.appendChild(nextBtn); area.appendChild(paginationDiv); } safeCreateIcons(); // Initialize result navigation (select first result) setTimeout(() => { if (window.navigateSearchResults) window.navigateSearchResults(0); }, 50); }