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:
+19
-1
@@ -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).
|
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
|
> **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
|
## [2.66.0] — 2026-10-10
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -84,9 +84,12 @@ async def api_search(
|
|||||||
get_search_executor(),
|
get_search_executor(),
|
||||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
# 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(
|
partial(
|
||||||
search_vaults, q, vault, tag, limit, offset,
|
search_vaults, q, vault, tag, limit, offset,
|
||||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
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):
|
class SearchResultItem(BaseModel):
|
||||||
"""A single search result."""
|
"""A single search result."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
vault: str = Field(description="Vault name")
|
vault: str = Field(description="Vault name")
|
||||||
path: str = Field(description="Relative file path")
|
path: str = Field(description="Relative file path")
|
||||||
title: str = Field(description="File title")
|
title: str = Field(description="File title")
|
||||||
tags: list[str] = Field(description="File tags")
|
tags: list[str] = Field(description="File tags")
|
||||||
score: int = Field(description="Relevance score")
|
score: int = Field(description="Relevance score")
|
||||||
snippet: str = Field(description="Content excerpt with highlights")
|
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):
|
class SearchResponse(BaseModel):
|
||||||
|
|||||||
@@ -13,18 +13,28 @@ def search_vaults(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
is_allowed: Callable[[str], bool] | None = None,
|
is_allowed: Callable[[str], bool] | None = None,
|
||||||
|
username: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Full-text search with pagination, returned as the API response payload.
|
"""Full-text search with pagination, returned as the API response payload.
|
||||||
|
|
||||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||||
restricted vault set neither distorts ``total`` nor the returned page.
|
restricted vault set neither distorts ``total`` nor the returned page.
|
||||||
The tool layer filters on its own and leaves it as ``None``.
|
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
|
from backend.search import search
|
||||||
|
|
||||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||||
if is_allowed is not None:
|
if is_allowed is not None:
|
||||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
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)
|
total = len(all_results)
|
||||||
page = all_results[offset: offset + limit]
|
page = all_results[offset: offset + limit]
|
||||||
return {
|
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]:
|
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||||
"""Return tag → count, optionally restricted to a single vault."""
|
"""Return tag → count, optionally restricted to a single vault."""
|
||||||
from backend.search import get_all_tags
|
from backend.search import get_all_tags
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.66.0"
|
version = "2.67.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.66.0"
|
version = "2.67.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.66.0",
|
"version": "2.67.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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] Scope de `GET /api/shares` ; révocation créateur/admin uniquement
|
||||||
- [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
|
- [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
|
||||||
- [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature
|
- [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>`)
|
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe
|
||||||
si un vrai push est demandé).
|
si un vrai push est demandé).
|
||||||
- La date d'« expiration » reste globale au partage (pas par destinataire).
|
- 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`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { api, AuthManager, initMfaSettings } from './auth.js';
|
import { api, AuthManager, initMfaSettings } from './auth.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { el, icon, openFile, filterSavedSearches } from './viewer.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 { escapeHtml, safeCreateIcons } from './utils.js';
|
||||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||||
import { t, setLocale, getLocale } from './i18n.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 () => {
|
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
|
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
|
||||||
|
invalidateSharedPaths();
|
||||||
showToast(t("config.share_revoked"), "success");
|
showToast(t("config.share_revoked"), "success");
|
||||||
existingShare = null;
|
existingShare = null;
|
||||||
renderContent();
|
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 }),
|
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null, shared_with: recipients }),
|
||||||
});
|
});
|
||||||
existingShare = share;
|
existingShare = share;
|
||||||
|
invalidateSharedPaths();
|
||||||
renderContent();
|
renderContent();
|
||||||
showToast(t("config.link_created"), "success");
|
showToast(t("config.link_created"), "success");
|
||||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||||
|
|||||||
@@ -911,8 +911,13 @@ export function renderSearchResults(data, query, tagFilter) {
|
|||||||
});
|
});
|
||||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||||
}
|
}
|
||||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
// #196 : document reçu par partage → ouvre la page de partage (lecture seule).
|
||||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
|
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);
|
container.appendChild(item);
|
||||||
});
|
});
|
||||||
area.appendChild(container);
|
area.appendChild(container);
|
||||||
|
|||||||
+127
-8
@@ -1,6 +1,6 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.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 { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.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 { t } from './i18n.js';
|
||||||
import { filterChatMessages as _filterChat } from './filechat.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
|
// Vault context switching
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -306,7 +347,45 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
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) => {
|
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;
|
if (!shouldDisplayPath(item.path, vaultName)) return;
|
||||||
|
|
||||||
const existing = existingItems[item.path];
|
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);
|
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : 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 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);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
scrollTreeItemIntoView(fileItem, false);
|
scrollTreeItemIntoView(fileItem, false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -629,9 +708,47 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
}
|
}
|
||||||
container.innerHTML = "";
|
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();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
data.items.forEach((item) => {
|
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
|
// Apply client-side filtering for hidden files
|
||||||
if (!shouldDisplayPath(item.path, vaultName)) {
|
if (!shouldDisplayPath(item.path, vaultName)) {
|
||||||
return; // Skip this item
|
return; // Skip this item
|
||||||
@@ -673,7 +790,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : 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);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
@@ -705,6 +823,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar filter
|
// 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 };
|
||||||
|
|||||||
@@ -1721,6 +1721,7 @@
|
|||||||
"shortcut.search_in_doc": "Search in document",
|
"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.",
|
"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.all_vaults": "All vaults",
|
||||||
|
"sidebar.shared_folder": "Shared",
|
||||||
"sidebar.case_sensitive": "Case sensitive",
|
"sidebar.case_sensitive": "Case sensitive",
|
||||||
"sidebar.clear_filter": "Clear",
|
"sidebar.clear_filter": "Clear",
|
||||||
"sidebar.expand_collapse": "Expand/Collapse",
|
"sidebar.expand_collapse": "Expand/Collapse",
|
||||||
|
|||||||
@@ -1721,6 +1721,7 @@
|
|||||||
"shortcut.search_in_doc": "Rechercher dans le document",
|
"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é.",
|
"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.all_vaults": "Tous les vaults",
|
||||||
|
"sidebar.shared_folder": "Partage",
|
||||||
"sidebar.case_sensitive": "Respecter la casse",
|
"sidebar.case_sensitive": "Respecter la casse",
|
||||||
"sidebar.clear_filter": "Effacer",
|
"sidebar.clear_filter": "Effacer",
|
||||||
"sidebar.expand_collapse": "Développer/Réduire",
|
"sidebar.expand_collapse": "Développer/Réduire",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -140,3 +140,30 @@ class TestDirectedShareGate:
|
|||||||
assert user_client.get(f"/s/{body['token']}").status_code == 200
|
assert user_client.get(f"/s/{body['token']}").status_code == 200
|
||||||
admin_client.delete(f"/api/share/{body['id']}")
|
admin_client.delete(f"/api/share/{body['id']}")
|
||||||
assert user_client.get(f"/s/{body['token']}").status_code == 404
|
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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user