Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffc9d851a | ||
|
|
1e0e419210 | ||
|
|
2813d546a7 | ||
|
|
19e94dfa51 | ||
|
|
49f97fc26e | ||
|
|
562290d922 | ||
|
|
66a5505965 |
+10
-6
@@ -58,6 +58,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -78,6 +79,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -174,12 +176,14 @@ jobs:
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
|
||||
# des versions préinstallées dans la toolcache de l'image du runner :
|
||||
# en dessous, pip répond « already satisfied » et n'aligne jamais
|
||||
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
|
||||
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
|
||||
# ces planchers.
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
|
||||
+48
-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.45.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.47.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,53 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.47.1] — 2026-10-02
|
||||
|
||||
---
|
||||
|
||||
## [2.47.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — panneau de facettes repliable.** Le bloc **Vaults · Tags · Extensions** des résultats
|
||||
de recherche se masque/restaure d'un clic sur un bouton discret (chevron), état mémorisé
|
||||
dans `localStorage` et conservé d'une recherche à l'autre.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#157 — facette « Extensions » absente de l'API.** `SearchFacets` (`backend/schemas.py`)
|
||||
ne déclarait que `tags` et `vaults` : la clé `extensions` ajoutée par `backend/search.py`
|
||||
était supprimée par le `response_model` avant d'atteindre le navigateur. Champ ajouté,
|
||||
plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search`
|
||||
et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion
|
||||
stricte sur `facets.extensions`.
|
||||
- **#157 — bouton de repli des facettes maladroit sur mobile.** En dessous de 768 px, le
|
||||
panneau passe en colonne et le bouton s'étirait en pleine largeur d'écran, pivotait
|
||||
entièrement à la vertical au clic. Le bouton reste compact (`align-self: flex-start`) et
|
||||
**seule l'icône chevron pivote** (transition 120 ms), sur mobile comme sur desktop.
|
||||
|
||||
---
|
||||
|
||||
## [2.46.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de
|
||||
facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les
|
||||
extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs
|
||||
compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un
|
||||
`ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.45.2] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.1] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.0] — 2026-09-30
|
||||
|
||||
### Ajouté
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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.45.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.47.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.45.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.47.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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.45.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.47.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.45.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.47.1 | Last updated: September 2026*
|
||||
|
||||
@@ -37,3 +37,5 @@ odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
|
||||
+3
-1
@@ -646,6 +646,7 @@ class SearchFacets(BaseModel):
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
@@ -655,7 +656,7 @@ class AdvancedSearchResponse(BaseModel):
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
@@ -666,6 +667,7 @@ class TitleSuggestion(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
|
||||
+16
-3
@@ -373,6 +373,7 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
@@ -403,6 +404,7 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
@@ -417,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -1324,6 +1327,7 @@ def advanced_search(
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
facet_extensions: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
@@ -1423,6 +1427,11 @@ def advanced_search(
|
||||
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
|
||||
for tag in result.get("tags", []):
|
||||
facet_tags[tag] = facet_tags.get(tag, 0) + 1
|
||||
# Extension normalised without leading dot ("md", not ".md") so it can be
|
||||
# fed back directly as the `ext:` query operator.
|
||||
ext = str(result.get("extension") or "").lower().lstrip(".")
|
||||
if ext:
|
||||
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
|
||||
|
||||
total = len(scored_results)
|
||||
page = scored_results[offset: offset + limit]
|
||||
@@ -1436,6 +1445,7 @@ def advanced_search(
|
||||
"facets": {
|
||||
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
|
||||
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
|
||||
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
|
||||
},
|
||||
"query_time_ms": elapsed_ms,
|
||||
"semantic_available": semantic_available,
|
||||
@@ -1538,7 +1548,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1547,7 +1557,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1565,7 +1575,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.45.0"
|
||||
version = "2.47.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.45.0"
|
||||
version = "2.47.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.45.0",
|
||||
"version": "2.47.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -19,7 +19,8 @@ avec :
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau
|
||||
repliable d'un clic (chevron, état mémorisé).
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.45.0 | **Dernière mise à jour :** 2026-09-30
|
||||
> **Version :** 2.47.1 | **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)**
|
||||
@@ -304,6 +304,7 @@
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -419,6 +419,24 @@ première modification et téléchargement du fichier d'origine.
|
||||
|
||||
---
|
||||
|
||||
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
|
||||
|
||||
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
|
||||
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
|
||||
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
|
||||
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
|
||||
L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état
|
||||
mémorisé dans `localStorage`.
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) |
|
||||
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) |
|
||||
| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
|
||||
| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
+7
-4
@@ -1196,16 +1196,15 @@
|
||||
>Ctrl+Shift+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de commandes</span
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
<span class="qh-kbd"
|
||||
>Ctrl+Alt+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
<span class="qh-desc">Palette de commandes</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
@@ -3695,6 +3694,10 @@
|
||||
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
|
||||
par vault</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
|
||||
Compteurs par extension de fichier dans les résultats</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
|
||||
TF-IDF (défaut)</span></li>
|
||||
|
||||
+36
-7
@@ -14,17 +14,26 @@ function getCurrentFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Query parser: +word = required, -word = exclude, word = required ──
|
||||
// ── Query parser: +word = required, -word = exclude, word = required, ext:xxx, vault:xxx, tag:xxx ──
|
||||
function parseQuery(raw) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
const filters = { ext: null, vault: null, tag: null };
|
||||
const parts = (raw || '').split(/\s+/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
const lower = p.toLowerCase();
|
||||
if (lower.startsWith('ext:')) {
|
||||
filters.ext = lower.slice(4).trim().replace(/^\./, '').toLowerCase();
|
||||
} else if (lower.startsWith('vault:')) {
|
||||
filters.vault = lower.slice(6).trim();
|
||||
} else if (lower.startsWith('tag:') || (lower.startsWith('#') && p.length > 1)) {
|
||||
const tag = lower.startsWith('tag:') ? lower.slice(4) : lower.slice(1);
|
||||
if (tag) filters.tag = tag.trim();
|
||||
} else if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
else if (p.startsWith('+')) positive.push(p.slice(1).toLowerCase());
|
||||
else positive.push(p.toLowerCase());
|
||||
}
|
||||
return { positive, negative };
|
||||
return { positive, negative, filters };
|
||||
}
|
||||
|
||||
// ── DOM / state ──
|
||||
@@ -407,8 +416,8 @@ function createDOM() {
|
||||
_overlay.className = 'command-palette-overlay';
|
||||
_overlay.tabIndex = -1;
|
||||
_overlay.innerHTML = `<div class="command-palette">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure ext:md vault:nom tag:monTag)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot ext:xxx vault:nom tag:xxx) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-results"></div>
|
||||
<div class="cp-footer"><span>↑↓ naviguer</span><span>↵ ouvrir/entrer</span><span>⌫ monter</span><span>Esc fermer</span></div></div>`;
|
||||
_input = _overlay.querySelector('.cp-input');
|
||||
@@ -488,11 +497,14 @@ async function searchFiles(rawQ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { positive, negative } = parseQuery(rawQ);
|
||||
const { positive, negative, filters } = parseQuery(rawQ);
|
||||
|
||||
try {
|
||||
const q = positive.join(' ');
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=all`);
|
||||
let vaultFilter = 'all';
|
||||
if (filters.vault) vaultFilter = filters.vault;
|
||||
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=${encodeURIComponent(vaultFilter)}`);
|
||||
let sug = d.suggestions || [];
|
||||
|
||||
// Client-side negative filter
|
||||
@@ -506,6 +518,23 @@ async function searchFiles(rawQ) {
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by extension if specified
|
||||
if (filters.ext) {
|
||||
sug = sug.filter(s => {
|
||||
const name = s.path.split('/').pop() || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
return ext === filters.ext;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by tag if specified
|
||||
if (filters.tag) {
|
||||
sug = sug.filter(s => {
|
||||
const tags = s.tags || [];
|
||||
return tags.some(t => t.toLowerCase() === filters.tag.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Aucun résultat — naviguer manuellement', act:()=>startBrowse('open-file') }];
|
||||
} else {
|
||||
|
||||
+50
-4
@@ -1034,8 +1034,27 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
}
|
||||
|
||||
// Facets panel
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
|
||||
const facetsDiv = el("div", { class: "search-facets" });
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) {
|
||||
const collapsed = localStorage.getItem("obsigate_facets_collapsed") === "1";
|
||||
const facetsDiv = el("div", { class: "search-facets", "data-collapsed": collapsed ? "true" : "false" });
|
||||
|
||||
// Discreet collapse toggle for the whole panel (vaults + tags + extensions)
|
||||
const toggleBtn = el("button", {
|
||||
class: "search-facets__toggle",
|
||||
type: "button",
|
||||
"aria-expanded": String(!collapsed),
|
||||
title: collapsed ? t("search.facets_expand") : t("search.facets_collapse"),
|
||||
});
|
||||
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const nowCollapsed = facetsDiv.getAttribute("data-collapsed") !== "true";
|
||||
facetsDiv.setAttribute("data-collapsed", nowCollapsed ? "true" : "false");
|
||||
toggleBtn.setAttribute("aria-expanded", String(!nowCollapsed));
|
||||
toggleBtn.title = nowCollapsed ? t("search.facets_expand") : t("search.facets_collapse");
|
||||
localStorage.setItem("obsigate_facets_collapsed", nowCollapsed ? "1" : "0");
|
||||
});
|
||||
facetsDiv.appendChild(toggleBtn);
|
||||
const facetsBody = el("div", { class: "search-facets__body" });
|
||||
|
||||
// Vault facets
|
||||
const vaultFacets = data.facets.vaults || {};
|
||||
@@ -1056,7 +1075,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
// Tag facets
|
||||
@@ -1075,9 +1094,36 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
// Extension facets
|
||||
const extFacets = data.facets.extensions || {};
|
||||
if (Object.keys(extFacets).length > 0) {
|
||||
const group = el("div", { class: "search-facets__group", "data-facet": "extensions" });
|
||||
const label = el("span", { class: "search-facets__label" });
|
||||
label.textContent = "Extensions";
|
||||
group.appendChild(label);
|
||||
for (const [extName, count] of Object.entries(extFacets)) {
|
||||
const item = el("span", { class: "search-facets__item" });
|
||||
// textContent: extension suffixes come from filenames (untrusted), never innerHTML
|
||||
item.appendChild(document.createTextNode(`.${extName} `));
|
||||
const facetCount = el("span", { class: "facet-count" });
|
||||
facetCount.textContent = String(count);
|
||||
item.appendChild(facetCount);
|
||||
item.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
// Replace any existing ext: operator, then trigger a new search
|
||||
const current = input.value.replace(/ext:\S+\s*/gi, "").trim();
|
||||
input.value = current + " ext:" + extName;
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
facetsDiv.appendChild(facetsBody);
|
||||
area.appendChild(facetsDiv);
|
||||
}
|
||||
|
||||
|
||||
@@ -1101,6 +1101,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).",
|
||||
"help.desc_facet_vaults": ": Counters by vault",
|
||||
"help.desc_facet_extensions": ": Counters by file extension in the results",
|
||||
"help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.",
|
||||
"help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": File title suggestions",
|
||||
@@ -1608,6 +1609,8 @@
|
||||
"search.exclude_placeholder": "Exclude: vendor/*, *.lock",
|
||||
"search.facet_tags": "Tags",
|
||||
"search.facet_vaults": "Vaults",
|
||||
"search.facets_collapse": "Collapse facets",
|
||||
"search.facets_expand": "Expand facets",
|
||||
"search.files_filtered": "{count} file(s) with filters",
|
||||
"search.filter_path_title": "Path filters (Alt-F)",
|
||||
"search.filters": "Filters",
|
||||
|
||||
@@ -1101,6 +1101,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
|
||||
"help.desc_facet_vaults": ": Compteurs par vault",
|
||||
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
|
||||
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
|
||||
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
|
||||
@@ -1608,6 +1609,8 @@
|
||||
"search.exclude_placeholder": "Exclure: vendor/*, *.lock",
|
||||
"search.facet_tags": "Tags",
|
||||
"search.facet_vaults": "Vaults",
|
||||
"search.facets_collapse": "Réduire les facettes",
|
||||
"search.facets_expand": "Afficher les facettes",
|
||||
"search.files_filtered": "{count} fichier(s) avec filtres",
|
||||
"search.filter_path_title": "Filtres de chemin (Alt-F)",
|
||||
"search.filters": "Filtres",
|
||||
|
||||
@@ -5476,6 +5476,45 @@ body.resizing-v {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-facets__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-start; /* mobile (colonne) : ne jamais s'étirer en pleine largeur */
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
color 120ms,
|
||||
background 120ms;
|
||||
}
|
||||
.search-facets__toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Seule l'icône pivote — la boîte du bouton reste fixe */
|
||||
.search-facets__toggle i,
|
||||
.search-facets__toggle svg {
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle i,
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.search-facets__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Pagination
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.45.0",
|
||||
"version": "2.47.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": {
|
||||
|
||||
@@ -143,6 +143,33 @@ test.describe('Full-Text Search', () => {
|
||||
// Not always present — depends on vault content
|
||||
});
|
||||
|
||||
test('should show the extensions facet and collapse the facets panel (#157)', async ({ page }) => {
|
||||
await goHome(page);
|
||||
await searchFor(page, 'test');
|
||||
const extFacet = page.locator('.search-facets__group[data-facet="extensions"]');
|
||||
await expect(extFacet).toBeVisible();
|
||||
await expect(extFacet.locator('.search-facets__item').first()).toBeVisible();
|
||||
// discreet collapse/expand of the whole vaults+tags+extensions section
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeHidden();
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeVisible();
|
||||
|
||||
// mobile: the toggle stays compact (no full-width stretch) and only the
|
||||
// icon rotates — the button box itself never spins
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const toggle = page.locator('.search-facets__toggle');
|
||||
const box = await toggle.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeLessThan(150);
|
||||
await toggle.click();
|
||||
expect(await toggle.evaluate((el) => getComputedStyle(el).transform)).toBe('none');
|
||||
const icon = toggle.locator('svg');
|
||||
expect(await icon.evaluate((el) => getComputedStyle(el).transform)).not.toBe('none');
|
||||
await toggle.click();
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
test('should navigate results with floating bar arrows', async ({ page }) => {
|
||||
await goHome(page);
|
||||
await searchFor(page, 'test');
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM tests for the search results facets panel (#157):
|
||||
* vault, tag and file-extension facets rendered by
|
||||
* `renderAdvancedSearchResults()` (frontend/js/search.js).
|
||||
*
|
||||
* Covers:
|
||||
* - the three facet groups are rendered (Vaults / Tags / Extensions)
|
||||
* - the extensions facet appears even when tags and vaults are empty
|
||||
* - clicking an extension replaces any `ext:` operator in the search
|
||||
* input and triggers a new search
|
||||
*
|
||||
* Usage: node tests/frontend/search-facets.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>
|
||||
<input id="search-input" value="" />
|
||||
<select id="vault-filter"><option value="all">all</option></select>
|
||||
<div id="results-bar" style="display:none"></div>
|
||||
<span id="rb-count"></span>
|
||||
<div id="content-area"></div>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend payload shape for /api/search/advanced (facets only matter here).
|
||||
const PAYLOAD = {
|
||||
results: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
query_time_ms: 1,
|
||||
facets: {
|
||||
vaults: { IT: 4, Recettes: 2 },
|
||||
tags: { linux: 3 },
|
||||
extensions: { md: 5, pdf: 2, xlsx: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => PAYLOAD,
|
||||
});
|
||||
|
||||
const { renderAdvancedSearchResults } = await import(
|
||||
pathToFileURL(path.join(JS_DIR, "search.js")).href
|
||||
);
|
||||
|
||||
const facetGroups = () =>
|
||||
Array.from(document.querySelectorAll(".search-facets__group"));
|
||||
const extGroup = () =>
|
||||
document.querySelector('.search-facets__group[data-facet="extensions"]');
|
||||
const extItems = () =>
|
||||
Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []);
|
||||
|
||||
console.log("Search facets (#157) — Vaults / Tags / Extensions");
|
||||
|
||||
await test("renders the three facet groups", async () => {
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
assert.equal(facetGroups().length, 3, "vault, tag and extension groups");
|
||||
const labels = facetGroups().map((g) =>
|
||||
g.querySelector(".search-facets__label").textContent,
|
||||
);
|
||||
assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]);
|
||||
});
|
||||
|
||||
await test("lists extensions with their counts", async () => {
|
||||
const texts = extItems().map((i) => i.textContent.trim());
|
||||
assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]);
|
||||
assert.equal(
|
||||
extItems()[0].querySelector(".facet-count").textContent,
|
||||
"5",
|
||||
"count lives in .facet-count",
|
||||
);
|
||||
});
|
||||
|
||||
await test("extension facet shows even with empty tags and vaults", async () => {
|
||||
renderAdvancedSearchResults(
|
||||
{ ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } },
|
||||
"",
|
||||
null,
|
||||
);
|
||||
assert.ok(extGroup(), "extensions group rendered alone");
|
||||
assert.equal(extItems().length, 1);
|
||||
});
|
||||
|
||||
await test("clicking an extension adds the ext: operator", async () => {
|
||||
document.getElementById("search-input").value = "python ext:md";
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
extItems()[1].click(); // .pdf
|
||||
assert.equal(
|
||||
document.getElementById("search-input").value,
|
||||
"python ext:pdf",
|
||||
"existing ext: operator replaced, not duplicated",
|
||||
);
|
||||
});
|
||||
|
||||
await test("facets panel collapses and restores via the toggle", async () => {
|
||||
localStorage.removeItem("obsigate_facets_collapsed");
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
const panel = document.querySelector(".search-facets");
|
||||
const toggle = panel.querySelector(".search-facets__toggle");
|
||||
assert.ok(toggle, "collapse toggle rendered");
|
||||
assert.equal(panel.getAttribute("data-collapsed"), "false", "expanded by default");
|
||||
assert.ok(panel.querySelector(".search-facets__body"), "groups live in the body");
|
||||
|
||||
toggle.click();
|
||||
assert.equal(panel.getAttribute("data-collapsed"), "true", "click collapses");
|
||||
assert.equal(
|
||||
localStorage.getItem("obsigate_facets_collapsed"),
|
||||
"1",
|
||||
"collapsed state persisted",
|
||||
);
|
||||
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
const panel2 = document.querySelector(".search-facets");
|
||||
assert.equal(
|
||||
panel2.getAttribute("data-collapsed"),
|
||||
"true",
|
||||
"collapsed state survives a re-render",
|
||||
);
|
||||
panel2.querySelector(".search-facets__toggle").click();
|
||||
assert.equal(panel2.getAttribute("data-collapsed"), "false", "toggle restores");
|
||||
localStorage.removeItem("obsigate_facets_collapsed");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -254,6 +254,7 @@ class TestDependencySecurityFloors:
|
||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
|
||||
+15
-5
@@ -204,7 +204,7 @@ class TestAdvancedSearchAPI:
|
||||
pytest.skip(f"Advanced search non-JSON response: {resp.text[:200]}")
|
||||
|
||||
def test_basic(self, client):
|
||||
resp = client.get("/api/advanced-search?q=python&vault=all")
|
||||
resp = client.get("/api/search/advanced?q=python&vault=all")
|
||||
data = self._check(resp)
|
||||
assert data["total"] >= 1
|
||||
assert len(data["results"]) > 0
|
||||
@@ -217,17 +217,27 @@ class TestAdvancedSearchAPI:
|
||||
assert "path" in r
|
||||
|
||||
def test_pagination(self, client):
|
||||
resp = client.get("/api/advanced-search?q=python&limit=1&offset=0")
|
||||
resp = client.get("/api/search/advanced?q=python&limit=1&offset=0")
|
||||
data = self._check(resp)
|
||||
assert len(data["results"]) <= 1
|
||||
|
||||
def test_facets(self, client):
|
||||
resp = client.get("/api/advanced-search?q=python&vault=all")
|
||||
data = self._check(resp)
|
||||
resp = client.get("/api/search/advanced?q=python&vault=all")
|
||||
# Hard assertion: the facet contract must hold through the response_model
|
||||
# (regression #157: `extensions` used to be stripped by SearchFacets).
|
||||
assert resp.status_code == 200, resp.text[:200]
|
||||
data = resp.json()
|
||||
assert "facets" in data
|
||||
assert "extensions" in data["facets"]
|
||||
exts = data["facets"]["extensions"]
|
||||
assert isinstance(exts, dict)
|
||||
assert all(not e.startswith(".") for e in exts)
|
||||
if data["total"] > 0:
|
||||
assert len(exts) >= 1
|
||||
assert sum(exts.values()) <= data["total"]
|
||||
|
||||
def test_empty_query(self, client):
|
||||
resp = client.get("/api/advanced-search?q=")
|
||||
resp = client.get("/api/search/advanced?q=")
|
||||
data = self._check(resp)
|
||||
# Empty query should return 0 results
|
||||
assert data["total"] == 0
|
||||
|
||||
Reference in New Issue
Block a user