Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1234429a46 | ||
|
|
f7a0597575 | ||
|
|
e89f7903e1 | ||
|
|
938f8b5eb5 | ||
|
|
5f365b4b9f | ||
|
|
d867fbffbf | ||
|
|
b90941111a | ||
|
|
35f1cccd03 | ||
|
|
f29a640d9a | ||
|
|
cf98880fc8 | ||
|
|
6e86b09327 | ||
|
|
d41a79a9ed | ||
|
|
725f78cb68 | ||
|
|
39750851b0 | ||
|
|
b2d2fd1eee | ||
|
|
0de3b65869 | ||
|
|
903616a8cd | ||
|
|
e5282079b8 | ||
|
|
a30fa9d8f3 | ||
|
|
61f299305a | ||
|
|
ecf7f09fc5 | ||
|
|
9772ecdcf6 | ||
|
|
f503331773 | ||
|
|
c75ef7764f | ||
|
|
961556fb78 | ||
|
|
7a68606aa7 | ||
|
|
8015d22a06 | ||
|
|
39b5fd88f9 |
@@ -1,5 +1,15 @@
|
||||
## 0.37.3 — 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.
|
||||
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CORS bornée à `/api/events`.
|
||||
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), nouvel endpoint JSON `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
|
||||
|
||||
# Changelog
|
||||
|
||||
## 0.37.2 — 2026-10-28 — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
||||
|
||||
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||
|
||||
## 0.37.1 — 2026-10-26
|
||||
|
||||
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.37.1"
|
||||
APP_VERSION = "0.37.3"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+113
@@ -390,6 +390,19 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
if (path.startswith("/mcp") or path.startswith("/api/")) \
|
||||
and _bearer_user(request) is not None:
|
||||
return await call_next(request)
|
||||
# Recherche partout (#68) : /api/search accepte aussi le jeton
|
||||
# SSO draco_session (mono-utilisateur : un jeton valide désigne
|
||||
# l'opérateur), comme les API de recherche des sœurs. Les
|
||||
# preflights OPTIONS ne portent jamais de cookies : ils sont
|
||||
# répondus par RechercheCorsMiddleware avant d'arriver ici.
|
||||
if path == "/api/search":
|
||||
try:
|
||||
from app import draco
|
||||
|
||||
if draco.claims_for(request) is not None:
|
||||
return await call_next(request)
|
||||
except Exception:
|
||||
pass
|
||||
# API consumers get a plain 401 (no browser prompt, no
|
||||
# redirect they cannot follow); browsers get the login page.
|
||||
if path.startswith("/api/") or "text/html" not in request.headers.get(
|
||||
@@ -405,9 +418,63 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
||||
|
||||
|
||||
class DracoAlertesCorsMiddleware(BaseHTTPMiddleware):
|
||||
"""CORS Fusion #69, limité à GET /api/events.
|
||||
|
||||
Le panneau Alertes unifiées lit ce journal depuis les autres
|
||||
hôtes dracodev, avec credentials. Seules les origines des
|
||||
applications soeurs (résolues comme les liens de la barre :
|
||||
explicite > hôte de la requête > dérivation > défaut officiel,
|
||||
via resoudre_urls_draco()) reçoivent Access-Control-Allow-Origin.
|
||||
Le preflight OPTIONS ne porte aucune donnée et ne contourne
|
||||
aucune authentification : le GET reste soumis à AuthMiddleware
|
||||
(session ou jeton). Toute autre origine ou route traverse sans
|
||||
en-tête CORS — le navigateur bloque alors la lecture, comme
|
||||
avant. Aucune CSP connect-src n'est à ouvrir ici : Kanbante
|
||||
n'énonce que frame-src (console Spark).
|
||||
"""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
if request.url.path != "/api/events":
|
||||
return await call_next(request)
|
||||
from app.config import resoudre_urls_draco
|
||||
|
||||
urls = resoudre_urls_draco(request.headers.get("host"))
|
||||
soeurs = {urls.sso_url, urls.flowdeck_url, urls.tiktak_url,
|
||||
urls.public_url}
|
||||
origine = request.headers.get("origin", "")
|
||||
if origine not in soeurs:
|
||||
return await call_next(request)
|
||||
headers = {
|
||||
"Access-Control-Allow-Origin": origine,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
if request.method == "OPTIONS":
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return JSONResponse({}, status_code=200, headers=headers)
|
||||
response = await call_next(request)
|
||||
response.headers.update(headers)
|
||||
return response
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
|
||||
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajouté
|
||||
# après AuthMiddleware donc exécuté avant lui (dernier ajouté = le
|
||||
# plus externe) : les preflights OPTIONS sont répondus ici, avant
|
||||
# tout refus d'authentification.
|
||||
from app.recherche import RechercheCorsMiddleware
|
||||
|
||||
app.add_middleware(RechercheCorsMiddleware)
|
||||
app.add_middleware(DracoAlertesCorsMiddleware)
|
||||
|
||||
|
||||
def _safe_next(raw: str) -> str:
|
||||
"""Only local paths may be a post-login destination."""
|
||||
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
|
||||
@@ -4311,6 +4378,51 @@ async def search_page(request: Request, q: str = ""):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/recherche", response_class=HTMLResponse)
|
||||
async def recherche_page(request: Request, q: str = ""):
|
||||
"""Page de résultats unifiée « Rechercher partout » (fusion #68).
|
||||
|
||||
Coquille seule : les résultats sont chargés côté client par
|
||||
static/js/recherche.js. La page /search native reste inchangée.
|
||||
"""
|
||||
return render(request, "recherche.html", q=q.strip())
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
async def recherche_api(request: Request, q: str = "", limit: int = 20):
|
||||
"""Recherche JSON pour la page « Rechercher partout » (fusion #68).
|
||||
|
||||
Même source que la page /search : recherche Gitea (titres + corps,
|
||||
tous états, tous projets du workspace), groupée par dépôt avec la
|
||||
colonne du tableau. Auth : voir AuthMiddleware (session, Basic,
|
||||
jeton API, ou jeton SSO draco_session pour ce chemin). Les corps
|
||||
sont tronqués à 160 caractères ; seuls les champs utiles sont
|
||||
exposés (voir app/recherche.py).
|
||||
"""
|
||||
from app.recherche import groupes_vers_json
|
||||
|
||||
q = q.strip()
|
||||
limit = max(1, min(limit, 50))
|
||||
if not q:
|
||||
return {"query": q, "groups": [], "total": 0}
|
||||
workspace, _role = _current_workspace(request)
|
||||
conn_ids = (
|
||||
models.workspace_connection_ids(workspace["id"])
|
||||
if workspace
|
||||
else [registry.default_id]
|
||||
)
|
||||
results: list[dict] = []
|
||||
for cid in conn_ids:
|
||||
try:
|
||||
found = await _forge_client(cid).search_issues(q=q, state="all")
|
||||
results.extend(found)
|
||||
except Exception as exc:
|
||||
logger.debug("search failed for conn %s: %s", cid, exc)
|
||||
groups = _group_issue_results(results)
|
||||
data, total = groupes_vers_json(groups, limit)
|
||||
return {"query": q, "groups": data, "total": total}
|
||||
|
||||
|
||||
@app.get("/my", response_class=HTMLResponse)
|
||||
async def my_issues(request: Request):
|
||||
"""Open issues assigned to the token owner, per connection."""
|
||||
@@ -6914,3 +7026,4 @@ async def readiness():
|
||||
},
|
||||
status_code=200 if ready else 503,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Recherche fédérée (fusion-trois-apps #68) : JSON de recherche,
|
||||
CORS bornée et conversion des groupes d'issues.
|
||||
|
||||
Ce module ne dépend d'aucun autre module applicatif à l'import (les
|
||||
résolveurs sont appelés paresseusement) : les fonctions pures sont
|
||||
testables sans base ni serveur.
|
||||
|
||||
- ``GET /api/search`` (route dans ``app/main.py``) réutilise la
|
||||
recherche Gitea de la page ``/search`` (titres + corps, tous états,
|
||||
tous projets du workspace) et la regroupe par dépôt.
|
||||
- La CORS bornée n'autorise que ``GET /api/search``, et seulement
|
||||
pour les origines sœurs résolues par ``app.config`` (explicite >
|
||||
hôte de la requête > dérivation > défaut officiel — donc ``-dev``
|
||||
reste ``-dev``), jamais ``*``. Le middleware est un ASGI pur :
|
||||
il répond lui-même aux preflights OPTIONS (qui ne portent jamais
|
||||
de cookies, donc qu'``AuthMiddleware`` refuserait sinon) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SEARCH_PATH = "/api/search"
|
||||
_SNIPPET_LEN = 160
|
||||
|
||||
PROD_ORIGINS = frozenset({
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
})
|
||||
|
||||
# Clés du dictionnaire rendu par render() (app/main.py).
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower()
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 4 and first.endswith("-dev")
|
||||
|
||||
|
||||
def origin_of(url: str | None) -> str | None:
|
||||
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return None
|
||||
host = parsed.hostname.lower()
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return f"{parsed.scheme}://{host}{port}"
|
||||
|
||||
|
||||
def sister_origins(
|
||||
resolved: dict | None = None, request_host: str | None = None
|
||||
) -> frozenset:
|
||||
"""Origines autorisées pour la lecture cross-origin de /api/search.
|
||||
|
||||
``resolved`` : dictionnaire ``draco_urls`` rendu par ``render()``.
|
||||
Sans argument, le résolveur est appelé (import paresseux) avec
|
||||
l'hôte de la requête quand il est connu — ce middleware s'exécute
|
||||
avant tout poseur de contexte d'hôte, et sans lui la résolution
|
||||
retomberait sur la configuration seule et raterait l'environnement
|
||||
``-dev`` ; en cas d'échec, repli sur prod + dev officiels
|
||||
(premier parti uniquement).
|
||||
"""
|
||||
if resolved is None:
|
||||
try:
|
||||
from app.config import get_settings, resoudre_urls_draco
|
||||
|
||||
urls = resoudre_urls_draco(hote_requete=request_host, base=get_settings())
|
||||
resolved = {
|
||||
"obsigate": urls.sso_url,
|
||||
"flowdeck": urls.flowdeck_url,
|
||||
"tiktak": urls.tiktak_url,
|
||||
"kanbante": urls.public_url,
|
||||
}
|
||||
except Exception:
|
||||
return PROD_ORIGINS | DEV_ORIGINS
|
||||
origins = set()
|
||||
for key in _SOEUR_KEYS:
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
|
||||
if not origin:
|
||||
return None
|
||||
if origin.strip() not in sister_origins(resolved, request_host):
|
||||
return None
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin.strip(),
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
|
||||
|
||||
def preflight_headers(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
|
||||
headers = cors_headers_for(origin, resolved, request_host)
|
||||
if headers is None:
|
||||
return None
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
def groupes_vers_json(groups, limit: int = 20):
|
||||
"""Convertit la sortie de ``_group_issue_results`` en JSON public.
|
||||
|
||||
Seuls les champs utiles sont exposés (numéro, titre, état, corps
|
||||
tronqué à 160 caractères, labels, colonne). Retourne
|
||||
``(groupes, total)`` où ``total`` est le nombre de lignes
|
||||
retournées (plafonné à ``limit``).
|
||||
"""
|
||||
out: list[dict] = []
|
||||
total = 0
|
||||
restant = max(1, min(int(limit), 50))
|
||||
for group in groups or []:
|
||||
rows: list[dict] = []
|
||||
for row in (group or {}).get("rows", []) or []:
|
||||
if restant <= 0:
|
||||
break
|
||||
issue = (row or {}).get("issue", {}) or {}
|
||||
labels = []
|
||||
for label in issue.get("labels") or []:
|
||||
if isinstance(label, dict):
|
||||
labels.append({"name": label.get("name"), "color": label.get("color")})
|
||||
rows.append({
|
||||
"issue": {
|
||||
"number": issue.get("number"),
|
||||
"title": issue.get("title"),
|
||||
"state": issue.get("state"),
|
||||
"body": (issue.get("body") or "")[:_SNIPPET_LEN],
|
||||
"labels": labels,
|
||||
},
|
||||
"column": (row or {}).get("column"),
|
||||
})
|
||||
restant -= 1
|
||||
total += 1
|
||||
if rows:
|
||||
out.append({"full_name": (group or {}).get("full_name"), "rows": rows})
|
||||
if restant <= 0:
|
||||
break
|
||||
return out, total
|
||||
|
||||
|
||||
class RechercheCorsMiddleware:
|
||||
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
headers_in = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
origin = headers_in.get("origin", "")
|
||||
request_host = (
|
||||
headers_in.get("host", "") or headers_in.get("x-forwarded-host", "")
|
||||
).split(",")[0].strip()
|
||||
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
|
||||
preflight = preflight_headers(origin, request_host=request_host)
|
||||
if preflight is not None:
|
||||
body = b""
|
||||
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in preflight.items()]
|
||||
headers_out.append((b"content-length", b"0"))
|
||||
await send({"type": "http.response.start", "status": 204,
|
||||
"headers": headers_out})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
return
|
||||
if path != SEARCH_PATH:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_cors(message):
|
||||
if message["type"] == "http.response.start":
|
||||
cors = cors_headers_for(origin, request_host=request_host)
|
||||
if cors is not None:
|
||||
message["headers"] = list(message.get("headers", [])) + [
|
||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in cors.items()
|
||||
]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cors)
|
||||
@@ -10,6 +10,7 @@
|
||||
wallboard ni l'écran de connexion, volontaires). #}
|
||||
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ version }}">
|
||||
<script>document.body.classList.add('has-draco-bar');</script>
|
||||
<header class="draco-bar" data-current="kanbante">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
@@ -61,7 +62,6 @@
|
||||
<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-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
|
||||
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="Opérateur Kanbante">{{ draco_avatar }}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -71,8 +71,9 @@
|
||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||
le rail pilote le repli natif de la barre latérale, la palette
|
||||
ouvre la recherche & commandes native (Ctrl+K) ; les alertes
|
||||
unifiées, la recherche fédérée et Spark arrivent avec les
|
||||
phases suivantes du programme Fusion. */
|
||||
unifiées passent par le panneau draco-alertes.js (Fusion #69,
|
||||
agrégation à la demande, pas une fédération temps réel) ;
|
||||
recherche partout : page /recherche (Fusion #68). */
|
||||
window.DRACO_BAR_CONFIG = {
|
||||
current: "kanbante",
|
||||
/* v0.1.6 : user = initiale auth_user ou K (draco_avatar). Sans
|
||||
@@ -95,14 +96,9 @@
|
||||
};
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; }
|
||||
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
|
||||
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||
/* v0.1.6 : le toast placeholder "fédération" est remplacé
|
||||
par le menu par défaut du nouveau JS — retourner false le
|
||||
demande explicitement (un onAction existant garde sinon la
|
||||
main). Contenu : Console agent + applications, URL résolues
|
||||
donc dérivées -dev sur un hôte -dev. */
|
||||
if (act === "menu") { return false; }
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { location.href = "/activity"; } return; }
|
||||
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). */
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||
@@ -119,3 +115,5 @@
|
||||
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
|
||||
<script>window.DRACO_PANNEAU_CONFIG = { api: "{{ agent_url }}" };</script>
|
||||
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
|
||||
<script defer src="/static/js/draco-alertes.js?v={{ version }}"></script>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rechercher partout — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/recherche.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="rp-page" id="recherche" data-host="kanbante"
|
||||
data-base-obsigate="{{ draco_urls.obsigate }}"
|
||||
data-base-flowdeck="{{ draco_urls.flowdeck }}"
|
||||
data-base-tiktak="{{ draco_urls.tiktak }}"
|
||||
data-base-kanbante="{{ draco_urls.kanbante }}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<section class="rp-section" data-rp-section="obsigate">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="flowdeck">
|
||||
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="tiktak">
|
||||
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="kanbante">
|
||||
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · recherche partout
|
||||
</footer>
|
||||
<script src="/static/js/recherche.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||
.draco-alertes-overlay {
|
||||
position: fixed; inset: 0; z-index: 30000;
|
||||
display: none; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||
}
|
||||
.draco-alertes-box {
|
||||
width: 720px; max-width: 100%;
|
||||
background: var(--bg-primary, #1f1f1f);
|
||||
color: var(--text, #eceee9);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||
.draco-alertes-close {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||
.draco-alertes-item {
|
||||
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||
padding: 9px 12px; margin: 6px 0;
|
||||
}
|
||||
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||
.draco-alertes-title:hover { text-decoration: underline; }
|
||||
.draco-alertes-detail {
|
||||
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||
.draco-alertes-empty a { color: inherit; }
|
||||
@@ -3,8 +3,10 @@
|
||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
|
||||
que le menu des applications) ; l'avatar est un vrai <button>.
|
||||
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
|
||||
menu des applications) ; l'avatar est un vrai <button>.
|
||||
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
|
||||
défaut supprimés ; le menu de la marque gagne « Console Spark ».
|
||||
Jetons repris du prototype validé Console v7.2.
|
||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||
@@ -34,6 +36,7 @@
|
||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
@@ -45,15 +48,15 @@
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
|
||||
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
|
||||
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
|
||||
.draco-more-menu, .draco-compte-menu {
|
||||
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
|
||||
* droite ; ses items réutilisent .draco-apps-item. */
|
||||
.draco-compte-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
|
||||
min-width: 248px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
|
||||
.draco-compte-menu[hidden] { display: none; }
|
||||
.draco-menu-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||
@@ -174,8 +177,8 @@
|
||||
@media (max-width: 900px) {
|
||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
||||
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||
avatar. Le rail, la recherche et les domaines disparaissent :
|
||||
chaque app garde ses contrôles natifs (barre
|
||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||
.rp-input {
|
||||
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||
}
|
||||
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||
.rp-section { margin-bottom: 28px; }
|
||||
.rp-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||
.rp-count {
|
||||
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||
}
|
||||
.rp-count:empty { display: none; }
|
||||
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||
.rp-state { opacity: .75; margin: 8px 0; }
|
||||
.rp-error { opacity: 1; }
|
||||
.rp-item {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||
.rp-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; }
|
||||
@@ -0,0 +1,427 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
|
||||
*
|
||||
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||
* interroge les API de notifications réellement exposées par chaque
|
||||
* application, avec les identifiants du navigateur (credentials:
|
||||
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||
* ou non authentifiée affiche son état réel (« Connexion requise »)
|
||||
* avec un lien profond vers sa page native, jamais du vide déguisé.
|
||||
*
|
||||
* Sources réelles (audit #69, 2026-10-28) :
|
||||
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||
* - TikTak : GET {base}/api/notifications
|
||||
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||
* base + "/notifications".
|
||||
* - Kanbante : GET {base}/api/events?limit=100
|
||||
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||
* masqués), pas un journal lisible par l'utilisateur. La section
|
||||
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||
*
|
||||
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||
* 3. href rendu de l'onglet .draco-apptab[data-app] ;
|
||||
* 4. défaut officiel (production).
|
||||
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||
* lit aussi window.DRACO_URLS (injecté par son backend).
|
||||
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||
* que sur un hôte -dev, exactement comme la barre.
|
||||
*
|
||||
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||
* pour la pastille (repli : mise à jour directe du badge).
|
||||
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||
* avec une valeur venue du serveur.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_URLS = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/"
|
||||
};
|
||||
var DEV_URLS = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante"
|
||||
};
|
||||
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||
var EVENT_LABELS = {
|
||||
created: "créée", moved: "déplacée", closed: "fermée",
|
||||
reopened: "rouverte", commented: "commentée"
|
||||
};
|
||||
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
function cleanUrl(u) {
|
||||
if (!u || typeof u !== "string") return null;
|
||||
if (u.indexOf("{{") !== -1) return null;
|
||||
if (!u.replace(/\s/g, "")) return null;
|
||||
return u;
|
||||
}
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||
}
|
||||
function joinUrl(base, path) {
|
||||
var b = withSlash(base);
|
||||
if (!path) return b;
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return b + String(path).replace(/^\//, "");
|
||||
}
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||
? String(s).replace(" ", "T") + "Z" : s);
|
||||
return isNaN(t.getTime()) ? null : t;
|
||||
}
|
||||
function fmtDate(s) {
|
||||
var t = parseDate(s);
|
||||
if (!t) return s ? String(s).slice(0, 16) : "";
|
||||
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||
}
|
||||
|
||||
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
|
||||
function resolveUrls(env) {
|
||||
env = env || {};
|
||||
var hostname = env.hostname;
|
||||
if (hostname === undefined) {
|
||||
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||
}
|
||||
var dev = isDevHost(hostname);
|
||||
var urls = {};
|
||||
APP_ORDER.forEach(function (app) {
|
||||
var u = cleanUrl(env.barUrls && env.barUrls[app])
|
||||
|| cleanUrl(env.configUrls && env.configUrls[app]);
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
if (!u) {
|
||||
var href = null;
|
||||
if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
|
||||
else if (typeof document !== "undefined" && document.querySelector) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
href = tab ? tab.getAttribute("href") : null;
|
||||
}
|
||||
u = cleanUrl(href);
|
||||
}
|
||||
if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
|
||||
if (!u) {
|
||||
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||
try {
|
||||
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[app] = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
function browserEnv() {
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
var bar = global.DRACO_BAR || {};
|
||||
var tabs = {};
|
||||
try {
|
||||
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||
}
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
return {
|
||||
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFlowdeck(base, payload) {
|
||||
var list = (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.message || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeTiktak(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.body || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.kind || "",
|
||||
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeKanbante(base, payload) {
|
||||
var list = (payload && payload.events) || [];
|
||||
return list.map(function (e) {
|
||||
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||
return {
|
||||
app: "kanbante", id: e.id,
|
||||
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||
detail: detail, date: e.created_at || "",
|
||||
active: false, state: label, kind: e.event_type || "",
|
||||
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier). */
|
||||
function sortAlerts(list) {
|
||||
return list.slice().sort(function (a, b) {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
var da = parseDate(a.date), db = parseDate(b.date);
|
||||
if (da && db) return db - da;
|
||||
if (da) return -1;
|
||||
if (db) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
function countActive(list) {
|
||||
var n = 0;
|
||||
list.forEach(function (a) { if (a.active) n++; });
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ---- Rendu (navigateur seulement) ---- */
|
||||
var panel = null, listEl = null, badgeTimer = null;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var d = document.createElement(tag);
|
||||
if (cls) d.className = cls;
|
||||
if (text !== undefined && text !== null) d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
function ensurePanel() {
|
||||
if (panel) return panel;
|
||||
var ov = el("div", "draco-alertes-overlay");
|
||||
ov.setAttribute("role", "dialog");
|
||||
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||
var box = el("div", "draco-alertes-box");
|
||||
var head = el("div", "draco-alertes-head");
|
||||
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||
var note = el("p", "draco-alertes-note",
|
||||
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||
"les alertes sont relues dans chaque application à l'ouverture.");
|
||||
var close = el("button", "draco-alertes-close", "✕");
|
||||
close.setAttribute("aria-label", "Fermer");
|
||||
close.addEventListener("click", closePanel);
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
box.appendChild(note);
|
||||
listEl = el("div", "draco-alertes-list");
|
||||
box.appendChild(listEl);
|
||||
ov.appendChild(box);
|
||||
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") closePanel();
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
panel = ov;
|
||||
return ov;
|
||||
}
|
||||
function closePanel() { if (panel) panel.style.display = "none"; }
|
||||
|
||||
function renderSection(title, items, fallback) {
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
|
||||
if (!items.length) {
|
||||
sec.appendChild(el("p", "draco-alertes-empty", fallback));
|
||||
return sec;
|
||||
}
|
||||
items.forEach(function (a) {
|
||||
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||
var main = el("div", "draco-alertes-main");
|
||||
var link = el("a", "draco-alertes-title", a.title);
|
||||
link.href = a.url;
|
||||
main.appendChild(link);
|
||||
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||
var meta = el("div", "draco-alertes-meta",
|
||||
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
|
||||
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
|
||||
main.appendChild(meta);
|
||||
row.appendChild(main);
|
||||
sec.appendChild(row);
|
||||
});
|
||||
return sec;
|
||||
}
|
||||
function render(results, urls) {
|
||||
ensurePanel();
|
||||
listEl.textContent = "";
|
||||
var all = [];
|
||||
APP_ORDER.forEach(function (app) {
|
||||
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
|
||||
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
|
||||
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
|
||||
APP_ORDER.forEach(function (app) {
|
||||
var items = actives.filter(function (a) { return a.app === app; });
|
||||
if (results[app] && results[app].error) {
|
||||
var sec = renderSection(APP_LABELS[app], [], results[app].error);
|
||||
listEl.appendChild(sec);
|
||||
} else if (app === "obsigate") {
|
||||
listEl.appendChild(renderSection(APP_LABELS[app], [],
|
||||
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||
} else if (items.length) {
|
||||
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
|
||||
}
|
||||
});
|
||||
if (!actives.length) {
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
|
||||
}
|
||||
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
|
||||
APP_ORDER.forEach(function (app) {
|
||||
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
|
||||
if (app === "obsigate") {
|
||||
var link = el("div", "draco-alertes-empty");
|
||||
var a = el("a", "", "Ouvrir ObsiGate");
|
||||
a.href = urls.obsigate;
|
||||
link.appendChild(a);
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||
sec.appendChild(link);
|
||||
listEl.appendChild(sec);
|
||||
return;
|
||||
}
|
||||
var items = history.filter(function (x) { return x.app === app; });
|
||||
listEl.appendChild(renderSection(APP_LABELS[app], items,
|
||||
"Aucun historique disponible pour cette application."));
|
||||
});
|
||||
}
|
||||
|
||||
function fetchJson(url, timeoutMs) {
|
||||
var ctrl = null, timer = null;
|
||||
try {
|
||||
if (typeof AbortController !== "undefined") {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||
}
|
||||
} catch (e) { ctrl = null; }
|
||||
var opts = { credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
return fetch(url, opts).then(function (r) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!r.ok) {
|
||||
var err = new Error("HTTP " + r.status);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return r.json();
|
||||
}).catch(function (e) {
|
||||
if (timer) clearTimeout(timer);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
function loadAll() {
|
||||
var urls = resolveUrls(browserEnv());
|
||||
var jobs = {
|
||||
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||
};
|
||||
var keys = Object.keys(jobs);
|
||||
return Promise.all(keys.map(function (k) {
|
||||
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||
var msg = (e && e.status === 401)
|
||||
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||
return [k, { items: [], error: msg }];
|
||||
});
|
||||
})).then(function (pairs) {
|
||||
var results = { obsigate: { items: [] } };
|
||||
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||
return { urls: urls, results: results };
|
||||
});
|
||||
}
|
||||
function setBadge(n) {
|
||||
try {
|
||||
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||
global.DRACO_BAR.setCounts({ alertes: n });
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
try {
|
||||
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||
b.textContent = n;
|
||||
b.hidden = !(n > 0);
|
||||
});
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function refreshBadge() {
|
||||
return loadAll().then(function (r) {
|
||||
var all = [];
|
||||
APP_ORDER.forEach(function (app) {
|
||||
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
setBadge(countActive(all));
|
||||
return countActive(all);
|
||||
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||
}
|
||||
function openPanel() {
|
||||
ensurePanel();
|
||||
panel.style.display = "flex";
|
||||
listEl.textContent = "";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||
loadAll().then(function (r) {
|
||||
render(r.results, r.urls);
|
||||
var all = [];
|
||||
APP_ORDER.forEach(function (app) {
|
||||
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
setBadge(countActive(all));
|
||||
}).catch(function () {
|
||||
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
|
||||
});
|
||||
}
|
||||
|
||||
var api = {
|
||||
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||
_test: {
|
||||
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||
sortAlerts: sortAlerts, countActive: countActive,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
|
||||
}
|
||||
};
|
||||
global.DRACO_ALERTES = api;
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||
|
||||
/* Pastille au chargement (après les scripts defer de la barre). */
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
badgeTimer = setTimeout(refreshBadge, 300);
|
||||
});
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
+139
-94
@@ -4,7 +4,11 @@
|
||||
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
|
||||
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
|
||||
* correctif fusion #70 (SANS bump de version, publication séparée) :
|
||||
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
|
||||
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
|
||||
* « Menu des applications », « Focus » et « Écouteurs globaux »).
|
||||
*
|
||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
@@ -54,22 +58,64 @@
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||
* ouvre le menu des applications, construit depuis les onglets ;
|
||||
* ouvre le menu des applications, construit depuis les onglets, plus
|
||||
* (depuis le correctif #70) une entrée « Console Spark » vers la
|
||||
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
|
||||
* c'est le moyen de changer d'app en mobile, où les onglets sont
|
||||
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
|
||||
* repliés (v0.1.2). Menu compte : voir ci-dessous.
|
||||
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
|
||||
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
|
||||
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
|
||||
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
|
||||
* retirées des gabarits hôtes — le menu natif d'ObsiGate
|
||||
* (#header-menu-btn) lui-même n'est pas touché.
|
||||
*
|
||||
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
|
||||
* connu à gérer l'action "menu" via onAction (il déclenche son propre
|
||||
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
|
||||
* pas et le bouton restait mort (relais vers un onAction absent, puis
|
||||
* événement draco:bar sans écouteur). Comportement désormais :
|
||||
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
|
||||
* la console agent (URL agent résolue, donc dérivée -dev sur un
|
||||
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
|
||||
* par clic extérieur, Échap ou choix d'un lien ;
|
||||
* - avec onAction : l'hôte garde la main (comportement historique,
|
||||
* zéro régression pour ObsiGate) ; il peut demander explicitement
|
||||
* le menu par défaut en retournant false pour l'action "menu".
|
||||
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
|
||||
* son menu par défaut (console agent + applications) sont supprimés
|
||||
* du fragment, du JS et du CSS. La console agent reste joignable par
|
||||
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
|
||||
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
|
||||
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
|
||||
* onglets présents dans la barre, puis une entrée « Console Spark »
|
||||
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
||||
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) défaut officiel
|
||||
* prod https://agent.dracodev.net/ (il n'y a pas d'onglet agent, donc
|
||||
* pas d'étape « href rendu »). AUCUN hôte spark-dev n'est inventé :
|
||||
* la clé spark, elle, suit toujours son propre contrat (explicite >
|
||||
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
||||
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
||||
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
|
||||
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
|
||||
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
|
||||
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
|
||||
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
|
||||
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
|
||||
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
|
||||
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
|
||||
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
|
||||
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
|
||||
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
|
||||
* addGlobalListener), PAS via document.addEventListener. Cause
|
||||
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
|
||||
* remplace document/window.addEventListener par un registre
|
||||
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
|
||||
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
|
||||
* compris ceux de draco-barre.js (script defer, enregistré après le
|
||||
* tcInit() initial). La barre survit à la navigation (hors .layout
|
||||
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
|
||||
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
|
||||
* la marque, lien href="/", est interceptée par l'intercepteur SPA
|
||||
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
|
||||
* une navigation interne au lieu de basculer le menu. Les écouteurs
|
||||
* posés sur le prototype ne figurent pas dans le registre et ne sont
|
||||
* donc jamais supprimés par le teardown ; en complément, le gabarit
|
||||
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
|
||||
* la marque ne déclenche plus de navigation douce. Les autres hôtes
|
||||
* (SSR, rechargement complet) ne sont pas concernés par le teardown
|
||||
* mais bénéficient du même code ; repli : si EventTarget.prototype
|
||||
* est indisponible (mini-DOM de tests), document.addEventListener
|
||||
* classique est utilisé.
|
||||
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
|
||||
* un « B » codé en dur dans le fragment, est un vrai <button>
|
||||
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
|
||||
@@ -223,7 +269,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
|
||||
/* ── Menus déroulants (apps, compte) ───────────────────────────────
|
||||
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||
@@ -231,9 +277,11 @@
|
||||
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||
var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
|
||||
var avatarBtn = document.querySelector(".draco-avatar");
|
||||
var appsMenu = null, moreMenu = null, compteMenu = null;
|
||||
var appsMenu = null, compteMenu = null;
|
||||
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
|
||||
* lui rend le focus à la fermeture (correctif #70). */
|
||||
var lastOpener = null;
|
||||
|
||||
function setExpanded(btn, open) {
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
@@ -250,10 +298,13 @@
|
||||
menu.hidden = !open;
|
||||
setExpanded(btn, open);
|
||||
}
|
||||
function closeAllPopups() {
|
||||
function closeAllPopups(returnFocus) {
|
||||
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
|
||||
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
|
||||
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||
/* Échap seulement : le clic extérieur ne déplace pas le focus
|
||||
* (voir l'en-tête, « Focus »). */
|
||||
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
|
||||
}
|
||||
function tabLabel(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
@@ -265,28 +316,6 @@
|
||||
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||
return petit ? petit.textContent : "";
|
||||
}
|
||||
function menuLink(app, nom, role) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item draco-menu-item";
|
||||
a.href = resolvedUrls[app] || "#";
|
||||
a.dataset.app = app;
|
||||
a.setAttribute("role", "menuitem");
|
||||
var txt = document.createElement("span");
|
||||
txt.className = "draco-apps-item-txt";
|
||||
var n = document.createElement("span");
|
||||
n.className = "draco-apps-item-nom";
|
||||
n.textContent = nom;
|
||||
txt.appendChild(n);
|
||||
if (role) {
|
||||
var r = document.createElement("span");
|
||||
r.className = "draco-apps-item-role";
|
||||
r.textContent = role;
|
||||
txt.appendChild(r);
|
||||
}
|
||||
a.appendChild(txt);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* Menu des applications (v0.1.3) — construit depuis les onglets
|
||||
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
|
||||
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
|
||||
@@ -326,6 +355,37 @@
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
|
||||
* de l'agent commun (URL agent résolue — explicite > -dev >
|
||||
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
|
||||
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
|
||||
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
appsMenu.appendChild(sep);
|
||||
var sparkItem = document.createElement("a");
|
||||
sparkItem.className = "draco-apps-item";
|
||||
sparkItem.href = resolvedUrls.agent || "#";
|
||||
sparkItem.dataset.app = "agent";
|
||||
sparkItem.setAttribute("role", "menuitem");
|
||||
var sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#c2437e");
|
||||
sparkIco.textContent = "✦";
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
var sparkNom = document.createElement("span");
|
||||
sparkNom.className = "draco-apps-item-nom";
|
||||
sparkNom.textContent = "Console Spark";
|
||||
sparkTxt.appendChild(sparkNom);
|
||||
var sparkRole = document.createElement("span");
|
||||
sparkRole.className = "draco-apps-item-role";
|
||||
sparkRole.textContent = "Agent commun";
|
||||
sparkTxt.appendChild(sparkRole);
|
||||
sparkItem.appendChild(sparkTxt);
|
||||
appsMenu.appendChild(sparkItem);
|
||||
bar.appendChild(appsMenu);
|
||||
}
|
||||
function toggleAppsMenu(force) {
|
||||
@@ -333,34 +393,7 @@
|
||||
if (!appsMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||
togglePopup(appsMenu, brand, force);
|
||||
}
|
||||
|
||||
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
|
||||
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
|
||||
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
|
||||
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
|
||||
function buildMoreMenu() {
|
||||
if (!bar || !moreBtn || moreMenu) return;
|
||||
moreMenu = document.createElement("div");
|
||||
moreMenu.className = "draco-more-menu";
|
||||
moreMenu.hidden = true;
|
||||
moreMenu.setAttribute("role", "menu");
|
||||
moreMenu.setAttribute("aria-label", "Plus d'actions");
|
||||
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
moreMenu.appendChild(sep);
|
||||
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
|
||||
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
|
||||
});
|
||||
bar.appendChild(moreMenu);
|
||||
}
|
||||
function toggleMoreMenu(force) {
|
||||
buildMoreMenu();
|
||||
if (!moreMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(moreMenu);
|
||||
togglePopup(moreMenu, moreBtn, force);
|
||||
if (!appsMenu.hidden) lastOpener = brand;
|
||||
}
|
||||
|
||||
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
|
||||
@@ -399,6 +432,7 @@
|
||||
if (!compteMenu) return;
|
||||
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||
togglePopup(compteMenu, avatarBtn, force);
|
||||
if (!compteMenu.hidden) lastOpener = avatarBtn;
|
||||
}
|
||||
|
||||
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||
@@ -408,7 +442,21 @@
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
/* Écouteurs globaux posés sur le prototype EventTarget quand il
|
||||
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
|
||||
* voir l'en-tête) ; sinon document.addEventListener classique. */
|
||||
function addGlobalListener(type, fn) {
|
||||
try {
|
||||
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
|
||||
EventTarget.prototype.addEventListener) {
|
||||
EventTarget.prototype.addEventListener.call(document, type, fn, false);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
document.addEventListener(type, fn);
|
||||
}
|
||||
|
||||
addGlobalListener("click", function (ev) {
|
||||
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||
if (compteItem) {
|
||||
@@ -419,38 +467,21 @@
|
||||
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
if (moreMenu) toggleMoreMenu(false);
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
toggleAppsMenu();
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "menu") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
/* Hôte prioritaire : il garde la main, sauf s'il demande
|
||||
* explicitement le menu par défaut en retournant false.
|
||||
* Sans onAction, le menu par défaut s'ouvre (il ne restait
|
||||
* auparavant qu'un événement draco:bar sans écouteur). */
|
||||
if (typeof cfg.onAction === "function") {
|
||||
var handled = cfg.onAction("menu", el, ev);
|
||||
if (handled === false) toggleMoreMenu();
|
||||
} else {
|
||||
toggleMoreMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "compte") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
if (moreMenu) toggleMoreMenu(false);
|
||||
toggleCompteMenu();
|
||||
return;
|
||||
}
|
||||
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||
* sur son déclencheur déjà traité ci-dessus). */
|
||||
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups();
|
||||
ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups(false);
|
||||
}
|
||||
if (!el) return;
|
||||
var act = el.dataset.act;
|
||||
@@ -458,8 +489,23 @@
|
||||
relay(act, el, ev);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") closeAllPopups();
|
||||
addGlobalListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") { closeAllPopups(true); return; }
|
||||
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
|
||||
* ouverture explicite + focus sur le premier item du menu (#70).
|
||||
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
|
||||
* ci-dessus (pas de double bascule ici). */
|
||||
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
|
||||
var brandEl = ev.target.closest('[data-act="apps"]');
|
||||
if (brandEl) {
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
toggleAppsMenu();
|
||||
if (appsMenu && !appsMenu.hidden) {
|
||||
var first = appsMenu.querySelector(".draco-apps-item");
|
||||
if (first && typeof first.focus === "function") first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||
@@ -481,7 +527,6 @@
|
||||
|
||||
applyUrls(resolvedUrls);
|
||||
buildAppsMenu();
|
||||
buildMoreMenu();
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Contrat d'API (audité, rien d'inventé) :
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||
* -> { results: [{ vault, path, title, snippet }] }
|
||||
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||
* - Flowdeck : GET {base}/api/search?q=
|
||||
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
|
||||
* collections: [{ title, subtitle, url: "/db/<id>" }] }
|
||||
* - 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)
|
||||
* -> { 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).
|
||||
*
|
||||
* 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 >
|
||||
* hôte de la requête > dérivation > défaut officiel) ;
|
||||
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
|
||||
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
|
||||
* 4. href des onglets .draco-apptab rendus ;
|
||||
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
|
||||
*
|
||||
* Authentification cross-origin : les appels sœurs partent avec
|
||||
* 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 "*").
|
||||
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
/* globalThis : le fichier est chargé comme script classique dans les
|
||||
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
|
||||
* et importé en ESM par le test ObsiGate (frontend/js est "type":
|
||||
* "module") — où `this` de niveau supérieur vaut undefined. */
|
||||
|
||||
var PROD = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
};
|
||||
var DEV = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
};
|
||||
var APPS = [
|
||||
{ key: "obsigate", label: "ObsiGate" },
|
||||
{ key: "flowdeck", label: "Flowdeck" },
|
||||
{ key: "tiktak", label: "TikTak" },
|
||||
{ key: "kanbante", label: "Kanbante" },
|
||||
];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
qa: "QA",
|
||||
review: "Relecture",
|
||||
todo: "À faire",
|
||||
done: "Terminé",
|
||||
};
|
||||
var TIMEOUT_MS = 8000;
|
||||
var DEBOUNCE_MS = 250;
|
||||
var PER_APP_LIMIT = 8;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanUrl(u) {
|
||||
if (!u) return null;
|
||||
u = String(u).trim();
|
||||
if (!u || u.indexOf("{{") !== -1) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) === "/" ? u : u + "/";
|
||||
}
|
||||
|
||||
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
|
||||
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
|
||||
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
|
||||
function resolveBases(opts) {
|
||||
opts = opts || {};
|
||||
var dev = isDevHost(opts.host);
|
||||
var out = {};
|
||||
APPS.forEach(function (app) {
|
||||
var key = app.key;
|
||||
var u = cleanUrl(opts.explicit && opts.explicit[key]);
|
||||
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
|
||||
if (!u && dev) u = DEV[key];
|
||||
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
|
||||
if (!u) u = PROD[key];
|
||||
out[key] = withSlash(u);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q) {
|
||||
if (app === "obsigate") {
|
||||
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||
}
|
||||
return "api/search?q=" + encodeURIComponent(q);
|
||||
}
|
||||
|
||||
function snippetOf(s, n) {
|
||||
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
||||
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
data = data || {};
|
||||
if (app === "obsigate") {
|
||||
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return {
|
||||
title: r.title || r.path || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || ""),
|
||||
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
|
||||
url:
|
||||
base +
|
||||
"#file=" +
|
||||
encodeURIComponent(r.vault || "") +
|
||||
":" +
|
||||
encodeURIComponent(r.path || ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "flowdeck") {
|
||||
var items = (data.pages || []).concat(data.collections || []);
|
||||
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
var rel = String(r.url || "").replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || "Sans titre",
|
||||
snippet: snippetOf(r.excerpt || ""),
|
||||
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
|
||||
url: base + rel,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "tiktak") {
|
||||
return (data.tasks || []).slice(0, PER_APP_LIMIT).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 {
|
||||
title: t.title || "Sans titre",
|
||||
snippet: snippetOf(t.description || ""),
|
||||
context: "Tâche · " + ctx,
|
||||
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "kanbante") {
|
||||
var rows = [];
|
||||
(data.groups || []).forEach(function (g) {
|
||||
(g.rows || []).forEach(function (row) {
|
||||
rows.push({ group: g.full_name, row: row });
|
||||
});
|
||||
});
|
||||
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||
var issue = x.row.issue || {};
|
||||
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||
return {
|
||||
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
|
||||
snippet: snippetOf(issue.body || ""),
|
||||
context: x.group + (col ? " · " + col : ""),
|
||||
url: base + "board/" + x.group,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||
function nativeSearchUrl(app, base, q) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
return base + "search?q=" + encodeURIComponent(q || "");
|
||||
}
|
||||
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
|
||||
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
|
||||
return base;
|
||||
}
|
||||
|
||||
function nativeSearchLabel(app) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
return "Ouvrir la recherche dans " + appLabel(app);
|
||||
}
|
||||
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
||||
}
|
||||
|
||||
function appLabel(key) {
|
||||
for (var i = 0; i < APPS.length; i++) {
|
||||
if (APPS[i].key === key) return APPS[i].label;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
var api = {
|
||||
esc: esc,
|
||||
isDevHost: isDevHost,
|
||||
resolveBases: resolveBases,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
nativeSearchUrl: nativeSearchUrl,
|
||||
PROD: PROD,
|
||||
DEV: DEV,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.RecherchePartout = api;
|
||||
|
||||
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
function fetchSection(app, url, onDone) {
|
||||
var ctrl = null;
|
||||
var timer = null;
|
||||
try {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () {
|
||||
ctrl.abort();
|
||||
}, TIMEOUT_MS);
|
||||
} catch (e) {
|
||||
ctrl = null;
|
||||
}
|
||||
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
fetch(url, opts)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
var err = new Error("HTTP " + resp.status);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (timer) clearTimeout(timer);
|
||||
onDone(null, data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (timer) clearTimeout(timer);
|
||||
onDone(err, null);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection(section, state) {
|
||||
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||
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 (state.status === "loading") {
|
||||
count.textContent = "";
|
||||
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||
return;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state rp-error">' +
|
||||
esc(state.message) +
|
||||
' — <a href="' +
|
||||
esc(nativeSearchUrl(state.app, state.base, state.q)) +
|
||||
'">' +
|
||||
esc(nativeSearchLabel(state.app)) +
|
||||
"</a></p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||
return;
|
||||
}
|
||||
count.textContent = String(state.items.length);
|
||||
body.innerHTML = state.items
|
||||
.map(function (item) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' +
|
||||
esc(item.url) +
|
||||
'">' +
|
||||
'<span class="rp-title">' +
|
||||
esc(item.title) +
|
||||
"</span>" +
|
||||
(item.context
|
||||
? '<span class="rp-context">' + esc(item.context) + "</span>"
|
||||
: "") +
|
||||
(item.snippet
|
||||
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||
: "") +
|
||||
"</a>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function init() {
|
||||
var root = document.getElementById("recherche");
|
||||
if (!root) return;
|
||||
var input = document.getElementById("rp-q");
|
||||
if (!input) return;
|
||||
var hostApp = root.getAttribute("data-host") || "";
|
||||
var explicit = {};
|
||||
APPS.forEach(function (app) {
|
||||
var v = root.getAttribute("data-base-" + app.key);
|
||||
if (v) explicit[app.key] = v;
|
||||
});
|
||||
var barUrls = {};
|
||||
try {
|
||||
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
|
||||
} catch (e) {}
|
||||
var tabHrefs = {};
|
||||
try {
|
||||
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
|
||||
var href = tab.getAttribute("href");
|
||||
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
|
||||
});
|
||||
} catch (e) {}
|
||||
var bases = resolveBases({
|
||||
host: global.location.hostname,
|
||||
explicit: explicit,
|
||||
barUrls: barUrls,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var sections = {};
|
||||
APPS.forEach(function (app) {
|
||||
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||
if (el) sections[app.key] = el;
|
||||
});
|
||||
|
||||
var currentQ = "";
|
||||
var runId = 0;
|
||||
var debounceTimer = null;
|
||||
|
||||
function runSearch(q) {
|
||||
currentQ = q;
|
||||
var myRun = ++runId;
|
||||
if (!q) {
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.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], "");
|
||||
});
|
||||
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 });
|
||||
var url;
|
||||
if (app.key === hostApp) {
|
||||
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||
url = "/" + searchPath(app.key, q);
|
||||
} else {
|
||||
url = bases[app.key] + searchPath(app.key, q);
|
||||
}
|
||||
fetchSection(app.key, 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 === 401 || err.status === 403)
|
||||
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, {
|
||||
status: "error",
|
||||
message: msg,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
return;
|
||||
}
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSearch(q, immediate) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (immediate) {
|
||||
runSearch(q);
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(function () {
|
||||
runSearch(q);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
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());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
syncUrl(q);
|
||||
scheduleSearch(q, false);
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
var first = document.querySelector("[data-rp-link]");
|
||||
if (first) {
|
||||
e.preventDefault();
|
||||
first.click();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
input.value = "";
|
||||
syncUrl("");
|
||||
scheduleSearch("", true);
|
||||
input.focus();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
});
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
function (e) {
|
||||
var target = e.target;
|
||||
var typing =
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable);
|
||||
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
function moveFocus(delta, e) {
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||
if (!links.length) return;
|
||||
var active = document.activeElement;
|
||||
var idx = links.indexOf(active);
|
||||
var next;
|
||||
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
|
||||
else next = (idx + delta + links.length) % links.length;
|
||||
e.preventDefault();
|
||||
links[next].focus();
|
||||
}
|
||||
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var initialQ = (params.get("q") || "").trim();
|
||||
if (initialQ) {
|
||||
input.value = initialQ;
|
||||
runSearch(initialQ);
|
||||
} else {
|
||||
runSearch("");
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: this
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
// Tests unitaires (node:test) du module partagé draco-alertes.js.
|
||||
// Exécutés dans la sandbox : node --test draco-alertes.test.js
|
||||
// Aucune donnée inventée : les payloads reprennent la forme exacte
|
||||
// des API réelles (Flowdeck /api/notifications, TikTak
|
||||
// /api/notifications, Kanbante /api/events).
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const t = require("../static/js/draco-alertes.js");
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
|
||||
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||
const explicit = t.resolveUrls({
|
||||
hostname: "tiktak-dev.dracodev.net",
|
||||
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||
});
|
||||
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||
const href = t.resolveUrls({
|
||||
hostname: "example.org",
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/" },
|
||||
});
|
||||
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
|
||||
});
|
||||
|
||||
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
],
|
||||
unread: 1,
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].state, "active");
|
||||
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
|
||||
assert.equal(items[1].active, false);
|
||||
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
|
||||
});
|
||||
|
||||
test("normalizeTiktak: lien tache, sinon page notifications", () => {
|
||||
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]);
|
||||
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
|
||||
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
|
||||
assert.equal(items[0].active, true);
|
||||
});
|
||||
|
||||
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
|
||||
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.ok(items.every((i) => i.active === false));
|
||||
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
|
||||
assert.match(items[0].title, /#69/);
|
||||
});
|
||||
|
||||
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||
const list = [
|
||||
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
|
||||
{ app: "kanbante", active: false, date: "" },
|
||||
];
|
||||
const sorted = t.sortAlerts(list);
|
||||
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
|
||||
assert.equal(sorted[0].active, true);
|
||||
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
|
||||
assert.equal(sorted[3].date, "");
|
||||
assert.equal(t.countActive(list), 2);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Tests Node sans navigateur de recherche.js (logique pure).
|
||||
* Usage : node recherche.test.js — sortie 0 si tout passe. */
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const rp = require("../static/js/recherche.js");
|
||||
|
||||
let n = 0;
|
||||
function ok(cond, label) {
|
||||
assert.ok(cond, label);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
/* esc */
|
||||
ok(rp.esc('<a href="x">&') === "<a href="x">&", "esc échappe HTML");
|
||||
|
||||
/* isDevHost : même règle que la barre v0.1.6 */
|
||||
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
|
||||
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
|
||||
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
|
||||
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
|
||||
|
||||
/* resolveBases : ordre explicite > barre > dev > href > prod */
|
||||
let b = rp.resolveBases({
|
||||
host: "tiktak-dev.dracodev.net",
|
||||
explicit: { obsigate: "https://og-perso.example/" },
|
||||
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
|
||||
tabHrefs: { kanbante: "https://kanbante-href.example/" },
|
||||
});
|
||||
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
|
||||
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
|
||||
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
|
||||
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
|
||||
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
|
||||
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
|
||||
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
|
||||
b = rp.resolveBases({});
|
||||
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
|
||||
|
||||
/* searchPath */
|
||||
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
|
||||
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
|
||||
|
||||
/* normalize ObsiGate */
|
||||
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
|
||||
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
|
||||
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
|
||||
|
||||
/* normalize Flowdeck */
|
||||
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
|
||||
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
|
||||
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
|
||||
|
||||
/* normalize TikTak */
|
||||
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
|
||||
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
|
||||
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
|
||||
|
||||
/* normalize Kanbante */
|
||||
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
|
||||
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
|
||||
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
|
||||
|
||||
/* nativeSearchUrl */
|
||||
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)");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Fusion #69 — Alertes unifiées (chantier 2, Kanbante).
|
||||
|
||||
Kanbante n'a pas de table de notifications : sa source réelle est le
|
||||
journal d'événements, déjà exposé par GET /api/events (aucun nouvel
|
||||
endpoint). Kanbante ne porte aucun état lu/non lu : ses événements
|
||||
sont donc tous de l'historique et ne sont jamais comptés comme
|
||||
actifs par le panneau. Couverts ici : placeholder disparu, module
|
||||
servi, CORS limité à /api/events pour les seules origines soeurs.
|
||||
"""
|
||||
|
||||
SOEUR = "https://flowdeck.dracodev.net"
|
||||
|
||||
|
||||
def test_barre_alertes_sans_placeholder_et_module_servi(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Alertes unifiées : arrive avec la fédération" not in r.text
|
||||
assert "draco-alertes.js" in r.text
|
||||
assert "draco-alertes.css" in r.text
|
||||
js = client.get("/static/js/draco-alertes.js").text
|
||||
assert "DRACO_ALERTES" in js
|
||||
assert "api/events" in js
|
||||
|
||||
|
||||
def test_events_forme(client):
|
||||
r = client.get("/api/events")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert isinstance(body["events"], list)
|
||||
|
||||
|
||||
def test_cors_events_soeur_preflight_get_et_tiers(client):
|
||||
r = client.options("/api/events",
|
||||
headers={"Origin": SOEUR,
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("access-control-allow-origin") == SOEUR
|
||||
assert r.headers.get("access-control-allow-credentials") == "true"
|
||||
r2 = client.get("/api/events", headers={"Origin": SOEUR})
|
||||
assert r2.status_code == 200
|
||||
assert r2.headers.get("access-control-allow-origin") == SOEUR
|
||||
r3 = client.options("/api/events",
|
||||
headers={"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert "access-control-allow-origin" not in r3.headers
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests de la recherche fédérée Kanbante (fusion #68).
|
||||
|
||||
Fonctions pures de app/recherche.py uniquement : aucune base, aucun
|
||||
serveur, aucune forge.
|
||||
"""
|
||||
|
||||
from app.recherche import (
|
||||
cors_headers_for,
|
||||
groupes_vers_json,
|
||||
is_dev_host,
|
||||
origin_of,
|
||||
preflight_headers,
|
||||
sister_origins,
|
||||
)
|
||||
|
||||
RESOLVED_DEV = {
|
||||
"obsigate": "https://og-dev.dracodev.net/",
|
||||
"flowdeck": "https://flowdeck-dev.dracodev.net",
|
||||
"tiktak": "https://tiktak-dev.dracodev.net/",
|
||||
"kanbante": "https://kanbante-dev.dracodev.net/",
|
||||
}
|
||||
|
||||
|
||||
def test_is_dev_host():
|
||||
assert is_dev_host("kanbante-dev.dracodev.net") is True
|
||||
assert is_dev_host("kanbante.dracodev.net") is False
|
||||
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||
assert is_dev_host(None) is False
|
||||
|
||||
|
||||
def test_origin_of():
|
||||
assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("{{URL_OBSIGATE}}") is None
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
headers = preflight_headers("https://og-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||
|
||||
|
||||
def test_groupes_vers_json_tronque_et_filtre():
|
||||
groups = [{
|
||||
"full_name": "Projets/site",
|
||||
"rows": [
|
||||
{"issue": {"number": 12, "title": "Bug", "state": "open",
|
||||
"body": "x" * 500, "labels": [{"name": "bug", "color": "ff0000"}],
|
||||
"token_secret": "ne-doit-pas-sortir"},
|
||||
"column": "in_progress"},
|
||||
{"issue": {"number": 13, "title": "Autre", "state": "closed",
|
||||
"body": "", "labels": []},
|
||||
"column": "done"},
|
||||
],
|
||||
}]
|
||||
data, total = groupes_vers_json(groups, limit=1)
|
||||
assert total == 1
|
||||
assert len(data[0]["rows"]) == 1
|
||||
row = data[0]["rows"][0]
|
||||
assert row["issue"]["number"] == 12
|
||||
assert len(row["issue"]["body"]) == 160
|
||||
assert "token_secret" not in row["issue"]
|
||||
assert row["issue"]["labels"] == [{"name": "bug", "color": "ff0000"}]
|
||||
assert row["column"] == "in_progress"
|
||||
|
||||
|
||||
def test_groupes_vers_json_vide():
|
||||
assert groupes_vers_json([], limit=20) == ([], 0)
|
||||
assert groupes_vers_json(None, limit=20) == ([], 0)
|
||||
|
||||
|
||||
def test_sister_origins_hote_requete():
|
||||
# Configuration muette + hôte dev : le repli inclut les -dev.
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante-dev.dracodev.net"
|
||||
)
|
||||
Reference in New Issue
Block a user