Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d4f43a8bf | ||
|
|
4231f2e929 | ||
|
|
8f26a418a9 |
@@ -47,6 +47,7 @@ jobs:
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -62,6 +63,7 @@ jobs:
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
|
||||
+37
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.5.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.6.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,42 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.6.1] — 2026-09-16
|
||||
|
||||
---
|
||||
|
||||
## [2.6.0] — 2026-09-16
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes » (#99)** : la barre
|
||||
de recherche de la sidebar agit désormais sur les onglets **Récents**
|
||||
(`filterRecentFiles`, filtrage titre/chemin/vault/aperçu/tags) et **Sauvegardes**
|
||||
(`filterSavedSearches`, cumulable avec les pills Tous/Recherches/Répertoires) —
|
||||
insensible à la casse et aux accents, avec message d'absence de résultat et
|
||||
placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`). Le routage de
|
||||
`initSidebarFilter` est unifié (`routeFilter`/`routeClear`) pour couvrir les cinq
|
||||
onglets. Fiche : [docs/features/sidebar-filters.md](./docs/features/sidebar-filters.md).
|
||||
- **Assistant IA — Deep Research en pastille (#100)** : « Deep Research » ajoute
|
||||
désormais une **pastille** (comme les skills) au lieu d'écrire la directive dans la
|
||||
zone de saisie ; le mode Agent est activé et la directive est injectée au moment de
|
||||
l'envoi, sans polluer le message affiché.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-049 — icône du bouton « + » de l'assistant invisible** : la règle générique
|
||||
`.bookslm-input-area button` écrasait `.bookslm-btn-plus` (`padding: 8px 16px` sur une
|
||||
largeur de 32 px ⇒ largeur de contenu nulle ⇒ SVG à 0 px). Sélecteur porté à
|
||||
`.bookslm-input-area button.bookslm-btn-plus`, l'icône `plus` est de nouveau visible
|
||||
(vérifié en navigateur : SVG 0 px → 18 px). Tests : `tests/frontend/ai.test.mjs` (+1),
|
||||
`tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests).
|
||||
|
||||
---
|
||||
|
||||
## [2.5.2] — 2026-09-16
|
||||
|
||||
---
|
||||
|
||||
## [2.5.1] — 2026-09-16
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -916,8 +916,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.5.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.6.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.5.1 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.6.1 | Dernière mise à jour : Juin 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1085,8 +1085,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.5.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.6.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.5.1 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.6.1 | Last updated: May 2026*
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.5.1"
|
||||
version = "2.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.5.1"
|
||||
version = "2.6.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -205,6 +205,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| 2026-09-16 | #94, #95, #96, #97 | Feature | `backend/ai_history.py` (nouveau), `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-assistant-history.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **Assistant IA** : #95 historique **permanent** (backend `data/ai_history/{user}.json`, cap 200, endpoints CRUD `/api/ai/bookslm/history[…]` résumés/full, sync frontend debounced 600 ms + repli localStorage + migration des clés legacy `bookslm-sessions-*`/`bookslm-history-*`, événement `bookslm:history-updated`) ; #96 onglet sidebar `#sidebar-tab-ai` (`messages-square`) + panneau `#sidebar-panel-ai` (liste chronologique, ouverture via `openWithSession`) ; #97 bouton **« + »** remplaçant « Attach an image » + panneau modulaire `.bookslm-ext-menu` (registre `_extensions` : Fichiers, Image, Contextes, Skills, Deep Research = mode agent + prompt, Recherche web & Canva en « Bientôt ») ; #94 bouton d'envoi circulaire + icône Lucide `arrow-up`. Vérifié : pytest 1088 passed / 6 skipped, ruff 0, mypy 0 (71 fichiers), tests frontend IA 84/84, unit 9/9, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-16 | BUG-048, #98 | Correction + feature | `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/ai-sidebar.test.mjs` (nouveau), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-048** : les entrées « Contextes » et « Skills » du menu « + » ouvraient bien leur menu (`@` / `/`), mais le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone → menu jamais affiché ; correction par `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu`. **#98** : la barre de filtrage de la sidebar agit désormais sur l'onglet « Historique IA » — `filterAIHistory()` (config.js) filtre par titre, aperçu, répertoire, contexte ou libellé de mode, insensible casse/accents (`_aiNorm`), cache sessions `_aiSessionsCache`, message « aucune correspondance » (`bookslm.history_no_match`) dans la liste et placeholder dédié (`sidebar.filter_ai`) ; `initSidebarFilter` (sidebar.js) route saisie/touche casse/bouton « × » vers `filterAIHistory` quand l'onglet IA est actif ; chaque entrée du panneau « + » porte l'icône Lucide `plus`. Vérifié : tests frontend IA 87/87 (+3), nouvelle suite `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, pytest / ruff / mypy inchangés (aucune modification backend). | 🟢 corrigé (en attente vérif utilisateur) — CI Gitea verte (lint, test, security, build, e2e) pour v2.5.0 (run #1511) |
|
||||
| 2026-09-16 | BUG-049, #99, #100 | Correction + feature | `frontend/style.css`, `frontend/js/config.js`, `frontend/js/viewer.js`, `frontend/js/sidebar.js`, `frontend/js/bookslm.js`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/sidebar-filters.test.mjs` (nouveau), `docs/features/sidebar-filters.md` (nouvelle), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-049** : icône du bouton « + » de l'assistant invisible — la règle générique `.bookslm-input-area button` (spécificité supérieure) imposait `padding: 8px 16px` sur un bouton `width: 32px` ⇒ largeur de contenu nulle ⇒ SVG `width: 0px` ; sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (+ `:hover`), vérifié en navigateur (Playwright : SVG 0 px → 18 px). **#99** : la barre de filtrage de la sidebar agit désormais sur les vues **Récents** (`filterRecentFiles`, titre/chemin/vault/aperçu/tags) et **Sauvegardes** (`filterSavedSearches`, cumulable avec les pills type), insensible casse/accents (`_sidebarNorm`/`_savedNorm`), message d'absence de résultat (`sidebar.no_results`) et placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`) ; `initSidebarFilter` refactoré en `routeFilter`/`routeClear` couvrant les 5 onglets. **#100** : « Deep Research » ajoute une **pastille** `.bookslm-chip-deep-research` (au lieu d'injecter la directive dans le composeur), active le mode Agent et injecte la directive au moment de l'envoi. Vérifié : tests frontend IA 88/88 (+1), `sidebar-filters` 8/8 (nouveau), `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, vérification navigateur du bouton « + » ; backend inchangé (pytest / ruff / mypy valides). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.5.1 | **Dernière mise à jour :** 2026-09-16
|
||||
> **Version :** 2.6.1 | **Dernière mise à jour :** 2026-09-16
|
||||
> **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)**
|
||||
@@ -195,6 +195,8 @@
|
||||
| 96 | Assistant IA — Accès rapide à l'historique depuis la sidebar de navigation | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||
| 97 | Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills…) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||
| 98 | Assistant IA — Filtre de recherche dans la sidebar « Historique IA » | 2.5.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||
| 99 | Sidebar — Filtrage des vues « Récents » et « Sauvegardes » | 2.6.0 | [features/sidebar-filters.md](./features/sidebar-filters.md) |
|
||||
| 100 | Assistant IA — Deep Research en pastille (au lieu du texte injecté) | 2.6.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -202,7 +204,7 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–98 | ~113 jours réalisés |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100 | ~114 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||
| ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours |
|
||||
|
||||
@@ -141,4 +141,37 @@ bien l'icône Lucide `plus` (vérifié par test JSDOM).
|
||||
- `MAX_SESSIONS = 200` : le comportement de purge est testé (`TestAIGatewayHistoryStore`)
|
||||
; la rétention configurable (item #95 du backlog) pourra s'appuyer sur ce plafond.
|
||||
- Les modules web/Canva du panneau « + » sont volontairement désactivés tant que
|
||||
l'écosystème d'outils phase 2 (#92) n'est pas livré.
|
||||
l'écosystème d'outils phase 2 (#92) n'est pas livré.
|
||||
|
||||
## I. Complément — icône « + » et pastille Deep Research (BUG-049, #100) — ✅ livré
|
||||
|
||||
*(1) **BUG-049 — l'icône du bouton « + » n'était pas visible.*** Le SVG Lucide était
|
||||
bien rendu, mais la règle générique `.bookslm-input-area button { padding: 8px 16px;
|
||||
background: var(--accent); color:#fff }` l'emportait en **spécificité** sur
|
||||
`.bookslm-btn-plus` (une classe seule). Le bouton conservait `width:32px` avec
|
||||
`padding: 8px 16px` → **largeur de contenu = 0 px**, donc SVG à `width: 0px`
|
||||
(invisible). Correctif CSS : sélecteur porté à
|
||||
`.bookslm-input-area button.bookslm-btn-plus` (et `:hover`), qui reprend la main
|
||||
(`padding:0`, fond transparent, couleur `--text-secondary`). Vérifié en navigateur
|
||||
(Playwright) : `svgWidth` passe de `0px` à `18px`.
|
||||
|
||||
*(2) **#100 — Deep Research devient une pastille (comme les skills).*** Auparavant,
|
||||
cliquer sur « Deep Research » injectait la directive de recherche dans la zone de
|
||||
saisie. Désormais :
|
||||
|
||||
- `_startDeepResearch()` active le **mode Agent** (si nécessaire), positionne le drapeau
|
||||
`_activeDeepResearch` et rend une **pastille** `.bookslm-chip-deep-research`
|
||||
(`_renderAttachments()`), sans rien écrire dans le composeur.
|
||||
- La pastille se retire via son « × » (comme les chips skills/fichiers) et remet le
|
||||
drapeau à `false`.
|
||||
- La directive (`bookslm.deep_research_prompt`) est injectée **au moment de l'envoi**
|
||||
dans le `message` du payload (`_sendMessage()`), sans polluer le message affiché à
|
||||
l'utilisateur.
|
||||
- Message d'information mis à jour (`bookslm.deep_research_started`) : « Deep Research
|
||||
activé — ajoutez votre question puis envoyez. »
|
||||
|
||||
### Tests du complément
|
||||
- `tests/frontend/ai.test.mjs` (+1) : le clic sur « Deep Research » ajoute une pastille,
|
||||
laisse le composeur vide, positionne le drapeau, et le retrait de la pastille remet
|
||||
le drapeau à `false`.
|
||||
- Vérification navigateur du bouton « + » (Playwright, instance de test).
|
||||
@@ -0,0 +1,72 @@
|
||||
# #99 — Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes »
|
||||
|
||||
> **Statut :** ✅ livré · **Version :** 2.6.0 · **ID :** #99
|
||||
> **Lié :** BUG-049 (icône du bouton « + » de l'assistant), #100 (pastille Deep Research).
|
||||
|
||||
## Contexte
|
||||
|
||||
La barre de recherche/filtrage de la sidebar (`#sidebar-filter-input`) agissait
|
||||
historiquement sur les onglets **Fichiers** (arborescence) et **Tags**. L'onglet
|
||||
**Historique IA** a reçu son propre filtrage en #98. Les onglets **Récents** et
|
||||
**Sauvegardes** (« saved searches ») n'étaient pas couverts : taper dans la barre
|
||||
n'avait aucun effet dans ces deux vues.
|
||||
|
||||
## A. Onglet « Récents » — ✅ livré
|
||||
|
||||
- Nouveau cache module `_recentQuery` et fonction exportée `filterRecentFiles(query)`
|
||||
(`frontend/js/config.js`).
|
||||
- `_applyRecentFilter(files)` filtre le cache `_recentFilesCache` sur **titre, chemin,
|
||||
vault, aperçu et tags**, via `_sidebarNorm()` (normalisation `NFD` + suppression des
|
||||
diacritiques + minuscules) — donc insensible à la casse **et** aux accents.
|
||||
- `loadRecentFiles()` applique désormais le filtre courant après chaque chargement.
|
||||
- Aucun résultat → un message `sidebar.no_results` est inséré dans `#recent-list`
|
||||
(classe `.sidebar-filter-empty`, style existant).
|
||||
- `switchSidebarTab("recent")` applique la requête courante et pose le placeholder
|
||||
`sidebar.filter_recent`.
|
||||
|
||||
## B. Onglet « Sauvegardes » — ✅ livré
|
||||
|
||||
- Nouveau `_savedQuery` et fonction exportée `filterSavedSearches(query)`
|
||||
(`frontend/js/viewer.js`).
|
||||
- `_applySavedFilter()` combine désormais **le filtre de type** (pills Tous / Recherches
|
||||
/ Répertoires) **et la requête texte** : un élément est visible si son `data-type`
|
||||
correspond au pill **et** si son texte (requête, vault, chemins inclus/exclus) contient
|
||||
la requête normalisée (`_savedNorm`).
|
||||
- Aucun résultat avec une requête active → message `sidebar.no_results` ajouté à
|
||||
`#saved-searches-list` (nettoyé à chaque ré-application pour ne pas s'accumuler).
|
||||
- `switchSidebarTab("saved")` ré-applique la requête et pose le placeholder
|
||||
`sidebar.filter_saved`.
|
||||
|
||||
## C. Routage unifié de la barre de filtrage — ✅ livré
|
||||
|
||||
`initSidebarFilter()` (`frontend/js/sidebar.js`) est refactorisé autour de deux
|
||||
fonctions `routeFilter(q)` / `routeClear()` qui dirigent la saisie, la bascule
|
||||
casse (`Aa`) et le bouton « × » vers le filtre de l'onglet actif :
|
||||
|
||||
| Onglet | Filtre |
|
||||
|---|---|
|
||||
| `vaults` | `performTreeSearch` / `restoreSidebarTree` |
|
||||
| `recent` | `filterRecentFiles` |
|
||||
| `saved` | `filterSavedSearches` |
|
||||
| `ai` | `filterAIHistory` |
|
||||
| `tags` | `filterTagCloud` |
|
||||
|
||||
## D. i18n — ✅ livré
|
||||
|
||||
- `sidebar.filter_recent` : « Filtrer les fichiers récents... » / "Filter recent files..."
|
||||
- `sidebar.filter_saved` : « Filtrer les recherches sauvegardées... » / "Filter saved searches..."
|
||||
- Le message d'absence de résultat réutilise `sidebar.no_results`.
|
||||
|
||||
## E. Tests — ✅ livré
|
||||
|
||||
- `tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests) : rendu des listes,
|
||||
filtrage par titre/casse, accents/tags, vault/chemin, restauration, message
|
||||
d'absence de résultat, et combinaison pill + requête sur les sauvegardes.
|
||||
- Ajouté à la liste explicite du job `lint` de `.gitea/workflows/ci.yml`.
|
||||
|
||||
## F. Points d'attention
|
||||
|
||||
- Le filtrage est **client-side** (les listes sont déjà chargées) : aucune requête
|
||||
réseau supplémentaire n'est déclenchée par la frappe.
|
||||
- Sur « Sauvegardes », le filtre de type et la requête sont **cumulatifs** ; vider la
|
||||
barre (ou « × ») ne réinitialise pas le pill actif.
|
||||
+17
-14
@@ -114,6 +114,9 @@ class BooksLM {
|
||||
this._adhocDirs = [];
|
||||
this._images = [];
|
||||
this._activeSkill = null;
|
||||
// Deep Research: a skill-like chip that enables agent tools and injects a
|
||||
// research directive into the request (without polluting the composer).
|
||||
this._activeDeepResearch = false;
|
||||
this._skills = [];
|
||||
this._menuItems = [];
|
||||
this._menuIndex = 0;
|
||||
@@ -214,6 +217,7 @@ class BooksLM {
|
||||
this._adhocDirs = [];
|
||||
this._images = [];
|
||||
this._activeSkill = null;
|
||||
this._activeDeepResearch = false;
|
||||
this._isLoading = true;
|
||||
await this._loadHistory(preferredSessionId);
|
||||
|
||||
@@ -1216,7 +1220,7 @@ class BooksLM {
|
||||
const host = this._panel.querySelector('.bookslm-attachments');
|
||||
if (!host) return;
|
||||
host.innerHTML = '';
|
||||
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill;
|
||||
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill || this._activeDeepResearch;
|
||||
host.classList.toggle('empty', !hasAny);
|
||||
if (!hasAny) return;
|
||||
|
||||
@@ -1228,6 +1232,13 @@ class BooksLM {
|
||||
() => { this._activeSkill = null; this._renderAttachments(); },
|
||||
));
|
||||
}
|
||||
if (this._activeDeepResearch) {
|
||||
host.appendChild(this._chip(
|
||||
`🔎 ${t('bookslm.ext_deep_research')}`,
|
||||
'deep-research',
|
||||
() => { this._activeDeepResearch = false; this._renderAttachments(); },
|
||||
));
|
||||
}
|
||||
for (const file of this._adhocFiles) {
|
||||
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
|
||||
}
|
||||
@@ -1449,21 +1460,13 @@ class BooksLM {
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
/** #97 — Deep Research: enabled agent tools + a pre-filled directive. */
|
||||
/** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
|
||||
_startDeepResearch() {
|
||||
this._closeExtMenu();
|
||||
if (!this._agentMode) this._toggleAgentMode();
|
||||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||
if (textarea) {
|
||||
const directive = textarea.value.trim()
|
||||
? textarea.value.replace(/\s*$/, '')
|
||||
: '';
|
||||
textarea.value = (directive ? directive + '\n\n' : '') + t('bookslm.deep_research_prompt');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.focus();
|
||||
} else {
|
||||
showToast(t('bookslm.deep_research_started'), 'info');
|
||||
}
|
||||
this._activeDeepResearch = true;
|
||||
this._renderAttachments();
|
||||
showToast(t('bookslm.deep_research_started'), 'info');
|
||||
}
|
||||
|
||||
_onPaste(e) {
|
||||
@@ -2728,7 +2731,7 @@ class BooksLM {
|
||||
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
|
||||
)),
|
||||
skill: this._activeSkill,
|
||||
message: text,
|
||||
message: this._activeDeepResearch ? `${t('bookslm.deep_research_prompt')}\n\n${text}` : text,
|
||||
conversation_history: history,
|
||||
provider,
|
||||
model,
|
||||
|
||||
+48
-11
@@ -1,7 +1,7 @@
|
||||
// config.js — extracted from app.js (3872-4865)
|
||||
import { api, AuthManager, initMfaSettings } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { el, icon, openFile } from './viewer.js';
|
||||
import { el, icon, openFile, filterSavedSearches } from './viewer.js';
|
||||
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
@@ -11,6 +11,7 @@ import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
let _recentRefreshTimer = null;
|
||||
let _recentQuery = "";
|
||||
let _aiSessionsCache = [];
|
||||
let _aiQuery = "";
|
||||
|
||||
@@ -24,7 +25,7 @@ export async function loadRecentFiles(vaultFilter) {
|
||||
try {
|
||||
const data = await api(url);
|
||||
_recentFilesCache = data.files || [];
|
||||
renderRecentList(_recentFilesCache);
|
||||
renderRecentList(_applyRecentFilter(_recentFilesCache));
|
||||
} catch (err) {
|
||||
console.error("Failed to load recent files:", err);
|
||||
listEl.innerHTML = "";
|
||||
@@ -41,6 +42,9 @@ function renderRecentList(files) {
|
||||
listEl.innerHTML = "";
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
if (_recentFilesCache.length && _recentQuery) {
|
||||
listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))]));
|
||||
}
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.remove("hidden");
|
||||
safeCreateIcons();
|
||||
@@ -162,25 +166,45 @@ function _formatAIDate(ts) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accent- and case-insensitive normalization for the AI history filter (#98). */
|
||||
function _aiNorm(value) {
|
||||
/** Accent- and case-insensitive normalization for the sidebar filters (#98/#99). */
|
||||
function _sidebarNorm(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Apply the current sidebar query to the recent-files list (#99). */
|
||||
function _applyRecentFilter(files) {
|
||||
const q = _sidebarNorm(_recentQuery);
|
||||
if (!q) return files;
|
||||
return (files || []).filter((f) => {
|
||||
const tags = Array.isArray(f.tags) ? f.tags.join(" ") : "";
|
||||
return _sidebarNorm(f.title).includes(q)
|
||||
|| _sidebarNorm(f.path).includes(q)
|
||||
|| _sidebarNorm(f.vault).includes(q)
|
||||
|| _sidebarNorm(f.preview).includes(q)
|
||||
|| _sidebarNorm(tags).includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
/** Filter the sidebar recent-files list by the sidebar filter input (#99). */
|
||||
export function filterRecentFiles(query) {
|
||||
_recentQuery = (query || "").trim();
|
||||
renderRecentList(_applyRecentFilter(_recentFilesCache));
|
||||
}
|
||||
|
||||
/** Apply the current sidebar query to a session list, resolving the mode label. */
|
||||
function _applyAIFilter(sessions) {
|
||||
const q = _aiNorm(_aiQuery);
|
||||
const q = _sidebarNorm(_aiQuery);
|
||||
if (!q) return sessions;
|
||||
return (sessions || []).filter((s) => {
|
||||
const modeLabel = t("bookslm.mode_" + (s.mode || "general"));
|
||||
return _aiNorm(s.title).includes(q)
|
||||
|| _aiNorm(s.preview).includes(q)
|
||||
|| _aiNorm(s.directory).includes(q)
|
||||
|| _aiNorm(s.context).includes(q)
|
||||
|| _aiNorm(modeLabel).includes(q);
|
||||
return _sidebarNorm(s.title).includes(q)
|
||||
|| _sidebarNorm(s.preview).includes(q)
|
||||
|| _sidebarNorm(s.directory).includes(q)
|
||||
|| _sidebarNorm(s.context).includes(q)
|
||||
|| _sidebarNorm(modeLabel).includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,21 +298,34 @@ function switchSidebarTab(tab) {
|
||||
});
|
||||
const filterInput = document.getElementById("sidebar-filter-input");
|
||||
if (filterInput) {
|
||||
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: t("sidebar.filter_ai") };
|
||||
const placeholders = {
|
||||
vaults: "Filtrer fichiers...",
|
||||
tags: "Filtrer tags...",
|
||||
recent: t("sidebar.filter_recent"),
|
||||
saved: t("sidebar.filter_saved"),
|
||||
ai: t("sidebar.filter_ai"),
|
||||
};
|
||||
filterInput.placeholder = placeholders[tab] || "";
|
||||
}
|
||||
const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
|
||||
if (query) {
|
||||
if (tab === "vaults") performTreeSearch(query);
|
||||
else if (tab === "tags") filterTagCloud(query);
|
||||
else if (tab === "recent") filterRecentFiles(query);
|
||||
else if (tab === "saved") filterSavedSearches(query);
|
||||
else if (tab === "ai") filterAIHistory(query);
|
||||
}
|
||||
// Auto-load recent files when switching to the recent tab
|
||||
if (tab === "recent") {
|
||||
_populateRecentVaultFilter();
|
||||
if (filterInput) filterRecentFiles(filterInput.value.trim());
|
||||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||||
}
|
||||
// #99 — Re-apply the current sidebar query on the saved-searches tab.
|
||||
if (tab === "saved" && filterInput) {
|
||||
filterSavedSearches(filterInput.value.trim());
|
||||
}
|
||||
// #96/#98 — Auto-load the AI conversation history when entering its tab,
|
||||
// re-applying the current sidebar filter query.
|
||||
if (tab === "ai") {
|
||||
|
||||
+24
-35
@@ -2,8 +2,8 @@ import { state } from './state.js';
|
||||
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js';
|
||||
import { api } from './auth.js';
|
||||
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory } from './config.js';
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js';
|
||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
@@ -700,29 +700,32 @@ function initSidebarFilter() {
|
||||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||||
|
||||
// Route the query to the active tab's own filter (#98/#99).
|
||||
const routeFilter = async (q) => {
|
||||
const tab = state.activeSidebarTab;
|
||||
if (tab === "vaults") await performTreeSearch(q);
|
||||
else if (tab === "recent") filterRecentFiles(q);
|
||||
else if (tab === "saved") filterSavedSearches(q);
|
||||
else if (tab === "ai") filterAIHistory(q);
|
||||
else filterTagCloud(q);
|
||||
};
|
||||
const routeClear = async () => {
|
||||
const tab = state.activeSidebarTab;
|
||||
if (tab === "vaults") await restoreSidebarTree();
|
||||
else if (tab === "recent") filterRecentFiles("");
|
||||
else if (tab === "saved") filterSavedSearches("");
|
||||
else if (tab === "ai") filterAIHistory("");
|
||||
else filterTagCloud("");
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
clearTimeout(state.filterDebounce);
|
||||
state.filterDebounce = setTimeout(async () => {
|
||||
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||
if (hasText) {
|
||||
if (state.activeSidebarTab === "vaults") {
|
||||
await performTreeSearch(q);
|
||||
} else if (state.activeSidebarTab === "ai") {
|
||||
filterAIHistory(q);
|
||||
} else {
|
||||
filterTagCloud(q);
|
||||
}
|
||||
} else {
|
||||
if (state.activeSidebarTab === "vaults") {
|
||||
await restoreSidebarTree();
|
||||
} else if (state.activeSidebarTab === "ai") {
|
||||
filterAIHistory("");
|
||||
} else {
|
||||
filterTagCloud("");
|
||||
}
|
||||
}
|
||||
if (hasText) await routeFilter(q);
|
||||
else await routeClear();
|
||||
}, 220);
|
||||
});
|
||||
|
||||
@@ -730,15 +733,7 @@ function initSidebarFilter() {
|
||||
state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive;
|
||||
caseBtn.classList.toggle("active");
|
||||
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||
if (input.value.trim()) {
|
||||
if (state.activeSidebarTab === "vaults") {
|
||||
await performTreeSearch(q);
|
||||
} else if (state.activeSidebarTab === "ai") {
|
||||
filterAIHistory(q);
|
||||
} else {
|
||||
filterTagCloud(q);
|
||||
}
|
||||
}
|
||||
if (input.value.trim()) await routeFilter(q);
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", async () => {
|
||||
@@ -747,13 +742,7 @@ function initSidebarFilter() {
|
||||
state.sidebarFilterCaseSensitive = false;
|
||||
caseBtn.classList.remove("active");
|
||||
clearTimeout(state.filterDebounce);
|
||||
if (state.activeSidebarTab === "vaults") {
|
||||
await restoreSidebarTree();
|
||||
} else if (state.activeSidebarTab === "ai") {
|
||||
filterAIHistory("");
|
||||
} else {
|
||||
filterTagCloud("");
|
||||
}
|
||||
await routeClear();
|
||||
});
|
||||
|
||||
clearBtn.style.display = "none";
|
||||
|
||||
+32
-1
@@ -1811,6 +1811,14 @@ function _renderSyncPanel(panel) {
|
||||
// ── Saved searches filter ──
|
||||
var _savedFilterType = 'all';
|
||||
var _savedFilterInitDone = false;
|
||||
var _savedQuery = '';
|
||||
|
||||
function _savedNorm(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function _initSavedFilter() {
|
||||
if (_savedFilterInitDone) return;
|
||||
@@ -1827,18 +1835,41 @@ function _initSavedFilter() {
|
||||
_savedFilterInitDone = true;
|
||||
}
|
||||
|
||||
/** #99 — Filter the saved-searches list by the global sidebar filter input. */
|
||||
export function filterSavedSearches(query) {
|
||||
_savedQuery = (query || '').trim();
|
||||
_applySavedFilter();
|
||||
}
|
||||
|
||||
function _applySavedFilter() {
|
||||
var items = document.querySelectorAll('.saved-search-item');
|
||||
var list = document.getElementById('saved-searches-list');
|
||||
var q = _savedNorm(_savedQuery);
|
||||
var hasVisible = false;
|
||||
items.forEach(function(item) {
|
||||
var type = item.dataset.type;
|
||||
var show = _savedFilterType === 'all' || type === _savedFilterType;
|
||||
var typeOk = _savedFilterType === 'all' || type === _savedFilterType;
|
||||
var textOk = !q || _savedNorm(item.textContent).includes(q);
|
||||
var show = typeOk && textOk;
|
||||
item.style.display = show ? '' : 'none';
|
||||
if (show) hasVisible = true;
|
||||
});
|
||||
// Remove a previous "no match" hint before deciding whether to add one.
|
||||
if (list) {
|
||||
var prev = list.querySelector('.sidebar-filter-empty');
|
||||
if (prev) prev.remove();
|
||||
}
|
||||
// Show/hide empty state
|
||||
var empty = document.getElementById('saved-searches-empty');
|
||||
if (empty) empty.style.display = hasVisible ? 'none' : '';
|
||||
// Explain an empty result caused by the global search rather than by an
|
||||
// actually empty saved-searches list.
|
||||
if (!hasVisible && q && list && items.length) {
|
||||
var hint = document.createElement('div');
|
||||
hint.className = 'sidebar-filter-empty';
|
||||
hint.textContent = t('sidebar.no_results');
|
||||
list.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1545,6 +1545,8 @@
|
||||
"sidebar.file_icons": "Icons",
|
||||
"sidebar.filter_instant": "Instant search",
|
||||
"sidebar.filter_ai": "Filter AI history...",
|
||||
"sidebar.filter_recent": "Filter recent files...",
|
||||
"sidebar.filter_saved": "Filter saved searches...",
|
||||
"sidebar.filter_path": "Path filters",
|
||||
"sidebar.filter_placeholder": "Filter files...",
|
||||
"sidebar.filter_results_grouped": "Grouped results",
|
||||
@@ -1849,7 +1851,7 @@
|
||||
"bookslm.soon": "Soon",
|
||||
"bookslm.ext_more_coming": "More options coming…",
|
||||
"bookslm.deep_research_prompt": "Perform an in-depth analysis of the request by breaking it into steps, searching for relevant information in the vault, cross-referencing them, and producing a structured summary with sources.",
|
||||
"bookslm.deep_research_started": "Deep research started — enable Agent mode to use the tools.",
|
||||
"bookslm.deep_research_started": "Deep Research enabled — add your question and send.",
|
||||
"bookslm.mode_general": "General",
|
||||
"bookslm.mode_directory": "Directory",
|
||||
"bookslm.mode_documents": "Documents",
|
||||
|
||||
@@ -1545,6 +1545,8 @@
|
||||
"sidebar.file_icons": "Icônes",
|
||||
"sidebar.filter_instant": "Recherche instantanée",
|
||||
"sidebar.filter_ai": "Filtrer l'historique IA...",
|
||||
"sidebar.filter_recent": "Filtrer les fichiers récents...",
|
||||
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
|
||||
"sidebar.filter_path": "Filtres de chemin",
|
||||
"sidebar.filter_placeholder": "Filtrer fichiers...",
|
||||
"sidebar.filter_results_grouped": "Résultats groupés",
|
||||
@@ -1849,7 +1851,7 @@
|
||||
"bookslm.soon": "Bientôt",
|
||||
"bookslm.ext_more_coming": "D'autres options à venir…",
|
||||
"bookslm.deep_research_prompt": "Réalise une analyse approfondie de la demande en décomposant les étapes, en cherchant les informations pertinentes dans le vault, en les croisant, et en produisant une synthèse structurée avec sources.",
|
||||
"bookslm.deep_research_started": "Deep Research lancé — activez le mode Agent pour utiliser les outils.",
|
||||
"bookslm.deep_research_started": "Deep Research activé — ajoutez votre question puis envoyez.",
|
||||
"bookslm.mode_general": "Général",
|
||||
"bookslm.mode_directory": "Répertoire",
|
||||
"bookslm.mode_documents": "Documents",
|
||||
|
||||
+3
-2
@@ -9676,6 +9676,7 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
.bookslm-chip-remove:hover { color: #f87171; }
|
||||
.bookslm-chip-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 4px; }
|
||||
.bookslm-chip-skill { border-color: var(--accent); }
|
||||
.bookslm-chip-deep-research { border-color: var(--accent); }
|
||||
/* Composer menus (`/` commands and `@` mentions). */
|
||||
.bookslm-menu-layer { position: relative; }
|
||||
.bookslm-command-menu, .bookslm-mention-menu { position: absolute; left: 12px; right: 12px; bottom: 4px;
|
||||
@@ -9696,10 +9697,10 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
white-space: nowrap; }
|
||||
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
||||
/* Image attach button. */
|
||||
.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
.bookslm-input-area button.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
|
||||
border-radius: 50%; width: 32px; height: 32px; padding: 0; }
|
||||
.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.bookslm-input-area button.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.bookslm-file-input { display: none; }
|
||||
.bookslm-files-input { display: none; }
|
||||
/* Extensible "+" panel (#97). */
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.1",
|
||||
"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": {
|
||||
|
||||
@@ -1363,10 +1363,23 @@ async function main() {
|
||||
// ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ──
|
||||
await test("mention menu accepts accents and searches all vaults without a vault", async () => {
|
||||
localStorage.clear();
|
||||
let lastUrl = "";
|
||||
document.body.replaceChildren();
|
||||
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
|
||||
const prevVaults = stateMod.state.allVaults;
|
||||
const prevSelected = stateMod.state.selectedContextVault;
|
||||
const prevCurrent = stateMod.state.currentVault;
|
||||
stateMod.state.allVaults = [];
|
||||
stateMod.state.selectedContextVault = null;
|
||||
stateMod.state.currentVault = null;
|
||||
// Collect every request: `new BooksLM()` also hydrates the AI history
|
||||
// asynchronously, so the *last* URL is not necessarily the tree-search one.
|
||||
const urls = [];
|
||||
const sawTreeSearchAll = () => urls.some(
|
||||
(u) => u.includes("/api/tree-search") && u.includes("vault=all"),
|
||||
);
|
||||
globalThis.fetch = async (url) => {
|
||||
lastUrl = String(url);
|
||||
if (lastUrl.includes("/api/tree-search")) {
|
||||
urls.push(String(url));
|
||||
if (String(url).includes("/api/tree-search")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }),
|
||||
@@ -1382,13 +1395,21 @@ async function main() {
|
||||
ta.value = "@café";
|
||||
ta.selectionStart = ta.selectionEnd = 5;
|
||||
b._onComposerInput();
|
||||
await sleep(20);
|
||||
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
||||
let waited = 0;
|
||||
while (!sawTreeSearchAll() && waited < 500) {
|
||||
await sleep(20);
|
||||
waited += 20;
|
||||
}
|
||||
assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open");
|
||||
assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults");
|
||||
assert.ok(sawTreeSearchAll(),
|
||||
`no vault -> searches all vaults (got ${JSON.stringify(urls)})`);
|
||||
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
stateMod.state.allVaults = prevVaults;
|
||||
stateMod.state.selectedContextVault = prevSelected;
|
||||
stateMod.state.currentVault = prevCurrent;
|
||||
});
|
||||
|
||||
// ── 40. Keyboard navigation in the command menu (BUG-007) ──
|
||||
@@ -1671,10 +1692,10 @@ async function main() {
|
||||
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
|
||||
const prev = stateMod.state.allVaults;
|
||||
stateMod.state.allVaults = [{ name: "FallbackVault" }];
|
||||
let lastUrl = "";
|
||||
const urls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
lastUrl = String(url);
|
||||
if (lastUrl.includes("/files")) {
|
||||
urls.push(String(url));
|
||||
if (String(url).includes("/files")) {
|
||||
return { ok: true, status: 200, json: async () => ({ files: [{ path: "a.md" }] }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
@@ -1687,7 +1708,8 @@ async function main() {
|
||||
await b._showMentionMenu("");
|
||||
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
||||
assert.ok(!menu.classList.contains("hidden"), "empty-query menu is shown");
|
||||
assert.ok(lastUrl.includes("FallbackVault"), "uses the fallback vault");
|
||||
assert.ok(urls.some((u) => u.includes("/files") && u.includes("FallbackVault")),
|
||||
`uses the fallback vault (got ${JSON.stringify(urls)})`);
|
||||
stateMod.state.allVaults = prev;
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
@@ -1892,6 +1914,31 @@ async function main() {
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
await test("ext menu Deep Research adds a chip instead of composer text", async () => {
|
||||
localStorage.clear();
|
||||
document.body.replaceChildren();
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const ta = b._panel.querySelector("textarea");
|
||||
ta.value = "";
|
||||
b._toggleExtMenu();
|
||||
const btn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="deep_research"]');
|
||||
assert.ok(btn, "Deep Research entry present in the + panel");
|
||||
btn.click();
|
||||
await sleep(20);
|
||||
const chip = b._panel.querySelector(".bookslm-chip-deep-research");
|
||||
assert.ok(chip, "a Deep Research chip is added");
|
||||
assert.equal(ta.value, "", "the composer stays empty (no injected directive)");
|
||||
assert.equal(b._activeDeepResearch, true, "the deep-research flag is set");
|
||||
// Removing the chip clears the flag.
|
||||
chip.querySelector(".bookslm-chip-remove").click();
|
||||
assert.ok(!b._panel.querySelector(".bookslm-chip-deep-research"), "the chip is removed");
|
||||
assert.equal(b._activeDeepResearch, false, "the flag is cleared with the chip");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM tests for the sidebar search/filter bar on the
|
||||
* "Recent" and "Saved searches" tabs (#99).
|
||||
*
|
||||
* Covers:
|
||||
* - `filterRecentFiles()` (config.js): narrows the recent-files list by
|
||||
* title / path / vault / preview / tags, case- and accent-insensitively
|
||||
* - `filterSavedSearches()` (viewer.js): narrows the saved-searches list and
|
||||
* combines with the type pills, with a "no match" hint when empty
|
||||
*
|
||||
* Usage: node tests/frontend/sidebar-filters.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<aside class="sidebar">
|
||||
<input id="sidebar-filter-input" value="" />
|
||||
<button id="sidebar-filter-case-btn"></button>
|
||||
<button id="sidebar-filter-clear-btn"></button>
|
||||
|
||||
<div id="recent-list" class="recent-list"></div>
|
||||
<div id="recent-empty" class="recent-empty hidden"></div>
|
||||
<select id="recent-vault-filter"></select>
|
||||
|
||||
<div class="saved-filter-bar" id="saved-filter-bar">
|
||||
<button class="saved-filter-pill active" data-filter="all">Tous</button>
|
||||
<button class="saved-filter-pill" data-filter="search">Recherches</button>
|
||||
<button class="saved-filter-pill" data-filter="directory">Repertoires</button>
|
||||
</div>
|
||||
<div id="saved-searches-list" class="recent-list"></div>
|
||||
<div id="saved-searches-empty" class="recent-empty"></div>
|
||||
</aside>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.KeyboardEvent = w.KeyboardEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.MutationObserver = w.MutationObserver;
|
||||
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const RECENT = [
|
||||
{ vault: "V", path: "Recettes/Pizza.md", title: "Pizza maison", preview: "pâte", tags: ["cuisine"], mtime_human: "il y a 1 h" },
|
||||
{ vault: "V", path: "Notes/café.md", title: "Café du matin", preview: "arôme", tags: ["journal"], mtime_human: "il y a 2 h" },
|
||||
{ vault: "W", path: "Projets/plan.md", title: "Plan de projet", preview: "étapes", tags: [], mtime_human: "hier" },
|
||||
];
|
||||
|
||||
const SAVED = [
|
||||
{ id: "s1", query: "pizza", vault: "V", include_paths: "Recettes", case_sensitive: false },
|
||||
{ id: "s2", query: "", include_paths: "Recettes", vault: "V" },
|
||||
{ id: "s3", query: "roadmap", vault: "all" },
|
||||
];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/recent")) {
|
||||
return { ok: true, status: 200, json: async () => ({ files: RECENT }) };
|
||||
}
|
||||
if (u.includes("/api/saved-searches")) {
|
||||
return { ok: true, status: 200, json: async () => SAVED };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
|
||||
const configMod = await import(pathToFileURL(path.join(JS_DIR, "config.js")).href);
|
||||
const viewerMod = await import(pathToFileURL(path.join(JS_DIR, "viewer.js")).href);
|
||||
const { loadRecentFiles, filterRecentFiles } = configMod;
|
||||
const { loadSavedSearches, filterSavedSearches } = viewerMod;
|
||||
|
||||
const recentItems = () => Array.from(document.querySelectorAll("#recent-list .recent-item"));
|
||||
const visibleRecent = () => recentItems().filter((el) => el.style.display !== "none").length;
|
||||
const savedItems = () => Array.from(document.querySelectorAll(".saved-search-item"));
|
||||
const visibleSaved = () => savedItems().filter((el) => el.style.display !== "none").length;
|
||||
|
||||
console.log("Sidebar filters (#99) — Recent & Saved searches");
|
||||
|
||||
await test("loadRecentFiles renders every recent file", async () => {
|
||||
await loadRecentFiles(null);
|
||||
assert.equal(recentItems().length, 3, "three recent items rendered");
|
||||
});
|
||||
|
||||
await test("filterRecentFiles narrows by title, case-insensitively", async () => {
|
||||
filterRecentFiles("pizza");
|
||||
assert.equal(visibleRecent(), 1, "one recent item matches 'pizza'");
|
||||
filterRecentFiles("PLAN");
|
||||
assert.equal(visibleRecent(), 1, "case-insensitive match on 'PLAN'");
|
||||
});
|
||||
|
||||
await test("filterRecentFiles matches accents and tags", async () => {
|
||||
filterRecentFiles("cafe");
|
||||
assert.equal(visibleRecent(), 1, "'cafe' matches 'Café du matin'");
|
||||
filterRecentFiles("journal");
|
||||
assert.equal(visibleRecent(), 1, "tag match");
|
||||
});
|
||||
|
||||
await test("filterRecentFiles matches the vault and clears", async () => {
|
||||
filterRecentFiles("Projets");
|
||||
assert.equal(visibleRecent(), 1, "path match");
|
||||
filterRecentFiles("");
|
||||
assert.equal(visibleRecent(), 3, "no query restores everything");
|
||||
});
|
||||
|
||||
await test("filterRecentFiles shows a no-match hint", async () => {
|
||||
filterRecentFiles("zzz-inexistant");
|
||||
assert.equal(visibleRecent(), 0, "no item left");
|
||||
assert.ok(document.querySelector("#recent-list .sidebar-filter-empty"),
|
||||
"a no-match hint is rendered in the recent list");
|
||||
filterRecentFiles("");
|
||||
});
|
||||
|
||||
await test("loadSavedSearches renders every saved search", async () => {
|
||||
await loadSavedSearches();
|
||||
assert.equal(savedItems().length, 3, "three saved searches rendered");
|
||||
});
|
||||
|
||||
await test("filterSavedSearches narrows by query text", async () => {
|
||||
filterSavedSearches("pizza");
|
||||
assert.equal(visibleSaved(), 1, "one saved search matches 'pizza'");
|
||||
filterSavedSearches("roadmap");
|
||||
assert.equal(visibleSaved(), 1, "match on the query field");
|
||||
});
|
||||
|
||||
await test("filterSavedSearches combines with the type pills", async () => {
|
||||
filterSavedSearches("");
|
||||
assert.equal(visibleSaved(), 3, "all visible before the pill filter");
|
||||
document.querySelector('.saved-filter-pill[data-filter="directory"]').click();
|
||||
assert.equal(visibleSaved(), 1, "only the directory search stays visible");
|
||||
filterSavedSearches("roadmap");
|
||||
assert.equal(visibleSaved(), 0, "the query excludes the directory search");
|
||||
assert.ok(document.querySelector("#saved-searches-list .sidebar-filter-empty"),
|
||||
"a no-match hint is rendered in the saved list");
|
||||
document.querySelector('.saved-filter-pill[data-filter="all"]').click();
|
||||
filterSavedSearches("");
|
||||
assert.equal(visibleSaved(), 3, "clearing both filters restores everything");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user