ObsiGate/frontend/js/search.js
Bruno Charest 8da6e176f0
Some checks failed
CI / lint (push) Waiting to run
CI / security (push) Successful in 9s
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
fix: removeTagFilter non importé + null-guard sur _tabBar
2026-05-29 13:41:31 -04:00

1113 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = '<li class="search-dropdown__item search-dropdown__item--loading">Recherche...</li>';
}
if (!hasTags) {
this._tagsList.innerHTML = '<li class="search-dropdown__item search-dropdown__item--loading">Recherche...</li>';
}
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 = '<li class="search-dropdown__item" style="color:var(--text-muted);font-style:italic;padding:8px 12px">Aucun tag</li>';
}
this._tagsSection.hidden = false;
this.show();
} catch (err) {
if (err.name === "AbortError") return;
this._tagsList.innerHTML = '<li class="search-dropdown__item" style="color:var(--text-error);padding:8px 12px">Erreur chargement</li>';
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 = '<i data-lucide="clock" style="width:14px;height:14px"></i>';
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 = '<i data-lucide="file-text" style="width:14px;height:14px"></i>';
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 = '<i data-lucide="hash" style="width:14px;height:14px"></i>';
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 = '<i data-lucide="x" style="width:10px;height:10px"></i>';
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 += "&regex=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("<mark>")) {
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 = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> 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} <span class="facet-count">${count}</span>`;
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} <span class="facet-count">${count}</span>`;
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 <mark> tags)
const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" });
if (r.snippet && r.snippet.includes("<mark>")) {
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);
}