Compare commits

..
Author SHA1 Message Date
bruno fc44efabbb Changelog 2.82.3 : thème /recherche, CORS console (PR #49/#51)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:22:51 -04:00
bruno 3ed09cbc0f Bump 2.82.3
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 16:22:48 -04:00
bruno a4f27bdbe7 Bump 2.82.3
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:44 -04:00
bruno 05d1903045 Bump 2.82.3
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:22:39 -04:00
bruno b5b4a00b75 Bump 2.82.3
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:35 -04:00
bruno 97f997840c Bump 2.82.3
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 16:22:31 -04:00
bruno 474851b3bb Bump 2.82.3
CI / lint (push) Failing after 2s
CI / security (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:26 -04:00
bruno 4dfa8fd74c Merge pull request 'CORS recherche : autoriser la console Spark (agent prod+dev)' (#51) from federation-recherche-console into main
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:17:07 -04:00
bruno e709310e70 Merge pull request 'recherche: appliquer le thème ObsiGate à la page /recherche (défaut 4)' (#49) from obsigate-recherche-theme into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:17:05 -04:00
bruno 4d0c8ad934 Tests CORS recherche: origines agent autorisees, tiers refuse
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / security (pull_request) Failing after 1s
CI / e2e (pull_request) Skipped
2026-10-28 16:12:15 -04:00
bruno b609e8913c CORS recherche: autoriser les origines agent (console Spark) prod+dev 2026-10-28 16:12:10 -04:00
bruno f10e3d12e6 recherche: tests du theme de la page (defaut 4)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m57s
2026-10-28 15:59:18 -04:00
bruno 78c514c78b recherche: decliner la barre et les accents selon le theme (defaut 4) 2026-10-28 15:59:13 -04:00
bruno d20ae93b40 recherche: appliquer le theme ObsiGate (defaut 4) 2026-10-28 15:59:08 -04:00
bruno a885153a68 Bump ObsiGate 2.82.2 : changelog (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:30 -04:00
bruno 3edeb88c92 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 15:07:26 -04:00
bruno 677b174f09 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 15:07:23 -04:00
bruno ab3d0798c7 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:20 -04:00
bruno 332983c4d6 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:17 -04:00
bruno 2c2f23dc57 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:14 -04:00
bruno 097accc44c Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 15:07:10 -04:00
bruno 7ea5614093 Merge pull request 'recherche : onglet Console Spark sur GET /api/v1/recherche + barre /recherche re-vendangée (alignement)' (#48) from align-recherche-console into main
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 15:04:32 -04:00
bruno e4da00cf17 recherche: jeton URL_AGENT + connect-src agent pour la page /recherche
CI / lint (pull_request) Failing after 3s
CI / security (pull_request) Failing after 2s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 14:45:13 -04:00
bruno 03e8abf8c8 recherche: re-vendoring barre /recherche (5e onglet Console Spark) 2026-10-28 14:45:09 -04:00
bruno 6750cdf81a recherche: tests alignement console /api/v1/recherche 2026-10-28 14:45:06 -04:00
bruno ffc70e10b9 recherche: onglet Console Spark sur GET /api/v1/recherche (alignement) 2026-10-28 14:45:01 -04:00
bruno ed834a44b5 Version 2.82.1
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 14:27:58 -04:00
bruno 66312bba2d Version 2.82.1
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:27:54 -04:00
bruno 46572501cf Version 2.82.1
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:27:48 -04:00
bruno b9a570424b Version 2.82.1
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:27:38 -04:00
bruno c6c06a7db9 Version 2.82.1
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 14:27:35 -04:00
bruno f12f69124f Version 2.82.1
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 14:27:32 -04:00
bruno 504a349224 Version 2.82.1
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 14:27:29 -04:00
bruno d6549d1b86 Merge pull request 'Chantier 5 — Alertes en onglets + composant partagé + onglet Console Spark dans la barre' (#47) from alertes-onglets-5 into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:26:02 -04:00
bruno 9c9828e9bd Merge pull request 'Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page' (#46) from recherche-onglets-ux-68 into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:25:53 -04:00
bruno 5b9fe5944b Merge pull request 'CORS recherche : allowlist soeurs prod+dev explicites (fusion #68/#69)' (#44) from cors-recherche-alertes-68-69 into main
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 14:25:28 -04:00
bruno 3a7b119b24 CHANGELOG 2.82.0 (coffre distribuables, PR #45)
CI / lint (push) Failing after 2s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:00:12 -04:00
bruno 5ad6b68fef Version 2.82.0 (ROADMAP)
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 2s
CI / e2e (push) Skipped
2026-10-28 14:00:07 -04:00
bruno 37e3ffd586 Version 2.82.0 (Cargo.lock)
CI / lint (push) Failing after 3s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:00:03 -04:00
bruno 36dacc55da Version 2.82.0 (Cargo.toml)
CI / lint (push) Failing after 3s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:59:58 -04:00
bruno 3180ef63ce Version 2.82.0 (tauri.conf.json)
CI / lint (push) Failing after 2s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:59:53 -04:00
bruno bdba956bd2 Version 2.82.0 (package.json)
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / security (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:59:49 -04:00
bruno 732af9de55 Version 2.82.0 (coffre distribuables, PR #45)
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 1s
CI / e2e (push) Skipped
2026-10-28 13:59:45 -04:00
bruno 564f2a7b11 Merge pull request 'Coffre : endpoint de liste des secrets distribuables pour un consommateur (issue #43)' (#45) from coffre-distribuables into main
CI / lint (push) Failing after 4s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 13:57:16 -04:00
bruno 53b6f27fd2 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
CI / lint (pull_request) Failing after 3s
CI / security (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 13:50:50 -04:00
bruno 8f5e1f1b0c Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:46 -04:00
bruno 6eb679f234 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:42 -04:00
bruno 1604245f94 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:40 -04:00
bruno 5154afed01 Test : distribuables (octrois actifs, métadonnées seules, auth consommateur)
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 13:48:49 -04:00
bruno 8c40aba850 Coffre : endpoint GET /api/coffres/distribuables pour un consommateur (issue #43) 2026-10-28 13:48:46 -04:00
bruno b9a7824b28 Tests CORS recherche : prod+dev autorises, tiers refuse
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 13:45:55 -04:00
bruno 883ededa8f CORS recherche (fusion #68/#69) : allowlist soeurs prod+dev explicites 2026-10-28 13:45:52 -04:00
17 changed files with 1462 additions and 144 deletions
+26 -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.81.4**.
> [Unreleased](#unreleased). La dernière version livrée est **2.82.3**.
---
@@ -14,6 +14,31 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.82.3] — 2026-10-28
- **Recherche (PR #49)** : la page `/recherche` applique désormais le thème ObsiGate.
- **CORS recherche (PR #51)** : la recherche ObsiGate autorise désormais la console Spark (agent prod+dev) en inter-origines.
## [2.82.2] — 2026-10-28
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
## [2.82.1] — 2026-10-28
### Modifié
- **CORS recherche/alertes (#44)** : allowlist explicite des 8 origines sœurs prod+dev, avec credentials, jamais `*` — chaque serveur n'autorisait que l'environnement résolu (prod OU dev, jamais les deux).
- **Recherche partout (#46)** : page `/recherche` réintégrée dans le gabarit applicatif, focus automatique, « Tout voir » restant sur la page, onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark.
- **Alertes unifiées (#47)** : onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark / Historique (actives sauf Historique = fermées), filtre texte, composant partagé, bouton Console Spark en 5e onglet de l'en-tête après Kanbante.
---
## [2.82.0] — 2026-10-28
### Ajouté
- **Coffre : secrets distribuables (#43)** : nouvel endpoint `GET /api/coffres/distribuables` — authentifié par jeton consommateur (la même vérification que `/distribuer`), il renvoie les métadonnées seules des secrets pour lesquels ce consommateur a un octroi actif (coffre actif, secret non expiré, octroi ni révoqué ni expiré, octrois coffre-wide inclus). Un consommateur sans octroi reçoit une liste vide. Jamais de valeur dans la réponse. Consommateur : la console Spark (agent-commun #14 / PR #15), pour la liste déroulante des clés de fournisseurs LLM.
## [2.81.4] — 2026-10-28
### Modifié
+1 -1
View File
@@ -1 +1 @@
2.81.4
2.82.3
+12 -7
View File
@@ -966,12 +966,15 @@ if FRONTEND_DIR.exists():
frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment
de barre) et le nonce CSP frais ; la réponse pose sa propre
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose
que si elle est absente) dont le connect-src autorise les
origines sœurs — sinon le navigateur bloquerait les appels
sœurs avant même CORS. La CSP par défaut (same-origin) reste
inchangée pour toutes les autres pages.
de barre, jeton {{URL_AGENT}} inclus pour l'onglet Console
Spark de la barre re-vendangée) et le nonce CSP frais ; la
réponse pose sa propre CSP (préservée par
SecurityHeadersMiddleware, qui ne la pose que si elle est
absente) dont le connect-src autorise les origines sœurs ET
l'origine de l'agent — sinon le navigateur bloquerait les
appels sœurs et l'appel console (onglet Console Spark,
GET /api/v1/recherche) avant même CORS. La CSP par défaut
(same-origin) reste inchangée pour toutes les autres pages.
"""
import json as _json
@@ -989,6 +992,7 @@ if FRONTEND_DIR.exists():
("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"),
("{{URL_AGENT}}", "agent"),
):
html = html.replace(jeton, urls.get(cle, ""))
script = (
@@ -1003,7 +1007,8 @@ if FRONTEND_DIR.exists():
html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante")
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
"agent")
} - {""}))
csp = (
"default-src 'self'; "
+25 -12
View File
@@ -8,12 +8,19 @@ Ce module n'autorise donc que :
- le chemin ``/api/search`` (jamais les autres routes, jamais les
écritures) ;
- les origines sœurs : celles de ``backend.draco_config``
(``urls_publiques()`` : explicite > dérivation par l'hôte de
l'agent > défaut officiel), **plus** les origines prod ou ``-dev``
officielles correspondant à l'hôte de la requête — ce second
ensemble couvre le cas où la configuration seule ne déclare pas
l'environnement courant ;
- les origines sœurs : les origines officielles **prod ET dev,
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
ET dev — fédération de la recherche depuis la console,
2026-10-28), **plus**
celles de ``backend.draco_config`` (``urls_publiques()`` :
explicite > dérivation par l'hôte de l'agent > défaut officiel)
— jamais ``*``. Correctif 2026-10-28 (fusion #68/#69, matrice
constatée par Bruno) : l'ancienne allowlist ne contenait que
l'environnement de la configuration (plus celui de l'hôte de la
requête), jamais les deux à la fois — une page de l'autre
environnement voyait sa section afficher « Injoignable depuis
cet hôte (CORS ou réseau) » ;
- avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` via le repli de ``backend.auth.middleware``, ou
le cookie natif ``access_token``).
@@ -45,6 +52,12 @@ DEV_ORIGINS = frozenset({
"https://kanbante-dev.dracodev.net",
})
AGENT_ORIGINS = frozenset({
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
})
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
@@ -104,12 +117,12 @@ def sister_origins(resolved: dict | None = None, request_host: str | None = None
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
if is_dev_host(request_host):
origins |= DEV_ORIGINS
elif is_prod_host(request_host):
origins |= PROD_ORIGINS
if not origins:
return PROD_ORIGINS | DEV_ORIGINS
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
# l'environnement de la requête, désormais inclus d'office).
# ``resolved`` n'ajoute que des origines de configuration sans
# jamais retirer une origine officielle.
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
return frozenset(origins)
+45
View File
@@ -57,6 +57,7 @@ from backend.coffre_store import (
SecretExpireError,
ValidationError,
compter_octrois_actifs,
est_expire,
compter_secrets,
create_coffre,
create_consommateur,
@@ -495,6 +496,50 @@ async def api_distribuer(request: Request, body: dict = Body(...)):
}
@router.get("/api/coffres/distribuables")
async def api_distribuables(request: Request):
"""Secrets distribuables pour un consommateur (issue #43).
Authentification par jeton consommateur (la même que
/distribuer), jamais par session utilisateur : c'est le
serveur agent qui appelle, pour alimenter la liste déroulante
de ses fournisseurs LLM. Retour = métadonnées seules des
secrets pour lesquels ce consommateur a un octroi ACTIF
(coffre actif, secret non expiré, octroi ni révoqué ni
expiré, y compris les octrois coffre-wide). Un consommateur
sans octroi reçoit une liste vide — jamais d'erreur
révélatrice, jamais de valeur.
"""
_exiger_actives()
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
raise HTTPException(status_code=401,
detail="consommateur_invalide")
if consommateur["statut"] != "actif":
raise HTTPException(status_code=403,
detail="consommateur_revoque")
sortie = []
for coffre in list_coffres():
if coffre["statut"] != "actif":
continue
for secret in list_secrets(coffre["id"]):
if est_expire(secret.get("expiration")):
continue
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"])
if statut != "ok":
continue
sortie.append({
"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
"coffre": coffre["slug"],
"nom": secret["nom"],
"version": secret["version_courante"],
})
return sortie
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.81.4"
version = "2.82.3"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.81.4"
version = "2.82.3"
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.81.4",
"version": "2.82.3",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.81.4 | **Dernière mise à jour :** 2026-10-28
> **Version :** 2.82.3 | **Dernière mise à jour :** 2026-10-28
> **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)**
+641 -66
View File
@@ -1,11 +1,39 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
* chantier 2+3 : onglets, « Tout voir », focus).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
* de cette application (aucune troncature côté client — la requête
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
* jeu complet). « Tout voir » d'une section ne quitte plus la page :
* il bascule vers l'onglet de l'application (son href reste une URL
* partageable ?q=&tab= au cas où le JS ne tournerait pas).
* L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
* et le retour arrière navigateur le restaure (popstate).
*
* Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
* son application (pas le nombre tronqué affiché dans « Tout »),
* « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
* une section en erreur/indisponible (pas de faux 0). L'onglet Tout
* affiche « … » tant qu'une section charge, sinon la somme des totaux
* connus (une section en erreur compte pour 0) ; il est vide si les
* 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
* Une section injoignable ne casse jamais les autres.
*
* Focus : l'attribut autofocus seul ne survit pas au chargement des
* surcouches (barre, palette, app) — la page appelle donc focus()
* explicitement en fin d'init (plus un rappel rAF/timeout et un
* rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
* data-act="palette") sur cette même page replace aussi le curseur
* dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
*
* Contrat d'API (audité, rien d'inventé) :
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
@@ -14,10 +42,39 @@
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
* - Kanbante : GET {base}/api/search?q=
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* (agent-commun, authentifié par le cookie SSO draco_session) ->
* { q, domain, limite_par_type, resultats[], par_type,
* non_cherchables[] } ; chaque résultat porte type, id, titre,
* extrait, statut, domaine, section, objet et lien (relatif à
* l'origine console). q < 2 caractères -> 422. Types cherchables :
* discussion, mission, approbation, livrable, activite,
* planification, objectif, identite (aucun secret). Liens profonds
* (préfixés par la base console) : discussion
* /?section=conversation&disc=<id>, mission
* /?section=missions&objet=<id>, approbation
* /?section=approbations&objet=<id>, objectif
* /?section=objectifs&objet=<id>, activite /?activite=<id> ;
* livrable /?section=livrables, planification /?section=planifie
* et identite /?section=identites ouvrent la vue sans sélection
* d'objet. L'onglet appelle avec limite=20 (plafond par type) car
* il affiche la liste complète, comme les onglets d'applications.
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
* section affiche honnêtement « Console Spark : recherche
* indisponible pour l'instant » — jamais de faux résultats,
* jamais de faux 0. La base console vient, dans l'ordre, de
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
* prod agent.dracodev.net. L'onglet console n'est interrogé que
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
* polluer la vue groupée d'un message d'indisponibilité.
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
@@ -31,9 +88,12 @@
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
* (GET sur l'endpoint de recherche uniquement, pas de "*").
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* et par GET /api/v1/recherche de la console (identity() accepte
* draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
* côté console, un middleware borné à /api/v1/recherche autorise
* de même les 8 origines hôtes prod ET dev, avec credentials.
* Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native.
*/
@@ -56,12 +116,36 @@
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
var CONSOLE_SEARCH_PATH = "api/v1/recherche";
var CONSOLE_SEARCH_LIMIT = 20;
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
discussion: "Discussion",
mission: "Mission",
approbation: "Approbation",
livrable: "Livrable",
activite: "Activité",
planification: "Planification",
objectif: "Objectif",
identite: "Identité",
};
/* Lien profond console (relatif à l'origine console) quand le
* résultat n'en porte pas : même règle que _lien_recherche
* d'agent-commun (section + objet, activite en section=null). */
var CONSOLE_VIEW_LINKS = {
livrable: "/?section=livrables",
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
@@ -73,6 +157,7 @@
var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8;
var FULL_LIMIT = 100;
function esc(s) {
return String(s == null ? "" : s)
@@ -120,9 +205,92 @@
return out;
}
function searchPath(app, q) {
/* Base console : explicite > URL agent injectée par l'hôte >
* dérivation -dev > href d'onglet de barre console/spark > prod.
* opts : { host, explicit, agentUrl, tabHrefs:{} } */
function resolveConsoleBase(opts) {
opts = opts || {};
var u = cleanUrl(opts.explicit);
if (!u) u = cleanUrl(opts.agentUrl);
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
if (!u)
u = cleanUrl(
opts.tabHrefs &&
(opts.tabHrefs.console || opts.tabHrefs.spark || opts.tabHrefs.agent)
);
if (!u) u = PROD_AGENT;
return withSlash(u);
}
/* Onglet valide dans l'URL, sinon "all". */
function parseTab(v) {
return TABS.indexOf(v) !== -1 ? v : "all";
}
/* Sections visibles pour un onglet. « Tout » = les 4 sections
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
function visibleKeys(tab) {
tab = parseTab(tab);
if (tab === "all") return APPS.map(function (a) { return a.key; });
return [tab];
}
/* Troncature d'affichage : liste complète dans l'onglet propre à
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
function pageItems(items, full) {
items = items || [];
return full ? items : items.slice(0, PER_APP_LIMIT);
}
/* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */
function buildSearchUrl(q, tab) {
var parts = [];
if (q) parts.push("q=" + encodeURIComponent(q));
tab = parseTab(tab);
if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab));
return parts.length ? "?" + parts.join("&") : "";
}
/* Libellés des compteurs d'onglets à partir des états :
* states : { key: { status, total } }.
* Chargement -> "…", ok/vide -> total ("0" honnête),
* erreur/indisponible/inactif -> "" (pas de faux 0).
* Tout : "…" tant qu'une section charge, sinon somme des totaux
* connus, "" si les 4 sections sont en erreur/inactives. */
function tabCounts(states) {
states = states || {};
var out = {};
function labelFor(key) {
var st = states[key] || {};
if (st.status === "loading") return "…";
if (st.status === "ok" || st.status === "empty") return String(st.total || 0);
return "";
}
var allLoading = false;
var allTotal = 0;
var allKnown = false;
APPS.forEach(function (app) {
var st = states[app.key] || {};
out[app.key] = labelFor(app.key);
if (st.status === "loading") allLoading = true;
if (st.status === "ok" || st.status === "empty") {
allKnown = true;
allTotal += st.total || 0;
}
});
out.console = labelFor("console");
out.all = allLoading ? "…" : allKnown ? String(allTotal) : "";
return out;
}
function searchPath(app, q, full) {
if (app === "obsigate") {
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
return (
"api/search?q=" +
encodeURIComponent(q) +
"&vault=all&limit=" +
(full ? FULL_LIMIT : PER_APP_LIMIT)
);
}
return "api/search?q=" + encodeURIComponent(q);
}
@@ -133,11 +301,17 @@
}
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
function normalize(app, data, base, q) {
* base : base résolue de l'app (avec / final). q : requête (liens TikTak).
* full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT
* (comportement historique, conservé par défaut pour les tests). */
function normalize(app, data, base, q, full) {
data = data || {};
var limit = full ? null : PER_APP_LIMIT;
function cut(arr) {
return limit == null ? arr : arr.slice(0, limit);
}
if (app === "obsigate") {
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
return cut(data.results || []).map(function (r) {
return {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
@@ -153,7 +327,7 @@
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
return items.slice(0, PER_APP_LIMIT).map(function (r) {
return cut(items).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
@@ -164,7 +338,7 @@
});
}
if (app === "tiktak") {
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
return cut(data.tasks || []).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
@@ -182,7 +356,7 @@
rows.push({ group: g.full_name, row: row });
});
});
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
return cut(rows).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
@@ -196,6 +370,86 @@
return [];
}
/* Normalisation console : format réel GET /api/v1/recherche,
* { resultats: [{ type, id, titre, extrait, statut, domaine,
* section, objet, lien }] }. Le lien relatif est préfixé par la
* base console ; sans lien, il est reconstruit depuis type/id
* (même règle que la console) ; sans lien reconstructible,
* url=null et le résultat est rendu en <span>, pas en faux lien.
* Les anciennes formes tolérées (results/discussions/items, avec
* url/href/link) restent acceptées en repli, pour ne pas casser
* un déploiement console antérieur. Retourne { recognized,
* items } : recognized=false -> l'appelant affiche
* « indisponible », jamais un vide mensonger. */
function consoleLinkFor(r) {
var lien = r.lien || "";
if (lien) return lien;
var id = r.id != null ? String(r.id) : "";
if (r.type === "discussion")
return "/?section=conversation&disc=" + encodeURIComponent(id);
if (r.type === "mission")
return "/?section=missions&objet=" + encodeURIComponent(id);
if (r.type === "approbation")
return "/?section=approbations&objet=" + encodeURIComponent(id);
if (r.type === "objectif")
return "/?section=objectifs&objet=" + encodeURIComponent(id);
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
if (r.section)
return (
"/?section=" +
encodeURIComponent(r.section) +
(r.objet != null
? "&objet=" + encodeURIComponent(String(r.objet))
: "")
);
return "";
}
function normalizeConsole(data, base) {
data = data || {};
if (Array.isArray(data.resultats)) {
var items = data.resultats.map(function (r) {
r = r || {};
var rel = consoleLinkFor(r);
var url = null;
if (/^https?:\/\//.test(rel)) url = rel;
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
url = base + String(rel).replace(/^\//, "");
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
var context = label;
if (r.statut) context += " · " + r.statut;
if (r.domaine) context += " · " + r.domaine;
return {
title: r.titre || r.title || "Sans titre",
snippet: snippetOf(r.extrait || r.snippet || ""),
context: context,
url: url,
};
});
return { recognized: true, items: items };
}
var list = null;
if (Array.isArray(data.results)) list = data.results;
else if (Array.isArray(data.discussions)) list = data.discussions;
else if (Array.isArray(data.items)) list = data.items;
if (!list) return { recognized: false, items: [] };
var legacy = list.map(function (r) {
r = r || {};
var raw = r.url || r.href || r.link || "";
var url = null;
if (/^https?:\/\//.test(raw)) url = raw;
else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, "");
return {
title: r.title || r.name || r.subject || "Sans titre",
snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""),
context: r.subtitle || r.source || r.type || "",
url: url,
};
});
return { recognized: true, items: legacy };
}
/* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") {
@@ -214,6 +468,7 @@
}
function appLabel(key) {
if (key === "console") return CONSOLE_APP.label;
for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label;
}
@@ -224,11 +479,26 @@
esc: esc,
isDevHost: isDevHost,
resolveBases: resolveBases,
resolveConsoleBase: resolveConsoleBase,
parseTab: parseTab,
visibleKeys: visibleKeys,
pageItems: pageItems,
buildSearchUrl: buildSearchUrl,
tabCounts: tabCounts,
searchPath: searchPath,
normalize: normalize,
normalizeConsole: normalizeConsole,
nativeSearchUrl: nativeSearchUrl,
PROD: PROD,
DEV: DEV,
PROD_AGENT: PROD_AGENT,
DEV_AGENT: DEV_AGENT,
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
consoleLinkFor: consoleLinkFor,
PER_APP_LIMIT: PER_APP_LIMIT,
FULL_LIMIT: FULL_LIMIT,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api;
@@ -268,16 +538,24 @@
});
}
function renderSection(section, state) {
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
function renderSection(section, state, full) {
/* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable",
* items (complets), message, q, base, app }.
* full=true (onglet propre à l'app) : tout afficher ; sinon
* PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */
var body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all");
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
toutVoir.textContent = "Tout voir";
toutVoir.title = nativeSearchLabel(state.app);
if (toutVoir) {
try {
toutVoir.href =
global.location.pathname + buildSearchUrl(state.q, state.app);
} catch (e) {}
toutVoir.textContent = "Tout voir";
toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app);
}
if (state.status === "loading") {
count.textContent = "";
count.textContent = "…";
body.innerHTML = '<p class="rp-state">Recherche…</p>';
return;
}
@@ -293,19 +571,31 @@
"</a></p>";
return;
}
if (state.status === "empty") {
if (state.status === "unavailable") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
(state.message ? " (" + esc(state.message) + ")" : "") +
".</p>";
return;
}
if (state.status === "idle") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
return;
}
if (state.status === "empty") {
count.textContent = "0";
body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return;
}
var shown = pageItems(state.items, full);
count.textContent = String(state.items.length);
body.innerHTML = state.items
body.innerHTML = shown
.map(function (item) {
return (
'<a class="rp-item" data-rp-link href="' +
esc(item.url) +
'">' +
var inner =
'<span class="rp-title">' +
esc(item.title) +
"</span>" +
@@ -314,9 +604,13 @@
: "") +
(item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
: "") +
"</a>"
);
: "");
if (item.url) {
return (
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
);
}
return '<span class="rp-item">' + inner + "</span>";
})
.join("");
}
@@ -349,40 +643,213 @@
barUrls: barUrls,
tabHrefs: tabHrefs,
});
var consoleBase = resolveConsoleBase({
host: global.location.hostname,
explicit: root.getAttribute("data-base-console"),
agentUrl:
(global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL,
tabHrefs: tabHrefs,
});
var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]);
var sections = {};
APPS.forEach(function (app) {
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
if (el) sections[app.key] = el;
allKeys.forEach(function (key) {
var el = document.querySelector('[data-rp-section="' + key + '"]');
if (el) sections[key] = el;
});
var tabButtons = {};
document.querySelectorAll("[data-rp-tab]").forEach(function (btn) {
tabButtons[btn.getAttribute("data-rp-tab")] = btn;
});
var tablist = document.querySelector(".rp-tabs");
/* États par application (+ console). total = items.length. */
var states = {};
allKeys.forEach(function (key) {
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
});
var activeTab = "all";
var currentQ = "";
var runId = 0;
var consoleFetchedQ = null;
var debounceTimer = null;
function statesForCounts() {
var out = {};
allKeys.forEach(function (key) {
out[key] = { status: states[key].status, total: states[key].items.length };
});
return out;
}
function renderTabs() {
var counts = tabCounts(statesForCounts());
Object.keys(tabButtons).forEach(function (key) {
var btn = tabButtons[key];
var selected = key === activeTab;
btn.classList.toggle("active", selected);
btn.setAttribute("aria-selected", selected ? "true" : "false");
btn.tabIndex = selected ? 0 : -1;
var span = btn.querySelector(".rp-tab-count");
if (span) span.textContent = counts[key] || "";
});
}
/* Bascule d'onglet : visibilité des sections, re-rendu (plein dans
* l'onglet propre à l'app, tronqué dans « Tout »), compteurs.
* opts : { push (historique), focusTab }. */
function applyTab(tab, opts) {
opts = opts || {};
activeTab = parseTab(tab);
try {
root.setAttribute("data-rp-tab", activeTab);
} catch (e) {}
var visible = visibleKeys(activeTab);
allKeys.forEach(function (key) {
var sec = sections[key];
if (!sec) return;
var show = visible.indexOf(key) !== -1;
sec.hidden = !show;
if (show) renderSection(sec, states[key], activeTab === key);
var tv = sec.querySelector(".rp-all");
if (tv) tv.hidden = activeTab === key;
});
renderTabs();
if (opts.push) {
try {
global.history.pushState(
null,
"",
global.location.pathname + buildSearchUrl(currentQ, activeTab)
);
} catch (e) {}
}
if (opts.focusTab && tabButtons[activeTab]) {
try {
tabButtons[activeTab].focus();
} catch (e) {}
}
}
function setTab(tab, opts) {
applyTab(tab, opts);
/* La console n'est interrogée qu'une fois affichée. */
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
fetchConsole(currentQ, runId);
}
}
function fetchConsole(q, myRun) {
var sec = sections.console;
if (!sec) return;
consoleFetchedQ = q;
states.console = {
status: "loading",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
renderSection(sec, states.console, true);
renderTabs();
var url =
consoleBase +
CONSOLE_SEARCH_PATH +
"?q=" +
encodeURIComponent(q) +
"&limite=" +
CONSOLE_SEARCH_LIMIT;
fetchSection("console", url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
else if (err.status === 422)
msg = "au moins 2 caractères requis";
else if (err.status) msg = "HTTP " + err.status;
else msg = "injoignable (CORS ou réseau)";
states.console = {
status: "unavailable",
items: [],
message: msg,
q: q,
base: consoleBase,
app: "console",
};
} else {
var norm = normalizeConsole(data, consoleBase);
if (!norm.recognized) {
states.console = {
status: "unavailable",
items: [],
message: "format inattendu",
q: q,
base: consoleBase,
app: "console",
};
} else {
states.console = {
status: norm.items.length ? "ok" : "empty",
items: norm.items,
message: "",
q: q,
base: consoleBase,
app: "console",
};
}
}
renderSection(sec, states.console, true);
renderTabs();
});
}
function runSearch(q) {
currentQ = q;
var myRun = ++runId;
if (!q) {
APPS.forEach(function (app) {
var sec = sections[app.key];
allKeys.forEach(function (key) {
var sec = sections[key];
if (!sec) return;
sec.querySelector(".rp-body").innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
sec.querySelector(".rp-count").textContent = "";
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
renderSection(sec, states[key], activeTab === key);
});
consoleFetchedQ = null;
renderTabs();
return;
}
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
states[app.key] = {
status: "loading",
items: [],
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
renderSection(sec, states[app.key], activeTab === app.key);
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q);
url = "/" + searchPath(app.key, q, true);
} else {
url = bases[app.key] + searchPath(app.key, q);
url = bases[app.key] + searchPath(app.key, q, true);
}
fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return;
@@ -393,25 +860,45 @@
msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
renderSection(sec, {
states[app.key] = {
status: "error",
items: [],
message: msg,
app: app.key,
base: bases[app.key],
q: q,
});
return;
base: bases[app.key],
app: app.key,
};
} else {
var items = normalize(app.key, data, bases[app.key], q, true);
states[app.key] = {
status: items.length ? "ok" : "empty",
items: items,
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
}
var items = normalize(app.key, data, bases[app.key], q);
renderSection(sec, {
status: items.length ? "ok" : "empty",
items: items,
app: app.key,
base: bases[app.key],
q: q,
});
renderSection(sec, states[app.key], activeTab === app.key);
renderTabs();
});
});
/* Console : seulement si son onglet est affiché ; sinon son
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
if (activeTab === "console") {
fetchConsole(q, myRun);
} else {
states.console = {
status: "idle",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
consoleFetchedQ = null;
renderTabs();
}
}
function scheduleSearch(q, immediate) {
@@ -427,13 +914,33 @@
function syncUrl(q) {
try {
var url = new URL(global.location.href);
if (q) url.searchParams.set("q", q);
else url.searchParams.delete("q");
global.history.replaceState(null, "", url.toString());
global.history.replaceState(
null,
"",
global.location.pathname + buildSearchUrl(q, activeTab)
);
} catch (e) {}
}
function focusSearch() {
try {
input.focus({ preventScroll: true });
} catch (e) {
try {
input.focus();
} catch (e2) {}
}
}
function visibleLinks() {
return Array.prototype.slice
.call(document.querySelectorAll("[data-rp-link]"))
.filter(function (a) {
var s = a.closest ? a.closest("[data-rp-section]") : null;
return !s || !s.hidden;
});
}
input.addEventListener("input", function () {
var q = input.value.trim();
syncUrl(q);
@@ -441,7 +948,7 @@
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = document.querySelector("[data-rp-link]");
var first = visibleLinks()[0];
if (first) {
e.preventDefault();
first.click();
@@ -471,6 +978,9 @@
input.select();
return;
}
/* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les
* flèches ←/→ du tablist s'en chargent (voir ci-dessous). */
if (target && target.closest && target.closest(".rp-tab")) return;
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
@@ -478,8 +988,62 @@
true
);
/* « Tout voir » reste sur la page : bascule vers l'onglet. */
root.addEventListener("click", function (e) {
var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null;
if (!all) return;
var sec = all.closest ? all.closest("[data-rp-section]") : null;
if (!sec) return;
e.preventDefault();
setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true });
});
/* Clic sur la barre « Rechercher partout » (bouton
* data-act="palette") pendant qu'on est déjà sur la page :
* replacer le curseur dans la boîte au lieu de recharger. */
document.addEventListener("click", function (e) {
var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null;
if (!p) return;
e.preventDefault();
focusSearch();
try {
input.select();
} catch (err) {}
});
/* Onglets : clic + clavier (←/→, Début/Fin). */
Object.keys(tabButtons).forEach(function (key) {
tabButtons[key].addEventListener("click", function () {
setTab(key, { push: true });
});
});
if (tablist) {
tablist.addEventListener("keydown", function (e) {
var order = TABS;
var idx = order.indexOf(activeTab);
var next = null;
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
else if (e.key === "ArrowLeft")
next = order[(idx - 1 + order.length) % order.length];
else if (e.key === "Home") next = order[0];
else if (e.key === "End") next = order[order.length - 1];
if (!next) return;
e.preventDefault();
setTab(next, { push: true, focusTab: true });
});
}
/* Retour arrière navigateur : restaurer q + onglet. */
global.addEventListener("popstate", function () {
var params = new URLSearchParams(global.location.search);
var q = (params.get("q") || "").trim();
input.value = q;
applyTab(params.get("tab"));
runSearch(q);
});
function moveFocus(delta, e) {
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
var links = visibleLinks();
if (!links.length) return;
var active = document.activeElement;
var idx = links.indexOf(active);
@@ -492,12 +1056,23 @@
var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim();
if (initialQ) {
input.value = initialQ;
runSearch(initialQ);
} else {
runSearch("");
}
var initialTab = parseTab(params.get("tab"));
if (initialQ) input.value = initialQ;
applyTab(initialTab);
runSearch(initialQ);
/* Focus explicite (voir en-tête) : l'attribut autofocus seul ne
* survit pas au chargement des surcouches. */
focusSearch();
try {
if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch);
} catch (e) {}
try {
setTimeout(focusSearch, 0);
} catch (e) {}
global.addEventListener("pageshow", function (e) {
if (e.persisted) focusSearch();
});
}
if (document.readyState === "loading") {
+81 -5
View File
@@ -7,17 +7,17 @@
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
.rp-input {
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
border: 1px solid var(--border, #888); border-radius: 8px; background: inherit; color: inherit;
}
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.rp-input:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.rp-section { margin-bottom: 28px; }
.rp-head {
display: flex; align-items: baseline; gap: 10px;
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
border-bottom: 1px solid var(--border, #888); padding-bottom: 6px; margin-bottom: 8px;
}
.rp-head h2 { font-size: 1.05rem; margin: 0; }
.rp-count {
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
font-size: .8rem; font-weight: 700; background: var(--accent, #3b82f6); color: #fff;
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
}
.rp-count:empty { display: none; }
@@ -29,7 +29,83 @@
text-decoration: none; color: inherit;
}
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
.rp-item:focus-visible { outline: 2px solid var(--accent, #3b82f6); }
.rp-title { display: block; font-weight: 600; }
.rp-context { display: block; font-size: .8rem; opacity: .65; }
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
.rp-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
border-bottom: 1px solid var(--border, #888); margin-bottom: 20px;
}
.rp-tab {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
color: inherit; opacity: .7; white-space: nowrap;
}
.rp-tab:hover { opacity: 1; }
.rp-tab.active { opacity: 1; border-bottom-color: var(--accent, #3b82f6); font-weight: 700; }
.rp-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.rp-tab-count {
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
}
.rp-tab-count:empty { display: none; }
.rp-section[hidden] { display: none; }
.rp-all[hidden] { display: none; }
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
* Cause : recherche.html étend base.html sans layout_class, donc
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
* la largeur disponible, sans toucher aux autres pages (elles ne
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
* la grille n'a qu'une colonne : la page la prend entièrement. */
.layout > .rp-page {
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
}
@media (max-width: 760px) {
.layout > .rp-page { grid-column: 1 / -1; }
}
/* ---- ObsiGate : navigation statique de la page autonome (chantier
* 2+3). La page /recherche est servie hors SPA (route avant le
* catch-all) : elle reprend les classes de l'app (style.css :
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
* .content-area) et cette navigation, calée sur les variables de
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
* la SPA (voir le commentaire dans recherche.html). */
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
.rp-og-nav a {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
.rp-og-nav a[aria-current="page"] {
background: rgba(127, 127, 127, .18); font-weight: 700;
}
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
/* ---- Thème (défaut 4) : la page suit le réglage ObsiGate. ----
* L'étape 1 (script synchrone en tête de recherche.html) pose
* data-theme = mode persisté ; l'étape 2 (themes.js) pose la palette
* complète en variables inline sur :root. Les règles .rp-* ci-dessus
* utilisent donc var(--border) / var(--accent) avec les anciennes
* valeurs en repli, et le reste hérite déjà du corps (style.css).
* color-scheme : les contrôles natifs (barres de défilement, champ
* de recherche) suivent le mode au lieu de celui de l'OS.
* Barre : draco-barre.css (fichier commun vendangé, NON modifié ici)
* ne connaît que [data-theme="dark"] ; sans les règles ci-dessous,
* sa media query prefers-color-scheme:dark imposerait une barre
* sombre sur une page claire (OS sombre + mode ObsiGate clair), et
* les modes high-contrast/sepia n'auraient aucune déclinaison.
* Les valeurs reprennent les défauts clairs de draco-barre.css et
* les générateurs _genHighContrast/_genSepia de themes.js. */
[data-theme="dark"], [data-theme="high-contrast"] { color-scheme: dark; }
[data-theme="light"], [data-theme="sepia"] { color-scheme: light; }
[data-theme="light"] .draco-bar { --bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3; --ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9; }
[data-theme="high-contrast"] .draco-bar { --bg: #000000; --surface: #0a0a0a; --surface-2: #141414; --surface-3: #000000; --ink: #ffffff; --ink-2: #cccccc; --ink-3: #888888; --line: #444444; }
[data-theme="sepia"] .draco-bar { --bg: #f5ecd5; --surface: #eee3c8; --surface-2: #e8dcc0; --surface-3: #f5ecd5; --ink: #3d2e1a; --ink-2: #6b5a3e; --ink-3: #9a8a6a; --line: #c8b890; }
+209 -45
View File
@@ -1,55 +1,219 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — ObsiGate</title>
<link rel="stylesheet" href="/static/recherche.css">
<style>
/* Navigation minimale (la barre complète vit dans index.html, la SPA). */
.rp-nav { display: flex; gap: 16px; padding: 12px 16px; font-size: .9rem; }
.rp-nav a { color: inherit; opacity: .8; }
</style>
</head>
<body>
<!-- Jetons {{URL_*}} remplacés par la route /recherche (même contrat
que le fragment de barre). Servie brute via /static, la page
<!-- Gabarit ObsiGate (chantier 2+3) : la page /recherche est servie
hors SPA (route déclarée avant le catch-all, comme /admin.html),
elle reprend donc les feuilles de l'app (style.css + barre) et
la structure de index.html (barre de fusion, en-tête, sidebar,
zone de contenu) au lieu de la page nue précédente.
Jetons {{URL_*}} remplacés par la route /recherche (même contrat
que le fragment de barre) ; servie brute via /static, la page
retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée. -->
<nav class="rp-nav">
<a href="{{URL_OBSIGATE}}">ObsiGate</a>
<a href="{{URL_FLOWDECK}}">Flowdeck</a>
<a href="{{URL_TIKTAK}}">TikTak</a>
<a href="{{URL_KANBANTE}}">Kanbante</a>
</nav>
<main class="rp-page" id="recherche" data-host="obsigate"
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
data-base-kanbante="{{URL_KANBANTE}}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
le JS) — jamais d'URL inventée.
La barre ci-dessous est une copie statique du snippet figé de
index.html (commons v0.1.6 + chantier 5 fusion #69/#71, sans
bump) : les onglets sont des liens réels, le bouton central
replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Re-vendoring alignement recherche :
5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
remplacé par la route /recherche comme les 4 autres) et
icône SVG du bouton Alertes, repris d'index.html. Les menus,
le panneau Spark, les alertes et le domaine restent des
comportements de la SPA et ne sont pas rejoués ici.
Sidebar statique (navigation de l'app) : les vaults, tags et
récents dynamiques vivent dans la SPA, pas sur cette page. -->
Thème (défaut 4) : le thème ObsiGate n'est PAS une préférence
serveur — il vit dans localStorage, lu par la SPA au démarrage
(frontend/js/ui.js initTheme : mode ; frontend/js/sync.js
init() -> themes.js initThemes : clé + mode). Étape 1 ci-dessous
(script synchrone en tête, avant les feuilles) : pose
data-theme = mode persisté (obsigate-theme-mode, repli
obsigate-theme historique quand il valait déjà un mode, sinon
'dark', le défaut SPA) pour que la première peinture soit déjà
dans le bon mode clair/sombre. Étape 2 en fin de page (module
différé) : initThemes() de themes.js applique la palette
complète — 16 thèmes intégrés, modes high-contrast/sepia
générés, thèmes personnalisés — via variables inline sur :root,
exactement comme la SPA. data-theme="dark" sur <html> n'est
plus que le repli sans JS. Aucun endpoint serveur, aucune
police externe ajoutée (la CSP stricte de /recherche et le
repli typographique système sont inchangés).
<script>
/* Thème ObsiGate, étape 1 (synchrone, avant les feuilles) : poser
data-theme = mode persisté AVANT la première peinture, sinon la
page flasherait en sombre puis basculerait. Sources lues :
'obsigate-theme-mode' (SPA modulaire, js/ui.js initTheme) puis,
à défaut, 'obsigate-theme' quand il vaut déjà un mode (valeur
historique de l'app monolithique) ; toute autre valeur — clé de
thème comme 'dracula', mode inconnu, stockage indisponible —
retombe sur 'dark', le défaut SPA. La palette complète (clé)
est appliquée à l'étape 2 en fin de page. */
(function () {
var MODES = ['dark', 'light', 'high-contrast', 'sepia'];
var mode = null;
try { mode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) { mode = null; }
if (MODES.indexOf(mode) === -1) {
try {
var legacy = window.localStorage.getItem('obsigate-theme');
if (MODES.indexOf(legacy) !== -1) { mode = legacy; }
} catch (e) { /* stockage indisponible : repli sombre */ }
}
if (MODES.indexOf(mode) === -1) { mode = 'dark'; }
document.documentElement.setAttribute('data-theme', mode);
})();
</script>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/draco-barre.css">
<link rel="stylesheet" href="/static/recherche.css">
</head>
<body class="has-draco-bar rp-og">
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="{{URL_OBSIGATE}}">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="{{URL_FLOWDECK}}">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="{{URL_TIKTAK}}">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="{{URL_KANBANTE}}">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
<div id="app" class="app-container">
<header class="header">
<div class="header-left">
<div class="header-logo">ObsiGate</div>
<a href="/">Retour à l'application</a>
</div>
</header>
<div class="main-body">
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation ObsiGate">
<nav class="rp-og-nav">
<a href="/">Tableau de bord</a>
<a href="/recherche" aria-current="page">Rechercher partout</a>
<p class="rp-og-note">La navigation par vaults (fichiers, tags, récents) vit dans l'application ObsiGate.</p>
</nav>
</aside>
<div class="content-wrapper">
<div class="content-area">
<main class="rp-page" id="recherche" data-host="obsigate"
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
data-base-kanbante="{{URL_KANBANTE}}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
</div>
</div>
</div>
</div>
<script src="/static/js/recherche.js"></script>
<script type="module">
/* Thème ObsiGate, étape 2 (différée) : appliquer la palette
complète via le moteur de thèmes de la SPA (themes.js), seule
source de vérité des 16 thèmes, des modes high-contrast/sepia
et des thèmes personnalisés — aucune table recopiée ici.
initThemes() relit localStorage et pose les variables inline
sur :root + data-theme = mode, comme au démarrage de la SPA
(js/sync.js init). Garde historique : si 'obsigate-theme'
vaut encore un mode ('dark'/'light', app monolithique) sans
'obsigate-theme-mode', on garde le mode de l'étape 1 —
initThemes() y verrait une clé inconnue et retomberait sur
'spark'/'dark'. Échec de chargement silencieux : la page
garde alors le mode de l'étape 1, recherche.js n'est pas
touché (fichier partagé avec les 3 autres hôtes). */
(function () {
var saved = null;
var savedMode = null;
try { saved = window.localStorage.getItem('obsigate-theme'); } catch (e) {}
try { savedMode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) {}
if (savedMode === null && (saved === 'dark' || saved === 'light')) { return; }
import('/static/js/themes.js').then(function (mod) {
if (mod && typeof mod.initThemes === 'function') { mod.initThemes(); }
}).catch(function () {});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.81.4",
"version": "2.82.3",
"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": {
+117
View File
@@ -59,4 +59,121 @@ ok(
"repli TikTak",
);
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
/* parseTab */
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
ok(rp.parseTab("console") === "console", "onglet console valide");
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
let c = rp.tabCounts({
obsigate: { status: "loading", total: 0 },
flowdeck: { status: "ok", total: 3 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.obsigate === "…", "compteur … pendant le chargement");
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
ok(c.all === "…", "Tout = … tant qu'une section charge");
c = rp.tabCounts({
obsigate: { status: "ok", total: 5 },
flowdeck: { status: "ok", total: 2 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "ok", total: 4 },
});
ok(c.all === "7", "Tout = somme des totaux connus");
ok(c.console === "4", "compteur console");
c = rp.tabCounts({
obsigate: { status: "error", total: 0 },
flowdeck: { status: "error", total: 0 },
tiktak: { status: "error", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
/* buildSearchUrl : état onglet partageable */
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
/* searchPath complet (onglet) vs tronqué (Tout) */
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
/* normalize complet vs tronqué */
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
let rc;
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
rc = rp.normalizeConsole({ resultats: [
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
] }, "https://agent-dev.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
rc = rp.normalizeConsole({ resultats: [
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
] }, "https://agent.dracodev.net/");
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
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");
console.log(`recherche.test.mjs : ${n} assertions OK`);
+100
View File
@@ -44,6 +44,8 @@ from backend.coffre_crypto import (
)
from backend.coffre_store import (
CoffreExisteError,
create_octroi,
list_secrets,
SecretExpireError,
ValidationError,
create_coffre,
@@ -131,6 +133,17 @@ def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VAL
return resp.json()
def _creer_secret(client, slug="personnel", nom="second",
valeur=VALEUR_TEST_2):
resp = client.post(
f"/api/coffres/{slug}/secrets",
json={"nom": nom, "type": "token", "service": "Test",
"valeur": valeur},
)
assert resp.status_code == 200, resp.text
return resp.json()
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
assert resp.status_code == 200, resp.text
@@ -678,3 +691,90 @@ class TestConsommateurAvecOctrois:
assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
# ---------------------------------------------------------------------------
# Liste des distribuables pour un consommateur (issue #43)
# ---------------------------------------------------------------------------
class TestDistribuables:
def _lister(self, client, jeton=None):
headers = {}
if jeton is not None:
headers["Authorization"] = f"Bearer {jeton}"
return client.get("/api/coffres/distribuables", headers=headers)
def test_ne_liste_que_les_octrois_actifs_metadonnees_seules(
self, client, env_coffre):
_creer_coffre_secret(client) # personnel/gitea-token
_creer_secret(client, nom="sans-octroi")
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
resp = self._lister(client, jeton)
assert resp.status_code == 200, resp.text
assert resp.json() == [{
"reference": "coffre:personnel/gitea-token",
"coffre": "personnel",
"nom": "gitea-token",
"version": 1,
}]
assert VALEUR_TEST not in resp.text
assert VALEUR_TEST_2 not in resp.text
assert "valeur" not in resp.text
# Octroi révoqué : le secret disparaît, sans erreur.
client.delete(f"/api/coffres/octrois/{octroi['id']}")
assert self._lister(client, jeton).json() == []
def test_octroi_expire_exclu_et_coffre_wide_inclus(
self, client, env_coffre):
from datetime import datetime, timedelta, timezone
_creer_coffre_secret(client)
_creer_secret(client, nom="second")
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
# Octroi expiré (hier) sur « second » : exclu.
coffre = get_coffre_or_404("personnel")
second = [x for x in list_secrets(coffre["id"])
if x["nom"] == "second"][0]
create_octroi(coffre, "consommateur", consommateur["id"],
second["id"], "bruno",
expiration=(datetime.now(timezone.utc)
- timedelta(days=1)).isoformat())
# Octroi coffre-wide actif : couvre tous les secrets.
resp = client.post(
"/api/coffres/personnel/octrois",
json={"cible_type": "consommateur",
"cible_id": consommateur["id"],
"secret_nom": None},
)
# Si l'API refuse le coffre-wide sans nom, on le crée direct.
if resp.status_code != 200:
create_octroi(coffre, "consommateur", consommateur["id"],
None, "bruno")
refs = {x["reference"]
for x in self._lister(client, jeton).json()}
assert "coffre:personnel/gitea-token" in refs
assert "coffre:personnel/second" in refs
def test_auth_consommateur_uniquement(self, client, env_coffre):
_creer_coffre_secret(client)
_creer_consommateur_octroi(client)
# Sans jeton : 401 (la session utilisateur ne suffit pas).
assert self._lister(client).status_code == 401
# Jeton inconnu : 401, sans révéler d'inventaire.
assert self._lister(client, "jeton-inconnu").status_code == 401
# Consommateur sans octroi : liste vide, pas d'erreur.
resp = client.post("/api/coffres/consommateurs",
json={"nom": "autre-app"})
autre_jeton = resp.json()["jeton"]
assert self._lister(client, autre_jeton).json() == []
def test_consommateur_revoque_et_flag_desactive(
self, client, env_coffre, monkeypatch):
_creer_coffre_secret(client)
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
resp = self._lister(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "consommateur_revoque"
# Flag désactivé : 503 comme les autres routes.
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
assert self._lister(client, jeton).status_code == 503
+33 -2
View File
@@ -38,21 +38,48 @@ def test_origin_of():
def test_sister_origins_dev():
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
# quelle que soit la résolution.
assert sister_origins(RESOLVED_DEV) == {
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
}
def test_cors_headers_agent_console_autorise():
# Fédération depuis la console (2026-10-28) : les origines agent
# prod ET dev passent, avec credentials ; un tiers reste refusé.
for origine in ("https://agent.dracodev.net",
"https://agent-dev.dracodev.net"):
headers = cors_headers_for(origine, RESOLVED_DEV)
assert headers is not None
assert headers["Access-Control-Allow-Origin"] == origine
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://agent.dracodev.net.evil.example",
RESOLVED_DEV) is None
def test_sister_origins_ajoute_env_de_la_requete():
# Correctif 2026-10-28 : configuration muette, quel que soit
# l'hôte de la requête, prod ET dev passent — l'hôte ne
# restreint plus l'allowlist.
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
assert "https://og-dev.dracodev.net" not in sister_origins(
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
def test_cors_headers():
@@ -60,7 +87,11 @@ def test_cors_headers():
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
RESOLVED_DEV) is None
# Autre environnement autorisé (correctif 2026-10-28).
assert cors_headers_for("https://og.dracodev.net",
RESOLVED_DEV) is not None
def test_preflight_headers():
+167
View File
@@ -0,0 +1,167 @@
"""Tests — thème de la page /recherche ObsiGate (défaut 4).
Mécanisme audité (ne pas réinventer) : le thème ObsiGate n'est PAS une
préférence serveur. La SPA le lit dans localStorage au démarrage —
``frontend/js/ui.js`` ``initTheme`` (mode seul, pour la première
peinture) puis ``frontend/js/sync.js`` ``init`` -> ``themes.js``
``initThemes`` (clé + mode, palette complète en variables inline sur
``:root``). Clés : ``obsigate-theme`` (clé, ex. ``'spark'``,
``'dracula'``, ou historiquement un mode) et ``obsigate-theme-mode``
(``'dark'`` | ``'light'`` | ``'high-contrast'`` | ``'sepia'``).
La page /recherche, servie hors SPA avec un ``data-theme="dark"`` en
dur et aucun script de thème, restait donc sombre quel que soit le
réglage. Correctif verrouillé ici, en deux étapes dans
``frontend/recherche.html`` (aucun fichier partagé touché :
``js/recherche.js`` reste identique sur les 4 hôtes, ``draco-barre.css``
reste le fichier commun vendangé, ``backend/main.py`` et sa CSP
strictes sont inchangés) :
- étape 1 : script synchrone en tête, avant les feuilles — pose
``data-theme`` = mode persisté avant la première peinture ;
- étape 2 : module différé — ``initThemes()`` de ``themes.js`` pose la
palette complète (16 thèmes, modes générés, thèmes personnalisés).
``frontend/recherche.css`` décline la barre pour les modes que
``draco-barre.css`` ne connaît pas et branche bordures/accents sur
``var(--border)`` / ``var(--accent)`` (anciennes valeurs en repli).
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
MODES = ["dark", "light", "high-contrast", "sepia"]
NONCE = "TESTNONCE1234567890"
def _html() -> str:
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
def _css() -> str:
return (FRONTEND / "recherche.css").read_text(encoding="utf-8")
def _inline_scripts(html: str) -> list[str]:
"""Contenu des scripts inline exécutables (sans src)."""
return re.findall(
r"<script(?: type=\"module\")?>(.*?)</script>", html, re.DOTALL
)
def test_etape1_avant_les_feuilles_et_mode_valide():
html = _html()
scripts = _inline_scripts(html)
assert scripts, "aucun script inline dans recherche.html"
etape1 = scripts[0]
assert "obsigate-theme-mode" in etape1
assert "obsigate-theme" in etape1
assert "setAttribute('data-theme'" in etape1 or 'setAttribute("data-theme"' in etape1
for mode in MODES:
assert f"'{mode}'" in etape1, f"mode {mode} absent de l'étape 1"
# Le script étape 1 précède la première feuille de style.
assert html.index("<script>") < html.index('<link rel="stylesheet"')
# Repli sans JS : sombre, le défaut SPA.
assert '<html lang="fr" data-theme="dark">' in html
def test_etape1_ignore_les_cles_de_theme():
"""Une clé ('dracula', 'spark', …) ne doit jamais devenir un mode."""
etape1 = _inline_scripts(_html())[0]
# La validation passe par une liste fermée de modes, jamais par la
# valeur brute de 'obsigate-theme'.
assert "MODES.indexOf(mode)" in etape1
assert "MODES.indexOf(legacy)" in etape1
assert "'dark'" in etape1 # repli final
def test_etape2_importe_themes_js_avec_garde_historique():
html = _html()
modules = re.findall(
r'<script type="module">(.*?)</script>', html, re.DOTALL
)
assert len(modules) == 1, "exactement un module inline attendu"
etape2 = modules[0]
assert "import('/static/js/themes.js')" in etape2
assert "initThemes" in etape2
# Garde historique : 'obsigate-theme' valant encore un mode sans
# 'obsigate-theme-mode' -> on garde le mode de l'étape 1.
assert "savedMode === null" in etape2
assert "saved === 'dark'" in etape2 and "saved === 'light'" in etape2
# Échec de chargement silencieux : la page garde le mode étape 1.
assert ".catch(function () {})" in etape2
# Le module est après recherche.js (différé : il tourne avant
# l'init DOMContentLoaded de recherche.js, sans le modifier).
assert html.index("/static/js/recherche.js") < html.index(
'<script type="module">'
)
# themes.js existe bien à l'URL importée et exporte initThemes.
themes = (FRONTEND / "js" / "themes.js").read_text(encoding="utf-8")
assert "initThemes" in themes
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in themes
def test_nonce_csp_pour_les_deux_etapes():
"""Comme /recherche injecte window.DRACO_URLS avant </head>, les
trois scripts inline doivent tous recevoir le nonce frais."""
from backend.csp import inject_csp_nonce
html = _html().replace(
"</head>",
'<script>window.DRACO_URLS = {};</script></head>',
1,
)
out = inject_csp_nonce(html, NONCE)
assert not re.findall(r"<script>", out), "script classique sans nonce"
assert not re.findall(
r'<script type="module">', out
), "module inline sans nonce"
assert out.count(f'nonce="{NONCE}"') == 3
def test_barre_decline_tous_les_modes():
"""draco-barre.css ne connaît que [data-theme="dark"] : les autres
modes sont déclinés dans recherche.css (fichier de la page), sans
toucher au fichier commun vendangé."""
css = _css()
barre = (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
assert '[data-theme="light"] .draco-bar' not in barre
for mode in ("light", "high-contrast", "sepia"):
assert f'[data-theme="{mode}"] .draco-bar' in css, mode
# Valeurs reprises des générateurs themes.js (_genHighContrast /
# _genSepia) : fond noir / sépia, encre blanche / brune.
assert "--surface: #0a0a0a" in css and "--ink: #ffffff" in css
assert "--surface: #eee3c8" in css and "--ink: #3d2e1a" in css
assert "color-scheme: dark" in css and "color-scheme: light" in css
def test_champs_onglets_resultats_suivent_accent_et_bordure():
css = _css()
assert "border: 1px solid var(--border, #888)" in css
assert "background: var(--accent, #3b82f6)" in css
assert "border-bottom-color: var(--accent, #3b82f6)" in css
assert "outline: 2px solid var(--accent, #3b82f6)" in css
def test_invariants_page_intacts():
"""Barre (5e onglet Console Spark), onglets, focus, 'Tout voir',
repli typographique : rien ne régresse."""
html = _html()
for app in ("obsigate", "flowdeck", "tiktak", "kanbante", "agent"):
assert f'data-app="{app}"' in html, app
assert "{{URL_AGENT}}" in html
for tab in ("all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"):
assert f'data-rp-tab="{tab}"' in html, tab
assert "Tout voir" in html
assert 'id="rp-q"' in html and "autofocus" in html
assert '<script src="/static/js/recherche.js"></script>' in html
# Aucune police externe ajoutée : le repli système imposé par la
# CSP stricte de /recherche (font-src 'self' data:) est inchangé.
assert "fonts.googleapis.com" not in html
assert "fonts.gstatic.com" not in html
# Ordre des feuilles inchangé (recherche.css après la barre).
assert html.index("draco-barre.css") < html.index("recherche.css")