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.
1107 lines
52 KiB
JavaScript
1107 lines
52 KiB
JavaScript
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 += "®ex=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| |