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
+19 -1
View File
@@ -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-<user>/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é
+3 -3
View File
@@ -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*
+3 -3
View File
@@ -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*
+1 -1
View File
@@ -1 +1 @@
2.66.0
2.67.0
+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
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.66.0"
version = "2.67.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+10 -1
View File
@@ -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-<user>/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 (`<racine>/Home/<user>`)
+28
View File
@@ -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`.
+3 -1
View File
@@ -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"); }
+7 -2
View File
@@ -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);
+127 -8
View File
@@ -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<Set<"vault::path">> — 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 };
+1
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -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": {
+27
View File
@@ -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")]