Add search toggles, path filters, and find/replace functionality
This commit is contained in:
parent
6c282ac77f
commit
8fdcdaf412
110
backend/main.py
110
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:<text>`` — filter by path substring
|
||||
- ``ext:<type>`` — 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 ``<mark>``-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"),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -154,16 +154,38 @@
|
||||
<div class="search-wrapper">
|
||||
<i data-lucide="search" class="search-icon" style="width:16px;height:16px"></i>
|
||||
<div class="search-input-wrapper">
|
||||
<input type="text" id="search-input" placeholder="Recherche..." autocomplete="off">
|
||||
<input type="text" id="search-input" placeholder="Rechercher dans tous les fichiers..." autocomplete="off">
|
||||
<div class="search-actions">
|
||||
<button class="search-btn" id="search-case-btn" type="button" title="Respecter la casse" aria-label="Respecter la casse">
|
||||
<span>Aa</span>
|
||||
<button class="search-btn tog" id="search-case-btn" type="button" title="Respecter la casse (Alt-C)">Aa</button>
|
||||
<button class="search-btn tog" id="search-word-btn" type="button" title="Mot entier (Alt-W)">wd</button>
|
||||
<button class="search-btn tog" id="search-regex-btn" type="button" title="Expression régulière (Alt-R)">.*</button>
|
||||
<span class="search-sep"></span>
|
||||
<button class="search-btn icon-only" id="search-filter-btn" type="button" title="Filtres de chemin (Alt-F)">
|
||||
<i data-lucide="filter" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="search-btn" id="search-clear-btn" type="button" title="Effacer la recherche" aria-label="Effacer la recherche">
|
||||
<button class="search-btn icon-only" id="search-replace-btn" type="button" title="Rechercher et remplacer (Alt-Shift-H)">
|
||||
<i data-lucide="replace" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<span class="search-sep"></span>
|
||||
<span class="search-count" id="search-match-count">0/0</span>
|
||||
<button class="search-btn icon-only" id="search-prev-btn" type="button" title="Résultat précédent">↑</button>
|
||||
<button class="search-btn icon-only" id="search-next-btn" type="button" title="Résultat suivant">↓</button>
|
||||
<button class="search-btn icon-only" id="search-clear-btn" type="button" title="Effacer" aria-label="Effacer la recherche">
|
||||
<i data-lucide="x" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Path filter row -->
|
||||
<div class="search-filter-row" id="search-filter-row" style="display:none">
|
||||
<input type="text" class="search-filter-input" id="search-include-input" placeholder="Inclure: **/*.md, notes/**">
|
||||
<input type="text" class="search-filter-input" id="search-exclude-input" placeholder="Exclure: vendor/*, *.lock">
|
||||
</div>
|
||||
<!-- Replace row -->
|
||||
<div class="search-replace-row" id="search-replace-row" style="display:none">
|
||||
<input type="text" class="search-filter-input" id="search-replace-input" placeholder="Remplacer par...">
|
||||
<button class="search-replace-btn" id="search-replace-one-btn" type="button" title="Remplacer">Remplacer</button>
|
||||
<button class="search-replace-btn" id="search-replace-all-btn" type="button" title="Remplacer tout">Tout remplacer</button>
|
||||
</div>
|
||||
<!-- Advanced search autocomplete dropdown -->
|
||||
<div class="search-dropdown" id="search-dropdown" role="listbox" aria-label="Suggestions de recherche" hidden>
|
||||
<div class="search-dropdown__section search-dropdown__section--history" id="search-dropdown-history">
|
||||
|
||||
@ -5745,6 +5745,72 @@ body.popup-mode .content-area {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Enhanced Search Bar Toggles ── */
|
||||
.search-actions .tog {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
min-width: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.search-actions .tog.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.search-actions .icon-only {
|
||||
padding: 2px 4px;
|
||||
min-width: auto;
|
||||
}
|
||||
.search-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
margin: 0 2px;
|
||||
align-self: center;
|
||||
}
|
||||
.search-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.search-filter-row, .search-replace-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 0 0 0;
|
||||
}
|
||||
.search-filter-input {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
outline: none;
|
||||
}
|
||||
.search-filter-input:focus { border-color: var(--accent); }
|
||||
.search-filter-input::placeholder { color: var(--text-muted); }
|
||||
.search-replace-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-replace-btn:hover { background: var(--bg-hover); }
|
||||
.search-replace-btn.destructive { border-color: var(--text-error); color: var(--text-error); }
|
||||
|
||||
/* ── Dashboard Tab Navigation ── */
|
||||
.dashboard-tabs {
|
||||
display: flex;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user