Compare commits

..
Author SHA1 Message Date
bruno 8403b4fa08 chore(release): v2.82.4 — CHANGELOG.md
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 18:25:02 -04:00
bruno 757e32229d chore(release): v2.82.4 — docs/ROADMAP.md
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 18:24:54 -04:00
bruno 4c995f4c65 chore(release): v2.82.4 — desktop/Cargo.lock
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 2m4s
2026-10-28 18:24:49 -04:00
bruno c950940e22 chore(release): v2.82.4 — desktop/Cargo.toml
CI / security (push) Failing after 2s
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:24:41 -04:00
bruno 858dfd22e1 chore(release): v2.82.4 — desktop/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 18:24:36 -04:00
bruno ab4a4dbd9d chore(release): v2.82.4 — package.json
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 2m5s
2026-10-28 18:24:29 -04:00
bruno 0bc1aca9bc chore(release): v2.82.4 — VERSION
CI / security (push) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:24:24 -04:00
bruno 515727ab9b Merge pull request 'Retire l'ecran de chargement plein ecran au demarrage d'ObsiGate' (#53) from fix/retrait-splash-demarrage into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:22:24 -04:00
bruno 3f375bca2a Merge pull request 'Retire le texte de chantier theme rendu en haut de /recherche' (#52) from fix/recherche-texte-theme-parasite into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 1m53s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:22:09 -04:00
bruno 483af904af Test non-regression : aucun splash au demarrage, boot JS et indicateurs internes intacts
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Failing after 0s
2026-10-28 16:44:13 -04:00
bruno bfe0c75106 Retire le signal de boot vers le splash supprime (DOMContentLoaded simplifie) 2026-10-28 16:44:11 -04:00
bruno 2a96ad3b1d Retire l'ecran de chargement plein ecran au demarrage (splash, CSS de masquage, script de pilotage) 2026-10-28 16:44:08 -04:00
bruno 12a29b29f6 Test non-regression : aucun texte brut rendu dans le head de /recherche
CI / security (pull_request) Successful in 1m57s
CI / lint (pull_request) Failing after 2m17s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 16:43:35 -04:00
bruno 8feab0188c Retire le texte de chantier theme rendu en haut de /recherche (lignes hors commentaire) 2026-10-28 16:43:31 -04:00
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
21 changed files with 1580 additions and 220 deletions
+31 -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). 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 > **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.4**.
--- ---
@@ -14,6 +14,36 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.82.4] — 2026-10-28
- **Recherche (PR #52)** : texte parasite du mécanisme de thème retiré du haut de la page `/recherche` (lignes 28-43 de `frontend/recherche.html`, hors commentaire).
- **Démarrage (PR #53)** : écran de chargement plein écran `#boot-splash` retiré au démarrage d'ObsiGate (CSS, div et script de pilotage), sans écran de remplacement.
## [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 ## [2.81.4] — 2026-10-28
### Modifié ### Modifié
+1 -1
View File
@@ -1 +1 @@
2.81.4 2.82.4
+12 -7
View File
@@ -966,12 +966,15 @@ if FRONTEND_DIR.exists():
frontend/recherche.html est servi avec les jetons {{URL_*}} frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment remplacés par urls_publiques() (même contrat que le fragment
de barre) et le nonce CSP frais ; la réponse pose sa propre de barre, jeton {{URL_AGENT}} inclus pour l'onglet Console
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose Spark de la barre re-vendangée) et le nonce CSP frais ; la
que si elle est absente) dont le connect-src autorise les réponse pose sa propre CSP (préservée par
origines sœurs — sinon le navigateur bloquerait les appels SecurityHeadersMiddleware, qui ne la pose que si elle est
sœurs avant même CORS. La CSP par défaut (same-origin) reste absente) dont le connect-src autorise les origines sœurs ET
inchangée pour toutes les autres pages. 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 import json as _json
@@ -989,6 +992,7 @@ if FRONTEND_DIR.exists():
("{{URL_FLOWDECK}}", "flowdeck"), ("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"), ("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"), ("{{URL_KANBANTE}}", "kanbante"),
("{{URL_AGENT}}", "agent"),
): ):
html = html.replace(jeton, urls.get(cle, "")) html = html.replace(jeton, urls.get(cle, ""))
script = ( script = (
@@ -1003,7 +1007,8 @@ if FRONTEND_DIR.exists():
html = inject_csp_nonce(html, request.state.csp_nonce) html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({ soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or "" origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante") for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
"agent")
} - {""})) } - {""}))
csp = ( csp = (
"default-src 'self'; " "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 - le chemin ``/api/search`` (jamais les autres routes, jamais les
écritures) ; écritures) ;
- les origines sœurs : celles de ``backend.draco_config`` - les origines sœurs : les origines officielles **prod ET dev,
(``urls_publiques()`` : explicite > dérivation par l'hôte de listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
l'agent > défaut officiel), **plus** les origines prod ou ``-dev`` les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
officielles correspondant à l'hôte de la requête — ce second ET dev — fédération de la recherche depuis la console,
ensemble couvre le cas où la configuration seule ne déclare pas 2026-10-28), **plus**
l'environnement courant ; 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 - avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` via le repli de ``backend.auth.middleware``, ou ``/api/search`` via le repli de ``backend.auth.middleware``, ou
le cookie natif ``access_token``). le cookie natif ``access_token``).
@@ -45,6 +52,12 @@ DEV_ORIGINS = frozenset({
"https://kanbante-dev.dracodev.net", "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. # Clés de draco_config.urls_publiques() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante") _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)) origin = origin_of((resolved or {}).get(key))
if origin: if origin:
origins.add(origin) origins.add(origin)
if is_dev_host(request_host): # Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
origins |= DEV_ORIGINS # ne restreint plus l'allowlist (il ne servait qu'à ajouter
elif is_prod_host(request_host): # l'environnement de la requête, désormais inclus d'office).
origins |= PROD_ORIGINS # ``resolved`` n'ajoute que des origines de configuration sans
if not origins: # jamais retirer une origine officielle.
return PROD_ORIGINS | DEV_ORIGINS origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
return frozenset(origins) return frozenset(origins)
+45
View File
@@ -57,6 +57,7 @@ from backend.coffre_store import (
SecretExpireError, SecretExpireError,
ValidationError, ValidationError,
compter_octrois_actifs, compter_octrois_actifs,
est_expire,
compter_secrets, compter_secrets,
create_coffre, create_coffre,
create_consommateur, 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 # Un coffre, ses secrets, ses octrois, son journal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.81.4" version = "2.82.4"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.81.4" version = "2.82.4"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.81.4", "version": "2.82.4",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.81.4 | **Dernière mise à jour :** 2026-10-28 > **Version :** 2.82.4 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
+1 -66
View File
@@ -47,33 +47,6 @@
href="/static/icons/icon-192x192.svg" href="/static/icons/icon-192x192.svg"
/> />
<!-- Boot splash CSS — inline so it applies BEFORE /static/style.css is
available (desktop: embedded page served from tauri:// has no /static) -->
<style id="boot-splash-css">
body:not(.app-booted) > *:not(#boot-splash) { display: none !important; }
#boot-splash {
position: fixed; inset: 0; z-index: 2147483647;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
body.app-booted #boot-splash { display: none; }
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.bs-logo {
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
.bs-spinner {
width: 34px; height: 34px; border-radius: 50%;
border: 3px solid rgba(124, 58, 237, 0.25); border-top-color: #7c3aed;
animation: bs-spin 0.9s linear infinite;
}
@keyframes bs-spin { to { transform: rotate(360deg); } }
.bs-status { font-size: 13px; color: #9aa3b2; min-height: 18px; }
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
</style>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" /> <link rel="stylesheet" href="/static/draco-barre.css" />
<link rel="stylesheet" href="/static/draco-alertes.css" /> <link rel="stylesheet" href="/static/draco-alertes.css" />
@@ -89,7 +62,7 @@
id="hljs-theme-light" id="hljs-theme-light"
disabled disabled
/> />
<!-- defer: ne bloque pas le premier paint (le splash inline s'affiche immédiatement) --> <!-- defer: ne bloque pas le premier paint -->
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Langues highlight.js hors bundle "common" (enregistrement auto apres le core) --> <!-- Langues highlight.js hors bundle "common" (enregistrement auto apres le core) -->
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dockerfile.min.js"></script> <script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dockerfile.min.js"></script>
@@ -226,44 +199,6 @@
</script> </script>
</head> </head>
<body class="has-draco-bar"> <body class="has-draco-bar">
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
sans ce splash, le squelette HTML brut serait visible pendant le
démarrage du backend (FOUC 3-10 s). Styles 100% inline. -->
<div id="boot-splash" role="status" aria-live="polite">
<div class="bs-card">
<div class="bs-logo">📖</div>
<div class="bs-title">ObsiGate</div>
<div class="bs-spinner" aria-hidden="true"></div>
<div class="bs-status" id="boot-splash-status">Démarrage…</div>
</div>
</div>
<script>
(function () {
var done = false;
function bootReady() {
if (done) return;
done = true;
document.body.classList.add("app-booted");
var s = document.getElementById("boot-splash");
if (s && s.parentNode) s.parentNode.removeChild(s);
}
window.__obsigateBootReady = bootReady;
window.__setBootStatus = function (txt) {
var el = document.getElementById("boot-splash-status");
if (el) el.textContent = txt;
};
var poll = setInterval(function () {
if (window.__OBSIGATE_BOOTED) { clearInterval(poll); bootReady(); }
}, 80);
// Filet de sécurité : si le CSS principal a chargé (origine backend réelle)
// mais que le JS a échoué, révéler l'app après 10 s au lieu d'un splash infini.
setTimeout(function () {
var link = document.querySelector('link[href*="style.css"]');
if (link && link.sheet) { clearInterval(poll); bootReady(); }
}, 10000);
})();
</script>
<div class="search-progress-bar" id="search-progress-bar"> <div class="search-progress-bar" id="search-progress-bar">
<div class="search-progress-bar__fill"></div> <div class="search-progress-bar__fill"></div>
</div> </div>
+2 -10
View File
@@ -120,16 +120,8 @@ async function init() {
} }
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
try { await init();
await init(); Sync.init();
Sync.init();
} finally {
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
// initialisée : le splash est retiré et le squelette stylé révélé.
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
window.__OBSIGATE_BOOTED = true;
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
}
// Show version in header — always visible, fallback before the async fetch // Show version in header — always visible, fallback before the async fetch
populateVersions(); populateVersions();
fetch('/api/health') fetch('/api/health')
+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 * Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient * hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte. * 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é) : * 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 }] } * -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile). * Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q= * - Flowdeck : GET {base}/api/search?q=
@@ -14,10 +42,39 @@
* - TikTak : GET {base}/api/search?q= * - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... } * -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search). * 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 }] }] } * -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre * 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). * 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) : * 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 > * 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 * credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session * chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche * (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette * (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs * et par GET /api/v1/recherche de la console (identity() accepte
* (GET sur l'endpoint de recherche uniquement, pas de "*"). * 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 : * Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native. * sa section affiche l'erreur + un lien vers sa recherche native.
*/ */
@@ -56,12 +116,36 @@
tiktak: "https://tiktak-dev.dracodev.net/", tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-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 = [ var APPS = [
{ key: "obsigate", label: "ObsiGate" }, { key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" }, { key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" }, { key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" }, { 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 = { var COLUMN_LABELS = {
backlog: "Backlog", backlog: "Backlog",
in_progress: "En cours", in_progress: "En cours",
@@ -73,6 +157,7 @@
var TIMEOUT_MS = 8000; var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250; var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8; var PER_APP_LIMIT = 8;
var FULL_LIMIT = 100;
function esc(s) { function esc(s) {
return String(s == null ? "" : s) return String(s == null ? "" : s)
@@ -120,9 +205,92 @@
return out; 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") { 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); return "api/search?q=" + encodeURIComponent(q);
} }
@@ -133,11 +301,17 @@
} }
/* Normalise la réponse d'une app en [{ title, snippet, context, url }]. /* 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). */ * base : base résolue de l'app (avec / final). q : requête (liens TikTak).
function normalize(app, data, base, q) { * 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 || {}; data = data || {};
var limit = full ? null : PER_APP_LIMIT;
function cut(arr) {
return limit == null ? arr : arr.slice(0, limit);
}
if (app === "obsigate") { if (app === "obsigate") {
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) { return cut(data.results || []).map(function (r) {
return { return {
title: r.title || r.path || "Sans titre", title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""), snippet: snippetOf(r.snippet || ""),
@@ -153,7 +327,7 @@
} }
if (app === "flowdeck") { if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []); 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(/^\//, ""); var rel = String(r.url || "").replace(/^\//, "");
return { return {
title: r.title || "Sans titre", title: r.title || "Sans titre",
@@ -164,7 +338,7 @@
}); });
} }
if (app === "tiktak") { 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"; var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10); if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return { return {
@@ -182,7 +356,7 @@
rows.push({ group: g.full_name, row: row }); 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 issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || ""; var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return { return {
@@ -196,6 +370,86 @@
return []; 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). */ /* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) { function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") { if (app === "tiktak" || app === "kanbante") {
@@ -214,6 +468,7 @@
} }
function appLabel(key) { function appLabel(key) {
if (key === "console") return CONSOLE_APP.label;
for (var i = 0; i < APPS.length; i++) { for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label; if (APPS[i].key === key) return APPS[i].label;
} }
@@ -224,11 +479,26 @@
esc: esc, esc: esc,
isDevHost: isDevHost, isDevHost: isDevHost,
resolveBases: resolveBases, resolveBases: resolveBases,
resolveConsoleBase: resolveConsoleBase,
parseTab: parseTab,
visibleKeys: visibleKeys,
pageItems: pageItems,
buildSearchUrl: buildSearchUrl,
tabCounts: tabCounts,
searchPath: searchPath, searchPath: searchPath,
normalize: normalize, normalize: normalize,
normalizeConsole: normalizeConsole,
nativeSearchUrl: nativeSearchUrl, nativeSearchUrl: nativeSearchUrl,
PROD: PROD, PROD: PROD,
DEV: DEV, 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; if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api; global.RecherchePartout = api;
@@ -268,16 +538,24 @@
}); });
} }
function renderSection(section, state) { function renderSection(section, state, full) {
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */ /* 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 body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count"); var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all"); var toutVoir = section.querySelector(".rp-all");
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q); if (toutVoir) {
toutVoir.textContent = "Tout voir"; try {
toutVoir.title = nativeSearchLabel(state.app); 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") { if (state.status === "loading") {
count.textContent = ""; count.textContent = "…";
body.innerHTML = '<p class="rp-state">Recherche…</p>'; body.innerHTML = '<p class="rp-state">Recherche…</p>';
return; return;
} }
@@ -293,19 +571,31 @@
"</a></p>"; "</a></p>";
return; return;
} }
if (state.status === "empty") { if (state.status === "unavailable") {
count.textContent = ""; 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 = body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>"; '<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return; return;
} }
var shown = pageItems(state.items, full);
count.textContent = String(state.items.length); count.textContent = String(state.items.length);
body.innerHTML = state.items body.innerHTML = shown
.map(function (item) { .map(function (item) {
return ( var inner =
'<a class="rp-item" data-rp-link href="' +
esc(item.url) +
'">' +
'<span class="rp-title">' + '<span class="rp-title">' +
esc(item.title) + esc(item.title) +
"</span>" + "</span>" +
@@ -314,9 +604,13 @@
: "") + : "") +
(item.snippet (item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>" ? '<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(""); .join("");
} }
@@ -349,40 +643,213 @@
barUrls: barUrls, barUrls: barUrls,
tabHrefs: tabHrefs, 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 = {}; var sections = {};
APPS.forEach(function (app) { allKeys.forEach(function (key) {
var el = document.querySelector('[data-rp-section="' + app.key + '"]'); var el = document.querySelector('[data-rp-section="' + key + '"]');
if (el) sections[app.key] = el; 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 currentQ = "";
var runId = 0; var runId = 0;
var consoleFetchedQ = null;
var debounceTimer = 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) { function runSearch(q) {
currentQ = q; currentQ = q;
var myRun = ++runId; var myRun = ++runId;
if (!q) { if (!q) {
APPS.forEach(function (app) { allKeys.forEach(function (key) {
var sec = sections[app.key]; var sec = sections[key];
if (!sec) return; if (!sec) return;
sec.querySelector(".rp-body").innerHTML = states[key] = {
'<p class="rp-state">Tapez une recherche ci-dessus.</p>'; status: "idle",
sec.querySelector(".rp-count").textContent = ""; items: [],
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], ""); message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
renderSection(sec, states[key], activeTab === key);
}); });
consoleFetchedQ = null;
renderTabs();
return; return;
} }
APPS.forEach(function (app) { APPS.forEach(function (app) {
var sec = sections[app.key]; var sec = sections[app.key];
if (!sec) return; 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; var url;
if (app.key === hostApp) { if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */ /* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q); url = "/" + searchPath(app.key, q, true);
} else { } 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) { fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return; if (myRun !== runId) return;
@@ -393,25 +860,45 @@
msg = "Connexion requise sur " + app.label; msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")"; else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)"; else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
renderSection(sec, { states[app.key] = {
status: "error", status: "error",
items: [],
message: msg, message: msg,
app: app.key,
base: bases[app.key],
q: q, q: q,
}); base: bases[app.key],
return; 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, states[app.key], activeTab === app.key);
renderSection(sec, { renderTabs();
status: items.length ? "ok" : "empty",
items: items,
app: app.key,
base: bases[app.key],
q: q,
});
}); });
}); });
/* 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) { function scheduleSearch(q, immediate) {
@@ -427,13 +914,33 @@
function syncUrl(q) { function syncUrl(q) {
try { try {
var url = new URL(global.location.href); global.history.replaceState(
if (q) url.searchParams.set("q", q); null,
else url.searchParams.delete("q"); "",
global.history.replaceState(null, "", url.toString()); global.location.pathname + buildSearchUrl(q, activeTab)
);
} catch (e) {} } 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 () { input.addEventListener("input", function () {
var q = input.value.trim(); var q = input.value.trim();
syncUrl(q); syncUrl(q);
@@ -441,7 +948,7 @@
}); });
input.addEventListener("keydown", function (e) { input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { if (e.key === "Enter") {
var first = document.querySelector("[data-rp-link]"); var first = visibleLinks()[0];
if (first) { if (first) {
e.preventDefault(); e.preventDefault();
first.click(); first.click();
@@ -471,6 +978,9 @@
input.select(); input.select();
return; 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")) { if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e); moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
} }
@@ -478,8 +988,62 @@
true 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) { function moveFocus(delta, e) {
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]")); var links = visibleLinks();
if (!links.length) return; if (!links.length) return;
var active = document.activeElement; var active = document.activeElement;
var idx = links.indexOf(active); var idx = links.indexOf(active);
@@ -492,12 +1056,23 @@
var params = new URLSearchParams(global.location.search); var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim(); var initialQ = (params.get("q") || "").trim();
if (initialQ) { var initialTab = parseTab(params.get("tab"));
input.value = initialQ; if (initialQ) input.value = initialQ;
runSearch(initialQ); applyTab(initialTab);
} else { runSearch(initialQ);
runSearch("");
} /* 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") { if (document.readyState === "loading") {
+81 -5
View File
@@ -7,17 +7,17 @@
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; } .rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
.rp-input { .rp-input {
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px; 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-section { margin-bottom: 28px; }
.rp-head { .rp-head {
display: flex; align-items: baseline; gap: 10px; 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-head h2 { font-size: 1.05rem; margin: 0; }
.rp-count { .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; border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
} }
.rp-count:empty { display: none; } .rp-count:empty { display: none; }
@@ -29,7 +29,83 @@
text-decoration: none; color: inherit; text-decoration: none; color: inherit;
} }
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; } .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-title { display: block; font-weight: 600; }
.rp-context { display: block; font-size: .8rem; opacity: .65; } .rp-context { display: block; font-size: .8rem; opacity: .65; }
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; } .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; }
+193 -45
View File
@@ -1,55 +1,203 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — ObsiGate</title> <title>Rechercher partout — ObsiGate</title>
<link rel="stylesheet" href="/static/recherche.css"> <!-- Gabarit ObsiGate (chantier 2+3) : la page /recherche est servie
<style> hors SPA (route déclarée avant le catch-all, comme /admin.html),
/* Navigation minimale (la barre complète vit dans index.html, la SPA). */ elle reprend donc les feuilles de l'app (style.css + barre) et
.rp-nav { display: flex; gap: 16px; padding: 12px 16px; font-size: .9rem; } la structure de index.html (barre de fusion, en-tête, sidebar,
.rp-nav a { color: inherit; opacity: .8; } zone de contenu) au lieu de la page nue précédente.
</style> Jetons {{URL_*}} remplacés par la route /recherche (même contrat
</head> que le fragment de barre) ; servie brute via /static, la page
<body>
<!-- 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 retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée. --> le JS) — jamais d'URL inventée.
<nav class="rp-nav"> La barre ci-dessous est une copie statique du snippet figé de
<a href="{{URL_OBSIGATE}}">ObsiGate</a> index.html (commons v0.1.6 + chantier 5 fusion #69/#71, sans
<a href="{{URL_FLOWDECK}}">Flowdeck</a> bump) : les onglets sont des liens réels, le bouton central
<a href="{{URL_TIKTAK}}">TikTak</a> replace le curseur dans la boîte (recherche.js,
<a href="{{URL_KANBANTE}}">Kanbante</a> clic data-act="palette"). Re-vendoring alignement recherche :
</nav> 5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
<main class="rp-page" id="recherche" data-host="obsigate" remplacé par la route /recherche comme les 4 autres) et
data-base-obsigate="{{URL_OBSIGATE}}" icône SVG du bouton Alertes, repris d'index.html. Les menus,
data-base-flowdeck="{{URL_FLOWDECK}}" le panneau Spark, les alertes et le domaine restent des
data-base-tiktak="{{URL_TIKTAK}}" comportements de la SPA et ne sont pas rejoués ici.
data-base-kanbante="{{URL_KANBANTE}}"> Sidebar statique (navigation de l'app) : les vaults, tags et
<h1>Rechercher partout</h1> récents dynamiques vivent dans la SPA, pas sur cette page. -->
<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> <script>
<div class="rp-form"> /* Thème ObsiGate, étape 1 (synchrone, avant les feuilles) : poser
<input id="rp-q" class="rp-input" type="search" name="q" value="" data-theme = mode persisté AVANT la première peinture, sinon la
placeholder="Rechercher partout…" autocomplete="off" autofocus> 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> </div>
<section class="rp-section" data-rp-section="obsigate"> <div class="draco-bar-zone draco-bar-centre">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div> <button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div> <span class="draco-search-ico">⌕</span>
</section> <span class="draco-search-txt">Rechercher partout…</span>
<section class="rp-section" data-rp-section="flowdeck"> <kbd>⌘K</kbd>
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div> </button>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div> </div>
</section> <div class="draco-bar-zone draco-bar-droite">
<section class="rp-section" data-rp-section="tiktak"> <div class="draco-domaines" role="group" aria-label="Domaine">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div> <span class="draco-domaines-lbl">Domaine</span>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div> <button type="button" data-act="domaine" data-dom="pro">Pro</button>
</section> <button type="button" data-act="domaine" data-dom="famille">Famille</button>
<section class="rp-section" data-rp-section="kanbante"> <button type="button" data-act="domaine" data-dom="perso">Perso</button>
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div> </div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div> <button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
</section> <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>
</main> <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 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> </body>
</html> </html>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.81.4", "version": "2.82.4",
"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.", "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", "main": "patch.js",
"directories": { "directories": {
+117
View File
@@ -59,4 +59,121 @@ ok(
"repli TikTak", "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`); 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 ( from backend.coffre_store import (
CoffreExisteError, CoffreExisteError,
create_octroi,
list_secrets,
SecretExpireError, SecretExpireError,
ValidationError, ValidationError,
create_coffre, create_coffre,
@@ -131,6 +133,17 @@ def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VAL
return resp.json() 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"): def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"}) resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@@ -678,3 +691,90 @@ class TestConsommateurAvecOctrois:
assert len(resp.json()["octrois"]) == 1 assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1 assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant" 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(): 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) == { 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://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net", "https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net", "https://tiktak-dev.dracodev.net",
"https://kanbante-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(): 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( assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net" {}, 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" {}, request_host="og.dracodev.net"
) )
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
def test_cors_headers(): 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-Origin"] == "https://tiktak-dev.dracodev.net"
assert headers["Access-Control-Allow-Credentials"] == "true" assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None 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(): def test_preflight_headers():
@@ -0,0 +1,51 @@
"""Tests — page /recherche sans texte parasite dans <head>.
Régression du texte de chantier resté hors commentaire dans
``frontend/recherche.html`` : le commentaire de tête se fermait, puis
un paragraphe de description du mécanisme de thème suivait en texte
brut dans ``<head>`` — le navigateur le rendait tout en haut de la
page, au-dessus de la barre header. Le correctif supprime ce texte
du rendu (il n'est pas remis en commentaire : un grep doit donner
0 occurrence dans les gabarits) sans toucher au mécanisme lui-même,
déjà documenté par les commentaires des scripts étape 1 / étape 2.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
def _html() -> str:
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
def test_pas_de_texte_brut_dans_head():
"""Entre les balises et les scripts, <head> ne contient aucun
texte rendu : tout descriptif vit dans un commentaire ou dans un
commentaire de script, jamais en texte brut."""
head = _html().split("</head>")[0]
sans_commentaires = re.sub(r"<!--.*?-->", "", head, flags=re.DOTALL)
sans_scripts = re.sub(
r"<script.*?</script>", "", sans_commentaires, flags=re.DOTALL
)
sans_titre = re.sub(
r"<title>.*?</title>", "", sans_scripts, flags=re.DOTALL
)
texte = re.sub(r"<[^>]+>", "", sans_titre).strip()
assert texte == "", f"texte brut rendu dans <head> : {texte[:200]!r}"
def test_mecanisme_theme_intact():
"""Le retrait du texte ne touche ni l'étape 1 (script synchrone
avant les feuilles) ni l'étape 2 (initThemes différé)."""
html = _html()
assert html.index("<script>") < html.index('<link rel="stylesheet"')
assert "obsigate-theme-mode" in html
assert "import('/static/js/themes.js')" in html
assert "initThemes" in html
assert '<html lang="fr" data-theme="dark">' in html
assert '<script src="/static/js/recherche.js"></script>' in html
+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")
+75
View File
@@ -0,0 +1,75 @@
"""Tests — démarrage ObsiGate sans écran de chargement plein écran.
Le splash (``#boot-splash`` dans ``frontend/index.html`` : icône
livre, titre, spinner, « Démarrage… ») masquait tout le reste de la
page via ``body:not(.app-booted) > * { display: none !important }``
jusqu'au signal de ``frontend/js/app.js`` (ou un repli à 10 s).
Les autres applications (TikTak, Kanbante, Flowdeck, console Spark)
n'affichent aucun splash de ce type au démarrage : le contenu y
apparaît dès qu'il est prêt. Le retrait supprime donc l'écran, son
CSS de masquage, son script de pilotage et le signal côté ``app.js``,
sans toucher aux indicateurs internes aux vues ni à l'écran de
connexion.
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
MARQUEURS_SPLASH = (
"boot-splash",
"app-booted",
"__OBSIGATE_BOOTED",
"__obsigateBootReady",
"__setBootStatus",
"bs-card",
"bs-logo",
"bs-title",
"bs-spinner",
"bs-status",
"bs-spin",
"Démarrage…",
)
def _index() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def _app_js() -> str:
return (FRONTEND / "js" / "app.js").read_text(encoding="utf-8")
def test_aucun_marqueur_splash():
index = _index()
app_js = _app_js()
for marqueur in MARQUEURS_SPLASH:
assert marqueur not in index, f"{marqueur} encore dans index.html"
assert marqueur not in app_js, f"{marqueur} encore dans js/app.js"
def test_pas_de_masquage_global_au_demarrage():
"""Aucune règle ne cache tout le <body> en attendant un signal :
sans elle, un chargement lent ne peut plus donner un écran blanc
bloqué."""
index = _index()
assert "body:not(.app-booted)" not in index
assert "display: none !important" not in index
def test_boot_js_conserve_et_indicateurs_internes_intacts():
"""Le démarrage SPA reste branché ; seuls le splash et son signal
disparaissent. L'écran de connexion et la barre de progression
de recherche (indicateurs internes aux vues) ne sont PAS visés."""
app_js = _app_js()
assert 'document.addEventListener("DOMContentLoaded"' in app_js
assert "await init();" in app_js
assert "Sync.init();" in app_js
assert "populateVersions" in app_js
index = _index()
assert 'id="login-screen"' in index
assert 'id="search-progress-bar"' in index
assert 'id="app"' in index