From 8fdcdaf4120e8fcbbe0468a50c3821d236c0b453 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 26 May 2026 13:35:38 -0400 Subject: [PATCH] Add search toggles, path filters, and find/replace functionality --- backend/main.py | 110 +++++++++++++++++++++++++++++++++++++++----- backend/search.py | 73 +++++++++++++++++++++++++++++ frontend/app.js | 98 +++++++++++++++++++++++++++++++++++++++ frontend/index.html | 30 ++++++++++-- frontend/style.css | 66 ++++++++++++++++++++++++++ 5 files changed, 361 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index 52c96c4..cceed80 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1873,6 +1873,11 @@ async def api_advanced_search( limit: int = Query(50, ge=1, le=200, description="Results per page"), offset: int = Query(0, ge=0, description="Pagination offset"), sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"), + case_sensitive: bool = Query(False, description="Match case"), + whole_word: bool = Query(False, description="Match whole words only"), + regex: bool = Query(False, description="Treat query as regex"), + include_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to include"), + exclude_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to exclude"), current_user=Depends(require_auth), ): """Advanced full-text search with TF-IDF scoring, facets, and pagination. @@ -1884,28 +1889,109 @@ async def api_advanced_search( - ``path:`` — filter by path substring - ``ext:`` — filter by file extension - Remaining text is scored using TF-IDF with accent normalization. + - Toggles: case_sensitive, whole_word, regex + - Path filters: include_paths, exclude_paths (glob patterns) Results include ````-highlighted snippets and faceted tag/vault counts. - - Args: - q: Query string with optional operators. - vault: Vault name or ``"all"``. - tag: Extra comma-separated tag names to require. - limit: Max results per page (1–200). - offset: Pagination offset. - sort: ``"relevance"`` (TF-IDF) or ``"modified"`` (date). - - Returns: - ``AdvancedSearchResponse`` with scored results, facets, and pagination info. """ loop = asyncio.get_event_loop() return await loop.run_in_executor( _search_executor, partial(advanced_search, q, vault_filter=vault, tag_filter=tag, - limit=limit, offset=offset, sort_by=sort), + limit=limit, offset=offset, sort_by=sort, + case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, + include_paths=include_paths, exclude_paths=exclude_paths), ) +@app.post("/api/search/replace") +async def api_search_replace( + body: dict = Body(...), + current_user=Depends(require_auth), +): + """Find and replace across vault files.""" + import re as re_mod + query = body.get("query", "") + replacement = body.get("replacement", "") + vault_filter = body.get("vault", "all") + case_sensitive = body.get("case_sensitive", False) + whole_word = body.get("whole_word", False) + regex_mode = body.get("regex", False) + include_paths = body.get("include_paths") + exclude_paths = body.get("exclude_paths") + replace_all = body.get("replace_all", False) + dry_run = body.get("dry_run", not replace_all) + + if not query: + raise HTTPException(400, "Query is required") + + search_results = advanced_search( + query, vault_filter=vault_filter, + case_sensitive=case_sensitive, whole_word=whole_word, + regex=regex_mode, include_paths=include_paths, exclude_paths=exclude_paths, + limit=500, sort_by="relevance", + ) + + if not search_results["results"]: + return {"matches": [], "total_matches": 0} + + flags = 0 if case_sensitive else re_mod.IGNORECASE + if regex_mode: + pattern = re_mod.compile(query, flags) + elif whole_word: + pattern = re_mod.compile(rf"\b{re_mod.escape(query)}\b", flags) + else: + pattern = re_mod.compile(re_mod.escape(query), flags) + + matches = [] + total_replacements = 0 + + for result in search_results["results"]: + if not check_vault_access(result["vault"], current_user): + continue + vault_data = get_vault_data(result["vault"]) + if not vault_data: + continue + file_path = _resolve_safe_path(Path(vault_data["path"]), result["path"]) + if not file_path.exists(): + continue + try: + original = file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + continue + occurrences = list(pattern.finditer(original)) + if not occurrences: + continue + if dry_run: + previews = [] + for m in occurrences[:3]: + start = max(0, m.start() - 40) + end = min(len(original), m.end() + 40) + previews.append(f"...{original[start:end]}...") + matches.append({ + "vault": result["vault"], "path": result["path"], + "title": result["title"], "match_count": len(occurrences), + "preview": previews, + }) + total_replacements += len(occurrences) + else: + new_content, count = pattern.subn(replacement, original) + if count > 0: + _backup_file(file_path, result["vault"], result["path"]) + file_path.write_text(new_content, encoding="utf-8") + log_file_save(current_user["username"], result["vault"], result["path"], len(new_content)) + matches.append({ + "vault": result["vault"], "path": result["path"], + "title": result["title"], "replacements": count, + }) + total_replacements += count + await update_single_file(result["vault"], str(file_path)) + + if dry_run: + return {"matches": matches, "total_matches": total_replacements, "dry_run": True} + return {"replaced": matches, "total_replacements": total_replacements} + + @app.get("/api/suggest", response_model=SuggestResponse) async def api_suggest( q: str = Query("", description="Prefix to search for in file titles"), diff --git a/backend/search.py b/backend/search.py index 3ff27e4..6f169e0 100644 --- a/backend/search.py +++ b/backend/search.py @@ -759,6 +759,67 @@ def _split_query_tokens(raw: str) -> List[str]: return tokens +def _passes_search_filters( + file_info: dict, + query_terms: List[str], + raw_query: str, + case_sensitive: bool, + whole_word: bool, + regex: bool, + include_paths: Optional[str], + exclude_paths: Optional[str], +) -> bool: + """Post-filter a candidate by case-sensitive, whole-word, regex, and path filters.""" + title = file_info.get("title", "") + content = file_info.get("content", "") + path = file_info.get("path", "") + search_text = f"{title} {content}" + + # --- Regex mode --- + if regex and raw_query: + try: + flags = 0 if case_sensitive else re.IGNORECASE + if whole_word: + pattern = re.compile(rf"\b{raw_query}\b", flags) + else: + pattern = re.compile(raw_query, flags) + if not pattern.search(search_text): + return False + except re.error: + return False # invalid regex + return _passes_path_filters(path, include_paths, exclude_paths) + + # --- Case-sensitive --- + if case_sensitive and query_terms: + for term in query_terms: + if term not in search_text: + return False + + # --- Whole-word --- + if whole_word and query_terms: + for term in query_terms: + pattern = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE) + if not pattern.search(search_text): + return False + + # --- Path filters (glob-like) --- + return _passes_path_filters(path, include_paths, exclude_paths) + + +def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[str]) -> bool: + """Check if a file path passes include/exclude glob patterns.""" + import fnmatch + if include: + patterns = [p.strip() for p in include.split(",") if p.strip()] + if patterns and not any(fnmatch.fnmatch(path, p) for p in patterns): + return False + if exclude: + patterns = [p.strip() for p in exclude.split(",") if p.strip()] + if patterns and any(fnmatch.fnmatch(path, p) for p in patterns): + return False + return True + + def advanced_search( query: str, vault_filter: str = "all", @@ -766,6 +827,11 @@ def advanced_search( limit: int = ADVANCED_SEARCH_DEFAULT_LIMIT, offset: int = 0, sort_by: str = "relevance", + case_sensitive: bool = False, + whole_word: bool = False, + regex: bool = False, + include_paths: Optional[str] = None, + exclude_paths: Optional[str] = None, ) -> Dict[str, Any]: """Advanced full-text search with TF-IDF scoring, facets, and pagination. @@ -923,6 +989,13 @@ def advanced_search( score = 1.0 if score > 0: + # --- Post-filters: case-sensitive, whole-word, regex, path filters --- + if not _passes_search_filters( + file_info, query_terms, query, + case_sensitive, whole_word, regex, include_paths, exclude_paths + ): + continue + # Build highlighted snippet content = file_info.get("content", "") if has_terms: diff --git a/frontend/app.js b/frontend/app.js index da47a57..5916c2e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -4673,10 +4673,97 @@ // --------------------------------------------------------------------------- // Search (enhanced with autocomplete, keyboard nav, global shortcuts) // --------------------------------------------------------------------------- + // ── Search toggle state ── + let searchCaseSensitive = false; + let searchWholeWord = false; + let searchRegex = false; + let searchFilterVisible = false; + let searchReplaceVisible = false; + function initSearch() { const input = document.getElementById("search-input"); 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 replaceBtn = document.getElementById("search-replace-btn"); const clearBtn = document.getElementById("search-clear-btn"); + const replaceInput = document.getElementById("search-replace-input"); + const replaceOneBtn = document.getElementById("search-replace-one-btn"); + const replaceAllBtn = document.getElementById("search-replace-all-btn"); + const filterRow = document.getElementById("search-filter-row"); + const replaceRow = document.getElementById("search-replace-row"); + const countEl = document.getElementById("search-match-count"); + + function _updateToggleUI() { + caseBtn.classList.toggle("active", searchCaseSensitive); + wordBtn.classList.toggle("active", searchWholeWord); + regexBtn.classList.toggle("active", searchRegex); + filterBtn.classList.toggle("active", searchFilterVisible); + replaceBtn.classList.toggle("active", searchReplaceVisible); + } + + // Toggle buttons + caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; _updateToggleUI(); _research(); }); + if (wordBtn) wordBtn.addEventListener("click", () => { searchWholeWord = !searchWholeWord; _updateToggleUI(); _research(); }); + if (regexBtn) regexBtn.addEventListener("click", () => { searchRegex = !searchRegex; _updateToggleUI(); _research(); }); + if (filterBtn) filterBtn.addEventListener("click", () => { searchFilterVisible = !searchFilterVisible; if (filterRow) filterRow.style.display = searchFilterVisible ? "flex" : "none"; searchReplaceVisible = false; if (replaceRow) replaceRow.style.display = "none"; _updateToggleUI(); }); + if (replaceBtn) replaceBtn.addEventListener("click", () => { searchReplaceVisible = !searchReplaceVisible; if (replaceRow) replaceRow.style.display = searchReplaceVisible ? "flex" : "none"; searchFilterVisible = false; if (filterRow) filterRow.style.display = "none"; _updateToggleUI(); }); + + // Replace buttons + if (replaceOneBtn) replaceOneBtn.addEventListener("click", () => _doReplace(false)); + if (replaceAllBtn) replaceAllBtn.addEventListener("click", () => _doReplace(true)); + + async function _doReplace(replaceAll) { + const q = input.value.trim(); + const replacement = replaceInput ? replaceInput.value : ""; + if (!q) return; + try { + const vault = document.getElementById("vault-filter").value; + const includeEl = document.getElementById("search-include-input"); + const excludeEl = document.getElementById("search-exclude-input"); + const data = await api("/api/search/replace", { + method: "POST", + body: JSON.stringify({ + query: q, replacement, vault, + case_sensitive: searchCaseSensitive, whole_word: searchWholeWord, regex: searchRegex, + include_paths: includeEl?.value || null, exclude_paths: excludeEl?.value || null, + replace_all: replaceAll, + }), + }); + if (data.dry_run) { + const total = data.total_matches || 0; + showToast(`${total} occurrence(s) trouvée(s) dans ${data.matches?.length || 0} fichier(s). Cliquez "Tout remplacer" pour appliquer.`, "info"); + } else { + showToast(`${data.total_replacements || 0} remplacement(s) effectué(s).`, "success"); + _research(); + } + } catch (err) { showToast("Erreur: " + err.message, "error"); } + } + + function _research() { + const q = input.value.trim(); + if (q.length >= _getEffective("min_query_length", 2)) { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + const vault = document.getElementById("vault-filter").value; + const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; + 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(); } + else if (e.key === "H" || e.key === "h") { e.preventDefault(); if (replaceBtn) replaceBtn.click(); } + } + }); // Initialize sub-controllers AutocompleteDropdown.init(); @@ -4849,6 +4936,13 @@ const effectiveLimit = _getEffective("results_per_page", 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 (searchCaseSensitive) url += "&case_sensitive=true"; + if (searchWholeWord) url += "&whole_word=true"; + if (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( @@ -4932,6 +5026,10 @@ 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" }); diff --git a/frontend/index.html b/frontend/index.html index 4f9bcdf..03ff7cc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -154,16 +154,38 @@
- +
- + + + + - + + 0/0 + + +
+ + + +