diff --git a/backend/main.py b/backend/main.py index e928b62..bdb9746 100644 --- a/backend/main.py +++ b/backend/main.py @@ -182,6 +182,7 @@ class AdvancedSearchResultItem(BaseModel): score: float = Field(description="TF-IDF relevance score") snippet: str = Field(description="Content excerpt with 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. diff --git a/backend/saved_searches.py b/backend/saved_searches.py new file mode 100644 index 0000000..3a74a9e --- /dev/null +++ b/backend/saved_searches.py @@ -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 diff --git a/backend/search.py b/backend/search.py index 5a84b70..badbc22 100644 --- a/backend/search.py +++ b/backend/search.py @@ -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)) diff --git a/frontend/app.js b/frontend/app.js index 6c14cc2..ef0b9c3 100644 --- a/frontend/app.js +++ b/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 = ' 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 => ` +
+
${escapeHtml(s.query.length > 50 ? s.query.slice(0, 50) + "..." : s.query)}
+
+ ${s.case_sensitive ? 'Aa' : ""} + ${s.whole_word ? 'wd' : ""} + ${s.regex ? '.*' : ""} + ${escapeHtml(s.vault)} +
+ +
+ `).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() { diff --git a/frontend/index.html b/frontend/index.html index d6d8760..f8c77c0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -320,6 +320,11 @@ Récent + @@ -356,6 +361,15 @@ + + + diff --git a/frontend/style.css b/frontend/style.css index 4503ac4..0976c2c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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;