From a50227849de6cf8f0979835c7310c851e73c542a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 2 Oct 2026 12:27:23 -0400 Subject: [PATCH] feat: onglet de navigation avec facettes, tris et retour Home complet #158 - clic sur un repertoire de l'arbre (modes focus vault et All) ouvre un onglet de navigation dedie qui coexiste avec les onglets de fichiers ouverts - vue: sous-repertoires cliquables, facettes Vaults/Tags/Extensions (meme mecanisme que la recherche), tris Pertinence/Date, bouton Sauver; tags indexes exposes par GET /api/vault/{v}/files - BUG-100: showWelcome re-injecte la section Raccourcis & Astuces et goHome efface la recherche globale + le filtre de la sidebar --- .gitea/workflows/ci.yml | 1 + CHANGELOG.md | 29 +- README.fr.md | 8 +- README.md | 8 +- VERSION | 2 +- backend/schemas.py | 1 + backend/services/vaults.py | 24 ++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 30 +- docs/features/navigation-tab-158.md | 59 +++ frontend/js/legacy.js | 7 + frontend/js/navfacets.js | 77 ++++ frontend/js/sidebar.js | 10 +- frontend/js/ui.js | 47 +++ frontend/js/vaulthome.js | 630 ++++++++++++++++++---------- frontend/js/viewer.js | 15 + frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/style.css | 6 + package.json | 2 +- tests/frontend/navfacets.test.mjs | 86 ++++ tests/frontend/unit.test.mjs | 1 + tests/test_nav_files.py | 49 +++ 26 files changed, 870 insertions(+), 232 deletions(-) create mode 100644 docs/features/navigation-tab-158.md create mode 100644 frontend/js/navfacets.js create mode 100644 tests/frontend/navfacets.test.mjs create mode 100644 tests/test_nav_files.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9a92e62..439a452 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: - name: Frontend unit tests run: | node tests/frontend/unit.test.mjs + node tests/frontend/navfacets.test.mjs node tests/frontend/image-viewer.test.mjs node tests/frontend/pdf-viewer.test.mjs node tests/frontend/forge-completion.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b430724..0041021 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.47.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.48.0**. --- @@ -14,6 +14,33 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.48.0] — 2026-10-02 + +### Ajouté + +- **#158 — onglet de navigation.** Chaque clic sur un répertoire de l'arbre (mode focus vault + **et** mode All) ouvre un **onglet de navigation** dédié qui coexiste avec les onglets de + fichiers : la vue chemin + Récents + fichiers n'est plus écrasée par un document ouvert, et + change de répertoire en se mettant à jour dans son onglet. La ligne d'un vault reste un + simple expandeur ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette Vaults) + ou le sélecteur de vault. +- **#158 — contenu de la page de navigation.** La vue liste désormais les **sous-répertoires + cliquables** et embarque le mécanisme de la page de recherche : panneau de facettes + **Vaults · Tags · Extensions** (repli partagé, filtre local avec pastille active), tri + **Pertinence / Date** et bouton **Sauver** (enregistre le répertoire comme recherche + sauvegardée). `GET /api/vault/{vault}/files` renvoie les `tags` indexés de chaque fichier + (champ `tags` ajouté à `VaultFileEntry`) pour calculer les facettes. + +### Corrigé + +- **BUG-100 — retour Home incomplet.** Le clic sur le titre perdait la section + **Raccourcis & Astuces** (le template de reconstruction de `showWelcome()` ne la contenait + pas) et laissait la recherche globale ainsi que le filtre de la sidebar actifs : le bloc + `#quick-help` est désormais capturé puis réinjecté, et `goHome()` efface la recherche + (chips, barre de résultats) avant d'afficher l'accueil. + +--- + ## [2.47.1] — 2026-10-02 --- diff --git a/README.fr.md b/README.fr.md index 3daa6cf..b660f30 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.47.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.48.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/) @@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G - **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md)) - **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click) - **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément -- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar +- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts - **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md)) - **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier - **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels @@ -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.47.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.48.0). --- -*Projet : ObsiGate | Version : 2.47.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.48.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 10df296..f98b197 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.47.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.48.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/) @@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/): - **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md)) - **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click) - **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously -- **🌳 Tree Navigation** : Browse your folders and files in the sidebar +- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files - **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md)) - **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation - **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips @@ -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.47.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.48.0). --- -*Project: ObsiGate | Version: 2.47.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.48.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 24c6ede..9a9feb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.47.1 +2.48.0 diff --git a/backend/schemas.py b/backend/schemas.py index 1e3da00..70fdd8a 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -863,6 +863,7 @@ class VaultFileEntry(BaseModel): modified_iso: str | None = None extension: str = "" rel_dir: str | None = None + tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)") class VaultFilesResponse(BaseModel): diff --git a/backend/services/vaults.py b/backend/services/vaults.py index 2e3ad44..b448e0a 100644 --- a/backend/services/vaults.py +++ b/backend/services/vaults.py @@ -102,6 +102,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]: return {"vault": vault_name, "path": path, "items": items} +def _indexed_tags(vault_name: str, rel_path: str) -> list[str]: + """Tags of a file as stored in the search index (#158). + + Empty list when the file is not indexed yet (binary formats, index still + building) — the listing itself comes from the filesystem, tags are a + decoration used by the navigation page facets/filters. + + Args: + vault_name: Vault name. + rel_path: Path of the file relative to the vault root (``/`` separated). + + Returns: + The file's tags, or an empty list when unknown. + """ + try: + from backend.search import get_inverted_index + + info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}") + except Exception: + return [] + return list((info or {}).get("tags") or []) + + def list_all_files( vault_name: str, dir: str = "", @@ -189,6 +212,7 @@ def list_all_files( "modified": stat.st_mtime, "modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "extension": ext.lstrip(".") if ext else "", + "tags": _indexed_tags(vault_name, rel_path), } if rel_to_dir and rel_to_dir != ".": file_entry["rel_dir"] = rel_to_dir diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 5c1e057..21054a1 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.47.1" +version = "2.48.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a6d89e8..f5ac513 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.47.1" +version = "2.48.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 eecfa52..9e5e119 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.47.1", + "version": "2.48.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 159536c..ae10cc7 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -207,6 +207,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` | | *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` | | *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` | +| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -309,6 +310,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré | | 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré | | 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) | +| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1b88268..086f116 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.47.1 | **Dernière mise à jour :** 2026-10-02 +> **Version :** 2.48.0 | **Dernière mise à jour :** 2026-10-02 > **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)** @@ -137,6 +137,33 @@ --- +## ✅ Terminé — Navigation + +### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet + +- **Effort :** 1-2 jours | **Impact :** 🟡 +- **Statut :** ✅ **livré** — 2026-10-02 (A1 onglet, A2 contenu/filtres/tris, A3 retour Home + + **BUG-100**) +- **Détail de conception :** [features/navigation-tab-158.md](./features/navigation-tab-158.md) +- **Description :** la vue « chemin + Récents + fichiers du répertoire » (vault home) n'apparaît + qu'en mode focus vault, disparaît dès qu'un fichier est ouvert, n'expose ni sous-répertoires ni + filtres, et le clic sur le titre ne redonne pas la vraie page d'accueil (section + **Raccourcis & Astuces** manquante, recherche/filtres non effacés). +- **Sous-tâches :** + - [x] **A1 — onglet de navigation.** Tout clic sur un **répertoire** de l'arbre (en mode focus + vault **et** en mode All) ouvre/actualise un **onglet de navigation** dédié, qui + coexiste avec les onglets de fichiers ouverts. La ligne d'un vault dans l'arbre garde son + rôle d'expansion ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette + Vaults) ou le sélecteur de vault. + - [x] **A2 — contenu de la vue.** Liste des **sous-répertoires cliquables**, panneau de + **facettes Vaults · Tags · Extensions** (même mécanisme/visuel que la page de recherche), + boutons de tri **Pertinence / Date** et bouton **Sauver**. + - [x] **A3 — retour Home complet.** Clic sur le titre = page d'accueil d'un refresh + (section **Raccourcis & Astuces** restaurée) + effacement de la recherche globale et de + la barre de filtre de la sidebar gauche. + +--- + ## ✅ Terminé — Tableur Excel (complétude) ### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse) @@ -305,6 +332,7 @@ | 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) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) | +| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver) + retour Home complet (**BUG-100**) | 2.48.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) | --- diff --git a/docs/features/navigation-tab-158.md b/docs/features/navigation-tab-158.md new file mode 100644 index 0000000..89f1f79 --- /dev/null +++ b/docs/features/navigation-tab-158.md @@ -0,0 +1,59 @@ +# #158 — Navigation : vue répertoire en onglet, filtres & tris, retour Home complet + +> **Statut :** livré le 2026-10-02 · **ID :** `#158` (correctif associé : `BUG-100`) +> **Fichiers :** `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, +> `frontend/js/navfacets.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/legacy.js`, +> `backend/services/vaults.py`, `backend/schemas.py`, `frontend/style.css`, +> `frontend/locales/{fr,en}.json` + +## Problème + +La vue « chemin + Récents + fichiers du répertoire » (vault home) ne s'affichait qu'en mode +focus vault, disparaissait dès qu'un fichier était ouvert (aucun onglet), ne listait pas les +sous-répertoires, n'avait ni filtre ni tri, et le clic sur le titre ne rendait pas la vraie +page d'accueil. + +## Conception + +**Un onglet de navigation par vault.** `TabManager.openNav(vault, dir)` (`frontend/js/ui.js`) +crée/rétarget l'onglet `nav::` (icône dossier) ; `TabManager.activate()` branche sur +`cache.nav` et rend `showVaultHome(vault, dir)` au lieu de fetcher un fichier. Les onglets de +fichiers ne sont jamais fermés. Le dashboard appelle `TabManager.deactivate()` : aucun onglet +ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (avant : early-return +et vue figée). + +Points d'entrée des clics (`frontend/js/sidebar.js`) : clic **répertoire** dans l'arbre (2 sites, +mode focus et mode All), changement de contexte vault (sélecteur), facettes/filtres et fil +d'Ariane de la vue (`vaulthome.js` route tout par `openNav`, plus aucune délégation sur +l'arbre). La **ligne d'un vault** garde son rôle d'expansion seul : un onglet de navigation à +l'ouverture d'un vault casserait les compteurs d'onglets et « fermer le dernier onglet → +dashboard » (13 tests E2E `split-view*`). + +**Contenu de la vue** (`vaulthome.js`) : fil d'Ariane · barre **Pertinence / Date** + **Sauver** +· panneau de facettes · **Répertoires** cliquables (`GET /api/browse`) · Récents (racine) · +fichiers (`GET /api/vault/{v}/files?recursive=false`). Les facettes réutilisent les classes de +la page de recherche (`search-facets`, `search-sort`, `search-save-btn`) — même visuel, même +repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête). + +**Facettes** (`frontend/js/navfacets.js`, pur, sans DOM) : `buildNavFacets`, +`filterNavFiles`, `sortNavFiles`. Le listing embarque les tags indexés : +`GET /api/vault/{v}/files` renvoie `tags` (`_indexed_tags()` dans +`backend/services/vaults.py`, lookup `vault::path` dans l'index inversé, `[]` si non indexé). + +## Décisions / limites connues + +- **Un seul onglet de navigation par vault** : cliquer un autre répertoire re-cible le même + onglet (pas d'empilement d'onglets). En mode split, la vue s'affiche dans la zone principale. +- **« Pertinence » = tri alphabétique** (`ponytail:` commenté) : un listing de répertoire n'a + aucun signal de pertinence tant que la vue n'a pas sa propre requête — voir + `navfacets.js::sortNavFiles`. +- Facettes calculées sur la liste courante (répertoire non récursif, 200 fichiers max) : les + sous-répertoires non listés n'apportent aucune compte. + +## Tests + +- `tests/test_nav_files.py` (3) : `tags` exposés + survivent au `response_model`, tags en + sous-répertoire, miss d'index → `[]`. +- `tests/frontend/navfacets.test.mjs` (10) : comptages, filtres tag/ext (insensible au point), + combinaison ET, tris, non-mutation. +- `tests/frontend/unit.test.mjs` : `navfacets.js` ajouté à la liste des modules purs. diff --git a/frontend/js/legacy.js b/frontend/js/legacy.js index ba4e83b..6ee47df 100644 --- a/frontend/js/legacy.js +++ b/frontend/js/legacy.js @@ -288,6 +288,13 @@ function initSearch() { } function goHome() { + // #158 — a refresh-equivalent home: wipe the global search (chips, result + // bar, filters) and the sidebar filter bar before showing the dashboard. + const searchClear = document.getElementById("search-clear-btn"); + if (searchClear) searchClear.click(); + const sideClear = document.getElementById("sidebar-filter-clear-btn"); + if (sideClear) sideClear.click(); + const searchInput = document.getElementById("search-input"); if (searchInput) searchInput.value = ""; diff --git a/frontend/js/navfacets.js b/frontend/js/navfacets.js new file mode 100644 index 0000000..dd04dac --- /dev/null +++ b/frontend/js/navfacets.js @@ -0,0 +1,77 @@ +/** + * Pure helpers for the navigation page facets / filters (#158). + * + * No DOM, no imports — usable straight from Node tests. + * Mirrors the search page's facet mechanism (Vaults / Tags / Extensions) + * over a plain directory listing instead of a search result set. + */ + +/** Strip the leading dot and lowercase a file extension. */ +function normExt(extension) { + return String(extension || "").toLowerCase().replace(/^\./, ""); +} + +/** Display name used for labels and alphabetical sorting (.md removed). */ +export function navDisplayName(file) { + const name = file.name || String(file.path || "").split("/").pop() || ""; + return name.endsWith(".md") ? name.slice(0, -3) : name; +} + +/** + * Count vaults / tags / extensions over a file listing. + * @param {Array} files + * @returns {{vaults: object, tags: object, extensions: object}} counts sorted desc + */ +export function buildNavFacets(files) { + const facets = { vaults: {}, tags: {}, extensions: {} }; + for (const f of files || []) { + if (f.vault) facets.vaults[f.vault] = (facets.vaults[f.vault] || 0) + 1; + const ext = normExt(f.extension); + if (ext) facets.extensions[ext] = (facets.extensions[ext] || 0) + 1; + for (const tag of f.tags || []) { + facets.tags[tag] = (facets.tags[tag] || 0) + 1; + } + } + for (const key of Object.keys(facets)) { + facets[key] = Object.fromEntries( + Object.entries(facets[key]).sort((a, b) => b[1] - a[1]), + ); + } + return facets; +} + +/** + * Keep only the files matching the active tag / extension filters. + * @param {Array} files + * @param {{tag?: string, ext?: string}} filters + */ +export function filterNavFiles(files, filters) { + const tag = (filters && filters.tag) || ""; + const ext = normExt((filters && filters.ext) || ""); + if (!tag && !ext) return files || []; + return (files || []).filter((f) => { + if (ext && normExt(f.extension) !== ext) return false; + if (tag && !(f.tags || []).includes(tag)) return false; + return true; + }); +} + +/** + * Sort the listing: `modified` (newest first, server default) or + * `relevance` (A→Z). + * + * ponytail: a directory listing carries no relevance signal, `relevance` is an + * alphabetical stand-in — upgrade path: sort by scoped search score once the + * navigation page owns a query. + */ +export function sortNavFiles(files, mode) { + const out = (files || []).slice(); + if (mode === "relevance") { + out.sort((a, b) => + navDisplayName(a).localeCompare(navDisplayName(b), undefined, { sensitivity: "base" }), + ); + } else { + out.sort((a, b) => (b.modified || 0) - (a.modified || 0)); + } + return out; +} diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 5f81028..e34fc65 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -43,10 +43,12 @@ async function setSelectedVaultContext(vaultName, options) { loadRecentFiles(vaultName === "all" ? null : vaultName); } - showWelcome(); - // If a specific vault is selected, switch to vault home page + // Onglet de navigation (#158) si un vault précis est sélectionné, + // sinon la page d'accueil. Les onglets de fichiers restent ouverts. if (vaultName !== "all") { - import('./vaulthome.js').then(m => m.showVaultHome(vaultName, '')); + TabManager.openNav(vaultName, ""); + } else { + showWelcome(); } if (options && options.focusVault && vaultName !== "all") { await focusVaultInSidebar(vaultName); @@ -341,6 +343,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { fragment.appendChild(subContainer); dirItem.addEventListener("click", async () => { + TabManager.openNav(vaultName, item.path); scrollTreeItemIntoView(dirItem, false); if (subContainer.classList.contains("collapsed")) { if (subContainer.children.length === 0) { @@ -635,6 +638,7 @@ async function loadDirectory(vaultName, dirPath, container) { fragment.appendChild(subContainer); dirItem.addEventListener("click", async () => { + TabManager.openNav(vaultName, item.path); scrollTreeItemIntoView(dirItem, false); if (subContainer.classList.contains("collapsed")) { if (subContainer.children.length === 0) { diff --git a/frontend/js/ui.js b/frontend/js/ui.js index b70aab3..35b3183 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -2105,6 +2105,41 @@ export const TabManager = { this.activate(tabId); }, + /** Open (or retarget) the navigation tab for a vault directory (#158). + * One navigation tab per vault: clicking another directory reuses it and + * updates its directory, so open file tabs are never displaced. */ + async openNav(vault, dir = "") { + if (!vault || vault === "all") return; + dir = dir || ""; + const tabId = `nav::${vault}`; + const name = dir ? dir.split("/").pop() : vault; + + const existing = this._tabs.find(t => t.id === tabId); + if (existing) { + existing.path = dir; + existing.name = name; + this._tabCache[tabId].path = dir; + this._tabCache[tabId].title = name; + // Force a re-render when it is already the active tab + if (this._activeTabId === tabId) this._activeTabId = null; + this._renderTabs(); + this.activate(tabId); + return; + } + + this._tabs.push({ id: tabId, vault, path: dir, name, icon: "folder", nav: true }); + this._tabCache[tabId] = { vault, path: dir, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "folder", nav: true }; + this._renderTabs(); + this.activate(tabId); + }, + + /** Drop the active-tab marker — the dashboard/home page is showing. + * Keeps the tabs open; clicking one re-activates (and re-renders) it. */ + deactivate() { + this._activeTabId = null; + this._renderTabs(); + }, + /** Activate a specific tab */ async activate(tabId) { if (this._activeTabId === tabId && this._tabs.length > 0) return; @@ -2125,6 +2160,18 @@ export const TabManager = { const cache = this._tabCache[tabId]; if (!cache) return; + // Navigation tab — render the directory view instead of fetching a file + if (cache.nav) { + state.currentVault = cache.vault; + state.currentPath = cache.path || null; + const navMod = await import("./vaulthome.js"); + await navMod.showVaultHome(cache.vault, cache.path); + if (history.pushState) { + history.pushState(null, "", `#/nav/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); + } + return; + } + // Update global state state.currentVault = cache.vault; state.currentPath = cache.path; diff --git a/frontend/js/vaulthome.js b/frontend/js/vaulthome.js index f21f44b..198a7df 100644 --- a/frontend/js/vaulthome.js +++ b/frontend/js/vaulthome.js @@ -1,17 +1,19 @@ /** - * Vault Home Page module for ObsiGate. + * Vault Home / navigation page module for ObsiGate (#158). * - * When a specific vault is selected (not "All"), replaces the dashboard - * with a non-recursive file listing sorted by modification time (newest first). - * Only files from the selected directory are shown (not subdirectories) - * to keep the listing fast and lightweight. - * The listing updates automatically when browsing into subdirectories. + * The view is mounted by `TabManager.openNav(vault, dir)` as a dedicated + * **navigation tab**: it opens on every directory click (vault root included, + * vault focus mode *and* "All" mode), coexists with open file tabs, and + * re-renders when the user drills into a subdirectory or the breadcrumb. * - * The visual style mirrors the search results (.search-result-item) for - * consistency. + * Content: breadcrumb, sort/save toolbar, facets panel (Vaults · Tags · + * Extensions — same mechanism and look as the search page), subdirectory list, + * recent files (root only) and the non-recursive file listing of the current + * directory. * * Usage: - * import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js'; + * import { showVaultHome, isVaultHomeActive } from './vaulthome.js'; + * // …but in practice: TabManager.openNav(vault, dirPath) */ import { api } from './auth.js'; @@ -19,6 +21,7 @@ import { el } from './viewer.js'; import { getFileIcon, safeCreateIcons } from './utils.js'; import { TabManager } from './ui.js'; import { t, getLocale } from './i18n.js'; +import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from './navfacets.js'; // --------------------------------------------------------------------------- // State @@ -27,25 +30,35 @@ import { t, getLocale } from './i18n.js'; const state = { active: false, currentVault: null, - currentDir: '', // empty = vault root + currentDir: '', // empty = vault root + sort: 'modified', // 'modified' (Date) | 'relevance' (Pertinence) + filterTag: '', + filterExt: '', + files: [], // last listing, unfiltered + dirs: [], // subdirectories of the current directory }; +// Handles of the mounted view (only one navigation view exists at a time). +let _view = null; +let _renderSeq = 0; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- -/** Check if vault home is currently displayed. */ +/** Check if the navigation view is currently displayed. */ export function isVaultHomeActive() { return state.active; } /** - * Display the vault home page for a vault directory. + * Display the navigation view for a vault directory. + * Called by TabManager when the navigation tab is activated. * @param {string} vaultName - Vault name * @param {string} dirPath - Directory path (empty for root) */ export async function showVaultHome(vaultName, dirPath = '') { - if (vaultName === 'all' || !vaultName) { + if (!vaultName || vaultName === 'all') { state.active = false; return; } @@ -53,6 +66,9 @@ export async function showVaultHome(vaultName, dirPath = '') { state.active = true; state.currentVault = vaultName; state.currentDir = dirPath || ''; + state.filterTag = ''; + state.filterExt = ''; + state.sort = 'modified'; const area = document.getElementById('content-area'); if (!area) return; @@ -61,7 +77,7 @@ export async function showVaultHome(vaultName, dirPath = '') { const home = document.getElementById('dashboard-home'); if (home) home.remove(); - // Build vault home container + const seq = ++_renderSeq; area.innerHTML = ''; const container = el('div', { class: 'vault-home', id: 'vault-home' }); @@ -70,117 +86,399 @@ export async function showVaultHome(vaultName, dirPath = '') { // Breadcrumb renderBreadcrumb(container, vaultName, state.currentDir); + // Sort + save toolbar (same classes as the search results header) + container.appendChild(buildToolbar()); + + // Facets host (filled once the listing is loaded) + const facetsHost = el('div', { id: 'vault-home-facets' }); + container.appendChild(facetsHost); + // Results container const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' }); container.appendChild(resultsArea); - - // Show loading resultsArea.innerHTML = `

${t('vaulthome.loading')}

`; + safeCreateIcons(); - await loadAndRender(vaultName, state.currentDir, resultsArea); + _view = { container, facetsHost, resultsArea, vault: vaultName, dirs: [] }; - // Also load recent files for this vault (only at root level) - if (!state.currentDir) { - loadRecentSection(vaultName, container); - } -} - -/** - * Update the vault home directory without rebuilding the whole page. - * Called when navigating into a subdirectory. - * @param {string} vaultName - * @param {string} dirPath - */ -export async function updateVaultHomeDir(vaultName, dirPath) { - if (!state.active || state.currentVault !== vaultName) return; - - state.currentDir = dirPath || ''; - - const container = document.getElementById('vault-home'); - if (!container) return; - - // Update breadcrumb - const bc = container.querySelector('.vault-home-breadcrumb'); - if (bc) { - bc.innerHTML = ''; - buildBreadcrumbHTML(bc, vaultName, state.currentDir); - safeCreateIcons(); - } - - // Remove recent section when navigating into subdirectory - const recentSection = container.querySelector('.vault-home-recent'); - if (recentSection) recentSection.remove(); - - // Reload results - const resultsArea = document.getElementById('vault-home-results'); - if (resultsArea) { + // Listing + subdirectories + let files; + try { + const data = await api( + `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(state.currentDir)}&limit=200&recursive=false`, + ); + files = data.files || []; + } catch (err) { + if (seq !== _renderSeq) return; + const errMsg = escapeHtml( + t('vaulthome.error_loading') + ' ' + (err?.detail || err?.message || t('common.unknown')), + ); resultsArea.innerHTML = ` -
-
+
+

${errMsg}

`; - await loadAndRender(vaultName, state.currentDir, resultsArea); - } -} - -// --------------------------------------------------------------------------- -// Tree click delegation — update vault home on directory clicks -// --------------------------------------------------------------------------- - -let _treeDelegationInstalled = false; - -function _initTreeDelegation() { - if (_treeDelegationInstalled) return; - - const tree = document.getElementById('vault-tree'); - if (!tree) { - // Retry once when DOM might not be ready — use MutationObserver as fallback - const observer = new MutationObserver(() => { - const t = document.getElementById('vault-tree'); - if (t) { - observer.disconnect(); - _attachTreeListener(t); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); return; } - _attachTreeListener(tree); + try { + const browse = await api( + `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(state.currentDir)}`, + ); + state.dirs = (browse.items || []).filter((i) => i.type === 'directory'); + } catch { + state.dirs = []; // no subdirectory list — the file listing still renders + } + if (seq !== _renderSeq) return; + + state.files = files; + _view.dirs = state.dirs; + const facets = buildFacets(); + if (facets) facetsHost.appendChild(facets); + renderResults(); + + // Recent files for this vault (root level only) + if (!state.currentDir) { + loadRecentSection(vaultName, container, resultsArea); + } } -function _attachTreeListener(tree) { - if (_treeDelegationInstalled) return; - _treeDelegationInstalled = true; - - tree.addEventListener('click', (e) => { - if (!state.active) return; - - // Find the closest tree-item that is a directory - const item = e.target.closest('.tree-item'); - if (!item || !item.dataset.vault || item.dataset.path === undefined) return; - - const vault = item.dataset.vault; - const path = item.dataset.path; - - // Only react to directory clicks (items that have a child container) - const hasChildContainer = item.nextElementSibling - && item.nextElementSibling.classList.contains('tree-children'); - if (!hasChildContainer) return; - - // Update vault home dir - updateVaultHomeDir(vault, path); - }); -} - -// Initialize delegation when module loads -_initTreeDelegation(); - // --------------------------------------------------------------------------- -// Rendering +// Sort + save toolbar +// --------------------------------------------------------------------------- + +function buildToolbar() { + const header = el('div', { class: 'search-results-header' }); + + const sortDiv = el('div', { class: 'search-sort' }); + const btnRelevance = el('button', { + class: 'search-sort__btn' + (state.sort === 'relevance' ? ' active' : ''), + type: 'button', + }); + btnRelevance.textContent = 'Pertinence'; + btnRelevance.addEventListener('click', () => { + state.sort = 'relevance'; + btnRelevance.classList.add('active'); + btnDate.classList.remove('active'); + renderResults(); + }); + const btnDate = el('button', { + class: 'search-sort__btn' + (state.sort === 'modified' ? ' active' : ''), + type: 'button', + }); + btnDate.textContent = 'Date'; + btnDate.addEventListener('click', () => { + state.sort = 'modified'; + btnDate.classList.add('active'); + btnRelevance.classList.remove('active'); + renderResults(); + }); + sortDiv.appendChild(btnRelevance); + sortDiv.appendChild(btnDate); + header.appendChild(sortDiv); + + // Save the current directory as a saved search — same payload as the search + // page's "save directory" action (query vide + include_paths). + const saveBtn = el('button', { class: 'search-save-btn', type: 'button', title: t('search.save_search') }); + saveBtn.innerHTML = ' Sauver'; + saveBtn.addEventListener('click', async () => { + try { + await api('/api/saved-searches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: '', + vault: state.currentVault, + case_sensitive: false, + whole_word: false, + regex: false, + include_paths: state.currentDir ? state.currentDir + '/**' : '**', + exclude_paths: '', + }), + }); + flashSaveBtn(saveBtn, false); + import('./viewer.js').then((m) => m.loadSavedSearches()); + } catch (err) { + flashSaveBtn(saveBtn, true, err); + } + }); + header.appendChild(saveBtn); + safeCreateIcons(); + return header; +} + +/** Tiny inline feedback on the save button (no toast module dependency). */ +function flashSaveBtn(btn, isError, err) { + const original = btn.innerHTML; + if (isError) { + btn.title = String(err?.detail || err?.message || err); + } + btn.textContent = isError ? '✕' : '✓'; + setTimeout(() => { + btn.innerHTML = original; + safeCreateIcons(); + }, 1200); +} + +// --------------------------------------------------------------------------- +// Facets panel — same mechanism and visuals as the search page (#157/#158) +// --------------------------------------------------------------------------- + +function buildFacets() { + const facets = buildNavFacets(state.files); + // Parité avec la page de recherche : pas de panneau vide (listing sans fichier). + if (!Object.keys(facets.vaults).length && !Object.keys(facets.tags).length + && !Object.keys(facets.extensions).length) { + return null; + } + const collapsed = localStorage.getItem('obsigate_facets_collapsed') === '1'; + const facetsDiv = el('div', { class: 'search-facets', 'data-collapsed': collapsed ? 'true' : 'false' }); + + 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 body = el('div', { class: 'search-facets__body' }); + + // Vaults — the view is scoped to one vault; clicking the chip jumps to the + // vault root (never a dead control). + if (Object.keys(facets.vaults).length > 0) { + const group = el('div', { class: 'search-facets__group' }); + group.appendChild(facetLabel('Vaults')); + for (const [vaultName, count] of Object.entries(facets.vaults)) { + const item = facetItem(vaultName, count, false); + item.addEventListener('click', () => TabManager.openNav(vaultName, '')); + group.appendChild(item); + } + body.appendChild(group); + } + + // Tags — filter the listing client-side (toggle). + if (Object.keys(facets.tags).length > 0) { + const group = el('div', { class: 'search-facets__group' }); + group.appendChild(facetLabel('Tags')); + for (const [tagName, count] of Object.entries(facets.tags).slice(0, 12)) { + const item = facetItem('#' + tagName, count, state.filterTag === tagName); + item.addEventListener('click', () => { + state.filterTag = state.filterTag === tagName ? '' : tagName; + refreshView(); + }); + group.appendChild(item); + } + body.appendChild(group); + } + + // Extensions — filter the listing client-side (toggle). + if (Object.keys(facets.extensions).length > 0) { + const group = el('div', { class: 'search-facets__group', 'data-facet': 'extensions' }); + group.appendChild(facetLabel('Extensions')); + for (const [extName, count] of Object.entries(facets.extensions)) { + const item = facetItem('.' + extName, count, state.filterExt === extName); + item.addEventListener('click', () => { + state.filterExt = state.filterExt === extName ? '' : extName; + refreshView(); + }); + group.appendChild(item); + } + body.appendChild(group); + } + + facetsDiv.appendChild(body); + return facetsDiv; +} + +function facetLabel(text) { + const label = el('span', { class: 'search-facets__label' }); + label.textContent = text; + return label; +} + +function facetItem(label, count, active) { + // textContent: facet labels come from user data (names, tags, extensions), + // never innerHTML. + const item = el('span', { class: 'search-facets__item' + (active ? ' active' : '') }); + item.appendChild(document.createTextNode(label + ' ')); + const countEl = el('span', { class: 'facet-count' }); + countEl.textContent = String(count); + item.appendChild(countEl); + return item; +} + +/** Re-render facets + listing after a filter change (no refetch). */ +function refreshView() { + if (!_view) return; + _view.facetsHost.replaceChildren(); + const facets = buildFacets(); + if (facets) _view.facetsHost.appendChild(facets); + renderResults(); + safeCreateIcons(); +} + +// --------------------------------------------------------------------------- +// Listing: subdirectories + files +// --------------------------------------------------------------------------- + +function renderResults() { + if (!_view) return; + const container = _view.resultsArea; + container.innerHTML = ''; + + const files = filterNavFiles(state.files, { tag: state.filterTag, ext: state.filterExt }); + const isEmptyListing = state.files.length === 0; + + if (isEmptyListing && state.dirs.length === 0) { + container.innerHTML = ` +
+ +

${t('vaulthome.empty')}

+
`; + safeCreateIcons(); + return; + } + + // Header: count + directory label + const header = el('div', { class: 'vault-home-header' }); + const shown = isEmptyListing ? state.dirs.length : files.length; + const countKey = shown > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count'; + header.appendChild(el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [ + document.createTextNode(t(countKey, { count: shown })), + ])); + header.appendChild(el('span', { class: 'vault-home-dir-info' }, [ + document.createTextNode(state.currentDir ? `/${state.currentDir}` : ` ${t('common.root')}`), + ])); + container.appendChild(header); + + // Active filter chips (same badges as the search page) + if (state.filterTag || state.filterExt) { + const activeFilters = el('div', { class: 'search-results-active-tags' }); + const addChip = (label, onRemove) => { + const removeBtn = el('button', { class: 'search-results-active-tag-remove', title: label }, [ + document.createTextNode('×'), + ]); + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + onRemove(); + }); + activeFilters.appendChild(el('span', { class: 'search-results-active-tag' }, [ + document.createTextNode(label), removeBtn, + ])); + }; + if (state.filterTag) addChip('#' + state.filterTag, () => { state.filterTag = ''; refreshView(); }); + if (state.filterExt) addChip('.' + state.filterExt, () => { state.filterExt = ''; refreshView(); }); + container.appendChild(activeFilters); + } + + // Subdirectories — clickable, navigate deeper + if (state.dirs.length > 0) { + const section = el('div', { class: 'vault-home-recent vault-home-dirs' }); + const secHeader = el('div', { class: 'vault-home-recent-header' }); + secHeader.innerHTML = ` + + ${t('vaulthome.directories')}`; + section.appendChild(secHeader); + const list = el('div', { class: 'vault-home-recent-list' }); + state.dirs.forEach((d) => { + const item = el('div', { class: 'vault-home-recent-item', 'data-path': d.path }); + item.innerHTML = ` + + ${escapeHtml(d.name)} + (${d.children_count})`; + item.addEventListener('click', () => TabManager.openNav(state.currentVault, d.path)); + list.appendChild(item); + }); + section.appendChild(list); + container.appendChild(section); + } + + // File list — search-result-item style + if (files.length === 0) { + if (!isEmptyListing) { + container.appendChild(el('p', { style: 'color:var(--text-muted);margin-top:20px' }, [ + document.createTextNode(t('search.no_results')), + ])); + } + safeCreateIcons(); + return; + } + + const list = el('div', { class: 'search-results' }); + const locale = getLocale(); + sortNavFiles(files, state.sort).forEach((f) => { + list.appendChild(buildFileItem(f, locale)); + }); + container.appendChild(list); + safeCreateIcons(); +} + +function buildFileItem(f, locale) { + const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file'; + const ext = f.extension || ''; + + const titleDiv = el('div', { class: 'search-result-title' }, [ + document.createTextNode(navDisplayName(f)), + ]); + + const modDate = new Date(f.modified * 1000); + const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + const metaDiv = el('div', { class: 'search-result-vault' }, [ + document.createTextNode([dateStr, formatSize(f.size)].join(' · ')), + ]); + + const item = el('div', { + class: 'search-result-item vault-home-item', + 'data-vault': f.vault, + 'data-path': f.path, + }, [ + el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]), + el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }), + titleDiv, + metaDiv, + ]); + + // Tags — clicking one applies the tag filter (like the search page) + if (f.tags && f.tags.length > 0) { + const tagsDiv = el('div', { class: 'search-result-tags' }); + f.tags.forEach((tag) => { + const tagEl = el('span', { class: 'file-tag' }, [document.createTextNode('#' + tag)]); + tagEl.addEventListener('click', (e) => { + e.stopPropagation(); + state.filterTag = tag; + refreshView(); + }); + tagsDiv.appendChild(tagEl); + }); + item.appendChild(tagsDiv); + } + + item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path)); + item.addEventListener('dblclick', (e) => { + e.preventDefault(); + TabManager.openPersistent(f.vault, f.path); + }); + return item; +} + +// --------------------------------------------------------------------------- +// Breadcrumb // --------------------------------------------------------------------------- function renderBreadcrumb(container, vaultName, dirPath) { @@ -190,12 +488,13 @@ function renderBreadcrumb(container, vaultName, dirPath) { } function buildBreadcrumbHTML(parentEl, vaultName, dirPath) { - // Vault icon + name (root) + // Vault icon + name (root) — routed through the navigation tab so the tab + // cache and the view never drift apart. const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [ el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }), el('span', {}, [document.createTextNode(vaultName)]), ]); - vaultLink.addEventListener('click', () => updateVaultHomeDir(vaultName, '')); + vaultLink.addEventListener('click', () => TabManager.openNav(vaultName, '')); vaultLink.style.cursor = 'pointer'; parentEl.appendChild(vaultLink); @@ -205,126 +504,31 @@ function buildBreadcrumbHTML(parentEl, vaultName, dirPath) { let cumulative = ''; for (const seg of segments) { cumulative = cumulative ? cumulative + '/' + seg : seg; - const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]); - parentEl.appendChild(sep); + parentEl.appendChild(el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')])); + const target = cumulative; const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [ el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }), el('span', {}, [document.createTextNode(seg)]), ]); - segLink.addEventListener('click', () => updateVaultHomeDir(vaultName, cumulative)); + segLink.addEventListener('click', () => TabManager.openNav(vaultName, target)); segLink.style.cursor = 'pointer'; parentEl.appendChild(segLink); } } -async function loadAndRender(vaultName, dirPath, container) { - try { - const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=false`; - const data = await api(url); - - if (!data.files || data.files.length === 0) { - container.innerHTML = ` -
- -

${t('vaulthome.empty')}

-
`; - safeCreateIcons(); - return; - } - - // Header - const header = el('div', { class: 'vault-home-header' }); - const countKey = data.files.length > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count'; - const countStr = t(countKey, { count: data.files.length }); - const count = el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [ - document.createTextNode(countStr), - ]); - header.appendChild(count); - - // Directory label - const dirLabel = dirPath ? `/${dirPath}` : ` ${t('common.root')}`; - const dirInfo = el('span', { class: 'vault-home-dir-info' }, [ - document.createTextNode(dirLabel), - ]); - header.appendChild(dirInfo); - - container.innerHTML = ''; - container.appendChild(header); - - // File list — using search-result-item style - const list = el('div', { class: 'search-results' }); - const locale = getLocale(); - - data.files.forEach((f) => { - const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file'; - const displayName = f.name.endsWith('.md') ? f.name.slice(0, -3) : f.name; - const ext = f.extension || ''; - - const titleDiv = el('div', { class: 'search-result-title' }, [ - document.createTextNode(displayName), - ]); - - // Format date using current locale - const modDate = new Date(f.modified * 1000); - const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', { - year: 'numeric', month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - }); - - // Build metadata line: date + size - const metaParts = []; - metaParts.push(dateStr); - metaParts.push(formatSize(f.size)); - - const metaDiv = el('div', { class: 'search-result-vault' }, [ - document.createTextNode(metaParts.join(' · ')), - ]); - - const item = el('div', { - class: 'search-result-item vault-home-item', - 'data-vault': f.vault, - 'data-path': f.path, - }, [ - el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]), - el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }), - titleDiv, - metaDiv, - ]); - - item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path)); - item.addEventListener('dblclick', (e) => { - e.preventDefault(); - TabManager.openPersistent(f.vault, f.path); - }); - - list.appendChild(item); - }); - - container.appendChild(list); - safeCreateIcons(); - } catch (err) { - const errMsg = t('vaulthome.error_loading') + ' ' + escapeHtml(err?.detail || err?.message || t('common.unknown')); - container.innerHTML = ` -
-

${errMsg}

-
`; - } -} - // --------------------------------------------------------------------------- -// Recent files section (loaded at vault root only) +// Recent files section (root only) // --------------------------------------------------------------------------- -async function loadRecentSection(vaultName, container) { +async function loadRecentSection(vaultName, container, resultsArea) { try { const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`); const files = data.files || []; if (files.length === 0) return; + // The view may have been replaced while fetching + if (!container.isConnected) return; - // Insert recent section BEFORE the results area - const resultsArea = document.getElementById('vault-home-results'); const section = el('div', { class: 'vault-home-recent' }); - const header = el('div', { class: 'vault-home-recent-header' }); header.innerHTML = ` @@ -351,16 +555,14 @@ async function loadRecentSection(vaultName, container) { }); list.appendChild(item); }); - section.appendChild(list); // Insert before the results area - if (resultsArea) { + if (resultsArea && resultsArea.parentNode === container) { container.insertBefore(section, resultsArea); } else { container.appendChild(section); } - safeCreateIcons(); } catch { // Silently skip if recent files can't be loaded diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 2e19ef8..a66081a 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -4120,9 +4120,23 @@ export function highlightSearchText(container, text, query, caseSensitive) { } } +// #158 — the static « Raccourcis & Astuces » block lives in index.html inside +// #dashboard-panel-stats; showWelcome() rebuilds #dashboard-home from a +// template that omits it, so the block disappeared after the first rebuild. +// Capture it once, at module load (before any rebuild can destroy it). +const QUICK_HELP_HTML = (() => { + if (typeof document === "undefined") return ""; + const qh = document.getElementById("quick-help"); + return qh ? qh.outerHTML : ""; +})(); + export function showWelcome() { hideProgressBar(); + // #158 — the dashboard takes over the content area: drop the active-tab + // marker so clicking a tab re-renders it instead of early-returning. + if (window.TabManager && window.TabManager.deactivate) window.TabManager.deactivate(); + // Restore or rebuild the dashboard with tabbed sections const area = getContentArea(); // #110 — keep playing media alive if the dashboard replaces its surface. @@ -4154,6 +4168,7 @@ export function showWelcome() {
${t('common.loading')}
+ ${QUICK_HELP_HTML} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 555078e..9e51ed2 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1806,6 +1806,7 @@ "vault.add_dynamic": "Add vault", "vault.reindex": "Reindex vault", "vault.remove_dynamic": "Remove vault", + "vaulthome.directories": "Folders", "vaulthome.empty": "No files in this directory", "vaulthome.error_loading": "Loading error:", "vaulthome.file_count": "{count} file", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b46a7d1..b8f9cc5 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1806,6 +1806,7 @@ "vault.add_dynamic": "Ajouter une vault", "vault.reindex": "Réindexer la vault", "vault.remove_dynamic": "Retirer la vault", + "vaulthome.directories": "Répertoires", "vaulthome.empty": "Aucun fichier dans ce repertoire", "vaulthome.error_loading": "Erreur de chargement:", "vaulthome.file_count": "{count} fichier", diff --git a/frontend/style.css b/frontend/style.css index b283152..75e869f 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5472,6 +5472,12 @@ body.resizing-v { border-color: var(--accent); background: var(--bg-hover); } +/* Facette actuellement appliquée (filtre local — recherche et navigation) */ +.search-facets__item.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--text-primary); +} .search-facets__item .facet-count { font-size: 0.68rem; color: var(--text-muted); diff --git a/package.json b/package.json index 6c466f8..1695f55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.47.1", + "version": "2.48.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/frontend/navfacets.test.mjs b/tests/frontend/navfacets.test.mjs new file mode 100644 index 0000000..fdc00be --- /dev/null +++ b/tests/frontend/navfacets.test.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * ObsiGate — pure tests for the navigation page facets/filters (#158): + * `frontend/js/navfacets.js` (no DOM, real module import). + * + * Usage: node tests/frontend/navfacets.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from "../../frontend/js/navfacets.js"; + +const files = [ + { name: "note2.md", path: "note2.md", vault: "V1", extension: "md", modified: 100, tags: ["docker"] }, + { name: "note1.md", path: "note1.md", vault: "V1", extension: "md", modified: 300, tags: ["python", "docker"] }, + { name: "photo.png", path: "sub/photo.png", vault: "V1", extension: ".PNG", modified: 200, tags: [] }, + { name: "sans-titre.md", path: "sans-titre.md", vault: "V1", extension: "md", modified: 400 }, +]; + +let pass = 0; +function test(name, fn) { + try { + fn(); + pass++; + console.log(` ✓ ${name}`); + } catch (err) { + console.error(` ✗ ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +test("buildNavFacets counts vaults/tags/extensions, sorted desc", () => { + const f = buildNavFacets(files); + assert.deepStrictEqual(f.vaults, { V1: 4 }); + assert.deepStrictEqual(f.tags, { docker: 2, python: 1 }); + // leading dot stripped + lowercased, untagged file contributes nothing + assert.deepStrictEqual(f.extensions, { md: 3, png: 1 }); +}); + +test("buildNavFacets tolerates empty/missing input", () => { + assert.deepStrictEqual(buildNavFacets([]), { vaults: {}, tags: {}, extensions: {} }); + assert.deepStrictEqual(buildNavFacets(undefined), { vaults: {}, tags: {}, extensions: {} }); +}); + +test("filterNavFiles: no filter returns the input untouched", () => { + assert.strictEqual(filterNavFiles(files, {}), files); + assert.strictEqual(filterNavFiles(files, { tag: "", ext: "" }), files); +}); + +test("filterNavFiles: tag filter keeps files carrying the tag", () => { + const out = filterNavFiles(files, { tag: "docker" }); + assert.deepStrictEqual(out.map((f) => f.name).sort(), ["note1.md", "note2.md"]); +}); + +test("filterNavFiles: extension filter is dot-insensitive", () => { + assert.deepStrictEqual(filterNavFiles(files, { ext: "png" }).map((f) => f.name), ["photo.png"]); + assert.deepStrictEqual(filterNavFiles(files, { ext: ".png" }).map((f) => f.name), ["photo.png"]); +}); + +test("filterNavFiles: tag + ext combine (AND) and can empty the list", () => { + assert.deepStrictEqual(filterNavFiles(files, { tag: "docker", ext: "md" }).map((f) => f.name).sort(), ["note1.md", "note2.md"]); + assert.deepStrictEqual(filterNavFiles(files, { tag: "python", ext: "png" }), []); +}); + +test("sortNavFiles 'modified' = newest first", () => { + assert.deepStrictEqual(sortNavFiles(files, "modified").map((f) => f.modified), [400, 300, 200, 100]); +}); + +test("sortNavFiles 'relevance' = alphabetical, .md stripped, case-insensitive", () => { + const names = sortNavFiles(files, "relevance").map(navDisplayName); + assert.deepStrictEqual(names, ["note1", "note2", "photo.png", "sans-titre"]); +}); + +test("sortNavFiles does not mutate its input", () => { + const before = files.map((f) => f.name); + sortNavFiles(files, "relevance"); + sortNavFiles(files, "modified"); + assert.deepStrictEqual(files.map((f) => f.name), before); +}); + +test("navDisplayName strips only a trailing .md", () => { + assert.strictEqual(navDisplayName({ name: "a.md" }), "a"); + assert.strictEqual(navDisplayName({ name: "a.markdown" }), "a.markdown"); + assert.strictEqual(navDisplayName({ path: "dir/b.md" }), "b"); +}); + +console.log(`\n${pass} passed`); diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs index dd0e85c..56d73f8 100644 --- a/tests/frontend/unit.test.mjs +++ b/tests/frontend/unit.test.mjs @@ -109,6 +109,7 @@ function testModulesHaveImports() { { file: 'pane-manager.js', reason: 'standalone — no deps' }, { file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' }, { file: 'themes.js', reason: 'standalone — no deps' }, + { file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' }, { file: 'sidebar_raw.js', reason: 'legacy file — excluded' }, { file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' }, ]; diff --git a/tests/test_nav_files.py b/tests/test_nav_files.py new file mode 100644 index 0000000..7cccaea --- /dev/null +++ b/tests/test_nav_files.py @@ -0,0 +1,49 @@ +# tests/test_nav_files.py — Navigation page listing (#158) +# +# The vault home / navigation page lists a directory with its indexed tags so +# the Vaults · Tags · Extensions facets can filter the listing client-side. +# The tags must survive the endpoint's response_model. +from __future__ import annotations + + +def test_vault_files_expose_indexed_tags(client): + """Every entry carries a `tags` list; markdown tags come from the index.""" + resp = client.get("/api/vault/TestVault/files", params={"recursive": False}) + assert resp.status_code == 200 + + data = resp.json() + files = data["files"] + assert files, "expected a non-empty listing" + + # The field is always present (response_model must not strip it) + assert all("tags" in f for f in files) + + by_name = {f["name"]: f for f in files} + assert "note1.md" in by_name + assert sorted(by_name["note1.md"]["tags"]) == ["python", "tutorial"] + + # Unindexed / tagless files keep an empty list, never null + assert sorted(by_name["note2.md"]["tags"]) == ["devops", "docker"] + for f in files: + assert isinstance(f["tags"], list) + + +def test_vault_files_tags_in_subdirectory(client): + """Tags are resolved per directory listing, not only at the vault root.""" + resp = client.get( + "/api/vault/TestVault/files", + params={"dir": "Projets", "recursive": False}, + ) + assert resp.status_code == 200 + + files = resp.json()["files"] + by_name = {f["name"]: f for f in files} + assert "projet.md" in by_name + assert sorted(by_name["projet.md"]["tags"]) == ["projet", "python"] + + +def test_indexed_tags_unknown_vault_returns_empty(): + """Index miss (unknown vault/file) degrades to an empty tag list.""" + from backend.services.vaults import _indexed_tags + + assert _indexed_tags("NoSuchVault", "ghost.md") == []