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 ed45cd1..84d6113 100644 --- a/app/templates/_draco_bar.html +++ b/app/templates/_draco_bar.html @@ -94,7 +94,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; } /* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). */ if (act === "spark") { @@ -113,3 +113,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.

+
+ +
+
+

ObsiGate — notes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Flowdeck — pages

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

TikTak — tâches

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Kanbante — cartes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+ + + + + 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" + )