diff --git a/CHANGELOG.md b/CHANGELOG.md index f458642..8deb468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.66.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.67.0**. --- @@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.67.0] — 2026-10-10 + +### Ajouté + +- **#196 — Partage dirigé (itération 2 : visibilité & recherche)** + - Icône « partagé » (share-2 bleu) dans l'arborescence sur tout fichier + porteur d'un partage actif (créé ou reçu). + - Dossier virtuel **Partage** à la racine du dossier personnel du + destinataire : documents reçus listés avec l'icône de partage et + « Partagé par X » en infobulle ; ouverture en lecture seule via la page + de partage. + - Les documents reçus sont **cherchables** : la recherche plein texte + couvre les partages dirigés reçus (résultats `home-/Partage/…`, + clic → page de partage, jamais l'éditeur du vault d'autrui). + - Cache d'icônes partagé invalidé à la création/révocation. + +--- + ## [2.66.0] — 2026-10-10 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 3ae12d5..0e17edb 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.66.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.67.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.66.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.0). --- -*Projet : ObsiGate | Version : 2.66.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.67.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 4c9ddf9..8b67fbb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.66.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.67.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.66.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.0). --- -*Project: ObsiGate | Version: 2.66.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.67.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 3d6ac35..ed283c8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.66.0 +2.67.0 diff --git a/backend/routers/search.py b/backend/routers/search.py index e9a3c57..2568bc3 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -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-/Partage"). partial( search_vaults, q, vault, tag, limit, offset, is_allowed=lambda v: check_vault_access(v, current_user), + username=current_user["username"], ), ) diff --git a/backend/schemas.py b/backend/schemas.py index 7dc25c1..fb0e5d2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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): diff --git a/backend/services/search.py b/backend/services/search.py index 7cb0f0b..93fad0d 100644 --- a/backend/services/search.py +++ b/backend/services/search.py @@ -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 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 8362965..f087db5 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.66.0" +version = "2.67.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index b6bd557..2956c9a 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.66.0" +version = "2.67.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 91a396c..b45de36 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.66.0", + "version": "2.67.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a578cfa..6e4b134 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.66.0 | **Dernière mise à jour :** 2026-10-10 +> **Version :** 2.67.0 | **Dernière mise à jour :** 2026-10-10 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -48,6 +48,15 @@ - [x] Scope de `GET /api/shares` ; révocation créateur/admin uniquement - [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X » - [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature + - [x] **Itération 2 (2026-10-11)** : icône « partagé » (share-2 bleu) sur les + fichiers porteurs d'un partage actif dans l'arborescence ; dossier + virtuel **Partage** à la racine du home du destinataire (fichiers reçus, + ouverture lecture seule `/s/{token}`) ; les documents reçus sont + **cherchables** (merge dans `search_vaults`, résultats + `home-/Partage/…` avec `share_token`, clic → page de partage — + jamais l'éditeur du vault d'autrui) ; cache front invalidé à la + création/révocation. +2 tests (recherche trouvée par le destinataire, + aucune fuite vers un non-destinataire). ### 194. Dossier personnel par utilisateur (`/Home/`) diff --git a/docs/features/directed-shares-196.md b/docs/features/directed-shares-196.md index aae1c23..656c2f7 100644 --- a/docs/features/directed-shares-196.md +++ b/docs/features/directed-shares-196.md @@ -77,3 +77,31 @@ peut pas révoquer (403), créateur révoque, la révocation coupe l'accès. rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe si un vrai push est demandé). - La date d'« expiration » reste globale au partage (pas par destinataire). + +## Itération 2 — visibilité & recherche (2026-10-11) + +| Élément | Où | +|---|---| +| Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence | `frontend/js/sidebar.js` (`loadSharedPaths` + `sharedFileIconSync`, appliqué dans `loadDirectory` et `incrementalLoadDirectory`) | +| Dossier virtuel **Partage** à la racine du home du destinataire | `frontend/js/sidebar.js` (injection `virtual-share` + rendu, clic fichier → `/s/{token}`) | +| Recherche des documents reçus | `backend/services/search.py::_shared_results` (merge dans `search_vaults` avant pagination), `backend/routers/search.py` (passe `username`), `SearchResultItem.share_token` | +| Clic sur un résultat reçu → page de partage | `frontend/js/search.js` | +| Invalidation du cache front | `invalidateSharedPaths()` exporté, appelé par `openShareDialog` après création/révocation | + +Décisions : + +- **Dossier virtuel, pas physique** : l'indexeur exclut les symlinks + (sécurité path traversal) et un dossier réel ferait double indexation. + La source de vérité reste `data/shares.json`. +- **Recherche à la volée** (`_shared_results`) : le contenu des documents + reçus est lu au moment de la requête, uniquement pour les destinataires — + jamais indexé dans le TF-IDF global, donc aucune fuite vers un + non-destinataire (testé). ponytail: lecture disque par requête, plafond + ~quelques centaines de partages reçus par user ; indexer dans le TF-IDF + avec un champ "destinataires" si le volume devient réel. +- Icône déterminée côté client via `/api/shares` (déjà scopé #196) : la + liste des partages actifs "créés OU reçus" suffit — le créateur voit + l'icône sur son fichier source, le destinataire voit le dossier Partage. + ++2 tests : `test_search_finds_received_share_for_recipient`, +`test_search_does_not_leak_share_to_other_user`. diff --git a/frontend/js/config.js b/frontend/js/config.js index e5714ef..a86a1bb 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -2,7 +2,7 @@ import { api, AuthManager, initMfaSettings } from './auth.js'; import { state } from './state.js'; import { el, icon, openFile, filterSavedSearches } from './viewer.js'; -import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js'; +import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState, invalidateSharedPaths } from './sidebar.js'; import { escapeHtml, safeCreateIcons } from './utils.js'; import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js'; import { t, setLocale, getLocale } from './i18n.js'; @@ -1691,6 +1691,7 @@ export async function openShareDialog(vault, path) { div.querySelector(".share-revoke-btn").addEventListener("click", async () => { try { await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); + invalidateSharedPaths(); showToast(t("config.share_revoked"), "success"); existingShare = null; renderContent(); @@ -1744,6 +1745,7 @@ export async function openShareDialog(vault, path) { body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null, shared_with: recipients }), }); existingShare = share; + invalidateSharedPaths(); renderContent(); showToast(t("config.link_created"), "success"); } catch (err) { showToast("Erreur: " + err.message, "error"); } diff --git a/frontend/js/search.js b/frontend/js/search.js index f3cb470..60cd9ea 100644 --- a/frontend/js/search.js +++ b/frontend/js/search.js @@ -911,8 +911,13 @@ export function renderSearchResults(data, query, tagFilter) { }); if (tagsDiv.children.length > 0) item.appendChild(tagsDiv); } - item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); - item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + // #196 : document reçu par partage → ouvre la page de partage (lecture seule). + if (r.share_token) { + item.addEventListener("click", () => window.open(`/s/${r.share_token}`, "_blank")); + } else { + item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path)); + item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); + } container.appendChild(item); }); area.appendChild(container); diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 99cae5e..8d40666 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -1,6 +1,6 @@ import { state } from './state.js'; import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js'; -import { api } from './auth.js'; +import { api, AuthManager } from './auth.js'; import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js'; import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js'; import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js'; @@ -8,6 +8,47 @@ import { performAdvancedSearch } from './search.js'; import { t } from './i18n.js'; import { filterChatMessages as _filterChat } from './filechat.js'; +// --------------------------------------------------------------------------- +// Shared-file decoration (#196) : fichier porteur d'un partage actif → +// icône « share » dans l'arborescence ; dossier virtuel « Partage » dans le +// home du destinataire. +// --------------------------------------------------------------------------- +let _sharedPaths = null; // Promise> — parts actives (créées ou reçues) + +function loadSharedPaths() { + if (!_sharedPaths) { + _sharedPaths = api("/api/shares") + .then(shares => new Set(shares.map(s => `${s.vault}::${s.path}`))) + .catch(() => new Set()); + } + return _sharedPaths; +} + +function invalidateSharedPaths() { _sharedPaths = null; } + +/** Partages dirigés reçus par l'utilisateur courant (#196). */ +async function _loadReceivedShares() { + const me = (AuthManager.getUser() || {}).username; + if (!me) return []; + try { + const shares = await api("/api/shares"); + return shares.filter(s => s.created_by && s.created_by !== me); + } catch { return []; } +} + +/** Icône « partagé » (#196) — share-2 bleu. */ +function _sharedIcon(size = 16) { + const i = icon("share-2", size); + i.style.color = "var(--accent-blue, #3b82f6)"; + return i; +} + +/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */ +function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) { + if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon(); + return icon(getFileIcon(itemName), 16); +} + // --------------------------------------------------------------------------- // Vault context switching // --------------------------------------------------------------------------- @@ -306,7 +347,45 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { const fragment = document.createDocumentFragment(); + // #196 : icône « partagé » — résout le cache avant le rendu. + const sharedPaths = await loadSharedPaths(); + + // #196 : dossier virtuel « Partage » à la racine du home du destinataire. + const items = Array.isArray(data.items) ? data.items.slice() : []; + const me = (AuthManager.getUser() || {}).username; + if (me && vaultName === `home-${me}` && !dirPath) { + const myShares = await _loadReceivedShares(); + if (myShares.length) items.unshift({ type: "virtual-share", shares: myShares }); + } + data.items = items; + data.items.forEach((item) => { + // #196 : dossier virtuel « Partage » (fichiers reçus via partage dirigé). + if (item.type === "virtual-share") { + const shareDir = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Partage", "data-type": "directory" }, [icon("chevron-right", 14), _sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("sidebar.shared_folder"))]), smallBadge(item.shares.length)]); + const shareSub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Partage` }); + item.shares.forEach((s) => { + const name = (s.path || "").split("/").pop().replace(/\.md$/i, ""); + const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Partage/${s.path.split("/").pop()}`, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]); + fItem.title = t("dashboard.shared_by", { user: s.created_by || "" }); + fItem.addEventListener("click", () => { + scrollTreeItemIntoView(fItem, false); + window.open(`/s/${s.token}`, "_blank"); + }); + shareSub.appendChild(fItem); + }); + shareDir.addEventListener("click", () => { + TabManager.openNav(vaultName, "Partage"); + const expanded = !shareSub.classList.contains("collapsed"); + shareSub.classList.toggle("collapsed", expanded); + const chev = shareDir.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", expanded ? "chevron-right" : "chevron-down"); + safeCreateIcons(); + }); + fragment.appendChild(shareDir); + fragment.appendChild(shareSub); + return; + } if (!shouldDisplayPath(item.path, vaultName)) return; const existing = existingItems[item.path]; @@ -374,11 +453,11 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); }); } else { - const fileIconName = getFileIcon(item.name); - const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; - const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); - attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); - attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + const fileIconName = getFileIcon(item.name); + const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); if (window.innerWidth <= 768) { @@ -629,9 +708,47 @@ async function loadDirectory(vaultName, dirPath, container) { } container.innerHTML = ""; + // #196 : icône « partagé » — résout le cache avant le rendu synchrone. + const sharedPaths = await loadSharedPaths(); + + // #196 : dossier virtuel « Partage » à la racine du home du destinataire. + const items = Array.isArray(data.items) ? data.items.slice() : []; + const me = (AuthManager.getUser() || {}).username; + if (me && vaultName === `home-${me}` && !dirPath) { + const myShares = await _loadReceivedShares(); + if (myShares.length) items.unshift({ type: "virtual-share", shares: myShares }); + } + data.items = items; + const fragment = document.createDocumentFragment(); data.items.forEach((item) => { + // #196 : dossier virtuel « Partage » (fichiers reçus via partage dirigé). + if (item.type === "virtual-share") { + const shareDir = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Partage", "data-type": "directory" }, [icon("chevron-right", 14), _sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("sidebar.shared_folder"))]), smallBadge(item.shares.length)]); + const shareSub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Partage` }); + item.shares.forEach((s) => { + const name = (s.path || "").split("/").pop().replace(/\.md$/i, ""); + const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Partage/${s.path.split("/").pop()}`, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]); + fItem.title = t("dashboard.shared_by", { user: s.created_by || "" }); + fItem.addEventListener("click", () => { + scrollTreeItemIntoView(fItem, false); + window.open(`/s/${s.token}`, "_blank"); + }); + shareSub.appendChild(fItem); + }); + shareDir.addEventListener("click", () => { + TabManager.openNav(vaultName, "Partage"); + const expanded = !shareSub.classList.contains("collapsed"); + shareSub.classList.toggle("collapsed", expanded); + const chev = shareDir.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", expanded ? "chevron-right" : "chevron-down"); + safeCreateIcons(); + }); + fragment.appendChild(shareDir); + fragment.appendChild(shareSub); + return; + } // Apply client-side filtering for hidden files if (!shouldDisplayPath(item.path, vaultName)) { return; // Skip this item @@ -673,7 +790,8 @@ async function loadDirectory(vaultName, dirPath, container) { } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; - const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + const isShared = sharedPaths.has(`${vaultName}::${item.path}`); + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { @@ -705,6 +823,7 @@ async function loadDirectory(vaultName, dirPath, container) { safeCreateIcons(); } +// --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Sidebar filter // --------------------------------------------------------------------------- @@ -1133,4 +1252,4 @@ function searchByTag(tag) { } -export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter }; +export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter, invalidateSharedPaths }; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 84bcd87..9c8b919 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1721,6 +1721,7 @@ "shortcut.search_in_doc": "Search in document", "shortcut.tip_preview": "Hover a document with **Ctrl** for a quick preview. Click **Forge** in the toolbar for the advanced editor.", "sidebar.all_vaults": "All vaults", + "sidebar.shared_folder": "Shared", "sidebar.case_sensitive": "Case sensitive", "sidebar.clear_filter": "Clear", "sidebar.expand_collapse": "Expand/Collapse", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index ff35fd7..b4f1a63 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1721,6 +1721,7 @@ "shortcut.search_in_doc": "Rechercher dans le document", "shortcut.tip_preview": "Survolez un document avec **Ctrl** pour un aperçu rapide. Cliquez sur **Forge** dans la barre d'outils pour l'éditeur avancé.", "sidebar.all_vaults": "Tous les vaults", + "sidebar.shared_folder": "Partage", "sidebar.case_sensitive": "Respecter la casse", "sidebar.clear_filter": "Effacer", "sidebar.expand_collapse": "Développer/Réduire", diff --git a/package.json b/package.json index 9ed81ce..c59ac04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.66.0", + "version": "2.67.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_directed_shares.py b/tests/test_directed_shares.py index 1a215e5..aacd786 100644 --- a/tests/test_directed_shares.py +++ b/tests/test_directed_shares.py @@ -140,3 +140,30 @@ class TestDirectedShareGate: assert user_client.get(f"/s/{body['token']}").status_code == 200 admin_client.delete(f"/api/share/{body['id']}") assert user_client.get(f"/s/{body['token']}").status_code == 404 + + def test_search_finds_received_share_for_recipient(self, sessions): + # #196 : le contenu du document reçu est cherchable par le destinataire. + admin_client, user_client = sessions + path = _ensure_shared_file() + resp = admin_client.post("/api/share/TestVault", json={ + "path": path, "shared_with": ["normaluser"], + }) + token = resp.json()["token"] + resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"}) + assert resp.status_code == 200 + hits = [r for r in resp.json()["results"] if r.get("share_token") == token] + assert hits, "le destinataire doit trouver le document reçu via la recherche" + assert hits[0]["vault"] == "home-normaluser" + assert hits[0]["path"].startswith("Partage/") + + def test_search_does_not_leak_share_to_other_user(self, sessions): + # Un utilisateur non destinataire ne voit JAMAIS le contenu partagé + # dans ses résultats (le vault source lui est interdit). + admin_client, user_client = sessions + path = _ensure_shared_file() + admin_client.post("/api/share/TestVault", json={ + "path": path, "shared_with": ["admin"], # dirigé à admin seul + }) + resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"}) + assert resp.status_code == 200 + assert not [r for r in resp.json()["results"] if r.get("share_token")]