Compare commits

...
Author SHA1 Message Date
Spark b88191ffcb feat: HUB-21 — réglages généraux du Hub consommés par ObsiGate (bannière + drapeau recherche_partout) #229
CI / lint (pull_request) Successful in 1m16s
CI / security (pull_request) Successful in 19s
CI / test (pull_request) Successful in 3m41s
CI / build (pull_request) Successful in 13s
CI / e2e (pull_request) Failing after 14m58s
2026-11-06 17:02:04 +00:00
17 changed files with 647 additions and 15 deletions
+4 -1
View File
@@ -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.85.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.85.2**.
---
@@ -14,6 +14,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.85.2] — 2026-11-06
- **HUB-21 — Consommation des réglages généraux du Hub par ObsiGate (propagation du pilote TikTak 0.61.22, liste fermée D6(a), #229)** : nouveau client `backend/hub_reglages.py` — consultation côté serveur de `GET {hub}/api/reglages/effectifs` (endpoint public, aucun cookie, `Accept: application/json`, délai 2 s), forme validée strictement, cache mémoire 60 s sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` — jamais d'URL dev figée). Toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels. **Bannière** : fragment injecté côté serveur sous la barre commune (page principale `_index_html` et page `/recherche`), texte échappé (jamais de HTML injecté), bouton fermer avec mémorisation du texte fermé en `sessionStorage` (clé `obsigate_banniere_masquee`) ; bannière vide = aucun fragment, zéro changement visuel ; styles `.banniere-hub*` dans `frontend/style.css` (variables CSS du thème). **Drapeau `recherche_partout`** : `serve_recherche_page` remplace le jeton `{{RECHERCHE_PARTOUT}}` rendu en `data-recherche-partout` du `<main id="recherche">` de `frontend/recherche.html` ; si faux, le JS vendangé `frontend/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console — la recherche locale ObsiGate continue ; vrai ou Hub muet = comportement actuel. **`domaine_defaut` non consommé** : ObsiGate n'a pas de notion de domaine propre — le sélecteur de domaine de la barre commune pilote le panneau Spark, pas les vues d'ObsiGate ; la valeur est lue et validée par le client, sans effet local. Le libellé « Fermer l'annonce » est un attribut d'accessibilité du fragment injecté côté serveur (patron des autres hôtes), pas un texte d'interface du SPA : pas de clé i18n ajoutée. Fiche : `docs/features/hub-reglages-obsigate-229.md`. Tests : `tests/test_hub_reglages.py` (client : contrat exact, forme inattendue, erreurs, cache 60 s, ancien cache sans rafale ; intégration : bannière présente et échappée sur `/` et `/recherche`, absente si vide ou Hub muet, attribut du drapeau selon la valeur) ; `tests/frontend/recherche.test.mjs` étendu (`federationAutorisee`).
## [2.85.1] — 2026-11-06
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.85.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.85.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.2).
---
*Projet : ObsiGate | Version : 2.85.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.85.2 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.85.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.85.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.2).
---
*Project: ObsiGate | Version: 2.85.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.85.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.85.1
2.85.2
+154
View File
@@ -0,0 +1,154 @@
# backend/hub_reglages.py
# Hub HUB-21 (propagation) — Réglages généraux du Hub consommés par
# ObsiGate, sur le patron posé par l'hôte pilote TikTak (0.61.22).
#
# Le Hub expose ``GET {hub}/api/reglages/effectifs`` — endpoint
# **public** (aucun cookie relayé), ``Accept: application/json``,
# délai 2 s. Réponse 200, forme figée (liste fermée D6(a)) :
# ``{"recherche_partout": bool, "banniere": str,
# "domaine_defaut": "pro"|"famille"|"perso"|null,
# "ttl_secondes": 60}``.
#
# Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec
# conserve l'ancien cache et ne déclenche pas de rafale de requêtes
# vers un Hub en panne.
#
# **Repli intégral** : Hub injoignable, en erreur ou réponse de forme
# inattendue → les **défauts actuels** (``recherche_partout: true``,
# ``banniere: ""``, ``domaine_defaut: null``), c'est-à-dire le
# comportement d'ObsiGate avant HUB-21. Ce module ne lève jamais.
#
# Consommation chez ObsiGate :
# - ``banniere`` : injectée côté serveur sous la barre commune
# (page principale et page /recherche), texte échappé ;
# - ``recherche_partout`` : posé en attribut ``data-recherche-partout``
# de la page /recherche — si faux, la partie fédérée est désactivée
# (la recherche locale ObsiGate continue) ;
# - ``domaine_defaut`` : **non consommé** — ObsiGate n'a pas de
# notion de domaine propre ; le sélecteur de domaine de la barre
# commune pilote le panneau Spark, pas les vues d'ObsiGate. La
# valeur est lue et validée, sans effet local.
#
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
# la barre commune (#223) : variable explicite d'abord, dérivation
# par l'hôte sœur ensuite, défaut officiel enfin. Jamais d'URL dev
# figée.
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
logger = logging.getLogger("obsigate.hub_reglages")
#: Réglages par défaut = comportement actuel d'ObsiGate (repli).
DEFAUTS = {"recherche_partout": True, "banniere": "", "domaine_defaut": None}
#: Domaines reconnus du contrat D6(a).
DOMAINES = ("pro", "famille", "perso")
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
CACHE_TTL_SECONDS = 60
#: Délai maximal de la consultation du Hub (contrat : 2 s).
DELAI_SECONDS = 2
_lock = threading.Lock()
_cache: dict | None = None # derniers réglages validés
_cache_at: float | None = None # horodatage (monotone) du dernier appel
def _hub_url() -> str:
"""Base publique du Hub, résolue comme pour la barre commune."""
from backend.draco_config import app_url
return app_url("hub")
def _valider(donnees) -> dict | None:
"""Valide la forme figée du contrat ; ``None`` si inattendue."""
if not isinstance(donnees, dict):
return None
recherche = donnees.get("recherche_partout")
banniere = donnees.get("banniere")
domaine = donnees.get("domaine_defaut")
if not isinstance(recherche, bool):
return None
if not isinstance(banniere, str):
return None
if domaine is not None and domaine not in DOMAINES:
return None
return {
"recherche_partout": recherche,
"banniere": banniere,
"domaine_defaut": domaine,
}
def _consulter_hub(url: str) -> dict | None:
"""Appelle le Hub et retourne les réglages validés, ou ``None``.
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON
illisible, forme hors contrat) retourne ``None`` : repli.
Endpoint public : aucun cookie n'est relayé.
"""
requete = urllib.request.Request(
url, method="GET", headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
if reponse.status != 200:
return None
donnees = json.loads(reponse.read().decode("utf-8"))
except Exception: # les réglages ne cassent jamais une page
return None
return _valider(donnees)
def reglages_effectifs() -> dict:
"""Réglages généraux effectifs pour ObsiGate.
Réponse du Hub validée et mise en cache 60 s ; à défaut (Hub
muet, en erreur ou forme inattendue), l'ancien cache s'il
existe, sinon les défauts actuels. Ne lève jamais.
"""
global _cache, _cache_at
with _lock:
if _cache is not None and _cache_at is not None \
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS:
return dict(_cache)
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
# cache reste en vigueur, un échec ne déclenche pas de rafale.
_cache_at = time.monotonic()
url = f"{_hub_url().rstrip('/')}/api/reglages/effectifs"
reglages = _consulter_hub(url)
if reglages is None:
logger.warning(
"Réglages généraux du Hub indisponibles (%s) — "
"comportement actuel conservé", url)
with _lock:
return dict(_cache) if _cache is not None else dict(DEFAUTS)
with _lock:
_cache = reglages
_cache_at = time.monotonic()
return dict(reglages)
def banniere() -> str:
"""Texte de la bannière d'annonce (``""`` = aucune bannière)."""
return reglages_effectifs()["banniere"]
def recherche_partout() -> bool:
"""Drapeau de la recherche fédérée (défaut actuel : actif)."""
return bool(reglages_effectifs()["recherche_partout"])
def reinitialiser_cache() -> None:
"""Vide le cache (tests)."""
global _cache, _cache_at
with _lock:
_cache = None
_cache_at = None
+66
View File
@@ -859,6 +859,49 @@ def _html_with_nonce(request: Request, name: str) -> str:
)
def _fragment_banniere_hub() -> str:
"""Bandeau d'annonce des réglages généraux du Hub (HUB-21).
Retourne le fragment HTML à insérer sous la barre commune, ou
``""`` quand le Hub n'annonce rien (bannière vide ou Hub muet —
repli intégral du client :mod:`backend.hub_reglages`). Le texte
est **échappé** (jamais de HTML injecté). Le petit script inline
(le nonce CSP est ajouté par l'appelant via ``inject_csp_nonce``)
révèle le bandeau sauf si le même texte a déjà été fermé dans la
session du navigateur (``sessionStorage``, clé
``obsigate_banniere_masquee``) : une nouvelle annonce réapparaît.
"""
import html as _html
from backend import hub_reglages
texte = hub_reglages.banniere()
if not texte:
return ""
return (
'<div class="banniere-hub" id="banniere-hub" role="status" hidden>'
f'<span class="banniere-hub-texte">{_html.escape(texte)}</span>'
'<button type="button" class="banniere-hub-fermer"'
' id="banniere-hub-fermer" aria-label="Fermer l\'annonce">✕</button>'
"</div>"
"<script>(function(){try{"
'var b=document.getElementById("banniere-hub");if(!b)return;'
'var t=b.querySelector(".banniere-hub-texte").textContent;'
'if(sessionStorage.getItem("obsigate_banniere_masquee")===t)'
"{b.remove();return;}"
"b.hidden=false;"
'document.getElementById("banniere-hub-fermer")'
'.addEventListener("click",function(){'
'try{sessionStorage.setItem("obsigate_banniere_masquee",t);}'
"catch(e){}"
"b.remove();});"
"}catch(e){"
'var b2=document.getElementById("banniere-hub");'
"if(b2)b2.hidden=false;"
"}})();</script>"
)
def _index_html(request: Request) -> str:
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
@@ -886,6 +929,15 @@ def _index_html(request: Request) -> str:
html = html.replace("</head>", script + "</head>", 1)
else: # repli défensif : tout en tête de document
html = script + html
# Bannière d'annonce du Hub (HUB-21) : sous la barre commune,
# juste avant son rappel ; vide = aucun changement visuel.
fragment = _fragment_banniere_hub()
if fragment:
marqueur = '<div class="draco-bar-rappel"'
if marqueur in html:
html = html.replace(marqueur, fragment + marqueur, 1)
elif "</header>" in html:
html = html.replace("</header>", "</header>" + fragment, 1)
return inject_csp_nonce(html, request.state.csp_nonce)
if FRONTEND_DIR.exists():
@@ -989,6 +1041,15 @@ if FRONTEND_DIR.exists():
if not recherche_file.exists():
raise HTTPException(status_code=404, detail="Recherche page not found")
html = recherche_file.read_text(encoding="utf-8")
# Drapeau « Rechercher partout » des réglages généraux du Hub
# (HUB-21) : faux = la partie fédérée de la page est désactivée
# par le JS vendangé, la recherche locale continue ; Hub muet
# = défaut actuel (actif).
from backend import hub_reglages
html = html.replace(
"{{RECHERCHE_PARTOUT}}",
"true" if hub_reglages.recherche_partout() else "false")
urls = urls_publiques()
for jeton, cle in (
("{{URL_OBSIGATE}}", "obsigate"),
@@ -1008,6 +1069,11 @@ if FRONTEND_DIR.exists():
html = html.replace("</head>", script + "</head>", 1)
else:
html = script + html
# Bannière d'annonce du Hub (HUB-21) : sous la barre commune ;
# vide = aucun changement visuel.
fragment = _fragment_banniere_hub()
if fragment and "</header>" in html:
html = html.replace("</header>", "</header>" + fragment, 1)
html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.85.1"
version = "2.85.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.85.1"
version = "2.85.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.85.1",
"version": "2.85.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+23 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.85.1 | **Dernière mise à jour :** 2026-11-06
> **Version :** 2.85.2 | **Dernière mise à jour :** 2026-11-06
> **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)**
@@ -70,6 +70,28 @@
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
### 229. Hub HUB-21 — consommation des réglages généraux du Hub par ObsiGate
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.2 (voir CHANGELOG ; PR en attente de fusion ; redéploiement Docker requis)
- **Fiche :** [features/hub-reglages-obsigate-229.md](./features/hub-reglages-obsigate-229.md)
- **Description :** propagation de HUB-21 (pilote TikTak 0.61.22, liste
fermée D6(a)) : consultation côté serveur de
`GET {hub}/api/reglages/effectifs` (endpoint public, délai 2 s),
cache mémoire 60 s (horodatage posé avant l'appel), repli intégral
sur les défauts actuels si le Hub est muet, en erreur ou de forme
inattendue. Bannière d'annonce injectée sous la barre commune (page
principale et /recherche, texte échappé, fermable, masquage
mémorisé en `sessionStorage`) ; drapeau `recherche_partout` rendu
en `data-recherche-partout` de la page /recherche — faux = partie
fédérée désactivée, recherche locale intacte. `domaine_defaut`
**non consommé** : pas de notion de domaine propre à ObsiGate (le
sélecteur de la barre pilote le panneau Spark).
- **Sous-tâches :**
- [x] Client `backend/hub_reglages.py` (forme D6(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
- [x] Bannière injectée (`backend/main.py::_fragment_banniere_hub`, `_index_html`, `serve_recherche_page`) + styles `frontend/style.css`
- [x] Jeton `{{RECHERCHE_PARTOUT}}` dans `frontend/recherche.html` + branchement du JS vendangé `frontend/js/recherche.js`
- [x] Tests `tests/test_hub_reglages.py` (client + intégration) et `tests/frontend/recherche.test.mjs` étendu
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
@@ -0,0 +1,82 @@
# #229 — HUB-21 : consommation des réglages généraux du Hub par ObsiGate
**Carte :** propagation de HUB-21 (fusion #135) — le pilote TikTak
(0.61.22, en production) a posé le patron de client ; ObsiGate le
consomme à son tour, comme les autres hôtes de la suite.
**Version cible :** 2.85.2 (branche depuis le main 2.85.1).
## Contrat consommé (liste fermée D6(a))
- `GET {hub}/api/reglages/effectifs` — endpoint **public** (aucun
cookie relayé), `Accept: application/json`, délai **2 s** ;
- réponse 200, forme figée : `{"recherche_partout": bool,
"banniere": str, "domaine_defaut": "pro"|"famille"|"perso"|null,
"ttl_secondes": 60}`, `Cache-Control: public, max-age=60` ;
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de la
barre commune (#223) — variable explicite d'abord, dérivation par
l'hôte sœur ensuite, défaut officiel enfin. Jamais d'URL dev figée.
## Client — `backend/hub_reglages.py`
Sur le patron du pilote TikTak (`app/hub_reglages.py`, httpx → urllib
comme `backend/hub_roles.py`) :
- cache mémoire **60 s** ; horodatage posé **avant** l'appel : un
échec conserve l'ancien cache et ne déclenche pas de rafale vers un
Hub en panne ;
- forme validée strictement ; toute erreur (réseau, délai, statut
≠ 200, JSON illisible, forme hors contrat) → **repli intégral** sur
les défauts actuels (`recherche_partout: true`, `banniere: ""`,
`domaine_defaut: null`) — le comportement d'ObsiGate avant HUB-21.
Le module ne lève jamais ; aucun appel au démarrage.
## Consommation
1. **`banniere`** — `backend/main.py::_fragment_banniere_hub()`
construit le fragment (texte **échappé** via `html.escape`, jamais
de HTML injecté), injecté sous la barre commune par `_index_html`
(page principale, juste avant le rappel de la barre) et par
`serve_recherche_page` (page /recherche, après l'en-tête de la
barre). Le script inline (nonce CSP ajouté par `inject_csp_nonce`)
révèle le bandeau sauf si le même texte a été fermé dans la
session du navigateur (`sessionStorage`, clé
`obsigate_banniere_masquee`) : une nouvelle annonce réapparaît.
Bannière vide = aucun fragment rendu, zéro changement visuel.
Styles `.banniere-hub*` dans `frontend/style.css`, sur les
variables du thème (`var(--accent)`).
2. **`recherche_partout`** — `serve_recherche_page` remplace le jeton
`{{RECHERCHE_PARTOUT}}` de `frontend/recherche.html`, rendu en
attribut `data-recherche-partout` du `<main id="recherche">`. Le
JS vendangé `frontend/js/recherche.js` (modification identique
dans les 4 hôtes applicatifs : fonction pure
`federationAutorisee`) n'interroge plus les applications sœurs ni
la console quand la valeur est `false` — leurs sections affichent
« Recherche fédérée désactivée… » — tandis que la recherche locale
ObsiGate (même origine) continue. Attribut absent ou illisible =
fédération active (repli).
3. **`domaine_defaut` — non consommé.** ObsiGate n'a pas de notion
de domaine (Pro/Famille/Perso) propre à ses vues : le sélecteur de
domaine de la barre commune pilote le panneau Spark, pas
ObsiGate. La valeur est lue et validée par le client, sans effet
local. Seul l'hôte console (agent) applique ce réglage.
## i18n
Aucun texte d'interface du SPA n'est ajouté : la bannière est un
contenu d'annonce servi par le Hub (texte libre de l'administrateur),
et le libellé « Fermer l'annonce » est l'attribut d'accessibilité du
fragment injecté côté serveur, sur le même patron que les autres
hôtes. Pas de clé `frontend/locales/*` ajoutée.
## Tests
- `tests/test_hub_reglages.py` (nouveau) : contrat exact de l'appel
(URL, aucun cookie, délai 2 s), formes inattendues → repli, erreur
HTTP / Hub injoignable / JSON illisible → repli, cache 60 s,
ancien cache conservé sans rafale ; intégration TestClient :
bannière présente et échappée sur `/` et `/recherche`, absente si
vide ou Hub muet, `data-recherche-partout` selon la valeur du
drapeau (faux / vrai / Hub muet).
- `tests/frontend/recherche.test.mjs` : assertions ajoutées pour
`federationAutorisee` (seules les valeurs explicitement négatives
désactivent ; absent/illisible = actif).
+74 -2
View File
@@ -246,6 +246,21 @@
return [tab];
}
/* HUB-21 (propagation des réglages généraux du Hub) : le drapeau
* « Rechercher partout », rendu par l'hôte dans l'attribut
* data-recherche-partout de #recherche (« true »/« false », lu
* côté serveur depuis GET {hub}/api/reglages/effectifs, cache
* 60 s, patron du pilote TikTak 0.61.22). Seules les valeurs
* explicitement négatives désactivent la fédération ; attribut
* absent ou illisible = fédération active — le comportement
* actuel, qui est aussi le repli quand le Hub est muet.
* Fonction pure, exposée aux tests. */
function federationAutorisee(valeur) {
if (valeur == null) return true;
var v = String(valeur).trim().toLowerCase();
return v !== "false" && v !== "0" && v !== "non";
}
/* Troncature d'affichage : liste complète dans l'onglet propre à
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
function pageItems(items, full) {
@@ -514,6 +529,7 @@
visibleKeys: visibleKeys,
pageItems: pageItems,
buildSearchUrl: buildSearchUrl,
federationAutorisee: federationAutorisee,
tabCounts: tabCounts,
searchPath: searchPath,
normalize: normalize,
@@ -609,6 +625,14 @@
".</p>";
return;
}
if (state.status === "desactivee") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Recherche fédérée désactivée dans les ' +
"réglages généraux de la suite (Hub) : cette application " +
"n'est pas interrogée.</p>";
return;
}
if (state.status === "idle") {
count.textContent = "";
body.innerHTML =
@@ -651,6 +675,8 @@
var input = document.getElementById("rp-q");
if (!input) return;
var hostApp = root.getAttribute("data-host") || "";
var federation = federationAutorisee(
root.getAttribute("data-recherche-partout"));
var explicit = {};
APPS.forEach(function (app) {
var v = root.getAttribute("data-base-" + app.key);
@@ -772,7 +798,23 @@
applyTab(tab, opts);
/* La console n'est interrogée qu'une fois affichée. */
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
fetchConsole(currentQ, runId);
if (!federation) {
var secConsOnglet = sections.console;
if (secConsOnglet) {
states.console = {
status: "desactivee",
items: [],
message: "",
q: currentQ,
base: consoleBase,
app: "console",
};
renderSection(secConsOnglet, states.console, true);
renderTabs();
}
} else {
fetchConsole(currentQ, runId);
}
}
}
@@ -865,6 +907,22 @@
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
if (!federation && app.key !== hostApp) {
/* HUB-21 : fédération désactivée par le Hub — la section
* sœur n'est pas interrogée, la recherche locale
* (app.key === hostApp, même origine) continue. */
states[app.key] = {
status: "desactivee",
items: [],
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
renderSection(sec, states[app.key], activeTab === app.key);
renderTabs();
return;
}
states[app.key] = {
status: "loading",
items: [],
@@ -915,7 +973,21 @@
});
/* Console : seulement si son onglet est affiché ; sinon son
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
if (activeTab === "console") {
if (activeTab === "console" && !federation) {
var secConsRecherche = sections.console;
if (secConsRecherche) {
states.console = {
status: "desactivee",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
renderSection(secConsRecherche, states.console, true);
renderTabs();
}
} else if (activeTab === "console") {
fetchConsole(q, myRun);
} else {
states.console = {
+1
View File
@@ -129,6 +129,7 @@
<div class="content-wrapper">
<div class="content-area">
<main class="rp-page" id="recherche" data-host="obsigate"
data-recherche-partout="{{RECHERCHE_PARTOUT}}"
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
+9
View File
@@ -14378,3 +14378,12 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
bandeau injecté côté serveur sous la barre commune, fermable
(masquage mémorisé pour la session du navigateur). */
.banniere-hub { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--accent, #7c5cd6); color: #fff; font-size: 14px; }
.banniere-hub[hidden] { display: none; }
.banniere-hub-texte { flex: 1; overflow-wrap: anywhere; }
.banniere-hub-fermer { flex: none; background: transparent; border: none; color: inherit; font-size: 15px; line-height: 1; padding: 4px 6px; cursor: pointer; border-radius: 6px; }
.banniere-hub-fermer:hover { background: rgba(255, 255, 255, 0.18); }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.85.1",
"version": "2.85.2",
"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": {
+10
View File
@@ -207,4 +207,14 @@ ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide re
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
/* federationAutorisee (HUB-21) : seules les valeurs explicitement
négatives désactivent la fédération ; absent/illisible = actif
(repli = comportement actuel, Hub muet compris). */
ok(rp.federationAutorisee("false") === false, "fédération : false désactive");
ok(rp.federationAutorisee("0") === false, "fédération : 0 désactive");
ok(rp.federationAutorisee("true") === true, "fédération : true active");
ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif");
ok(rp.federationAutorisee("") === true, "fédération : vide = actif");
ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif");
console.log(`recherche.test.mjs : ${n} assertions OK`);
+213
View File
@@ -0,0 +1,213 @@
# tests/test_hub_reglages.py
# Hub HUB-21 (propagation) — Réglages généraux du Hub consommés par
# ObsiGate : client (parsing de la forme figée, cache 60 s, repli
# intégral), bannière injectée côté serveur (page principale et page
# /recherche, texte échappé) et drapeau ``recherche_partout`` rendu
# en attribut de la page /recherche. Patron posé par l'hôte pilote
# TikTak 0.61.22. ``domaine_defaut`` n'est pas appliqué : ObsiGate
# n'a pas de notion de domaine propre (voir backend/hub_reglages.py).
import json
import time
import urllib.error
import pytest
from backend import hub_reglages
@pytest.fixture(autouse=True)
def _hub_env(monkeypatch):
"""Cache vierge + Hub de test explicite pour chaque test."""
hub_reglages.reinitialiser_cache()
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
yield
hub_reglages.reinitialiser_cache()
def _reponse(corps=None, statut=200):
"""Fausse réponse urlopen portant un payload du contrat D6(a)."""
if corps is None:
corps = {"recherche_partout": True, "banniere": "",
"domaine_defaut": None, "ttl_secondes": 60}
charge = json.dumps(corps).encode("utf-8")
class _Resp:
status = statut
def read(self):
return charge
def __enter__(self):
return self
def __exit__(self, *exc):
return False
return _Resp()
def _patch_consultation(monkeypatch, reglages, appels=None):
"""Remplace la consultation HTTP par des réglages fixes."""
def _faux(url):
if appels is not None:
appels.append(url)
return reglages
monkeypatch.setattr(hub_reglages, "_consulter_hub", _faux)
# ── Client : parsing du contrat ─────────────────────────────────────
def test_consultation_parse_le_contrat(monkeypatch):
vu = {}
def _faux_urlopen(requete, timeout=None):
vu["url"] = requete.full_url
vu["cookie"] = requete.get_header("Cookie")
vu["accept"] = requete.get_header("Accept")
vu["timeout"] = timeout
return _reponse({"recherche_partout": False,
"banniere": "Maintenance dimanche",
"domaine_defaut": "famille", "ttl_secondes": 60})
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen", _faux_urlopen)
reglages = hub_reglages.reglages_effectifs()
assert reglages == {"recherche_partout": False,
"banniere": "Maintenance dimanche",
"domaine_defaut": "famille"}
assert vu["url"] == "https://hub-test.dracodev.net/api/reglages/effectifs"
assert vu["cookie"] is None # endpoint public : aucun cookie
assert vu["accept"] == "application/json"
assert vu["timeout"] == 2 # délai du contrat
@pytest.mark.parametrize("corps", [
{"recherche_partout": "oui", "banniere": "", "domaine_defaut": None},
{"recherche_partout": True, "banniere": 42, "domaine_defaut": None},
{"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"},
{"banniere": "", "domaine_defaut": None},
["pas", "un", "dict"],
])
def test_consultation_forme_inattendue_repli(monkeypatch, corps):
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
lambda requete, timeout=None: _reponse(corps))
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
def test_consultation_erreur_http_repli(monkeypatch):
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
lambda requete, timeout=None: _reponse(statut=500))
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
assert hub_reglages.banniere() == ""
assert hub_reglages.recherche_partout() is True
def test_consultation_hub_injoignable_repli(monkeypatch):
def _leve(requete, timeout=None):
raise urllib.error.URLError("injoignable")
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen", _leve)
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
def test_consultation_json_illisible_repli(monkeypatch):
class _Resp:
status = 200
def read(self):
return b"pas du json"
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
lambda requete, timeout=None: _Resp())
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
# ── Client : cache 60 s, ancien cache, pas de rafale ────────────────
def test_cache_60_s(monkeypatch):
appels = []
_patch_consultation(monkeypatch, {
"recherche_partout": False, "banniere": "Annonce A",
"domaine_defaut": None}, appels)
assert hub_reglages.banniere() == "Annonce A"
assert hub_reglages.recherche_partout() is False
assert len(appels) == 1 # cache 60 s
hub_reglages._cache_at = time.monotonic() - 61
assert hub_reglages.banniere() == "Annonce A"
assert len(appels) == 2 # expiration → rappel
def test_echec_ancien_cache_sans_rafale(monkeypatch):
appels = []
_patch_consultation(monkeypatch, {
"recherche_partout": True, "banniere": "Annonce A",
"domaine_defaut": None}, appels)
assert hub_reglages.banniere() == "Annonce A"
hub_reglages._cache_at = time.monotonic() - 61
_patch_consultation(monkeypatch, None, appels) # Hub tombe
assert hub_reglages.banniere() == "Annonce A" # ancien cache
assert len(appels) == 2 # pas de rafale
assert hub_reglages.banniere() == "Annonce A"
assert len(appels) == 2
# ── Intégration : bannière et drapeau dans les pages servies ────────
@pytest.fixture()
def hub_banniere(monkeypatch):
etat = {"reglages": None}
monkeypatch.setattr(hub_reglages, "_consulter_hub",
lambda url: etat["reglages"])
hub_reglages.reinitialiser_cache()
return etat
def test_banniere_presente_et_echappee_index(client, hub_banniere):
hub_banniere["reglages"] = {
"recherche_partout": True,
"banniere": "Maintenance dimanche <b>gras</b> & cie",
"domaine_defaut": None}
r = client.get("/")
assert r.status_code == 200
assert 'id="banniere-hub"' in r.text
assert "Maintenance dimanche &lt;b&gt;gras&lt;/b&gt; &amp; cie" in r.text
assert "obsigate_banniere_masquee" in r.text
def test_banniere_absente_si_vide_ou_hub_muet(client, hub_banniere):
hub_banniere["reglages"] = {
"recherche_partout": True, "banniere": "", "domaine_defaut": "pro"}
r = client.get("/")
assert r.status_code == 200
assert "banniere-hub" not in r.text
hub_banniere["reglages"] = None
hub_reglages.reinitialiser_cache()
r = client.get("/")
assert r.status_code == 200
assert "banniere-hub" not in r.text
def test_recherche_banniere_et_drapeau(client, hub_banniere):
hub_banniere["reglages"] = {
"recherche_partout": False, "banniere": "Annonce recherche",
"domaine_defaut": None}
r = client.get("/recherche")
assert r.status_code == 200
assert 'data-recherche-partout="false"' in r.text
assert 'id="banniere-hub"' in r.text
assert "Annonce recherche" in r.text
hub_banniere["reglages"] = {
"recherche_partout": True, "banniere": "", "domaine_defaut": None}
hub_reglages.reinitialiser_cache()
r = client.get("/recherche")
assert 'data-recherche-partout="true"' in r.text
assert "banniere-hub" not in r.text
hub_banniere["reglages"] = None # Hub muet → défaut actif
hub_reglages.reinitialiser_cache()
r = client.get("/recherche")
assert 'data-recherche-partout="true"' in r.text