Add saved searches with CRUD API and UI sidebar

Add extension field to search results and display it
Add active filter badges and save button to search header
This commit is contained in:
Bruno Charest 2026-05-27 08:39:52 -04:00
parent aa2c05b05f
commit e3c25b5b09
6 changed files with 318 additions and 2 deletions

View File

@ -182,6 +182,7 @@ class AdvancedSearchResultItem(BaseModel):
score: float = Field(description="TF-IDF relevance score")
snippet: str = Field(description="Content excerpt with <mark> highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
extension: str = Field(default="", description="File extension")
class SearchFacets(BaseModel):
@ -561,6 +562,7 @@ from backend.secret_redactor import redact_file_content
from backend.pdf_export import generate_pdf, build_pdf_html
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares
from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks
from backend.saved_searches import get_saved, save_search, delete_saved
app.include_router(auth_router)
@ -1017,6 +1019,32 @@ async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(r
return {"bookmarked": is_now_bookmarked}
@app.get("/api/saved-searches")
async def api_saved_searches(current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
return get_saved(username)
@app.post("/api/saved-searches")
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
return save_search(username, body)
@app.delete("/api/saved-searches/{search_id}")
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
if not delete_saved(username, search_id):
raise HTTPException(404, "Not found")
return {"status": "deleted"}
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
"""Browse directories and files in a vault at a given path level.

71
backend/saved_searches.py Normal file
View File

@ -0,0 +1,71 @@
"""
Saved search definitions persisted search queries with their active filters.
Stored in data/saved_searches.json per user.
"""
import json
import time
import logging
import shutil
from pathlib import Path
from typing import List, Dict, Any, Optional
logger = logging.getLogger("obsigate.saved_searches")
DATA_DIR = Path("data")
def _get_file(username: str) -> Path:
DATA_DIR.mkdir(parents=True, exist_ok=True)
return DATA_DIR / f"{username}_saved_searches.json"
def _read(file: Path) -> List[Dict[str, Any]]:
if not file.exists():
return []
try:
return json.loads(file.read_text(encoding="utf-8"))
except Exception:
return []
def _write(file: Path, data: List[Dict[str, Any]]):
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
def get_saved(username: str) -> List[Dict[str, Any]]:
return _read(_get_file(username))
def save_search(username: str, definition: dict) -> dict:
"""Save a search definition. Returns the saved entry."""
searches = _read(_get_file(username))
entry = {
"id": str(int(time.time() * 1000)),
"query": definition.get("query", ""),
"vault": definition.get("vault", "all"),
"case_sensitive": definition.get("case_sensitive", False),
"whole_word": definition.get("whole_word", False),
"regex": definition.get("regex", False),
"include_paths": definition.get("include_paths", ""),
"exclude_paths": definition.get("exclude_paths", ""),
"created_at": time.time(),
}
searches.insert(0, entry)
# Keep last 50
searches = searches[:50]
_write(_get_file(username), searches)
return entry
def delete_saved(username: str, search_id: str) -> bool:
searches = _read(_get_file(username))
new_list = [s for s in searches if s["id"] != search_id]
if len(new_list) == len(searches):
return False
_write(_get_file(username), new_list)
return True

View File

@ -1084,6 +1084,7 @@ def advanced_search(
"score": round(score, 4),
"snippet": snippet,
"modified": file_info.get("modified", ""),
"extension": file_info.get("extension", file_info.get("path", "").rsplit(".", 1)[-1] if "." in file_info.get("path", "") else ""),
}
scored_results.append((score, result))

View File

@ -5180,7 +5180,12 @@
} else {
snippetDiv.textContent = r.snippet || "";
}
const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [titleDiv, el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]), snippetDiv]);
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) => {
@ -5233,6 +5238,17 @@
}
header.appendChild(summaryText);
// Active filter badges
const filtersRow = el("div", { class: "search-filters-row" });
if (searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")]));
if (searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")]));
if (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" + (advancedSearchSort === "relevance" ? " active" : ""), type: "button" });
@ -5254,6 +5270,31 @@
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: searchCaseSensitive,
whole_word: searchWholeWord,
regex: 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
@ -5363,7 +5404,10 @@
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 }, [titleDiv, vaultPath, snippetDiv]);
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" });
@ -5995,6 +6039,72 @@
if (typeof DashboardSharedWidget !== "undefined") {
DashboardSharedWidget.load();
}
// Load saved searches sidebar
loadSavedSearches();
}
async function loadSavedSearches() {
const list = document.getElementById("saved-searches-list");
const empty = document.getElementById("saved-searches-empty");
if (!list) return;
try {
const searches = await api("/api/saved-searches");
if (!searches.length) {
list.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
list.innerHTML = searches.map(s => `
<div class="saved-search-item">
<div class="saved-search-query" title="${escapeHtml(s.query)}">${escapeHtml(s.query.length > 50 ? s.query.slice(0, 50) + "..." : s.query)}</div>
<div class="saved-search-meta">
${s.case_sensitive ? '<span class="search-filter-badge">Aa</span>' : ""}
${s.whole_word ? '<span class="search-filter-badge">wd</span>' : ""}
${s.regex ? '<span class="search-filter-badge">.*</span>' : ""}
<span class="saved-search-vault">${escapeHtml(s.vault)}</span>
</div>
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer"></button>
</div>
`).join("");
list.querySelectorAll(".saved-search-item").forEach(item => {
item.addEventListener("click", (e) => {
if (e.target.classList.contains("saved-search-delete")) return;
const idx = Array.from(list.children).indexOf(item);
const s = searches[idx];
if (!s) return;
// Apply the saved search
const input = document.getElementById("search-input");
if (input) input.value = s.query;
searchCaseSensitive = s.case_sensitive || false;
searchWholeWord = s.whole_word || false;
searchRegex = s.regex || false;
if (typeof _updateToggleUI === "function") _updateToggleUI();
if (s.include_paths) {
const incl = document.getElementById("search-include-input");
if (incl) incl.value = s.include_paths;
}
if (s.exclude_paths) {
const excl = document.getElementById("search-exclude-input");
if (excl) excl.value = s.exclude_paths;
}
// Execute the search
const vault = s.vault || "all";
if (input) { input.dispatchEvent(new Event("input")); }
clearTimeout(searchTimeout);
advancedSearchOffset = 0;
performAdvancedSearch(s.query, vault, null);
});
});
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
e.stopPropagation();
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
loadSavedSearches();
}));
safeCreateIcons();
} catch (err) { /* silently ignore */ }
}
}
function showLoading() {

View File

@ -320,6 +320,11 @@
<i data-lucide="clock" style="width:13px;height:13px"></i>
<span>Récent</span>
</button>
<button class="sidebar-tab" id="sidebar-tab-saved" role="tab"
aria-selected="false" aria-controls="sidebar-panel-saved" data-tab="saved">
<i data-lucide="bookmark" style="width:13px;height:13px"></i>
<span>Sauvegardes</span>
</button>
</div>
<!-- Vaults panel -->
@ -356,6 +361,15 @@
</div>
</div>
<!-- Saved Searches panel -->
<div class="sidebar-tab-panel" id="sidebar-panel-saved" role="tabpanel" aria-labelledby="sidebar-tab-saved">
<div id="saved-searches-list" class="recent-list"></div>
<div id="saved-searches-empty" class="recent-empty">
<i data-lucide="bookmark" style="width:32px;height:32px"></i>
<span>Aucune recherche sauvegardée</span>
</div>
</div>
</aside>
<!-- Sidebar resize handle -->

View File

@ -1858,7 +1858,99 @@ select {
.search-result-item:hover {
background: var(--bg-hover);
border-color: var(--accent);
position: relative;
}
.search-result-ext {
position: absolute;
top: 8px;
right: 10px;
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
background: var(--bg-hover);
padding: 1px 6px;
border-radius: 3px;
letter-spacing: 0.5px;
}
.search-filters-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.search-filter-badge {
font-size: 0.65rem;
font-family: "JetBrains Mono", monospace;
padding: 1px 6px;
border-radius: 3px;
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
font-weight: 600;
}
.search-filter-badge.path {
background: rgba(5, 150, 105, 0.1);
color: #059669;
}
/* Save search button */
.search-save-btn {
padding: 4px 10px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.search-save-btn:hover { background: var(--bg-hover); }
/* Saved searches sidebar */
.saved-search-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
position: relative;
}
.saved-search-item:hover { background: var(--bg-hover); }
.saved-search-query {
flex: 1;
font-size: 0.8rem;
font-family: "JetBrains Mono", monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.saved-search-meta {
display: flex;
gap: 3px;
align-items: center;
flex-shrink: 0;
}
.saved-search-vault {
font-size: 0.65rem;
color: var(--text-muted);
margin-left: 4px;
}
.saved-search-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 2px 4px;
opacity: 0;
transition: opacity 0.15s;
}
.saved-search-item:hover .saved-search-delete { opacity: 1; }
.saved-search-delete:hover { color: var(--text-error); }
.search-result-title {
font-family: "JetBrains Mono", monospace;
font-size: 0.92rem;