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