Add advanced TF-IDF search with autocomplete, query operators, facets, pagination, and accent normalization

This commit is contained in:
2026-03-23 12:09:46 -04:00
parent ba6271b89b
commit e171a0dc35
6 changed files with 1965 additions and 46 deletions
+770 -39
View File
@@ -25,6 +25,21 @@
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 = [];
// 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;
// ---------------------------------------------------------------------------
// File extension → Lucide icon mapping
// ---------------------------------------------------------------------------
@@ -75,6 +90,414 @@
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 = '<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;
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();
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 = '<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");
// 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 = '<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 = 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
// ---------------------------------------------------------------------------
@@ -1076,7 +1499,7 @@
} else {
const input = document.getElementById("search-input");
if (input.value.trim()) {
performSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
} else {
showWelcome();
}
@@ -1087,7 +1510,7 @@
const input = document.getElementById("search-input");
const query = input.value.trim();
const vault = document.getElementById("vault-filter").value;
performSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null);
performAdvancedSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null);
}
function buildSearchResultsHeader(data, query, tagFilter) {
@@ -1535,77 +1958,348 @@
}
// ---------------------------------------------------------------------------
// Search
// 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 > 0 || tagFilter) {
performSearch(q, vault, tagFilter);
performAdvancedSearch(q, vault, tagFilter);
} else {
SearchChips.clear();
showWelcome();
}
}, 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) {
// Cancel any in-flight search request
if (searchAbortController) {
searchAbortController.abort();
}
if (searchAbortController) searchAbortController.abort();
searchAbortController = new AbortController();
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 });
renderSearchResults(data, query, tagFilter);
} catch (err) {
if (err.name === "AbortError") return; // superseded by newer request
if (err.name === "AbortError") return;
showWelcome();
} finally {
searchAbortController = null;
}
}
// --- Advanced search with TF-IDF, facets, pagination ---
async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) {
if (searchAbortController) searchAbortController.abort();
searchAbortController = new AbortController();
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);
let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${ADVANCED_SEARCH_LIMIT}&offset=${ofs}&sort=${sortBy}`;
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
try {
const data = await api(url, { signal: searchAbortController.signal });
advancedSearchTotal = data.total;
advancedSearchOffset = ofs;
renderAdvancedSearchResults(data, query, tagFilter);
} catch (err) {
if (err.name === "AbortError") return;
showWelcome();
} finally {
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)`;
}
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} <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é."),
@@ -1613,52 +2307,89 @@
return;
}
// Results list
const container = el("div", { class: "search-results" });
data.results.forEach((r) => {
// Create title with highlighting
const titleDiv = el("div", { class: "search-result-title" });
if (query && query.trim()) {
highlightSearchText(titleDiv, r.title, query, searchCaseSensitive);
if (freeText) {
highlightSearchText(titleDiv, r.title, freeText, searchCaseSensitive);
} else {
titleDiv.textContent = r.title;
}
// Create snippet with highlighting
const snippetDiv = el("div", { class: "search-result-snippet" });
if (query && query.trim() && r.snippet) {
highlightSearchText(snippetDiv, r.snippet, query, searchCaseSensitive);
// 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, 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,
// 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);
});
tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); });
tagsDiv.appendChild(tagEl);
}
});
if (tagsDiv.children.length > 0) {
item.appendChild(tagsDiv);
}
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();
}
// ---------------------------------------------------------------------------
+25
View File
@@ -103,6 +103,31 @@
</button>
</div>
</div>
<!-- Advanced search autocomplete dropdown -->
<div class="search-dropdown" id="search-dropdown" role="listbox" aria-label="Suggestions de recherche" hidden>
<div class="search-dropdown__section search-dropdown__section--history" id="search-dropdown-history">
<div class="search-dropdown__section-header">
<span>Historique</span>
<button class="search-dropdown__clear-btn" id="search-dropdown-clear-history" type="button" title="Effacer l'historique">
<i data-lucide="trash-2" style="width:12px;height:12px"></i>
</button>
</div>
<ul class="search-dropdown__list" id="search-dropdown-history-list"></ul>
</div>
<div class="search-dropdown__section search-dropdown__section--titles" id="search-dropdown-titles">
<div class="search-dropdown__section-header"><span>Fichiers</span></div>
<ul class="search-dropdown__list" id="search-dropdown-titles-list"></ul>
</div>
<div class="search-dropdown__section search-dropdown__section--tags" id="search-dropdown-tags">
<div class="search-dropdown__section-header"><span>Tags</span></div>
<ul class="search-dropdown__list" id="search-dropdown-tags-list"></ul>
</div>
<div class="search-dropdown__empty" id="search-dropdown-empty" hidden>
Aucune suggestion
</div>
</div>
<!-- Active search filter chips -->
<div class="search-chips" id="search-chips" hidden></div>
</div>
</div>
+328
View File
@@ -2162,3 +2162,331 @@ body.resizing-v {
justify-content: center;
padding: 8px 16px;
}
/* ---------------------------------------------------------------------------
Advanced Search — Autocomplete Dropdown
--------------------------------------------------------------------------- */
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
z-index: 200;
max-height: 380px;
overflow-y: auto;
overflow-x: hidden;
}
.search-dropdown[hidden] {
display: none;
}
.search-dropdown__section {
border-bottom: 1px solid var(--border);
}
.search-dropdown__section:last-child {
border-bottom: none;
}
.search-dropdown__section[hidden] {
display: none;
}
.search-dropdown__section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-primary);
position: sticky;
top: 0;
z-index: 1;
}
.search-dropdown__clear-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.search-dropdown__clear-btn:hover {
color: var(--danger);
background: var(--danger-bg, rgba(255,0,0,0.08));
}
.search-dropdown__list {
list-style: none;
margin: 0;
padding: 0;
}
.search-dropdown__item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
cursor: pointer;
font-size: 0.82rem;
color: var(--text-primary);
transition: background 120ms ease;
}
.search-dropdown__item:hover,
.search-dropdown__item.active {
background: var(--bg-hover);
}
.search-dropdown__item--history {
color: var(--text-secondary);
}
.search-dropdown__item--history .search-dropdown__icon {
color: var(--text-muted);
}
.search-dropdown__item--title .search-dropdown__meta {
font-size: 0.72rem;
color: var(--text-muted);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.search-dropdown__item--tag .search-dropdown__badge {
margin-left: auto;
font-size: 0.7rem;
color: var(--text-muted);
background: var(--bg-primary);
padding: 1px 6px;
border-radius: 10px;
}
.search-dropdown__icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--text-muted);
}
.search-dropdown__text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-dropdown__text mark {
background: var(--accent);
color: var(--bg-primary);
border-radius: 2px;
padding: 0 1px;
}
.search-dropdown__empty {
padding: 16px 12px;
text-align: center;
font-size: 0.82rem;
color: var(--text-muted);
}
.search-dropdown__empty[hidden] {
display: none;
}
/* ---------------------------------------------------------------------------
Advanced Search — Filter Chips
--------------------------------------------------------------------------- */
.search-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 0 0;
}
.search-chips[hidden] {
display: none;
}
.search-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
border-radius: 12px;
background: var(--accent);
color: var(--bg-primary);
cursor: default;
max-width: 200px;
white-space: nowrap;
}
.search-chip__label {
overflow: hidden;
text-overflow: ellipsis;
}
.search-chip__remove {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
opacity: 0.7;
transition: opacity 120ms;
}
.search-chip__remove:hover {
opacity: 1;
}
.search-chip--tag {
background: var(--accent);
color: var(--bg-primary);
}
.search-chip--vault {
background: var(--success, #22c55e);
color: #fff;
}
.search-chip--title {
background: var(--warning, #f59e0b);
color: #1a1a1a;
}
.search-chip--path {
background: var(--text-muted);
color: var(--bg-primary);
}
/* ---------------------------------------------------------------------------
Advanced Search — Snippet Highlights
--------------------------------------------------------------------------- */
.search-result__snippet mark {
background: var(--accent);
color: var(--bg-primary);
border-radius: 2px;
padding: 0 2px;
font-weight: 600;
}
/* ---------------------------------------------------------------------------
Advanced Search — Facets Panel
--------------------------------------------------------------------------- */
.search-facets {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
}
.search-facets[hidden] {
display: none;
}
.search-facets__group {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.search-facets__label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-right: 4px;
}
.search-facets__item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-secondary);
background: var(--bg-secondary);
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
.search-facets__item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.search-facets__item .facet-count {
font-size: 0.68rem;
color: var(--text-muted);
}
/* ---------------------------------------------------------------------------
Advanced Search — Pagination
--------------------------------------------------------------------------- */
.search-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
}
.search-pagination__btn {
padding: 6px 14px;
font-size: 0.82rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
.search-pagination__btn:hover:not(:disabled) {
border-color: var(--accent);
background: var(--bg-hover);
}
.search-pagination__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.search-pagination__info {
font-size: 0.78rem;
color: var(--text-muted);
}
/* ---------------------------------------------------------------------------
Advanced Search — Sort Controls
--------------------------------------------------------------------------- */
.search-sort {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.search-sort__btn {
padding: 3px 10px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms;
}
.search-sort__btn.active {
border-color: var(--accent);
color: var(--accent);
background: var(--bg-hover);
}
/* ---------------------------------------------------------------------------
Advanced Search — Responsive
--------------------------------------------------------------------------- */
@media (max-width: 768px) {
.search-dropdown {
max-height: 280px;
}
.search-facets {
flex-direction: column;
gap: 8px;
}
.search-chip {
max-width: 150px;
}
}