Add search toggles, path filters, and find/replace functionality
This commit is contained in:
+98
-12
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user