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