diff --git a/app/main.py b/app/main.py
index 4764288..414c351 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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(
@@ -408,6 +421,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
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)
+
+
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 +4333,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 +6981,4 @@ async def readiness():
},
status_code=200 if ready else 503,
)
+
diff --git a/app/recherche.py b/app/recherche.py
new file mode 100644
index 0000000..381c520
--- /dev/null
+++ b/app/recherche.py
@@ -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)
diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html
index 04d2696..9c7d43c 100644
--- a/app/templates/_draco_bar.html
+++ b/app/templates/_draco_bar.html
@@ -95,7 +95,7 @@
};
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 === "palette") { location.href = "/recherche"; 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
@@ -119,3 +119,4 @@
+
diff --git a/app/templates/recherche.html b/app/templates/recherche.html
new file mode 100644
index 0000000..7a0f806
--- /dev/null
+++ b/app/templates/recherche.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ Rechercher partout — Kanbante
+
+
+
+
+
+ {% include "_header.html" %}
+
+
+ Rechercher partout
+ 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.
+
+
+
+
+
+ Tapez une recherche ci-dessus.
+
+
+
+ Tapez une recherche ci-dessus.
+
+
+
+ Tapez une recherche ci-dessus.
+
+
+
+ Tapez une recherche ci-dessus.
+
+
+
+
+ Kanbante v{{ version }} · recherche partout
+
+
+
+
diff --git a/static/css/recherche.css b/static/css/recherche.css
new file mode 100644
index 0000000..f8ab9d9
--- /dev/null
+++ b/static/css/recherche.css
@@ -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; }
diff --git a/static/js/recherche.js b/static/js/recherche.js
new file mode 100644
index 0000000..3f396bd
--- /dev/null
+++ b/static/js/recherche.js
@@ -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/" }],
+ * collections: [{ title, subtitle, url: "/db/" }] }
+ * - TikTak : GET {base}/api/search?q=
+ * -> { tasks: [{ id, title, description, status, due_at }], ... }
+ * Lien profond vérifié : {base}search?q=&task= (page /search).
+ * - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
+ * -> { groups: [{ full_name, rows: [{ issue, column }] }] }
+ * Lien profond : {base}board/ (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, """);
+ }
+
+ 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 = 'Recherche…
';
+ return;
+ }
+ if (state.status === "error") {
+ count.textContent = "";
+ body.innerHTML =
+ '' +
+ esc(state.message) +
+ ' — ' +
+ esc(nativeSearchLabel(state.app)) +
+ "
";
+ return;
+ }
+ if (state.status === "empty") {
+ count.textContent = "";
+ body.innerHTML =
+ 'Aucun résultat pour « ' + esc(state.q) + " ».
";
+ return;
+ }
+ count.textContent = String(state.items.length);
+ body.innerHTML = state.items
+ .map(function (item) {
+ return (
+ '' +
+ '' +
+ esc(item.title) +
+ " " +
+ (item.context
+ ? '' + esc(item.context) + " "
+ : "") +
+ (item.snippet
+ ? '' + esc(item.snippet) + " "
+ : "") +
+ " "
+ );
+ })
+ .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 =
+ 'Tapez une recherche ci-dessus.
';
+ 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
+);
diff --git a/tests/recherche.test.js b/tests/recherche.test.js
new file mode 100644
index 0000000..c1c3c02
--- /dev/null
+++ b/tests/recherche.test.js
@@ -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">&", "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");
diff --git a/tests/test_recherche.py b/tests/test_recherche.py
new file mode 100644
index 0000000..ecf6d1c
--- /dev/null
+++ b/tests/test_recherche.py
@@ -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"
+ )