feat: visibilité & recherche des partages dirigés #196
- icône share-2 bleue sur les fichiers partagés dans l'arborescence
- dossier virtuel Partage/ Ã la racine du home du destinataire (lecture seule /s/{token})
- recherche plein texte couvre les documents reçus (merge search_vaults, share_token)
- cache front invalidé à la création/révocation ; +2 tests non-fuite
This commit is contained in:
@@ -84,9 +84,12 @@ async def api_search(
|
||||
get_search_executor(),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
# #196 : les documents reçus par partage dirigé sont ajoutés aux
|
||||
# résultats (vault virtuel "home-<user>/Partage").
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
username=current_user["username"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+3
-1
@@ -611,13 +611,15 @@ class BookmarkToggleRequest(BaseModel):
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: int = Field(description="Relevance score")
|
||||
snippet: str = Field(description="Content excerpt with highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
modified: str | None = Field(default=None, description="ISO 8601 modification timestamp")
|
||||
share_token: str | None = Field(default=None, description="#196 token de partage dirigé (document reçu)")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
|
||||
@@ -13,18 +13,28 @@ def search_vaults(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
username: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
|
||||
``username`` (#196) : les documents **reçus** par cet utilisateur via un
|
||||
partage dirigé sont ajoutés aux résultats (lecture seule, sans indexer
|
||||
les fichiers d'autrui — le contenu est lu à la volée et mis en cache
|
||||
TF-IDF côté index, pas sur disque).
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
|
||||
if username is not None:
|
||||
all_results = all_results + _shared_results(username, q, vault)
|
||||
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
@@ -39,6 +49,67 @@ def search_vaults(
|
||||
}
|
||||
|
||||
|
||||
def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
|
||||
"""Search results for documents shared TO *username* (#196).
|
||||
|
||||
Reads the shared file content on the fly (read-only, source vault on
|
||||
disk) and returns hits shaped exactly like ordinary search results so
|
||||
the frontend can open them via the normal share page.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import list_shares
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if not q:
|
||||
return out
|
||||
q_lower = q.lower()
|
||||
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue # own share — already indexed in its own vault
|
||||
if vault not in ("all", f"home-{username}"):
|
||||
continue
|
||||
data = get_vault_data(s["vault"])
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
fp = resolve_safe_path(Path(data["path"]), s["path"])
|
||||
if not fp.exists() or fp.suffix.lower() != ".md":
|
||||
continue
|
||||
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
title = fp.stem
|
||||
occurrences = raw.lower().count(q_lower)
|
||||
if q_lower in title.lower():
|
||||
occurrences += 1
|
||||
if occurrences == 0:
|
||||
continue
|
||||
out.append({
|
||||
"vault": f"home-{username}",
|
||||
"path": f"Partage/{fp.name}",
|
||||
"title": title,
|
||||
"tags": [],
|
||||
"score": min(occurrences, 10),
|
||||
"snippet": _snippet(raw, q_lower),
|
||||
"modified": None,
|
||||
"share_token": s["token"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _snippet(text: str, q_lower: str, width: int = 160) -> str:
|
||||
"""Small excerpt around the first match (#196, shared-file search)."""
|
||||
idx = text.lower().find(q_lower)
|
||||
if idx < 0:
|
||||
return text[:width]
|
||||
start = max(0, idx - width // 2)
|
||||
return "…" + text[start:start + width].replace("\n", " ") + "…"
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
|
||||
Reference in New Issue
Block a user