feat: visibilité & recherche des partages dirigés #196
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s

- 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:
2026-10-10 21:23:00 -04:00
parent 49c715199d
commit fdc9f47a0d
19 changed files with 311 additions and 25 deletions
+3
View File
@@ -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
View File
@@ -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):
+71
View File
@@ -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