From 2ebfe7445978ff5e1d138152594f6df09107bf1a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 14 Oct 2026 16:56:53 -0400 Subject: [PATCH] =?UTF-8?q?perf:=20suppression=20de=20r=C3=83=C2=A9pertoir?= =?UTF-8?q?e=20incr=C3=83=C2=A9mentale=20au=20lieu=20du=20re-scan=20comple?= =?UTF-8?q?t=20#BUG-113?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 +++++++++++++- README.fr.md | 6 ++--- README.md | 6 ++--- VERSION | 2 +- backend/indexer.py | 46 ++++++++++++++++++++++++++++++++++ backend/routers/files_write.py | 7 +++--- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 ++ docs/ROADMAP.md | 2 +- package.json | 2 +- tests/test_api_main.py | 36 ++++++++++++++++++++++++++ 13 files changed, 118 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc9af6..0b7533c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.68.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.68.3**. --- @@ -14,6 +14,23 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.68.3] — 2026-10-14 + +### Amélioré + +- **BUG-113 — Suppression de répertoire rapide** : `DELETE /api/directory/{vault}` + re-scannait la vault entière et reconstruisait l'index inversé global après le + `rmtree` (`reload_single_vault`) — le temps croissait avec toute la vault, pas + avec le dossier supprimé. Nouveau chemin incrémental + (`remove_directory_from_index`) qui retire uniquement les fichiers du + sous-arbre (index, paths, index inversé et sémantique, dossiers fantômes + purgés). Mesuré sur l'instance E2E : 60 fichiers supprimés en **63 ms** au + lieu de **668 ms** (~10×, écart qui grandit avec la taille du home-user). + Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans + paths ni dans la recherche plein-texte après suppression). + +--- + ## [2.68.2] — 2026-10-14 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 66daf72..d7b69b1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.68.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.68.3-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.68.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.68.3). --- -*Projet : ObsiGate | Version : 2.68.2 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.68.3 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index cb38ff6..256109e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.68.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.68.3-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.68.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.68.3). --- -*Project: ObsiGate | Version: 2.68.2 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.68.3 | Last updated: September 2026* diff --git a/VERSION b/VERSION index b2e0ff6..879bd74 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.68.2 +2.68.3 diff --git a/backend/indexer.py b/backend/indexer.py index 504c9d3..d5ae560 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -1312,6 +1312,52 @@ async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict return await update_single_file(vault_name, dest_abs) +async def remove_directory_from_index(vault_name: str, dir_rel_path: str) -> int: + """Remove a directory subtree from the index without a full vault re-scan. + + Incremental equivalent of ``reload_single_vault`` for deletions : every + indexed file under *dir_rel_path* is dropped via + ``_remove_file_from_structures`` (which notifies the inverted and semantic + indexes through the change hook), and the subtree's directory entries are + purged from ``path_index``. Returns the number of files removed. + + Used by :func:`api_directory_delete` — previously a ``rmtree`` was followed + by a full vault re-scan + global inverted-index rebuild, which is linear in + the whole vault size on every single directory deletion. + + Args: + vault_name: Name of the vault. + dir_rel_path: Directory path relative to the vault root. + + Returns: + Number of indexed files removed. + """ + vault_data = index.get(vault_name) + if not vault_data: + return 0 + + prefix = dir_rel_path.rstrip("/") + "/" + targets = [f["path"] for f in vault_data.get("files", []) if f["path"].startswith(prefix)] + + lock = _get_async_lock() + async with lock: + removed = 0 + for rel_path in targets: + if _remove_file_from_structures(vault_name, rel_path): + removed += 1 + # Purge the subtree's directory entries too, otherwise empty dirs stay + # in path_index as ghosts (file entries were removed per-file above). + if vault_name in path_index: + path_index[vault_name] = [ + p for p in path_index[vault_name] + if not (p["path"] == dir_rel_path or p["path"].startswith(prefix)) + ] + + if removed: + logger.info(f"Directory removed from index: {vault_name}/{dir_rel_path} ({removed} files)") + return removed + + async def remove_vault_from_index(vault_name: str): """Remove an entire vault from the index. diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py index 2bc7a25..ab2a3d4 100644 --- a/backend/routers/files_write.py +++ b/backend/routers/files_write.py @@ -532,9 +532,10 @@ async def api_directory_delete( result = service_delete_directory(vault_name, path, recursive=True) file_count = result["deleted_count"] - # Update index - from backend.indexer import reload_single_vault - await reload_single_vault(vault_name) + # Update index — incremental subtree removal (no full vault re-scan, no + # global inverted-index rebuild). + from backend.indexer import remove_directory_from_index + await remove_directory_from_index(vault_name, result["path"]) # Broadcast SSE event await sse_manager.broadcast("directory_deleted", { diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index b166cc0..08057ce 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.68.2" +version = "2.68.3" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 259193b..750d9dd 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.68.2" +version = "2.68.3" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index bc8a3fd..2a1e80c 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.68.2", + "version": "2.68.3", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 92badc7..a50c009 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -220,6 +220,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles | | *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | | *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | +| *BUG-113* | Perf : suppression d'un répertoire dans la vault home-user très lente — le temps croît avec la vault entière, pas avec le dossier supprimé | 🟢 corrigé | P1 | ⚙️ backend / perf | IA | `backend/routers/files_write.py` (`api_directory_delete`), `backend/indexer.py` (`remove_directory_from_index` nouveau) | Supprimer un gros répertoire du home-user → l'API met des secondes (re-scan complet de la vault + rebuild de l'index inversé global) | `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan de TOUTE la vault + `init_inverted_index()` global. Remplacé par `remove_directory_from_index` : retire du sous-arbre uniquement (fichiers + entrées dossiers de `path_index`, notification incrémentale de l'index inversé et sémantique via le hook existant) | Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant (~10×) ; aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression (`test_delete_directory_subtree_clears_index`) ; 232 tests verts, ruff 0, mypy 0 | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -347,6 +348,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `` remplacé) | ✅ livré (en attente vérif utilisateur) | | 2026-10-14 | BUG-112 | Correction | `scripts/e2e-server.ps1`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **BUG-112 — `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine jamais.** Constat : le démarrage n'est pas lent (~7 s, READY, health 200, 137 fichiers indexés) — c'est le pipe qui ne reçoit jamais d'EOF. Preuve : le pipeline survit 494 s+ puis se termine en <2 s dès qu'uvicorn est tué. Cause : `Start-Process` laisse l'enfant hériter des handles stdout/stderr du pwsh parent ; le serveur (immortel par design) maintient le pipe ouvert → `head` attend un EOF qui n'arrivera jamais. Correctif : `Win32_Process.Create` (WMI) lance uvicorn détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs). Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-10-14 | BUG-113 | Perf | `backend/indexer.py`, `backend/routers/files_write.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-113 — suppression de répertoire ~10× plus rapide.** Constat : dans le home-user, supprimer un répertoire prenait des secondes — le temps croissait avec la **vault entière**, pas avec le dossier supprimé. Cause : `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan complet de la vault + rebuild de l'index inversé **global** (toutes les vaults). Correctif : `remove_directory_from_index` (indexer.py) retire uniquement les fichiers du sous-arbre (files, tags, `_file_lookup`, `path_index` avec purge des dossiers fantômes) et notifie l'index inversé + sémantique **incrémentalement** via le hook existant — le même chemin que le watcher pour une suppression de fichier. Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant. Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression). Vérifié : 232 passed / 1 skip (api_main + indexer_advanced + tools_mutations + search), ruff 0, mypy 0 | 🟢 corrigé (en attente vérif utilisateur) | | 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 006d2ec..a8d40c7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.68.2 | **Dernière mise à jour :** 2026-10-14 +> **Version :** 2.68.3 | **Dernière mise à jour :** 2026-10-14 > **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)** diff --git a/package.json b/package.json index 680c086..3e9f28e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.68.2", + "version": "2.68.3", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_api_main.py b/tests/test_api_main.py index dcfdf54..145f9af 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -340,6 +340,42 @@ class TestDirectoryCRUD: data = resp.json() assert data["success"] is True + def test_delete_directory_subtree_clears_index(self, client): + """BUG-113 : suppression incrémentale — aucun fantôme dans paths ni + dans l'index inversé, sans re-scan complet de la vault.""" + # Create files inside the directory + for name in ("alpha.md", "nested/beta.md"): + r = client.post("/api/file/TestVault", json={ + "path": f"Projets/{name}", + "content": f"contenu unique {name}", + }) + assert r.status_code == 200 + + # Indexed before deletion + paths_before = client.get("/api/vault/TestVault/paths").json()["results"] + assert any(p["path"] == "Projets/alpha.md" for p in paths_before) + hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json() + results = hit.get("results", hit if isinstance(hit, list) else []) + assert any(r["path"] == "Projets/alpha.md" for r in results) + + # Delete the whole directory + resp = client.delete("/api/directory/TestVault", params={"path": "Projets"}) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["deleted_count"] >= 2 + + # No ghost in flat paths + paths_after = client.get("/api/vault/TestVault/paths").json()["results"] + ghost_dirs = [p["path"] for p in paths_after if p["path"].startswith("Projets")] + assert ghost_dirs == [], f"fantômes dans paths : {ghost_dirs}" + + # No ghost in inverted index (full-text search) + hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json() + results = hit.get("results", hit if isinstance(hit, list) else []) + assert all(r["path"] != "Projets/alpha.md" for r in results) + assert all(r["path"] != "Projets/nested/beta.md" for r in results) + # ═══════════════════════════════════════════════════════════════════ # Search & Tags (supplement existing tests)