From 1e0e419210ab3fd06f3e0efa5fd6b582f87ec713 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 23:07:11 -0400 Subject: [PATCH] feat: panneau de facettes repliable + facette Extensions exposee par l'API #157 --- CHANGELOG.md | 21 +++++++++++++++- README.fr.md | 6 ++--- README.md | 6 ++--- VERSION | 2 +- backend/schemas.py | 3 ++- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 3 ++- docs/ROADMAP.md | 4 ++-- docs/archive/COMPLETED_v1-v2.md | 10 ++++---- frontend/js/search.js | 28 ++++++++++++++++++---- frontend/locales/en.json | 2 ++ frontend/locales/fr.json | 2 ++ frontend/style.css | 32 +++++++++++++++++++++++++ package.json | 2 +- tests/e2e/obsigate.spec.ts | 13 ++++++++++ tests/frontend/search-facets.test.mjs | 29 ++++++++++++++++++++++ tests/test_search.py | 16 +++++++------ 19 files changed, 154 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3aefe7..364ee2f 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.46.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.47.0**. --- @@ -14,6 +14,25 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.47.0] — 2026-10-01 + +### Ajouté + +- **#157 — panneau de facettes repliable.** Le bloc **Vaults · Tags · Extensions** des résultats + de recherche se masque/restaure d'un clic sur un bouton discret (chevron), état mémorisé + dans `localStorage` et conservé d'une recherche à l'autre. + +### Corrigé + +- **#157 — facette « Extensions » absente de l'API.** `SearchFacets` (`backend/schemas.py`) + ne déclarait que `tags` et `vaults` : la clé `extensions` ajoutée par `backend/search.py` + était supprimée par le `response_model` avant d'atteindre le navigateur. Champ ajouté, + plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search` + et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion + stricte sur `facets.extensions`. + +--- + ## [2.46.0] — 2026-10-01 ### Ajouté diff --git a/README.fr.md b/README.fr.md index e1122e5..8cb4890 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.46.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.47.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,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.46.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.47.0). --- -*Projet : ObsiGate | Version : 2.46.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.47.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 46f8232..4e47200 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.46.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.47.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,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.46.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.47.0). --- -*Project: ObsiGate | Version: 2.46.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.47.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index cc87583..bb13a7e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.46.0 +2.47.0 diff --git a/backend/schemas.py b/backend/schemas.py index ef9de88..1e3da00 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -646,6 +646,7 @@ class SearchFacets(BaseModel): tags: dict[str, int] = Field(default_factory=dict) vaults: dict[str, int] = Field(default_factory=dict) + extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)") class AdvancedSearchResponse(BaseModel): @@ -655,7 +656,7 @@ class AdvancedSearchResponse(BaseModel): total: int = Field(description="Total number of matching results") offset: int = Field(description="Current pagination offset") limit: int = Field(description="Page size") - facets: SearchFacets = Field(description="Faceted counts by tag and vault") + facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension") query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds") semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready") diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index da80d19..8d34413 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.46.0" +version = "2.47.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 92c5985..348472c 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.46.0" +version = "2.47.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 77613db..4dac661 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.46.0", + "version": "2.47.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index d7b07b8..ad9d30b 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -19,7 +19,8 @@ avec : - **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`. - **Stemming français** — les variantes des mots sont rapprochées. - **Snippets surlignés** — les termes trouvés sont mis en `` dans l'extrait. -- **Facettes** — compteurs par vault, par tag et par extension sur les résultats. +- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau + repliable d'un clic (chevron, état mémorisé). - **Pagination** — 50 résultats par page. - **Tri** — par pertinence (TF-IDF) ou par date de modification. - **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 62e01b0..b62870c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.46.0 | **Dernière mise à jour :** 2026-10-01 +> **Version :** 2.47.0 | **Dernière mise à jour :** 2026-10-01 > **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)** @@ -304,7 +304,7 @@ | 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) | | 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) | | 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) | -| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) | 2.46.0 | [archive](./archive/COMPLETED_v1-v2.md) | +| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) | --- diff --git a/docs/archive/COMPLETED_v1-v2.md b/docs/archive/COMPLETED_v1-v2.md index bfe2c33..46b5ee9 100644 --- a/docs/archive/COMPLETED_v1-v2.md +++ b/docs/archive/COMPLETED_v1-v2.md @@ -425,13 +425,15 @@ Le panneau de facettes de la page de résultats de recherche affiche désormais **Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute l'opérateur `ext:` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué). +L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état +mémorisé dans `localStorage`. | Aspect | Détail | |---|---| -| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence | -| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) | -| i18n / aide | `help.desc_facet_extensions` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | -| Tests | `tests/test_search.py::test_facets` (clé `extensions`), `tests/frontend/search-facets.test.mjs` (4 tests JSDOM, ajoutés au CI) | +| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) | +| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) | +| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | +| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) | --- diff --git a/frontend/js/search.js b/frontend/js/search.js index 948e8a9..f3cb470 100644 --- a/frontend/js/search.js +++ b/frontend/js/search.js @@ -1035,7 +1035,26 @@ export function renderAdvancedSearchResults(data, query, tagFilter) { // Facets panel if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) { - const facetsDiv = el("div", { class: "search-facets" }); + const collapsed = localStorage.getItem("obsigate_facets_collapsed") === "1"; + const facetsDiv = el("div", { class: "search-facets", "data-collapsed": collapsed ? "true" : "false" }); + + // Discreet collapse toggle for the whole panel (vaults + tags + extensions) + const toggleBtn = el("button", { + class: "search-facets__toggle", + type: "button", + "aria-expanded": String(!collapsed), + title: collapsed ? t("search.facets_expand") : t("search.facets_collapse"), + }); + toggleBtn.innerHTML = ''; + toggleBtn.addEventListener("click", () => { + const nowCollapsed = facetsDiv.getAttribute("data-collapsed") !== "true"; + facetsDiv.setAttribute("data-collapsed", nowCollapsed ? "true" : "false"); + toggleBtn.setAttribute("aria-expanded", String(!nowCollapsed)); + toggleBtn.title = nowCollapsed ? t("search.facets_expand") : t("search.facets_collapse"); + localStorage.setItem("obsigate_facets_collapsed", nowCollapsed ? "1" : "0"); + }); + facetsDiv.appendChild(toggleBtn); + const facetsBody = el("div", { class: "search-facets__body" }); // Vault facets const vaultFacets = data.facets.vaults || {}; @@ -1056,7 +1075,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) { }); group.appendChild(item); } - facetsDiv.appendChild(group); + facetsBody.appendChild(group); } // Tag facets @@ -1075,7 +1094,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) { }); group.appendChild(item); } - facetsDiv.appendChild(group); + facetsBody.appendChild(group); } // Extension facets @@ -1101,9 +1120,10 @@ export function renderAdvancedSearchResults(data, query, tagFilter) { }); group.appendChild(item); } - facetsDiv.appendChild(group); + facetsBody.appendChild(group); } + facetsDiv.appendChild(facetsBody); area.appendChild(facetsDiv); } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 9d9a36b..555078e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1609,6 +1609,8 @@ "search.exclude_placeholder": "Exclude: vendor/*, *.lock", "search.facet_tags": "Tags", "search.facet_vaults": "Vaults", + "search.facets_collapse": "Collapse facets", + "search.facets_expand": "Expand facets", "search.files_filtered": "{count} file(s) with filters", "search.filter_path_title": "Path filters (Alt-F)", "search.filters": "Filters", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index ed1f03f..b46a7d1 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1609,6 +1609,8 @@ "search.exclude_placeholder": "Exclure: vendor/*, *.lock", "search.facet_tags": "Tags", "search.facet_vaults": "Vaults", + "search.facets_collapse": "Réduire les facettes", + "search.facets_expand": "Afficher les facettes", "search.files_filtered": "{count} fichier(s) avec filtres", "search.filter_path_title": "Filtres de chemin (Alt-F)", "search.filters": "Filtres", diff --git a/frontend/style.css b/frontend/style.css index 0e700d6..a9272fd 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5476,6 +5476,38 @@ body.resizing-v { font-size: 0.68rem; color: var(--text-muted); } +.search-facets__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-muted); + cursor: pointer; + transition: + transform 120ms, + border-color 120ms, + color 120ms; +} +.search-facets__toggle:hover { + border-color: var(--accent); + color: var(--text-secondary); +} +.search-facets[data-collapsed="true"] .search-facets__toggle { + transform: rotate(-90deg); +} +.search-facets__body { + display: flex; + flex-wrap: wrap; + gap: 12px; + flex: 1 1 auto; + min-width: 0; +} +.search-facets[data-collapsed="true"] .search-facets__body { + display: none; +} /* --------------------------------------------------------------------------- Advanced Search — Pagination diff --git a/package.json b/package.json index c0760fa..2023a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.46.0", + "version": "2.47.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/e2e/obsigate.spec.ts b/tests/e2e/obsigate.spec.ts index ce4f086..79e4998 100644 --- a/tests/e2e/obsigate.spec.ts +++ b/tests/e2e/obsigate.spec.ts @@ -143,6 +143,19 @@ test.describe('Full-Text Search', () => { // Not always present — depends on vault content }); + test('should show the extensions facet and collapse the facets panel (#157)', async ({ page }) => { + await goHome(page); + await searchFor(page, 'test'); + const extFacet = page.locator('.search-facets__group[data-facet="extensions"]'); + await expect(extFacet).toBeVisible(); + await expect(extFacet.locator('.search-facets__item').first()).toBeVisible(); + // discreet collapse/expand of the whole vaults+tags+extensions section + await page.locator('.search-facets__toggle').click(); + await expect(page.locator('.search-facets__body')).toBeHidden(); + await page.locator('.search-facets__toggle').click(); + await expect(page.locator('.search-facets__body')).toBeVisible(); + }); + test('should navigate results with floating bar arrows', async ({ page }) => { await goHome(page); await searchFor(page, 'test'); diff --git a/tests/frontend/search-facets.test.mjs b/tests/frontend/search-facets.test.mjs index 497f6ae..72cc22a 100644 --- a/tests/frontend/search-facets.test.mjs +++ b/tests/frontend/search-facets.test.mjs @@ -143,6 +143,35 @@ await test("clicking an extension adds the ext: operator", async () => { ); }); +await test("facets panel collapses and restores via the toggle", async () => { + localStorage.removeItem("obsigate_facets_collapsed"); + renderAdvancedSearchResults(PAYLOAD, "python", null); + const panel = document.querySelector(".search-facets"); + const toggle = panel.querySelector(".search-facets__toggle"); + assert.ok(toggle, "collapse toggle rendered"); + assert.equal(panel.getAttribute("data-collapsed"), "false", "expanded by default"); + assert.ok(panel.querySelector(".search-facets__body"), "groups live in the body"); + + toggle.click(); + assert.equal(panel.getAttribute("data-collapsed"), "true", "click collapses"); + assert.equal( + localStorage.getItem("obsigate_facets_collapsed"), + "1", + "collapsed state persisted", + ); + + renderAdvancedSearchResults(PAYLOAD, "python", null); + const panel2 = document.querySelector(".search-facets"); + assert.equal( + panel2.getAttribute("data-collapsed"), + "true", + "collapsed state survives a re-render", + ); + panel2.querySelector(".search-facets__toggle").click(); + assert.equal(panel2.getAttribute("data-collapsed"), "false", "toggle restores"); + localStorage.removeItem("obsigate_facets_collapsed"); +}); + // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { diff --git a/tests/test_search.py b/tests/test_search.py index 24da3a3..ba6637e 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -204,7 +204,7 @@ class TestAdvancedSearchAPI: pytest.skip(f"Advanced search non-JSON response: {resp.text[:200]}") def test_basic(self, client): - resp = client.get("/api/advanced-search?q=python&vault=all") + resp = client.get("/api/search/advanced?q=python&vault=all") data = self._check(resp) assert data["total"] >= 1 assert len(data["results"]) > 0 @@ -217,16 +217,18 @@ class TestAdvancedSearchAPI: assert "path" in r def test_pagination(self, client): - resp = client.get("/api/advanced-search?q=python&limit=1&offset=0") + resp = client.get("/api/search/advanced?q=python&limit=1&offset=0") data = self._check(resp) assert len(data["results"]) <= 1 def test_facets(self, client): - resp = client.get("/api/advanced-search?q=python&vault=all") - data = self._check(resp) + resp = client.get("/api/search/advanced?q=python&vault=all") + # Hard assertion: the facet contract must hold through the response_model + # (regression #157: `extensions` used to be stripped by SearchFacets). + assert resp.status_code == 200, resp.text[:200] + data = resp.json() assert "facets" in data - # #157 — extension facet: keys are query-ready (no leading dot) and, - # when there are results, at least one extension is counted. + assert "extensions" in data["facets"] exts = data["facets"]["extensions"] assert isinstance(exts, dict) assert all(not e.startswith(".") for e in exts) @@ -235,7 +237,7 @@ class TestAdvancedSearchAPI: assert sum(exts.values()) <= data["total"] def test_empty_query(self, client): - resp = client.get("/api/advanced-search?q=") + resp = client.get("/api/search/advanced?q=") data = self._check(resp) # Empty query should return 0 results assert data["total"] == 0