Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f7754f5f | ||
|
|
43e416a889 | ||
|
|
fefd9b1ad3 | ||
|
|
3b43be3761 | ||
|
|
972d40a75f | ||
|
|
fb942bc871 | ||
|
|
b8ebea1479 | ||
|
|
ee065978b2 | ||
|
|
71c7113a8c | ||
|
|
c0bf0e8523 | ||
|
|
9ae054d1ba | ||
|
|
1e552a55a4 | ||
|
|
fd9df60788 | ||
|
|
984e92b593 | ||
|
|
8ba38a69a4 | ||
|
|
ee477bf2d1 | ||
|
|
ba5f8c8eab | ||
|
|
d4fef9c1bf | ||
|
|
f708fa77e2 | ||
|
|
7711bd3af7 | ||
|
|
b4923493ac | ||
|
|
0ead9fea4a | ||
|
|
8b871ee39f | ||
|
|
171fdf063b | ||
|
|
ea12938e7c | ||
|
|
3f14dc7130 | ||
|
|
71454f94d1 | ||
|
|
3145cea8e1 | ||
|
|
f49eb62172 | ||
|
|
746ab4c0e1 | ||
|
|
cdf943aff5 | ||
|
|
751daa43dd | ||
|
|
3895e79167 | ||
|
|
8dcddfe34f | ||
|
|
b506a447c5 | ||
|
|
9ca831f816 | ||
|
|
c09a0ac51d | ||
|
|
2d3a93511b | ||
|
|
dd979f81a7 |
@@ -1,3 +1,19 @@
|
||||
## v7.84.5 (2026-10-28) — Corrections console/recherche : texte parasite, onglets, CORS console
|
||||
|
||||
- **Barre (PR #37)** : suppression du texte parasite « Chantier 5 » rendu au-dessus du header.
|
||||
- **Recherche (PR #38)** : onglets visibles après déploiement (base console explicite + purge cache service worker).
|
||||
- **CORS recherche (PR #40)** : la recherche Flowdeck autorise désormais la console Spark (agent prod+dev) en inter-origines.
|
||||
|
||||
## v7.84.4 (2026-10-28) — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||
|
||||
- **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.
|
||||
|
||||
## v7.84.3 (2026-10-28) — Corrections recherche/alertes : CORS prod+dev, recherche en onglets, alertes en onglets
|
||||
|
||||
- **CORS recherche/alertes (#68/#69)** : allowlist d'origines soeurs prod+dev explicites + credentials, jamais `*` — chaque serveur n'autorisait que l'environnement resolu, d'ou les echecs inter-apps constates.
|
||||
- **Recherche partout (#68)** : recherche pleine largeur, reintegree dans le gabarit, focus auto, « Tout voir » restant sur place, onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark.
|
||||
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark/Historique, actives sauf Historique = fermees), filtre texte, composant partage vendange byte-identique ; bouton Console Spark en 5e onglet de la barre apres Kanbante.
|
||||
|
||||
## v7.84.2 (2026-10-28) — Programme Fusion : menu barre, alertes unifiées, recherche partout
|
||||
|
||||
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||
|
||||
+12
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
||||
|
||||
|
||||
# Littéral de version synchronisé avec le fichier VERSION :
|
||||
# version="7.84.2"
|
||||
# version="7.84.3"
|
||||
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
||||
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||
@@ -251,6 +251,17 @@ _CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | {
|
||||
_draco_urls69.origine(_toutes69[k])
|
||||
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
} - {""})
|
||||
# Correctif CORS alertes (fusion #69, 2026-10-28) : allow_origins
|
||||
# est figé à l'import (configuration seule, sans hôte de requête)
|
||||
# — il ne doit donc pas dépendre de l'environnement résolu. Les
|
||||
# origines sœurs prod ET dev sont listées explicitement (module
|
||||
# CORS de la recherche, jamais "*") : c'est ce qui manquait à
|
||||
# /api/notifications lu depuis les autres hôtes.
|
||||
from app.middleware.recherche_cors import DEV_ORIGINS as _DEV_ORIGINS
|
||||
from app.middleware.recherche_cors import PROD_ORIGINS as _PROD_ORIGINS
|
||||
|
||||
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | set(_DEV_ORIGINS)
|
||||
| set(_PROD_ORIGINS))
|
||||
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
||||
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
||||
app.add_middleware(
|
||||
|
||||
@@ -8,15 +8,21 @@ Ce module n'autorise donc que :
|
||||
|
||||
- le chemin ``/api/search`` (jamais les autres routes, jamais les
|
||||
écritures) ;
|
||||
- les origines sœurs : celles résolues par
|
||||
``app.services.draco_urls.toutes()`` (explicite > hôte > dérivation
|
||||
> défaut officiel), **plus** les origines prod ou ``-dev``
|
||||
officielles correspondant à l'hôte de la requête — ce second
|
||||
ensemble est nécessaire car ce middleware s'exécute avant le
|
||||
middleware CSP qui pose le contexte d'hôte de la requête pour
|
||||
``draco_urls`` ; sans lui, une page servie par ``flowdeck-dev``
|
||||
verrait ses sœurs ``-dev`` refusées quand la configuration seule
|
||||
ne les déclare pas ;
|
||||
- les origines sœurs : les origines officielles **prod ET dev,
|
||||
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
|
||||
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
|
||||
ET dev — fédération de la recherche depuis la console,
|
||||
2026-10-28), **plus**
|
||||
celles résolues par ``app.services.draco_urls.toutes()``
|
||||
(explicite > hôte > dérivation > défaut officiel) — jamais ``*``.
|
||||
Correctif 2026-10-28 (fusion #68/#69, matrice constatée par
|
||||
Bruno) : l'ancienne allowlist ne contenait que l'environnement
|
||||
résolu (configuration seule, ce middleware s'exécutant avant le
|
||||
middleware CSP qui pose le contexte d'hôte, plus l'environnement
|
||||
de l'hôte de la requête) — jamais les deux à la fois. Une page
|
||||
servie dans l'autre environnement voyait donc sa section
|
||||
afficher « Injoignable depuis cet hôte (CORS ou réseau) », et
|
||||
c'est Flowdeck qui échouait depuis tous les hôtes ;
|
||||
- avec credentials (le cookie SSO ``draco_session``, accepté par
|
||||
``/api/search`` depuis cette livraison, ou la session native).
|
||||
|
||||
@@ -46,6 +52,12 @@ DEV_ORIGINS = frozenset({
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
})
|
||||
|
||||
|
||||
# Clés de draco_urls.toutes() qui sont des applications sœurs.
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
|
||||
@@ -105,12 +117,12 @@ def sister_origins(resolved: dict | None = None, request_host: str | None = None
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
if is_dev_host(request_host):
|
||||
origins |= DEV_ORIGINS
|
||||
elif is_prod_host(request_host):
|
||||
origins |= PROD_ORIGINS
|
||||
if not origins:
|
||||
return PROD_ORIGINS | DEV_ORIGINS
|
||||
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
|
||||
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
|
||||
# l'environnement de la requête, désormais inclus d'office).
|
||||
# ``resolved`` n'ajoute que des origines de configuration sans
|
||||
# jamais retirer une origine officielle.
|
||||
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ def recherche_page(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
bases = draco_urls.toutes()
|
||||
template = ENV.get_template("recherche.html")
|
||||
# Defaut 6 : la base agent est exposee explicitement (data-base-console
|
||||
# dans recherche.html), comme les 3 autres hotes — recherche.js ne doit
|
||||
# plus la deviner (derivation -dev / href d'onglet de barre) pour
|
||||
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
|
||||
return HTMLResponse(template.render(
|
||||
request=request, q=q.strip(), user=user,
|
||||
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante")},
|
||||
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
|
||||
"kanbante", "agent")},
|
||||
))
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
|
||||
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
|
||||
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
|
||||
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
|
||||
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
|
||||
(data-app="agent", draco_urls().agent), après Kanbante ; l'entrée
|
||||
« Console Spark » du menu de la marque est conservée.
|
||||
par défaut, noms en -dev déclarés dans le .env de l'environnement).
|
||||
5e onglet Console Spark (data-app="agent", draco_urls().agent),
|
||||
après Kanbante ; l'entrée « Console Spark » du menu de la marque
|
||||
est conservée (menu construit par draco-barre.js depuis les onglets). #}
|
||||
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ asset_version }}">
|
||||
<header class="draco-bar" data-current="flowdeck">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title_prefix %}Rechercher partout{% endblock %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="/static/css/recherche.css">
|
||||
{# Versionnes (?v=asset_version) comme les autres feuilles/scripts de
|
||||
base.html : sans version, le service worker (static/sw.js, cache-first
|
||||
des .js/.css) resservait indefiniment les copies pre-#33 de ces deux
|
||||
fichiers aux navigateurs les ayant deja visites — la page paraissait
|
||||
donc « non corrigee » malgre les PR #33/#36. #}
|
||||
<link rel="stylesheet" href="/static/css/recherche.css?v={{ asset_version }}">
|
||||
{# Console : data-base-console rendu explicitement, comme les 3 autres
|
||||
hotes — la route Flowdeck de /recherche expose la base agent dans
|
||||
son contexte `bases` (defaut 6). recherche.js garde ses replis
|
||||
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
|
||||
<main class="rp-page" id="recherche" data-host="flowdeck"
|
||||
data-base-obsigate="{{ bases.obsigate }}"
|
||||
data-base-flowdeck="{{ bases.flowdeck }}"
|
||||
data-base-tiktak="{{ bases.tiktak }}"
|
||||
data-base-kanbante="{{ bases.kanbante }}">
|
||||
data-base-kanbante="{{ bases.kanbante }}"
|
||||
data-base-console="{{ bases.agent }}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
|
||||
<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="{{ q }}"
|
||||
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>
|
||||
@@ -29,8 +47,12 @@
|
||||
<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>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/recherche.js"></script>
|
||||
<script src="/static/js/recherche.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,3 +33,58 @@
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid #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: #3b82f6; font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid #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; }
|
||||
|
||||
+641
-66
@@ -1,11 +1,39 @@
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
|
||||
* chantier 2+3 : onglets, « Tout voir », focus).
|
||||
*
|
||||
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
|
||||
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
|
||||
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
||||
*
|
||||
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
|
||||
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
|
||||
* de cette application (aucune troncature côté client — la requête
|
||||
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
|
||||
* jeu complet). « Tout voir » d'une section ne quitte plus la page :
|
||||
* il bascule vers l'onglet de l'application (son href reste une URL
|
||||
* partageable ?q=&tab= au cas où le JS ne tournerait pas).
|
||||
* L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
|
||||
* et le retour arrière navigateur le restaure (popstate).
|
||||
*
|
||||
* Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
|
||||
* son application (pas le nombre tronqué affiché dans « Tout »),
|
||||
* « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
|
||||
* une section en erreur/indisponible (pas de faux 0). L'onglet Tout
|
||||
* affiche « … » tant qu'une section charge, sinon la somme des totaux
|
||||
* connus (une section en erreur compte pour 0) ; il est vide si les
|
||||
* 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
|
||||
* Une section injoignable ne casse jamais les autres.
|
||||
*
|
||||
* Focus : l'attribut autofocus seul ne survit pas au chargement des
|
||||
* surcouches (barre, palette, app) — la page appelle donc focus()
|
||||
* explicitement en fin d'init (plus un rappel rAF/timeout et un
|
||||
* rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
|
||||
* data-act="palette") sur cette même page replace aussi le curseur
|
||||
* dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
|
||||
*
|
||||
* Contrat d'API (audité, rien d'inventé) :
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
|
||||
* -> { results: [{ vault, path, title, snippet }] }
|
||||
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||
* - Flowdeck : GET {base}/api/search?q=
|
||||
@@ -14,10 +42,39 @@
|
||||
* - TikTak : GET {base}/api/search?q=
|
||||
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
||||
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
|
||||
* - Kanbante : GET {base}/api/search?q=
|
||||
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
|
||||
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
|
||||
* adressable — même limite que la page /search native de Kanbante).
|
||||
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
|
||||
* (agent-commun, authentifié par le cookie SSO draco_session) ->
|
||||
* { q, domain, limite_par_type, resultats[], par_type,
|
||||
* non_cherchables[] } ; chaque résultat porte type, id, titre,
|
||||
* extrait, statut, domaine, section, objet et lien (relatif à
|
||||
* l'origine console). q < 2 caractères -> 422. Types cherchables :
|
||||
* discussion, mission, approbation, livrable, activite,
|
||||
* planification, objectif, identite (aucun secret). Liens profonds
|
||||
* (préfixés par la base console) : discussion
|
||||
* /?section=conversation&disc=<id>, mission
|
||||
* /?section=missions&objet=<id>, approbation
|
||||
* /?section=approbations&objet=<id>, objectif
|
||||
* /?section=objectifs&objet=<id>, activite /?activite=<id> ;
|
||||
* livrable /?section=livrables, planification /?section=planifie
|
||||
* et identite /?section=identites ouvrent la vue sans sélection
|
||||
* d'objet. L'onglet appelle avec limite=20 (plafond par type) car
|
||||
* il affiche la liste complète, comme les onglets d'applications.
|
||||
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
|
||||
* section affiche honnêtement « Console Spark : recherche
|
||||
* indisponible pour l'instant » — jamais de faux résultats,
|
||||
* jamais de faux 0. La base console vient, dans l'ordre, de
|
||||
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
|
||||
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
|
||||
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
|
||||
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
|
||||
* prod agent.dracodev.net. L'onglet console n'est interrogé que
|
||||
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
|
||||
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
|
||||
* polluer la vue groupée d'un message d'indisponibilité.
|
||||
*
|
||||
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||
@@ -31,9 +88,12 @@
|
||||
* credentials:"include". Le navigateur envoie alors les cookies de
|
||||
* chaque origine, dont le cookie SSO partagé draco_session
|
||||
* (Domain=.dracodev.net), accepté par les 4 API de recherche
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
|
||||
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
|
||||
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
|
||||
* et par GET /api/v1/recherche de la console (identity() accepte
|
||||
* draco_session). Chaque sœur n'autorise en CORS que les origines
|
||||
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
|
||||
* côté console, un middleware borné à /api/v1/recherche autorise
|
||||
* de même les 8 origines hôtes prod ET dev, avec credentials.
|
||||
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||
*/
|
||||
@@ -56,12 +116,36 @@
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
};
|
||||
var PROD_AGENT = "https://agent.dracodev.net/";
|
||||
var DEV_AGENT = "https://agent-dev.dracodev.net/";
|
||||
var CONSOLE_SEARCH_PATH = "api/v1/recherche";
|
||||
var CONSOLE_SEARCH_LIMIT = 20;
|
||||
var APPS = [
|
||||
{ key: "obsigate", label: "ObsiGate" },
|
||||
{ key: "flowdeck", label: "Flowdeck" },
|
||||
{ key: "tiktak", label: "TikTak" },
|
||||
{ key: "kanbante", label: "Kanbante" },
|
||||
];
|
||||
var CONSOLE_APP = { key: "console", label: "Console Spark" };
|
||||
var CONSOLE_TYPE_LABELS = {
|
||||
discussion: "Discussion",
|
||||
mission: "Mission",
|
||||
approbation: "Approbation",
|
||||
livrable: "Livrable",
|
||||
activite: "Activité",
|
||||
planification: "Planification",
|
||||
objectif: "Objectif",
|
||||
identite: "Identité",
|
||||
};
|
||||
/* Lien profond console (relatif à l'origine console) quand le
|
||||
* résultat n'en porte pas : même règle que _lien_recherche
|
||||
* d'agent-commun (section + objet, activite en section=null). */
|
||||
var CONSOLE_VIEW_LINKS = {
|
||||
livrable: "/?section=livrables",
|
||||
planification: "/?section=planifie",
|
||||
identite: "/?section=identites",
|
||||
};
|
||||
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
@@ -73,6 +157,7 @@
|
||||
var TIMEOUT_MS = 8000;
|
||||
var DEBOUNCE_MS = 250;
|
||||
var PER_APP_LIMIT = 8;
|
||||
var FULL_LIMIT = 100;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
@@ -120,9 +205,92 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q) {
|
||||
/* Base console : explicite > URL agent injectée par l'hôte >
|
||||
* dérivation -dev > href d'onglet de barre console/spark > prod.
|
||||
* opts : { host, explicit, agentUrl, tabHrefs:{} } */
|
||||
function resolveConsoleBase(opts) {
|
||||
opts = opts || {};
|
||||
var u = cleanUrl(opts.explicit);
|
||||
if (!u) u = cleanUrl(opts.agentUrl);
|
||||
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
|
||||
if (!u)
|
||||
u = cleanUrl(
|
||||
opts.tabHrefs &&
|
||||
(opts.tabHrefs.console || opts.tabHrefs.spark || opts.tabHrefs.agent)
|
||||
);
|
||||
if (!u) u = PROD_AGENT;
|
||||
return withSlash(u);
|
||||
}
|
||||
|
||||
/* Onglet valide dans l'URL, sinon "all". */
|
||||
function parseTab(v) {
|
||||
return TABS.indexOf(v) !== -1 ? v : "all";
|
||||
}
|
||||
|
||||
/* Sections visibles pour un onglet. « Tout » = les 4 sections
|
||||
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
|
||||
function visibleKeys(tab) {
|
||||
tab = parseTab(tab);
|
||||
if (tab === "all") return APPS.map(function (a) { return a.key; });
|
||||
return [tab];
|
||||
}
|
||||
|
||||
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
||||
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
||||
function pageItems(items, full) {
|
||||
items = items || [];
|
||||
return full ? items : items.slice(0, PER_APP_LIMIT);
|
||||
}
|
||||
|
||||
/* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */
|
||||
function buildSearchUrl(q, tab) {
|
||||
var parts = [];
|
||||
if (q) parts.push("q=" + encodeURIComponent(q));
|
||||
tab = parseTab(tab);
|
||||
if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab));
|
||||
return parts.length ? "?" + parts.join("&") : "";
|
||||
}
|
||||
|
||||
/* Libellés des compteurs d'onglets à partir des états :
|
||||
* states : { key: { status, total } }.
|
||||
* Chargement -> "…", ok/vide -> total ("0" honnête),
|
||||
* erreur/indisponible/inactif -> "" (pas de faux 0).
|
||||
* Tout : "…" tant qu'une section charge, sinon somme des totaux
|
||||
* connus, "" si les 4 sections sont en erreur/inactives. */
|
||||
function tabCounts(states) {
|
||||
states = states || {};
|
||||
var out = {};
|
||||
function labelFor(key) {
|
||||
var st = states[key] || {};
|
||||
if (st.status === "loading") return "…";
|
||||
if (st.status === "ok" || st.status === "empty") return String(st.total || 0);
|
||||
return "";
|
||||
}
|
||||
var allLoading = false;
|
||||
var allTotal = 0;
|
||||
var allKnown = false;
|
||||
APPS.forEach(function (app) {
|
||||
var st = states[app.key] || {};
|
||||
out[app.key] = labelFor(app.key);
|
||||
if (st.status === "loading") allLoading = true;
|
||||
if (st.status === "ok" || st.status === "empty") {
|
||||
allKnown = true;
|
||||
allTotal += st.total || 0;
|
||||
}
|
||||
});
|
||||
out.console = labelFor("console");
|
||||
out.all = allLoading ? "…" : allKnown ? String(allTotal) : "";
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q, full) {
|
||||
if (app === "obsigate") {
|
||||
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||
return (
|
||||
"api/search?q=" +
|
||||
encodeURIComponent(q) +
|
||||
"&vault=all&limit=" +
|
||||
(full ? FULL_LIMIT : PER_APP_LIMIT)
|
||||
);
|
||||
}
|
||||
return "api/search?q=" + encodeURIComponent(q);
|
||||
}
|
||||
@@ -133,11 +301,17 @@
|
||||
}
|
||||
|
||||
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
||||
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
|
||||
function normalize(app, data, base, q) {
|
||||
* base : base résolue de l'app (avec / final). q : requête (liens TikTak).
|
||||
* full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT
|
||||
* (comportement historique, conservé par défaut pour les tests). */
|
||||
function normalize(app, data, base, q, full) {
|
||||
data = data || {};
|
||||
var limit = full ? null : PER_APP_LIMIT;
|
||||
function cut(arr) {
|
||||
return limit == null ? arr : arr.slice(0, limit);
|
||||
}
|
||||
if (app === "obsigate") {
|
||||
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return cut(data.results || []).map(function (r) {
|
||||
return {
|
||||
title: r.title || r.path || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || ""),
|
||||
@@ -153,7 +327,7 @@
|
||||
}
|
||||
if (app === "flowdeck") {
|
||||
var items = (data.pages || []).concat(data.collections || []);
|
||||
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return cut(items).map(function (r) {
|
||||
var rel = String(r.url || "").replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || "Sans titre",
|
||||
@@ -164,7 +338,7 @@
|
||||
});
|
||||
}
|
||||
if (app === "tiktak") {
|
||||
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
|
||||
return cut(data.tasks || []).map(function (t) {
|
||||
var ctx = t.status === "done" ? "Terminée" : "Active";
|
||||
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
||||
return {
|
||||
@@ -182,7 +356,7 @@
|
||||
rows.push({ group: g.full_name, row: row });
|
||||
});
|
||||
});
|
||||
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||
return cut(rows).map(function (x) {
|
||||
var issue = x.row.issue || {};
|
||||
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||
return {
|
||||
@@ -196,6 +370,86 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Normalisation console : format réel GET /api/v1/recherche,
|
||||
* { resultats: [{ type, id, titre, extrait, statut, domaine,
|
||||
* section, objet, lien }] }. Le lien relatif est préfixé par la
|
||||
* base console ; sans lien, il est reconstruit depuis type/id
|
||||
* (même règle que la console) ; sans lien reconstructible,
|
||||
* url=null et le résultat est rendu en <span>, pas en faux lien.
|
||||
* Les anciennes formes tolérées (results/discussions/items, avec
|
||||
* url/href/link) restent acceptées en repli, pour ne pas casser
|
||||
* un déploiement console antérieur. Retourne { recognized,
|
||||
* items } : recognized=false -> l'appelant affiche
|
||||
* « indisponible », jamais un vide mensonger. */
|
||||
function consoleLinkFor(r) {
|
||||
var lien = r.lien || "";
|
||||
if (lien) return lien;
|
||||
var id = r.id != null ? String(r.id) : "";
|
||||
if (r.type === "discussion")
|
||||
return "/?section=conversation&disc=" + encodeURIComponent(id);
|
||||
if (r.type === "mission")
|
||||
return "/?section=missions&objet=" + encodeURIComponent(id);
|
||||
if (r.type === "approbation")
|
||||
return "/?section=approbations&objet=" + encodeURIComponent(id);
|
||||
if (r.type === "objectif")
|
||||
return "/?section=objectifs&objet=" + encodeURIComponent(id);
|
||||
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
|
||||
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
|
||||
if (r.section)
|
||||
return (
|
||||
"/?section=" +
|
||||
encodeURIComponent(r.section) +
|
||||
(r.objet != null
|
||||
? "&objet=" + encodeURIComponent(String(r.objet))
|
||||
: "")
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeConsole(data, base) {
|
||||
data = data || {};
|
||||
if (Array.isArray(data.resultats)) {
|
||||
var items = data.resultats.map(function (r) {
|
||||
r = r || {};
|
||||
var rel = consoleLinkFor(r);
|
||||
var url = null;
|
||||
if (/^https?:\/\//.test(rel)) url = rel;
|
||||
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
|
||||
url = base + String(rel).replace(/^\//, "");
|
||||
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
|
||||
var context = label;
|
||||
if (r.statut) context += " · " + r.statut;
|
||||
if (r.domaine) context += " · " + r.domaine;
|
||||
return {
|
||||
title: r.titre || r.title || "Sans titre",
|
||||
snippet: snippetOf(r.extrait || r.snippet || ""),
|
||||
context: context,
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
return { recognized: true, items: items };
|
||||
}
|
||||
var list = null;
|
||||
if (Array.isArray(data.results)) list = data.results;
|
||||
else if (Array.isArray(data.discussions)) list = data.discussions;
|
||||
else if (Array.isArray(data.items)) list = data.items;
|
||||
if (!list) return { recognized: false, items: [] };
|
||||
var legacy = list.map(function (r) {
|
||||
r = r || {};
|
||||
var raw = r.url || r.href || r.link || "";
|
||||
var url = null;
|
||||
if (/^https?:\/\//.test(raw)) url = raw;
|
||||
else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || r.name || r.subject || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""),
|
||||
context: r.subtitle || r.source || r.type || "",
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
return { recognized: true, items: legacy };
|
||||
}
|
||||
|
||||
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||
function nativeSearchUrl(app, base, q) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
@@ -214,6 +468,7 @@
|
||||
}
|
||||
|
||||
function appLabel(key) {
|
||||
if (key === "console") return CONSOLE_APP.label;
|
||||
for (var i = 0; i < APPS.length; i++) {
|
||||
if (APPS[i].key === key) return APPS[i].label;
|
||||
}
|
||||
@@ -224,11 +479,26 @@
|
||||
esc: esc,
|
||||
isDevHost: isDevHost,
|
||||
resolveBases: resolveBases,
|
||||
resolveConsoleBase: resolveConsoleBase,
|
||||
parseTab: parseTab,
|
||||
visibleKeys: visibleKeys,
|
||||
pageItems: pageItems,
|
||||
buildSearchUrl: buildSearchUrl,
|
||||
tabCounts: tabCounts,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
normalizeConsole: normalizeConsole,
|
||||
nativeSearchUrl: nativeSearchUrl,
|
||||
PROD: PROD,
|
||||
DEV: DEV,
|
||||
PROD_AGENT: PROD_AGENT,
|
||||
DEV_AGENT: DEV_AGENT,
|
||||
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
|
||||
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
|
||||
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
|
||||
consoleLinkFor: consoleLinkFor,
|
||||
PER_APP_LIMIT: PER_APP_LIMIT,
|
||||
FULL_LIMIT: FULL_LIMIT,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.RecherchePartout = api;
|
||||
@@ -268,16 +538,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection(section, state) {
|
||||
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||
function renderSection(section, state, full) {
|
||||
/* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable",
|
||||
* items (complets), message, q, base, app }.
|
||||
* full=true (onglet propre à l'app) : tout afficher ; sinon
|
||||
* PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */
|
||||
var body = section.querySelector(".rp-body");
|
||||
var count = section.querySelector(".rp-count");
|
||||
var toutVoir = section.querySelector(".rp-all");
|
||||
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
|
||||
toutVoir.textContent = "Tout voir";
|
||||
toutVoir.title = nativeSearchLabel(state.app);
|
||||
if (toutVoir) {
|
||||
try {
|
||||
toutVoir.href =
|
||||
global.location.pathname + buildSearchUrl(state.q, state.app);
|
||||
} catch (e) {}
|
||||
toutVoir.textContent = "Tout voir";
|
||||
toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app);
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
count.textContent = "";
|
||||
count.textContent = "…";
|
||||
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||
return;
|
||||
}
|
||||
@@ -293,19 +571,31 @@
|
||||
"</a></p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
if (state.status === "unavailable") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
|
||||
(state.message ? " (" + esc(state.message) + ")" : "") +
|
||||
".</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "idle") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
count.textContent = "0";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||
return;
|
||||
}
|
||||
var shown = pageItems(state.items, full);
|
||||
count.textContent = String(state.items.length);
|
||||
body.innerHTML = state.items
|
||||
body.innerHTML = shown
|
||||
.map(function (item) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' +
|
||||
esc(item.url) +
|
||||
'">' +
|
||||
var inner =
|
||||
'<span class="rp-title">' +
|
||||
esc(item.title) +
|
||||
"</span>" +
|
||||
@@ -314,9 +604,13 @@
|
||||
: "") +
|
||||
(item.snippet
|
||||
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||
: "") +
|
||||
"</a>"
|
||||
);
|
||||
: "");
|
||||
if (item.url) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
|
||||
);
|
||||
}
|
||||
return '<span class="rp-item">' + inner + "</span>";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -349,40 +643,213 @@
|
||||
barUrls: barUrls,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var consoleBase = resolveConsoleBase({
|
||||
host: global.location.hostname,
|
||||
explicit: root.getAttribute("data-base-console"),
|
||||
agentUrl:
|
||||
(global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]);
|
||||
var sections = {};
|
||||
APPS.forEach(function (app) {
|
||||
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||
if (el) sections[app.key] = el;
|
||||
allKeys.forEach(function (key) {
|
||||
var el = document.querySelector('[data-rp-section="' + key + '"]');
|
||||
if (el) sections[key] = el;
|
||||
});
|
||||
var tabButtons = {};
|
||||
document.querySelectorAll("[data-rp-tab]").forEach(function (btn) {
|
||||
tabButtons[btn.getAttribute("data-rp-tab")] = btn;
|
||||
});
|
||||
var tablist = document.querySelector(".rp-tabs");
|
||||
|
||||
/* États par application (+ console). total = items.length. */
|
||||
var states = {};
|
||||
allKeys.forEach(function (key) {
|
||||
states[key] = {
|
||||
status: "idle",
|
||||
items: [],
|
||||
message: "",
|
||||
q: "",
|
||||
base: key === "console" ? consoleBase : bases[key],
|
||||
app: key,
|
||||
};
|
||||
});
|
||||
|
||||
var activeTab = "all";
|
||||
var currentQ = "";
|
||||
var runId = 0;
|
||||
var consoleFetchedQ = null;
|
||||
var debounceTimer = null;
|
||||
|
||||
function statesForCounts() {
|
||||
var out = {};
|
||||
allKeys.forEach(function (key) {
|
||||
out[key] = { status: states[key].status, total: states[key].items.length };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
var counts = tabCounts(statesForCounts());
|
||||
Object.keys(tabButtons).forEach(function (key) {
|
||||
var btn = tabButtons[key];
|
||||
var selected = key === activeTab;
|
||||
btn.classList.toggle("active", selected);
|
||||
btn.setAttribute("aria-selected", selected ? "true" : "false");
|
||||
btn.tabIndex = selected ? 0 : -1;
|
||||
var span = btn.querySelector(".rp-tab-count");
|
||||
if (span) span.textContent = counts[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
/* Bascule d'onglet : visibilité des sections, re-rendu (plein dans
|
||||
* l'onglet propre à l'app, tronqué dans « Tout »), compteurs.
|
||||
* opts : { push (historique), focusTab }. */
|
||||
function applyTab(tab, opts) {
|
||||
opts = opts || {};
|
||||
activeTab = parseTab(tab);
|
||||
try {
|
||||
root.setAttribute("data-rp-tab", activeTab);
|
||||
} catch (e) {}
|
||||
var visible = visibleKeys(activeTab);
|
||||
allKeys.forEach(function (key) {
|
||||
var sec = sections[key];
|
||||
if (!sec) return;
|
||||
var show = visible.indexOf(key) !== -1;
|
||||
sec.hidden = !show;
|
||||
if (show) renderSection(sec, states[key], activeTab === key);
|
||||
var tv = sec.querySelector(".rp-all");
|
||||
if (tv) tv.hidden = activeTab === key;
|
||||
});
|
||||
renderTabs();
|
||||
if (opts.push) {
|
||||
try {
|
||||
global.history.pushState(
|
||||
null,
|
||||
"",
|
||||
global.location.pathname + buildSearchUrl(currentQ, activeTab)
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
if (opts.focusTab && tabButtons[activeTab]) {
|
||||
try {
|
||||
tabButtons[activeTab].focus();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(tab, opts) {
|
||||
applyTab(tab, opts);
|
||||
/* La console n'est interrogée qu'une fois affichée. */
|
||||
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
||||
fetchConsole(currentQ, runId);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchConsole(q, myRun) {
|
||||
var sec = sections.console;
|
||||
if (!sec) return;
|
||||
consoleFetchedQ = q;
|
||||
states.console = {
|
||||
status: "loading",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
renderSection(sec, states.console, true);
|
||||
renderTabs();
|
||||
var url =
|
||||
consoleBase +
|
||||
CONSOLE_SEARCH_PATH +
|
||||
"?q=" +
|
||||
encodeURIComponent(q) +
|
||||
"&limite=" +
|
||||
CONSOLE_SEARCH_LIMIT;
|
||||
fetchSection("console", url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
if (err) {
|
||||
var msg;
|
||||
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
|
||||
else if (err.status === 422)
|
||||
msg = "au moins 2 caractères requis";
|
||||
else if (err.status) msg = "HTTP " + err.status;
|
||||
else msg = "injoignable (CORS ou réseau)";
|
||||
states.console = {
|
||||
status: "unavailable",
|
||||
items: [],
|
||||
message: msg,
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
} else {
|
||||
var norm = normalizeConsole(data, consoleBase);
|
||||
if (!norm.recognized) {
|
||||
states.console = {
|
||||
status: "unavailable",
|
||||
items: [],
|
||||
message: "format inattendu",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
} else {
|
||||
states.console = {
|
||||
status: norm.items.length ? "ok" : "empty",
|
||||
items: norm.items,
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
}
|
||||
}
|
||||
renderSection(sec, states.console, true);
|
||||
renderTabs();
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch(q) {
|
||||
currentQ = q;
|
||||
var myRun = ++runId;
|
||||
if (!q) {
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
allKeys.forEach(function (key) {
|
||||
var sec = sections[key];
|
||||
if (!sec) return;
|
||||
sec.querySelector(".rp-body").innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
sec.querySelector(".rp-count").textContent = "";
|
||||
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
|
||||
states[key] = {
|
||||
status: "idle",
|
||||
items: [],
|
||||
message: "",
|
||||
q: "",
|
||||
base: key === "console" ? consoleBase : bases[key],
|
||||
app: key,
|
||||
};
|
||||
renderSection(sec, states[key], activeTab === key);
|
||||
});
|
||||
consoleFetchedQ = null;
|
||||
renderTabs();
|
||||
return;
|
||||
}
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
|
||||
states[app.key] = {
|
||||
status: "loading",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
renderSection(sec, states[app.key], activeTab === app.key);
|
||||
var url;
|
||||
if (app.key === hostApp) {
|
||||
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||
url = "/" + searchPath(app.key, q);
|
||||
url = "/" + searchPath(app.key, q, true);
|
||||
} else {
|
||||
url = bases[app.key] + searchPath(app.key, q);
|
||||
url = bases[app.key] + searchPath(app.key, q, true);
|
||||
}
|
||||
fetchSection(app.key, url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
@@ -393,25 +860,45 @@
|
||||
msg = "Connexion requise sur " + app.label;
|
||||
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
||||
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
||||
renderSection(sec, {
|
||||
states[app.key] = {
|
||||
status: "error",
|
||||
items: [],
|
||||
message: msg,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
return;
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
} else {
|
||||
var items = normalize(app.key, data, bases[app.key], q, true);
|
||||
states[app.key] = {
|
||||
status: items.length ? "ok" : "empty",
|
||||
items: items,
|
||||
message: "",
|
||||
q: q,
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
}
|
||||
var items = normalize(app.key, data, bases[app.key], q);
|
||||
renderSection(sec, {
|
||||
status: items.length ? "ok" : "empty",
|
||||
items: items,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
renderSection(sec, states[app.key], activeTab === app.key);
|
||||
renderTabs();
|
||||
});
|
||||
});
|
||||
/* Console : seulement si son onglet est affiché ; sinon son
|
||||
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
||||
if (activeTab === "console") {
|
||||
fetchConsole(q, myRun);
|
||||
} else {
|
||||
states.console = {
|
||||
status: "idle",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
consoleFetchedQ = null;
|
||||
renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch(q, immediate) {
|
||||
@@ -427,13 +914,33 @@
|
||||
|
||||
function syncUrl(q) {
|
||||
try {
|
||||
var url = new URL(global.location.href);
|
||||
if (q) url.searchParams.set("q", q);
|
||||
else url.searchParams.delete("q");
|
||||
global.history.replaceState(null, "", url.toString());
|
||||
global.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
global.location.pathname + buildSearchUrl(q, activeTab)
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
try {
|
||||
input.focus({ preventScroll: true });
|
||||
} catch (e) {
|
||||
try {
|
||||
input.focus();
|
||||
} catch (e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
function visibleLinks() {
|
||||
return Array.prototype.slice
|
||||
.call(document.querySelectorAll("[data-rp-link]"))
|
||||
.filter(function (a) {
|
||||
var s = a.closest ? a.closest("[data-rp-section]") : null;
|
||||
return !s || !s.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
syncUrl(q);
|
||||
@@ -441,7 +948,7 @@
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
var first = document.querySelector("[data-rp-link]");
|
||||
var first = visibleLinks()[0];
|
||||
if (first) {
|
||||
e.preventDefault();
|
||||
first.click();
|
||||
@@ -471,6 +978,9 @@
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
/* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les
|
||||
* flèches ←/→ du tablist s'en chargent (voir ci-dessous). */
|
||||
if (target && target.closest && target.closest(".rp-tab")) return;
|
||||
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
@@ -478,8 +988,62 @@
|
||||
true
|
||||
);
|
||||
|
||||
/* « Tout voir » reste sur la page : bascule vers l'onglet. */
|
||||
root.addEventListener("click", function (e) {
|
||||
var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null;
|
||||
if (!all) return;
|
||||
var sec = all.closest ? all.closest("[data-rp-section]") : null;
|
||||
if (!sec) return;
|
||||
e.preventDefault();
|
||||
setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true });
|
||||
});
|
||||
|
||||
/* Clic sur la barre « Rechercher partout » (bouton
|
||||
* data-act="palette") pendant qu'on est déjà sur la page :
|
||||
* replacer le curseur dans la boîte au lieu de recharger. */
|
||||
document.addEventListener("click", function (e) {
|
||||
var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null;
|
||||
if (!p) return;
|
||||
e.preventDefault();
|
||||
focusSearch();
|
||||
try {
|
||||
input.select();
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
/* Onglets : clic + clavier (←/→, Début/Fin). */
|
||||
Object.keys(tabButtons).forEach(function (key) {
|
||||
tabButtons[key].addEventListener("click", function () {
|
||||
setTab(key, { push: true });
|
||||
});
|
||||
});
|
||||
if (tablist) {
|
||||
tablist.addEventListener("keydown", function (e) {
|
||||
var order = TABS;
|
||||
var idx = order.indexOf(activeTab);
|
||||
var next = null;
|
||||
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
|
||||
else if (e.key === "ArrowLeft")
|
||||
next = order[(idx - 1 + order.length) % order.length];
|
||||
else if (e.key === "Home") next = order[0];
|
||||
else if (e.key === "End") next = order[order.length - 1];
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
setTab(next, { push: true, focusTab: true });
|
||||
});
|
||||
}
|
||||
|
||||
/* Retour arrière navigateur : restaurer q + onglet. */
|
||||
global.addEventListener("popstate", function () {
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var q = (params.get("q") || "").trim();
|
||||
input.value = q;
|
||||
applyTab(params.get("tab"));
|
||||
runSearch(q);
|
||||
});
|
||||
|
||||
function moveFocus(delta, e) {
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||
var links = visibleLinks();
|
||||
if (!links.length) return;
|
||||
var active = document.activeElement;
|
||||
var idx = links.indexOf(active);
|
||||
@@ -492,12 +1056,23 @@
|
||||
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var initialQ = (params.get("q") || "").trim();
|
||||
if (initialQ) {
|
||||
input.value = initialQ;
|
||||
runSearch(initialQ);
|
||||
} else {
|
||||
runSearch("");
|
||||
}
|
||||
var initialTab = parseTab(params.get("tab"));
|
||||
if (initialQ) input.value = initialQ;
|
||||
applyTab(initialTab);
|
||||
runSearch(initialQ);
|
||||
|
||||
/* Focus explicite (voir en-tête) : l'attribut autofocus seul ne
|
||||
* survit pas au chargement des surcouches. */
|
||||
focusSearch();
|
||||
try {
|
||||
if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch);
|
||||
} catch (e) {}
|
||||
try {
|
||||
setTimeout(focusSearch, 0);
|
||||
} catch (e) {}
|
||||
global.addEventListener("pageshow", function (e) {
|
||||
if (e.persisted) focusSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
+8
-1
@@ -8,7 +8,14 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
|
||||
const CACHE_NAME = 'flowdeck-v9'; // v9 : purge des caches pre-#33/#36 —
|
||||
// recherche.js/recherche.css etaient servis cache-first SANS version
|
||||
// (?v= absent dans recherche.html) : les navigateurs ayant visite
|
||||
// Flowdeck avant les onglets gardaient les copies pre-#33 (JS sans
|
||||
// onglets/compteurs/console, CSS sans .rp-tabs) malgre les deplois
|
||||
// 7.84.3/7.84.4. Le bump purge ces caches a l'activation, et les
|
||||
// liens versionnes (?v=asset_version) font des futurs changements
|
||||
// de nouvelles cles de cache.
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
|
||||
@@ -65,4 +65,120 @@ ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
|
||||
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
|
||||
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
|
||||
|
||||
|
||||
/* ---- 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) ---- */
|
||||
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)");
|
||||
let 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.js : " + n + " assertions OK");
|
||||
|
||||
@@ -61,6 +61,12 @@ def test_notifications_api_et_cors_soeur(client):
|
||||
headers={"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert "access-control-allow-origin" not in r4.headers
|
||||
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
|
||||
r5 = client.options("/api/notifications",
|
||||
headers={"Origin": "https://tiktak-dev.dracodev.net",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r5.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
|
||||
assert r5.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
|
||||
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Barre d'apps Flowdeck : aucun texte de chantier rendu hors commentaire.
|
||||
|
||||
Defaut 5 : les lignes « Chantier 5 (fusion #69/#71, sans bump) : ... »
|
||||
etaient placees APRES la fermeture `#}` du commentaire Jinja d'en-tete
|
||||
de `app/templates/_draco_bar.html` (lignes 9-11) : elles etaient donc
|
||||
rendues telles quelles, juste au-dessus du <header> de la barre, sur
|
||||
toutes les pages (la barre est incluse par base.html).
|
||||
|
||||
Test sans dependance (lecture du gabarit seul, comme
|
||||
test_draco_alertes.py) : il tourne sans serveur ni base, donc aussi
|
||||
en sandbox. La note documentaire peut vivre DANS le commentaire Jinja
|
||||
(jamais rendu) ; elle ne doit jamais en sortir.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _hors_commentaires(texte):
|
||||
"""Texte du gabarit sans les commentaires Jinja {# ... #}."""
|
||||
return re.sub(r"\{#.*?#\}", "", texte, flags=re.S)
|
||||
|
||||
|
||||
def test_aucun_texte_chantier_rendu():
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
assert "Chantier 5 (fusion" not in rendu
|
||||
assert "sans bump" not in rendu
|
||||
# Aucune ligne de texte libre entre le commentaire d'en-tete et <link> :
|
||||
# le rendu doit commencer (apres commentaires) par <link> ou <header>.
|
||||
assert rendu.lstrip().startswith("<link")
|
||||
|
||||
|
||||
def test_barre_intacte_apres_suppression():
|
||||
"""Le 5e onglet Console Spark et la marque survivent a la suppression."""
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
nav = rendu.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
|
||||
assert "Console Spark" in nav
|
||||
assert 'data-app="agent"' in nav
|
||||
assert 'class="draco-brand"' in rendu
|
||||
# L'entree « Console Spark » du menu de la marque est construite par
|
||||
# draco-barre.js depuis les onglets : l'onglet agent suffit a l'alimenter.
|
||||
js_barre = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert "Console Spark" in js_barre
|
||||
+33
-3
@@ -38,25 +38,51 @@ def test_origin_of():
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
|
||||
# quelle que soit la résolution.
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
# Configuration muette (hors requête) + hôte dev : les -dev passent.
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent — l'hôte ne
|
||||
# restreint plus l'allowlist.
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" not in sister_origins(
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck-dev.dracodev.net"
|
||||
)
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
@@ -64,7 +90,11 @@ def test_cors_headers():
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://og-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
# Autre environnement autorisé (correctif 2026-10-28).
|
||||
assert cors_headers_for("https://og.dracodev.net",
|
||||
RESOLVED_DEV) is not None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Page /recherche Flowdeck : onglets, base console, anti-cache (defaut 6).
|
||||
|
||||
Audit prealable (main 7.84.4, tag v7.84.4 = 984e92b) : la route
|
||||
(app/routers/search.py:51), le gabarit (app/templates/recherche.html),
|
||||
le JS (static/js/recherche.js) et le CSS (static/css/recherche.css)
|
||||
contenaient DEJA la page a onglets des PR #33/#36 — aucune route
|
||||
fantome, aucun bloc `scripts` manquant dans base.html:869, aucune
|
||||
regle CSP ne bloquant le script (script-src 'self'). Le symptome
|
||||
deploie (« pas d'onglets, resultats Flowdeck seuls ») ne s'explique
|
||||
donc PAS par un mauvais gabarit dans main, mais par deux causes
|
||||
reelles, corrigees ici :
|
||||
|
||||
1. Assets recherche servis CACHE-FIRST par le service worker
|
||||
(static/sw.js) SANS version (?v= absent) sous un CACHE_NAME
|
||||
inchange depuis A20 ('flowdeck-v8') : un navigateur ayant visite
|
||||
Flowdeck avant #33 gardait le recherche.js pre-#33 (sans onglets,
|
||||
sans compteurs, sans logique console, « Tout voir » naviguant
|
||||
ailleurs) et le recherche.css pre-#33 (sans .rp-tabs) — la page
|
||||
paraissait donc « non corrigee » malgre les deplois 7.84.3/7.84.4,
|
||||
et le repli networkFirst du HTML vers le cache couvrait aussi le
|
||||
cas reseau lent. Correctif : liens versionnes (?v=asset_version,
|
||||
comme tous les autres assets de base.html) + bump CACHE_NAME
|
||||
(purge a l'activation du nouveau worker).
|
||||
2. Flowdeck etait le seul hote a ne pas rendre data-base-console
|
||||
(son propre commentaire l'admettait) : la base console de l'onglet
|
||||
Console Spark etait devine (derivation -dev / href d'onglet de
|
||||
barre) au lieu d'etre explicite. Correctif : la route expose
|
||||
`bases.agent` et le gabarit le rend en data-base-console.
|
||||
|
||||
La logique d'onglets elle-meme (focus, compteurs, « Tout voir » sur
|
||||
place, etats par application, liens -dev, console sur
|
||||
GET /api/v1/recherche) est deja couverte par tests/recherche.test.js
|
||||
(suite Node, lancee en sandbox) ; ce fichier-ci verrouille le
|
||||
branchement gabarit/route/service worker, sans serveur ni base.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
ONGLETS_ATTENDUS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante",
|
||||
"console"]
|
||||
|
||||
|
||||
def test_gabarit_recherche_onglets_complets():
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
trouves = re.findall(r'data-rp-tab="([^"]+)"', gabarit)
|
||||
assert trouves == ONGLETS_ATTENDUS, trouves
|
||||
for section in ("obsigate", "flowdeck", "tiktak", "kanbante", "console"):
|
||||
assert f'data-rp-section="{section}"' in gabarit
|
||||
assert 'id="rp-q"' in gabarit and "autofocus" in gabarit
|
||||
|
||||
|
||||
def test_gabarit_recherche_base_console_explicite():
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
assert 'data-base-console="{{ bases.agent }}"' in gabarit
|
||||
|
||||
|
||||
def test_gabarit_recherche_assets_versionnes():
|
||||
"""Sans ?v=, le SW cache-first ressert les copies pre-#33."""
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
assert "/static/css/recherche.css?v={{ asset_version }}" in gabarit
|
||||
assert "/static/js/recherche.js?v={{ asset_version }}" in gabarit
|
||||
|
||||
|
||||
def test_route_recherche_expose_base_agent():
|
||||
route = (ROOT / "app/routers/search.py").read_text()
|
||||
assert '"agent"' in route or "'agent'" in route
|
||||
assert "bases.agent" in (ROOT / "app/templates/recherche.html").read_text()
|
||||
|
||||
|
||||
def test_service_worker_purge_anciens_caches():
|
||||
"""Le bump de CACHE_NAME purge les caches pre-#33 a l'activation."""
|
||||
sw = (ROOT / "static/sw.js").read_text()
|
||||
assert "const CACHE_NAME = 'flowdeck-v9'" in sw
|
||||
assert "flowdeck-v8" not in sw
|
||||
assert "cache-first" in sw or "cacheFirst" in sw
|
||||
|
||||
|
||||
def test_recherche_js_branche_console_v1():
|
||||
"""Rappel : l'onglet console appelle GET /api/v1/recherche (cf. #36)."""
|
||||
js = (ROOT / "static/js/recherche.js").read_text()
|
||||
assert 'CONSOLE_SEARCH_PATH = "api/v1/recherche"' in js
|
||||
assert "CONSOLE_SEARCH_LIMIT = 20" in js
|
||||
Reference in New Issue
Block a user