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:
parent
aa2c05b05f
commit
e3c25b5b09
@ -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
71
backend/saved_searches.py
Normal 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
|
||||
@ -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))
|
||||
|
||||
|
||||
114
frontend/app.js
114
frontend/app.js
@ -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() {
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user