ObsiGate/frontend/js/search.js
Bruno Charest 7866f93778
All checks were successful
CI / lint (push) Successful in 13s
CI / security (push) Successful in 8s
CI / test (push) Successful in 16s
CI / build (push) Successful in 6s
refactor: state.js → mutable object to fix 'assignment to constant' errors
ES module imports are read-only live bindings — can't reassign
imported let/const variables. Replace individual 'export let' with
single 'export const state = {...}' mutable object.

All modules updated: import { state } from './state.js'
All state access changed to state.xxx pattern.

Fixes cascade of 'Assignment to constant variable' errors.
2026-05-28 16:34:39 -04:00

1107 lines
52 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.

1|/* ObsiGate — Search module (extracted from app.js) */
2|
import { state } from './state.js';
4|import { safeCreateIcons } from './utils.js';
5|
6|// Re-export constants used internally
7|const state.SEARCH_HISTORY_KEY = state.SEARCH_HISTORY_KEY;
8|const state.MAX_HISTORY_ENTRIES = state.MAX_HISTORY_ENTRIES;
9|
10|// ---------------------------------------------------------------------------
11|// Search History Service (localStorage, LIFO, max 50, dedup)
12|// ---------------------------------------------------------------------------
13|export const SearchHistory = {
14| _load() {
15| try {
16| const raw = localStorage.getItem(state.SEARCH_HISTORY_KEY);
17| return raw ? JSON.parse(raw) : [];
18| } catch {
19| return [];
20| }
21| },
22| _save(entries) {
23| try {
24| localStorage.setItem(state.SEARCH_HISTORY_KEY, JSON.stringify(entries));
25| } catch {}
26| },
27| getAll() {
28| return this._load();
29| },
30| add(query) {
31| if (!query || !query.trim()) return;
32| const q = query.trim();
33| let entries = this._load().filter((e) => e !== q);
34| entries.unshift(q);
35| if (entries.length > state.MAX_HISTORY_ENTRIES) entries = entries.slice(0, state.MAX_HISTORY_ENTRIES);
36| this._save(entries);
37| },
38| remove(query) {
39| const entries = this._load().filter((e) => e !== query);
40| this._save(entries);
41| },
42| clear() {
43| this._save([]);
44| },
45| filter(prefix) {
46| if (!prefix) return this.getAll().slice(0, 8);
47| const lp = prefix.toLowerCase();
48| return this._load()
49| .filter((e) => e.toLowerCase().includes(lp))
50| .slice(0, 8);
51| },
52|};
53|
54|// ---------------------------------------------------------------------------
55|// Query Parser — extracts operators (tag:, #, vault:, title:, path:, ext:)
56|// ---------------------------------------------------------------------------
57|export const QueryParser = {
58| parse(raw) {
59| const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" };
60| if (!raw) return result;
61| const tokens = this._tokenize(raw);
62| const freeTokens = [];
63| for (const tok of tokens) {
64| const lower = tok.toLowerCase();
65| if (lower.startsWith("tag:")) {
66| const v = tok.slice(4).replace(/"/g, "").trim().replace(/^#/, "");
67| if (v) result.tags.push(v);
68| } else if (lower.startsWith("#") && tok.length > 1) {
69| result.tags.push(tok.slice(1));
70| } else if (lower.startsWith("vault:")) {
71| result.vault = tok.slice(6).replace(/"/g, "").trim();
72| } else if (lower.startsWith("title:")) {
73| result.title = tok.slice(6).replace(/"/g, "").trim();
74| } else if (lower.startsWith("path:")) {
75| result.path = tok.slice(5).replace(/"/g, "").trim();
76| } else if (lower.startsWith("ext:")) {
77| result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase();
78| } else {
79| freeTokens.push(tok);
80| }
81| }
82| result.freeText = freeTokens.join(" ");
83| return result;
84| },
85| _tokenize(raw) {
86| const tokens = [];
87| let i = 0;
88| const n = raw.length;
89| while (i < n) {
90| while (i < n && raw[i] === " ") i++;
91| if (i >= n) break;
92| if (raw[i] !== '"') {
93| let j = i;
94| while (j < n && raw[j] !== " ") {
95| if (raw[j] === '"') {
96| j++;
97| while (j < n && raw[j] !== '"') j++;
98| if (j < n) j++;
99| } else j++;
100| }
101| tokens.push(raw.slice(i, j).replace(/"/g, ""));
102| i = j;
103| } else {
104| i++;
105| let j = i;
106| while (j < n && raw[j] !== '"') j++;
107| tokens.push(raw.slice(i, j));
108| i = j + 1;
109| }
110| }
111| return tokens;
112| },
113| /** Detect the current operator context at cursor for autocomplete */
114| getContext(raw, cursorPos) {
115| const before = raw.slice(0, cursorPos);
116| // Check if we're typing a tag: or # value
117| const tagMatch = before.match(/(?:tag:|#)([\w-]*)$/i);
118| if (tagMatch) return { type: "tag", prefix: tagMatch[1] };
119| // Check if typing title:
120| const titleMatch = before.match(/title:([\w-]*)$/i);
121| if (titleMatch) return { type: "title", prefix: titleMatch[1] };
122| // Default: free text
123| const words = before.trim().split(/\s+/);
124| const lastWord = words[words.length - 1] || "";
125| return { type: "text", prefix: lastWord };
126| },
127|};
128|
129|// ---------------------------------------------------------------------------
130|// Autocomplete Dropdown Controller
131|// ---------------------------------------------------------------------------
132|export const AutocompleteDropdown = {
133| _dropdown: null,
134| _historySection: null,
135| _titlesSection: null,
136| _tagsSection: null,
137| _historyList: null,
138| _titlesList: null,
139| _tagsList: null,
140| _emptyEl: null,
141| _suggestTimer: null,
142|
143| init() {
144| this._dropdown = document.getElementById("search-dropdown");
145| this._historySection = document.getElementById("search-dropdown-history");
146| this._titlesSection = document.getElementById("search-dropdown-titles");
147| this._tagsSection = document.getElementById("search-dropdown-tags");
148| this._historyList = document.getElementById("search-dropdown-history-list");
149| this._titlesList = document.getElementById("search-dropdown-titles-list");
150| this._tagsList = document.getElementById("search-dropdown-tags-list");
151| this._emptyEl = document.getElementById("search-dropdown-empty");
152|
153| // Clear history button
154| const clearBtn = document.getElementById("search-dropdown-clear-history");
155| if (clearBtn) {
156| clearBtn.addEventListener("click", (e) => {
157| e.stopPropagation();
158| SearchHistory.clear();
159| this.hide();
160| });
161| }
162|
163| // Close dropdown on outside click
164| document.addEventListener("click", (e) => {
165| if (this._dropdown && !this._dropdown.contains(e.target) && e.target.id !== "search-input") {
166| this.hide();
167| }
168| });
169| },
170|
171| show() {
172| if (this._dropdown) this._dropdown.hidden = false;
173| },
174|
175| hide() {
176| if (this._dropdown) this._dropdown.hidden = true;
177| state.dropdownActiveIndex = -1;
178| state.dropdownItems = [];
179| },
180|
181| isVisible() {
182| return this._dropdown && !this._dropdown.hidden;
183| },
184|
185| /** Populate and show the dropdown with history, title suggestions, and tag suggestions */
186| async populate(inputValue, cursorPos) {
187| if (this._suppressNext) { this._suppressNext = false; return; }
188| // Cancel previous suggestion request
189| if (state.suggestAbortController) {
190| state.suggestAbortController.abort();
191| state.suggestAbortController = null;
192| }
193|
194| const ctx = QueryParser.getContext(inputValue, cursorPos);
195| const vault = document.getElementById("vault-filter").value;
196|
197| // History — always show filtered history
198| const historyItems = SearchHistory.filter(inputValue).slice(0, 5);
199| this._renderHistory(historyItems, inputValue);
200|
201| // Title and tag suggestions from API (debounced) — always fetch both
202| clearTimeout(this._suggestTimer);
203| const prefix = ctx.prefix;
204| if (prefix && prefix.length >= 2) {
205| // Only show placeholder if lists are empty (avoid flashing on fast typing)
206| const hasTitles = this._titlesList.children.length > 0 && !this._titlesList.querySelector(".search-dropdown__item--loading");
207| const hasTags = this._tagsList.children.length > 0 && !this._tagsList.querySelector(".search-dropdown__item--loading");
208| if (!hasTitles) {
209| this._titlesList.innerHTML = '<li class="search-dropdown__item search-dropdown__item--loading">Recherche...</li>';
210| }
211| if (!hasTags) {
212| this._tagsList.innerHTML = '<li class="search-dropdown__item search-dropdown__item--loading">Recherche...</li>';
213| }
214| this._titlesSection.hidden = false;
215| this._tagsSection.hidden = false;
216| this.show();
217| this._suggestTimer = setTimeout(() => this._fetchSuggestions(prefix, vault), 150);
218| } else {
219| this._renderTitles([], "");
220| this._renderTags([], "");
221| this._titlesSection.hidden = true;
222| this._tagsSection.hidden = true;
223| }
224|
225| // Show/hide sections
226| this._historySection.hidden = historyItems.length === 0;
227| const hasContent = historyItems.length > 0;
228| if (hasContent || (prefix && prefix.length >= 2)) {
229| this.show();
230| } else {
231| this.hide();
232| }
233|
234| this._collectItems();
235| },
236|
237| async _fetchSuggestions(prefix, vault) {
238| state.suggestAbortController = new AbortController();
239| // Fetch titles
240| try {
241| const titlesRes = await api(`/api/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: state.suggestAbortController.signal });
242| this._renderTitles(titlesRes.suggestions || [], prefix);
243| this._titlesSection.hidden = !(titlesRes.suggestions || []).length;
244| if (titlesRes.suggestions?.length) this.show();
245| } catch (err) {
246| if (err.name === "AbortError") return;
247| this._titlesSection.hidden = true;
248| }
249| // Fetch tags — keep section always visible to confirm it works
250| try {
251| const tagsRes = await api(`/api/tags/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: state.suggestAbortController.signal });
252| const items = tagsRes.suggestions || [];
253| if (items.length > 0) {
254| this._renderTags(items, prefix);
255| } else {
256| this._tagsList.innerHTML = '<li class="search-dropdown__item" style="color:var(--text-muted);font-style:italic;padding:8px 12px">Aucun tag</li>';
257| }
258| this._tagsSection.hidden = false;
259| this.show();
260| } catch (err) {
261| if (err.name === "AbortError") return;
262| this._tagsList.innerHTML = '<li class="search-dropdown__item" style="color:var(--text-error);padding:8px 12px">Erreur chargement</li>';
263| this._tagsSection.hidden = false;
264| }
265| this._collectItems();
266| },
267|
268| _renderHistory(items, query) {
269| this._historyList.innerHTML = "";
270| items.forEach((entry) => {
271| const li = el("li", { class: "search-dropdown__item search-dropdown__item--history", role: "option" });
272| const iconEl = el("span", { class: "search-dropdown__icon" });
273| iconEl.innerHTML = '<i data-lucide="clock" style="width:14px;height:14px"></i>';
274| const textEl = el("span", { class: "search-dropdown__text" });
275| textEl.textContent = entry;
276| li.appendChild(iconEl);
277| li.appendChild(textEl);
278| li.addEventListener("click", () => {
279| const input = document.getElementById("search-input");
280| input.value = entry;
281| input.dispatchEvent(new Event("input", { bubbles: true }));
282| this.hide();
283| _triggerAdvancedSearch(entry);
284| });
285| this._historyList.appendChild(li);
286| });
287| },
288|
289| _renderTitles(items, prefix) {
290| this._titlesList.innerHTML = "";
291| items.forEach((item) => {
292| const li = el("li", { class: "search-dropdown__item search-dropdown__item--title", role: "option" });
293| const iconEl = el("span", { class: "search-dropdown__icon" });
294| iconEl.innerHTML = '<i data-lucide="file-text" style="width:14px;height:14px"></i>';
295| const textEl = el("span", { class: "search-dropdown__text" });
296| if (prefix) {
297| this._highlightText(textEl, item.title, prefix);
298| } else {
299| textEl.textContent = item.title;
300| }
301| const metaEl = el("span", { class: "search-dropdown__meta" });
302| metaEl.textContent = item.vault;
303| li.appendChild(iconEl);
304| li.appendChild(textEl);
305| li.appendChild(metaEl);
306| li.addEventListener("click", () => {
307| this.hide();
308| TabManager.openPreview(item.vault, item.path);
309| });
310| this._titlesList.appendChild(li);
311| });
312| },
313|
314| _renderTags(items, prefix) {
315| this._tagsList.innerHTML = "";
316| items.forEach((item) => {
317| const li = el("li", { class: "search-dropdown__item search-dropdown__item--tag", role: "option" });
318| const iconEl = el("span", { class: "search-dropdown__icon" });
319| iconEl.innerHTML = '<i data-lucide="hash" style="width:14px;height:14px"></i>';
320| const textEl = el("span", { class: "search-dropdown__text" });
321| if (prefix) {
322| this._highlightText(textEl, item.tag, prefix);
323| } else {
324| textEl.textContent = item.tag;
325| }
326| const badge = el("span", { class: "search-dropdown__badge" });
327| badge.textContent = item.count;
328| li.appendChild(iconEl);
329| li.appendChild(textEl);
330| li.appendChild(badge);
331| li.addEventListener("click", () => {
332| const input = document.getElementById("search-input");
333| const current = input.value;
334| const cursorPos = input.selectionStart;
335| const ctx = QueryParser.getContext(current, cursorPos);
336| if (ctx.type === "tag") {
337| // Replace the partial tag prefix
338| const before = current.slice(0, cursorPos - ctx.prefix.length);
339| input.value = before + item.tag + " ";
340| } else {
341| // Replace the last word with tag: operator
342| const words = current.trim().split(/\s+/);
343| if (words.length > 0 && ctx.prefix && ctx.prefix.length > 0) {
344| words[words.length - 1] = ""; // remove last partial word
345| }
346| const base = words.filter(w => w).join(" ");
347| input.value = (base ? base + " " : "") + "tag:" + item.tag + " ";
348| }
349| input.dispatchEvent(new Event("input", { bubbles: true }));
350| this.hide();
351| input.focus();
352| _triggerAdvancedSearch(input.value);
353| });
354| this._tagsList.appendChild(li);
355| });
356| },
357|
358| _highlightText(container, text, query) {
359| const lower = text.toLowerCase();
360| const needle = query.toLowerCase();
361| const pos = lower.indexOf(needle);
362| if (pos === -1) {
363| container.textContent = text;
364| return;
365| }
366| container.appendChild(document.createTextNode(text.slice(0, pos)));
367| const markEl = el("mark", {}, [document.createTextNode(text.slice(pos, pos + query.length))]);
368| container.appendChild(markEl);
369| container.appendChild(document.createTextNode(text.slice(pos + query.length)));
370| },
371|
372| _collectItems() {
373| state.dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item"));
374| state.dropdownActiveIndex = -1;
375| state.dropdownItems.forEach((item) => item.classList.remove("active"));
376| },
377|
378| navigateDown() {
379| if (!this.isVisible() || state.dropdownItems.length === 0) return;
380| if (state.dropdownActiveIndex >= 0) state.dropdownItems[state.dropdownActiveIndex].classList.remove("active");
381| state.dropdownActiveIndex = (state.dropdownActiveIndex + 1) % state.dropdownItems.length;
382| state.dropdownItems[state.dropdownActiveIndex].classList.add("active");
383| state.dropdownItems[state.dropdownActiveIndex].scrollIntoView({ block: "nearest" });
384| },
385|
386| navigateUp() {
387| if (!this.isVisible() || state.dropdownItems.length === 0) return;
388| if (state.dropdownActiveIndex >= 0) state.dropdownItems[state.dropdownActiveIndex].classList.remove("active");
389| state.dropdownActiveIndex = state.dropdownActiveIndex <= 0 ? state.dropdownItems.length - 1 : state.dropdownActiveIndex - 1;
390| state.dropdownItems[state.dropdownActiveIndex].classList.add("active");
391| state.dropdownItems[state.dropdownActiveIndex].scrollIntoView({ block: "nearest" });
392| },
393|
394| selectActive() {
395| if (state.dropdownActiveIndex >= 0 && state.dropdownActiveIndex < state.dropdownItems.length) {
396| state.dropdownItems[state.dropdownActiveIndex].click();
397| return true;
398| }
399| return false;
400| },
401|};
402|
403|// ---------------------------------------------------------------------------
404|// Search Chips Controller — renders active filter chips from parsed query
405|// ---------------------------------------------------------------------------
406|export const SearchChips = {
407| _container: null,
408| init() {
409| this._container = document.getElementById("search-chips");
410| },
411| update(parsed) {
412| if (!this._container) return;
413| this._container.innerHTML = "";
414| let hasChips = false;
415| parsed.tags.forEach((tag) => {
416| this._addChip("tag", `tag:${tag}`, tag);
417| hasChips = true;
418| });
419| if (parsed.vault) {
420| this._addChip("vault", `vault:${parsed.vault}`, parsed.vault);
421| hasChips = true;
422| }
423| if (parsed.title) {
424| this._addChip("title", `title:${parsed.title}`, parsed.title);
425| hasChips = true;
426| }
427| if (parsed.path) {
428| this._addChip("path", `path:${parsed.path}`, parsed.path);
429| hasChips = true;
430| }
431| if (parsed.ext) {
432| this._addChip("ext", `ext:${parsed.ext}`, parsed.ext);
433| hasChips = true;
434| }
435| this._container.hidden = !hasChips;
436| },
437| clear() {
438| if (!this._container) return;
439| this._container.innerHTML = "";
440| this._container.hidden = true;
441| },
442| _addChip(type, fullOperator, displayText) {
443| const chip = el("span", { class: `search-chip search-chip--${type}` });
444| const label = el("span", { class: "search-chip__label" });
445| label.textContent = fullOperator;
446| const removeBtn = el("button", { class: "search-chip__remove", title: "Retirer ce filtre", type: "button" });
447| removeBtn.innerHTML = '<i data-lucide="x" style="width:10px;height:10px"></i>';
448| removeBtn.addEventListener("click", () => {
449| // Remove this operator from the input
450| const input = document.getElementById("search-input");
451| const raw = input.value;
452| // Remove the operator text from the query
453| const escaped = fullOperator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
454| input.value = raw.replace(new RegExp("\\s*" + escaped + "\\s*", "i"), " ").trim();
455| _triggerAdvancedSearch(input.value);
456| });
457| chip.appendChild(label);
458| chip.appendChild(removeBtn);
459| this._container.appendChild(chip);
460| safeCreateIcons();
461| },
462|};
463|
464|// ---------------------------------------------------------------------------
465|// Helper: trigger advanced search from input value
466|// ---------------------------------------------------------------------------
467|export function _triggerAdvancedSearch(rawQuery) {
468| const q = (rawQuery || "").trim();
469| const vault = document.getElementById("vault-filter").value;
470| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
471| state.advancedSearchOffset = 0;
472| if (q.length > 0 || tagFilter) {
473| SearchHistory.add(q);
474| performAdvancedSearch(q, vault, tagFilter);
475| } else {
476| SearchChips.clear();
477| showWelcome();
478| }
479|}
480|
481|// ---------------------------------------------------------------------------
482|// Search (enhanced with autocomplete, keyboard nav, global shortcuts)
483|// ---------------------------------------------------------------------------
484|// ── Search toggle state ──
485|
486|function initSearch() {
487| const input = document.getElementById("search-input");
488| if (!input) return;
489| const caseBtn = document.getElementById("search-case-btn");
490| const wordBtn = document.getElementById("search-word-btn");
491| const regexBtn = document.getElementById("search-regex-btn");
492| const filterBtn = document.getElementById("search-filter-btn");
493| const clearBtn = document.getElementById("search-clear-btn");
494| const filterRow = document.getElementById("search-filter-row");
495| const prevBtn = document.getElementById("search-prev-btn");
496| const nextBtn = document.getElementById("search-next-btn");
497| const countEl = document.getElementById("search-match-count");
498|
499| function _updateToggleUI() {
500| caseBtn.classList.toggle("active", state.searchCaseSensitive);
501| wordBtn.classList.toggle("active", state.searchWholeWord);
502| regexBtn.classList.toggle("active", state.searchRegex);
503| filterBtn.classList.toggle("active", state.searchFilterVisible);
504| }
505|
506| // Toggle buttons
507| caseBtn.addEventListener("click", () => { state.searchCaseSensitive = !state.searchCaseSensitive; _updateToggleUI(); _research(); });
508| if (wordBtn) wordBtn.addEventListener("click", () => { state.searchWholeWord = !state.searchWholeWord; _updateToggleUI(); _research(); });
509| if (regexBtn) regexBtn.addEventListener("click", () => { state.searchRegex = !state.searchRegex; _updateToggleUI(); _research(); });
510| if (filterBtn) filterBtn.addEventListener("click", () => { state.searchFilterVisible = !state.searchFilterVisible; if (filterRow) filterRow.style.display = state.searchFilterVisible ? "flex" : "none"; _updateToggleUI(); });
511|
512| // ── Result navigation (up/down arrows + Enter) ──
513| let _searchResultIdx = -1;
514| let _searchResultItems = [];
515|
516| function _updateResultHighlight() {
517| _searchResultItems.forEach((el, i) => {
518| el.classList.toggle("search-result-active", i === _searchResultIdx);
519| });
520| if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) {
521| _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" });
522| }
523| const countEl = document.getElementById("search-match-count");
524| if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`;
525| }
526|
527| function _refreshResultItems() {
528| _searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
529| _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1;
530| _updateResultHighlight();
531| }
532|
533| window.navigateSearchResults = function(delta) {
534| _searchResultItems = Array.from(document.querySelectorAll(".search-result-item"));
535| if (_searchResultItems.length === 0) return;
536| _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta));
537| _updateResultHighlight();
538| };
539|
540| if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1));
541| if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1));
542|
543| function _research() {
544| const q = input.value.trim();
545| if (q.length >= _getEffective("min_query_length", 2)) {
546| clearTimeout(state.searchTimeout);
547| state.searchTimeout = setTimeout(() => {
548| const vault = document.getElementById("vault-filter").value;
549| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
550| state.advancedSearchOffset = 0;
551| performAdvancedSearch(q, vault, tagFilter);
552| }, _getEffective("debounce_ms", 300));
553| }
554| }
555|
556| // Keyboard shortcuts
557| document.addEventListener("keydown", (e) => {
558| if (e.altKey && !e.ctrlKey && !e.metaKey) {
559| if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); }
560| else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); }
561| else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); }
562| else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); }
563| }
564| });
565|
566| // Initialize sub-controllers
567| AutocompleteDropdown.init();
568| SearchChips.init();
569|
570| // Initially hide clear button
571| if (clearBtn) clearBtn.style.display = "none";
572|
573| // --- Input handler: debounced search + autocomplete dropdown ---
574| input.addEventListener("input", () => {
575| const hasText = input.value.length > 0;
576| clearBtn.style.display = hasText ? "flex" : "none";
577|
578| // Show autocomplete dropdown while typing
579| AutocompleteDropdown.populate(input.value, input.selectionStart);
580|
581| // Debounced search execution
582| clearTimeout(state.searchTimeout);
583| state.searchTimeout = setTimeout(
584| () => {
585| const q = input.value.trim();
586| const vault = document.getElementById("vault-filter").value;
587| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
588| state.advancedSearchOffset = 0;
589| if (q.length >= _getEffective("min_query_length", state.MIN_SEARCH_LENGTH) || tagFilter) {
590| performAdvancedSearch(q, vault, tagFilter);
591| } else if (q.length === 0) {
592| SearchChips.clear();
593| showWelcome();
594| }
595| },
596| _getEffective("debounce_ms", 300),
597| );
598| });
599|
600| // --- Focus handler: show history dropdown ---
601| input.addEventListener("focus", () => {
602| if (input.value.length === 0) {
603| const historyItems = SearchHistory.filter("").slice(0, 5);
604| if (historyItems.length > 0) {
605| AutocompleteDropdown.populate("", 0);
606| }
607| }
608| });
609|
610| // --- Keyboard navigation in dropdown ---
611| input.addEventListener("keydown", (e) => {
612| if (AutocompleteDropdown.isVisible()) {
613| if (e.key === "ArrowDown") {
614| e.preventDefault();
615| AutocompleteDropdown.navigateDown();
616| } else if (e.key === "ArrowUp") {
617| e.preventDefault();
618| AutocompleteDropdown.navigateUp();
619| } else if (e.key === "Enter") {
620| // First: check dropdown suggestions (higher priority than search results)
621| if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
622| e.preventDefault();
623| return;
624| }
625| // Second: navigate search results if visible
626| const results = document.querySelectorAll(".search-result-item");
627| if (results.length > 0 && _searchResultIdx >= 0) {
628| const el = results[_searchResultIdx];
629| if (el) {
630| const vault = el.dataset.vault;
631| const path = el.dataset.path;
632| if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; }
633| }
634| }
635| // Third: execute search
636| AutocompleteDropdown.hide();
637| const q = input.value.trim();
638| if (q) {
639| SearchHistory.add(q);
640| clearTimeout(state.searchTimeout);
641| state.advancedSearchOffset = 0;
642| const vault = document.getElementById("vault-filter").value;
643| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
644| performAdvancedSearch(q, vault, tagFilter);
645| }
646| e.preventDefault();
647| } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) {
648| // Navigate search results when dropdown is closed
649| if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); }
650| } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) {
651| if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); }
652| } else if (e.key === "Escape") {
653| AutocompleteDropdown.hide();
654| e.stopPropagation();
655| }
656| } else if (e.key === "Enter") {
657| if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) {
658| e.preventDefault();
659| return;
660| }
661| const q = input.value.trim();
662| if (q) {
663| SearchHistory.add(q);
664| clearTimeout(state.searchTimeout);
665| state.advancedSearchOffset = 0;
666| const vault = document.getElementById("vault-filter").value;
667| const tagFilter = state.selectedTags.length > 0 ? state.selectedTags.join(",") : null;
668| performAdvancedSearch(q, vault, tagFilter);
669| }
670| e.preventDefault();
671| }
672| });
673|
674| clearBtn.addEventListener("click", () => {
675| input.value = "";
676| if (clearBtn) clearBtn.style.display = "none";
677| state.searchCaseSensitive = false;
678| state.searchWholeWord = false;
679| state.searchRegex = false;
680| _updateToggleUI();
681| SearchChips.clear();
682| AutocompleteDropdown.hide();
683| showWelcome();
684| });
685|
686| // --- Global keyboard shortcuts ---
687| document.addEventListener("keydown", (e) => {
688| // Ctrl+K or Cmd+K: focus search
689| if ((e.ctrlKey || e.metaKey) && e.key === "k") {
690| e.preventDefault();
691| input.focus();
692| input.select();
693| }
694| // "/" key: focus search (when not in an input/textarea)
695| if (e.key === "/" && !_isInputFocused()) {
696| e.preventDefault();
697| input.focus();
698| }
699| // Escape: blur search input and close dropdown
700| if (e.key === "Escape" && document.activeElement === input) {
701| AutocompleteDropdown.hide();
702| input.blur();
703| }
704| });
705|}
706|
707|/** Check if user is focused on an input/textarea/contenteditable */
708|function _isInputFocused() {
709| const tag = document.activeElement?.tagName;
710| if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
711| return document.activeElement?.isContentEditable === true;
712|}
713|
714|// --- Backward-compatible search (existing /api/search endpoint) ---
715|export async function performSearch(query, vaultFilter, tagFilter) {
716| if (state.searchAbortController) state.searchAbortController.abort();
717| state.searchAbortController = new AbortController();
718| const searchId = ++state.currentSearchId;
719| showLoading();
720| let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
721| if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
722| try {
723| const data = await api(url, { signal: state.searchAbortController.signal });
724| if (searchId !== state.currentSearchId) return;
725| renderSearchResults(data, query, tagFilter);
726| } catch (err) {
727| if (err.name === "AbortError") return;
728| if (searchId !== state.currentSearchId) return;
729| showWelcome();
730| } finally {
731| hideProgressBar();
732| if (searchId === state.currentSearchId) state.searchAbortController = null;
733| }
734|}
735|
736|// --- Advanced search with TF-IDF, facets, pagination ---
737|export async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) {
738| if (state.searchAbortController) state.searchAbortController.abort();
739| state.searchAbortController = new AbortController();
740| const searchId = ++state.currentSearchId;
741| showLoading();
742|
743| const ofs = offset !== undefined ? offset : state.advancedSearchOffset;
744| const sortBy = sort || state.advancedSearchSort;
745| state.advancedSearchLastQuery = query;
746|
747| // Update chips from parsed query
748| const parsed = QueryParser.parse(query);
749| SearchChips.update(parsed);
750|
751| const effectiveLimit = _getEffective("results_per_page", state.ADVANCED_SEARCH_LIMIT);
752| let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${effectiveLimit}&offset=${ofs}&sort=${sortBy}`;
753| if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
754| if (state.searchCaseSensitive) url += "&case_sensitive=true";
755| if (state.searchWholeWord) url += "&whole_word=true";
756| if (state.searchRegex) url += "&regex=true";
757| const includeEl = document.getElementById("search-include-input");
758| const excludeEl = document.getElementById("search-exclude-input");
759| if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`;
760| if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`;
761|
762| // Search timeout — abort if server takes too long
763| const timeoutId = setTimeout(
764| () => {
765| if (state.searchAbortController) state.searchAbortController.abort();
766| },
767| _getEffective("search_timeout_ms", state.SEARCH_TIMEOUT_MS),
768| );
769|
770| try {
771| const data = await api(url, { signal: state.searchAbortController.signal });
772| clearTimeout(timeoutId);
773| if (searchId !== state.currentSearchId) return;
774| state.advancedSearchTotal = data.total;
775| state.advancedSearchOffset = ofs;
776| renderAdvancedSearchResults(data, query, tagFilter);
777| } catch (err) {
778| clearTimeout(timeoutId);
779| if (err.name === "AbortError") return;
780| if (searchId !== state.currentSearchId) return;
781| showWelcome();
782| } finally {
783| hideProgressBar();
784| if (searchId === state.currentSearchId) state.searchAbortController = null;
785| }
786|}
787|
788|// --- Legacy search results renderer (kept for backward compat) ---
789|export function renderSearchResults(data, query, tagFilter) {
790| const area = document.getElementById("content-area");
791| area.innerHTML = "";
792| const header = buildSearchResultsHeader(data, query, tagFilter);
793| area.appendChild(header);
794| if (data.results.length === 0) {
795| area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")]));
796| return;
797| }
798| const container = el("div", { class: "search-results" });
799| data.results.forEach((r) => {
800| // Apply client-side filtering for hidden files
801| if (!shouldDisplayPath(r.path, r.vault)) {
802| return; // Skip this result
803| }
804|
805| const titleDiv = el("div", { class: "search-result-title" });
806| if (query && query.trim()) {
807| highlightSearchText(titleDiv, r.title, query, state.searchCaseSensitive);
808| } else {
809| titleDiv.textContent = r.title;
810| }
811| const snippetDiv = el("div", { class: "search-result-snippet" });
812| if (r.snippet && r.snippet.includes("<mark>")) {
813| snippetDiv.innerHTML = r.snippet;
814| } else if (query && query.trim() && r.snippet) {
815| highlightSearchText(snippetDiv, r.snippet, query, state.searchCaseSensitive);
816| } else {
817| snippetDiv.textContent = r.snippet || "";
818| }
819| const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [
820| el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]),
821| titleDiv,
822| el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]),
823| snippetDiv
824| ]);
825| if (r.tags && r.tags.length > 0) {
826| const tagsDiv = el("div", { class: "search-result-tags" });
827| r.tags.forEach((tag) => {
828| if (!TagFilterService.isTagFiltered(tag)) {
829| const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
830| tagEl.addEventListener("click", (e) => {
831| e.stopPropagation();
832| addTagFilter(tag);
833| });
834| tagsDiv.appendChild(tagEl);
835| }
836| });
837| if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
838| }
839| item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
840| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
841| container.appendChild(item);
842| });
843| area.appendChild(container);
844|}
845|
846|// --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) ---
847|export function renderAdvancedSearchResults(data, query, tagFilter) {
848| const area = document.getElementById("content-area");
849| area.innerHTML = "";
850|
851| // Update match counter
852| const countEl = document.getElementById("search-match-count");
853| if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`;
854|
855| // Header with result count and sort controls
856| const header = el("div", { class: "search-results-header" });
857| const summaryText = el("span", { class: "search-results-summary-text" });
858| const parsed = QueryParser.parse(query);
859| const freeText = parsed.freeText;
860|
861| if (freeText && tagFilter) {
862| summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`;
863| } else if (freeText) {
864| summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`;
865| } else if (parsed.tags.length > 0 || tagFilter) {
866| summaryText.textContent = `${data.total} fichier(s) avec filtres`;
867| } else {
868| summaryText.textContent = `${data.total} résultat(s)`;
869| }
870| if (data.query_time_ms !== undefined && data.query_time_ms > 0) {
871| const timeBadge = el("span", { class: "search-time-badge" });
872| timeBadge.textContent = `(${data.query_time_ms} ms)`;
873| summaryText.appendChild(timeBadge);
874| }
875| header.appendChild(summaryText);
876|
877| // Active filter badges
878| const filtersRow = el("div", { class: "search-filters-row" });
879| if (state.searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")]));
880| if (state.searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")]));
881| if (state.searchRegex) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode(".*")]));
882| const inclEl = document.getElementById("search-include-input");
883| const exclEl = document.getElementById("search-exclude-input");
884| if (inclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("incl: " + inclEl.value.trim())]));
885| if (exclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("excl: " + exclEl.value.trim())]));
886| if (filtersRow.children.length > 0) header.appendChild(filtersRow);
887|
888| // Sort controls
889| const sortDiv = el("div", { class: "search-sort" });
890| const btnRelevance = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "relevance" ? " active" : ""), type: "button" });
891| btnRelevance.textContent = "Pertinence";
892| btnRelevance.addEventListener("click", () => {
893| state.advancedSearchSort = "relevance";
894| state.advancedSearchOffset = 0;
895| const vault = document.getElementById("vault-filter").value;
896| performAdvancedSearch(query, vault, tagFilter, 0, "relevance");
897| });
898| const btnDate = el("button", { class: "search-sort__btn" + (state.advancedSearchSort === "modified" ? " active" : ""), type: "button" });
899| btnDate.textContent = "Date";
900| btnDate.addEventListener("click", () => {
901| state.advancedSearchSort = "modified";
902| state.advancedSearchOffset = 0;
903| const vault = document.getElementById("vault-filter").value;
904| performAdvancedSearch(query, vault, tagFilter, 0, "modified");
905| });
906| sortDiv.appendChild(btnRelevance);
907| sortDiv.appendChild(btnDate);
908| header.appendChild(sortDiv);
909|
910| // Save search button
911| const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" });
912| saveBtn.innerHTML = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> Sauver';
913| saveBtn.addEventListener("click", async () => {
914| const inclEl = document.getElementById("search-include-input");
915| const exclEl = document.getElementById("search-exclude-input");
916| try {
917| await api("/api/saved-searches", {
918| method: "POST",
919| headers: { "Content-Type": "application/json" },
920| body: JSON.stringify({
921| query: query,
922| vault: document.getElementById("vault-filter")?.value || "all",
923| case_sensitive: state.searchCaseSensitive,
924| whole_word: state.searchWholeWord,
925| regex: state.searchRegex,
926| include_paths: inclEl?.value || "",
927| exclude_paths: exclEl?.value || "",
928| }),
929| });
930| showToast("Recherche sauvegardée", "success");
931| } catch (err) { showToast("Erreur: " + err.message, "error"); }
932| });
933| header.appendChild(saveBtn);
934| area.appendChild(header);
935|
936| // Active sidebar tag chips
937| if (state.selectedTags.length > 0) {
938| const activeTags = el("div", { class: "search-results-active-tags" });
939| state.selectedTags.forEach((tag) => {
940| const removeBtn = el(
941| "button",
942| {
943| class: "search-results-active-tag-remove",
944| title: `Retirer ${tag} du filtre`,
945| },
946| [document.createTextNode("×")],
947| );
948| removeBtn.addEventListener("click", (e) => {
949| e.stopPropagation();
950| removeTagFilter(tag);
951| });
952| const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]);
953| activeTags.appendChild(chip);
954| });
955| area.appendChild(activeTags);
956| }
957|
958| // Facets panel
959| if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
960| const facetsDiv = el("div", { class: "search-facets" });
961|
962| // Vault facets
963| const vaultFacets = data.facets.vaults || {};
964| if (Object.keys(vaultFacets).length > 1) {
965| const group = el("div", { class: "search-facets__group" });
966| const label = el("span", { class: "search-facets__label" });
967| label.textContent = "Vaults";
968| group.appendChild(label);
969| for (const [vaultName, count] of Object.entries(vaultFacets)) {
970| const item = el("span", { class: "search-facets__item" });
971| item.innerHTML = `${vaultName} <span class="facet-count">${count}</span>`;
972| item.addEventListener("click", () => {
973| const input = document.getElementById("search-input");
974| // Add vault: operator
975| const current = input.value.replace(/vault:\S+\s*/gi, "").trim();
976| input.value = current + " vault:" + vaultName;
977| _triggerAdvancedSearch(input.value);
978| });
979| group.appendChild(item);
980| }
981| facetsDiv.appendChild(group);
982| }
983|
984| // Tag facets
985| const tagFacets = data.facets.tags || {};
986| if (Object.keys(tagFacets).length > 0) {
987| const group = el("div", { class: "search-facets__group" });
988| const label = el("span", { class: "search-facets__label" });
989| label.textContent = "Tags";
990| group.appendChild(label);
991| const entries = Object.entries(tagFacets).slice(0, 12);
992| for (const [tagName, count] of entries) {
993| const item = el("span", { class: "search-facets__item" });
994| item.innerHTML = `#${tagName} <span class="facet-count">${count}</span>`;
995| item.addEventListener("click", () => {
996| addTagFilter(tagName);
997| });
998| group.appendChild(item);
999| }
1000| facetsDiv.appendChild(group);
1001| }
1002|
1003| area.appendChild(facetsDiv);
1004| }
1005|
1006| // Empty state
1007| if (data.results.length === 0) {
1008| area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")]));
1009| return;
1010| }
1011|
1012| // Results list
1013| const container = el("div", { class: "search-results" });
1014| data.results.forEach((r) => {
1015| // Apply client-side filtering for hidden files
1016| if (!shouldDisplayPath(r.path, r.vault)) {
1017| return; // Skip this result
1018| }
1019|
1020| const titleDiv = el("div", { class: "search-result-title" });
1021| if (freeText) {
1022| highlightSearchText(titleDiv, r.title, freeText, state.searchCaseSensitive);
1023| } else {
1024| titleDiv.textContent = r.title;
1025| }
1026|
1027| // Snippet — use HTML from backend (already has <mark> tags)
1028| const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" });
1029| if (r.snippet && r.snippet.includes("<mark>")) {
1030| snippetDiv.innerHTML = r.snippet;
1031| } else if (freeText && r.snippet) {
1032| highlightSearchText(snippetDiv, r.snippet, freeText, state.searchCaseSensitive);
1033| } else {
1034| snippetDiv.textContent = r.snippet || "";
1035| }
1036|
1037| // Score badge
1038| const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" });
1039| scoreEl.textContent = `score: ${r.score}`;
1040|
1041| const vaultPath = el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path), scoreEl]);
1042|
1043| const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [
1044| el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]),
1045| titleDiv, vaultPath, snippetDiv
1046| ]);
1047|
1048| if (r.tags && r.tags.length > 0) {
1049| const tagsDiv = el("div", { class: "search-result-tags" });
1050| r.tags.forEach((tag) => {
1051| if (!TagFilterService.isTagFiltered(tag)) {
1052| const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
1053| tagEl.addEventListener("click", (e) => {
1054| e.stopPropagation();
1055| addTagFilter(tag);
1056| });
1057| tagsDiv.appendChild(tagEl);
1058| }
1059| });
1060| if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
1061| }
1062|
1063| item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
1064| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
1065| container.appendChild(item);
1066| });
1067| area.appendChild(container);
1068|
1069| // Pagination
1070| if (data.total > state.ADVANCED_SEARCH_LIMIT) {
1071| const paginationDiv = el("div", { class: "search-pagination" });
1072| const prevBtn = el("button", { class: "search-pagination__btn", type: "button" });
1073| prevBtn.textContent = "← Précédent";
1074| prevBtn.disabled = state.advancedSearchOffset === 0;
1075| prevBtn.addEventListener("click", () => {
1076| state.advancedSearchOffset = Math.max(0, state.advancedSearchOffset - state.ADVANCED_SEARCH_LIMIT);
1077| const vault = document.getElementById("vault-filter").value;
1078| performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset);
1079| document.getElementById("content-area").scrollTop = 0;
1080| });
1081|
1082| const info = el("span", { class: "search-pagination__info" });
1083| const from = state.advancedSearchOffset + 1;
1084| const to = Math.min(state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT, data.total);
1085| info.textContent = `${from}${to} sur ${data.total}`;
1086|
1087| const nextBtn = el("button", { class: "search-pagination__btn", type: "button" });
1088| nextBtn.textContent = "Suivant →";
1089| nextBtn.disabled = state.advancedSearchOffset + state.ADVANCED_SEARCH_LIMIT >= data.total;
1090| nextBtn.addEventListener("click", () => {
1091| state.advancedSearchOffset += state.ADVANCED_SEARCH_LIMIT;
1092| const vault = document.getElementById("vault-filter").value;
1093| performAdvancedSearch(query, vault, tagFilter, state.advancedSearchOffset);
1094| document.getElementById("content-area").scrollTop = 0;
1095| });
1096|
1097| paginationDiv.appendChild(prevBtn);
1098| paginationDiv.appendChild(info);
1099| paginationDiv.appendChild(nextBtn);
1100| area.appendChild(paginationDiv);
1101| }
1102|
1103| safeCreateIcons();
1104| // Initialize result navigation (select first result)
1105| setTimeout(() => { if (window.navigateSearchResults) window.navigateSearchResults(0); }, 50);
1106|}
1107|