Compare commits

...
Author SHA1 Message Date
bruno 53b6f27fd2 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
CI / lint (pull_request) Failing after 3s
CI / security (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 13:50:50 -04:00
bruno 8f5e1f1b0c Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:46 -04:00
bruno 6eb679f234 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:42 -04:00
bruno 1604245f94 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:40 -04:00
bruno b9db438034 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:01:28 -04:00
bruno 0273990196 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 13:01:22 -04:00
bruno 782d2bea72 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 13:01:17 -04:00
bruno f08883cb69 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / security (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:01:13 -04:00
bruno 3150aeecaa Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 13:01:09 -04:00
bruno c9c9a0dd6f Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 1s
CI / e2e (push) Skipped
2026-10-28 13:01:05 -04:00
bruno 2283b72b2f Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:01:01 -04:00
bruno 940a3b4534 Merge pull request 'Recherche partout (#68) : page de résultats unifiée + CORS bornée /api/search' (#42) from recherche-partout-68-rebase into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 2m4s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 12:53:37 -04:00
bruno 35a9c7ac80 Recherche partout (#68, rebase sur main post-#70 et post-#69)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 2m12s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:53:20 -04:00
bruno 2e8240347f Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:18 -04:00
bruno d69ff4a6f9 Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:14 -04:00
bruno 4ea4934b3c Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:11 -04:00
bruno add9252b43 Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:09 -04:00
bruno 45d5745b54 Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:06 -04:00
bruno c92054fb5c Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:03 -04:00
bruno 2e8d94d0bb Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:00 -04:00
bruno bfbca9dce4 Merge pull request 'Fusion #69 : Alertes unifiées (panneau + pastille, agrégation à la demande)' (#41) from alertes-unifiees-69-rebase into main
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 12:50:50 -04:00
15 changed files with 1720 additions and 8 deletions
+9 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.81.3**.
> [Unreleased](#unreleased). La dernière version livrée est **2.81.4**.
---
@@ -14,6 +14,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.81.4] — 2026-10-28
### Modifié
- **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 ; CSP connect-src sœurs.
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), CORS bornée à `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
## [2.81.3] — 2026-10-28
### Modifié
+1 -1
View File
@@ -1 +1 @@
2.81.3
2.81.4
+74
View File
@@ -531,6 +531,15 @@ app.add_middleware(
allow_headers=["*"],
)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
# après le CORSMiddleware global donc exécutée avant lui (dernier
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
# La politique same-origin explicite du middleware global reste la
# règle par défaut pour tout le reste.
from backend.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
@@ -950,6 +959,71 @@ if FRONTEND_DIR.exists():
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/recherche", response_class=HTMLResponse)
async def serve_recherche_page(request: Request):
"""Page « Rechercher partout » (fusion #68) — déclarée AVANT le
catch-all SPA comme /admin.html, sinon index.html serait servi.
frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment
de barre) et le nonce CSP frais ; la réponse pose sa propre
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose
que si elle est absente) dont le connect-src autorise les
origines sœurs — sinon le navigateur bloquerait les appels
sœurs avant même CORS. La CSP par défaut (same-origin) reste
inchangée pour toutes les autres pages.
"""
import json as _json
from backend.csp import inject_csp_nonce
from backend.draco_config import urls_publiques
from backend.recherche_cors import origin_of
recherche_file = FRONTEND_DIR / "recherche.html"
if not recherche_file.exists():
raise HTTPException(status_code=404, detail="Recherche page not found")
html = recherche_file.read_text(encoding="utf-8")
urls = urls_publiques()
for jeton, cle in (
("{{URL_OBSIGATE}}", "obsigate"),
("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"),
):
html = html.replace(jeton, urls.get(cle, ""))
script = (
"<script>window.DRACO_URLS = "
+ _json.dumps(urls).replace("<", "\\u003c")
+ ";</script>"
)
if "</head>" in html:
html = html.replace("</head>", script + "</head>", 1)
else:
html = script + html
html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
csp = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{request.state.csp_nonce}'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
f"connect-src 'self' {soeurs}; "
"font-src 'self' data:; "
"worker-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
return HTMLResponse(
content=html,
headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp},
)
@app.get("/{full_path:path}")
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA index.html for all non-API routes."""
+189
View File
@@ -0,0 +1,189 @@
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
La page « Rechercher partout » (``/recherche``) appelle, depuis le
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
en-têtes CORS, le navigateur refuse la lecture des réponses
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
Ce module n'autorise donc que :
- le chemin ``/api/search`` (jamais les autres routes, jamais les
écritures) ;
- les origines sœurs : celles de ``backend.draco_config``
(``urls_publiques()`` : explicite > dérivation par l'hôte de
l'agent > défaut officiel), **plus** les origines prod ou ``-dev``
officielles correspondant à l'hôte de la requête — ce second
ensemble couvre le cas où la configuration seule ne déclare pas
l'environnement courant ;
- avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` via le repli de ``backend.auth.middleware``, ou
le cookie natif ``access_token``).
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
les en-têtes aux réponses de recherche, sans toucher aux autres
routes ni à la politique same-origin explicite du CORSMiddleware
global (``allow_origins=[]``), qui reste la règle par défaut.
"""
from __future__ import annotations
from urllib.parse import urlparse
SEARCH_PATH = "/api/search"
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 de draco_config.urls_publiques() qui sont des applications sœurs.
_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 frontend/js/recherche.js)."""
if not hostname:
return False
h = hostname.lower().split(":")[0]
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 4 and first.endswith("-dev")
def is_prod_host(hostname: str | None) -> bool:
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
if not hostname:
return False
h = hostname.lower().split(":")[0]
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 0 and not 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 rendu par ``urls_publiques()``.
Sans argument, le résolveur est appelé (import paresseux) ; en cas
d'échec, repli sur prod + dev officiels (premier parti uniquement).
``request_host`` ajoute l'ensemble officiel de l'environnement de
la requête (voir le docstring du module).
"""
origins = set()
if resolved is None:
try:
from backend.draco_config import urls_publiques
resolved = urls_publiques()
except Exception:
resolved = {}
for key in _SOEUR_KEYS:
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
if is_dev_host(request_host):
origins |= DEV_ORIGINS
elif is_prod_host(request_host):
origins |= PROD_ORIGINS
if not origins:
return PROD_ORIGINS | DEV_ORIGINS
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
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("x-forwarded-host", "") or headers_in.get("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:
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": b""})
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)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.81.3"
version = "2.81.4"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.81.3"
version = "2.81.4"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.81.3",
"version": "2.81.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.81.3 | **Dernière mise à jour :** 2026-10-28
> **Version :** 2.81.4 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
+1 -1
View File
@@ -432,7 +432,7 @@
if (b) b.click();
return;
}
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "palette") { location.href = "/recherche"; return; }
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") {
+992
View File
@@ -0,0 +1,992 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
* chantier 2+3 : onglets, « Tout voir », focus).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
* de cette application (aucune troncature côté client — la requête
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
* jeu complet). « Tout voir » d'une section ne quitte plus la page :
* il bascule vers l'onglet de l'application (son href reste une URL
* partageable ?q=&tab= au cas où le JS ne tournerait pas).
* L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
* et le retour arrière navigateur le restaure (popstate).
*
* Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
* son application (pas le nombre tronqué affiché dans « Tout »),
* « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
* une section en erreur/indisponible (pas de faux 0). L'onglet Tout
* affiche « … » tant qu'une section charge, sinon la somme des totaux
* connus (une section en erreur compte pour 0) ; il est vide si les
* 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
* Une section injoignable ne casse jamais les autres.
*
* Focus : l'attribut autofocus seul ne survit pas au chargement des
* surcouches (barre, palette, app) — la page appelle donc focus()
* explicitement en fin d'init (plus un rappel rAF/timeout et un
* rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
* data-act="palette") sur cette même page replace aussi le curseur
* dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
*
* Contrat d'API (audité, rien d'inventé) :
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
* -> { 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=
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - Console Spark : AUCUN endpoint de recherche mergé au moment de
* cette livraison (agent-commun : seule la PR #12 est ouverte, autre
* programme — ce dépôt n'est pas touché ici). Le code tente quand
* même l'appel conventionnel GET {base-console}/api/search?q= (même
* convention que les autres hôtes ; chemin À CONFIRMER avec le
* travailleur console dès que son endpoint atterrit) et, en cas
* d'échec, de HTTP non-OK ou de format non reconnu, affiche
* honnêtement « Console Spark : recherche indisponible pour
* l'instant » — jamais de faux résultats, jamais de faux 0. La base
* console vient, dans l'ordre, de data-base-console rendu par l'hôte,
* de window.DRACO_URLS.agent (ObsiGate) / window.DRACO_AGENT_URL
* (TikTak), de la dérivation -dev si l'hôte courant est -dev, du
* href d'un onglet de barre data-app="console"/"spark" s'il existe,
* sinon du défaut prod agent.dracodev.net. L'onglet console n'est
* interrogé que lorsqu'il est affiché (ou quand une recherche a lieu
* pendant qu'il est affiché) : « Tout » ne le contient pas, pour ne
* pas polluer la vue groupée d'un message d'indisponibilité.
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
* 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 #68).
* 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.
* Le correctif CORS lui-même est chez un autre travailleur.
*/
(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 PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
var CONSOLE_SEARCH_PATH = "api/search";
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
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;
var FULL_LIMIT = 100;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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;
}
/* Base console : explicite > URL agent injectée par l'hôte >
* dérivation -dev > href d'onglet de barre console/spark > prod.
* opts : { host, explicit, agentUrl, tabHrefs:{} } */
function resolveConsoleBase(opts) {
opts = opts || {};
var u = cleanUrl(opts.explicit);
if (!u) u = cleanUrl(opts.agentUrl);
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
if (!u)
u = cleanUrl(
opts.tabHrefs && (opts.tabHrefs.console || opts.tabHrefs.spark)
);
if (!u) u = PROD_AGENT;
return withSlash(u);
}
/* Onglet valide dans l'URL, sinon "all". */
function parseTab(v) {
return TABS.indexOf(v) !== -1 ? v : "all";
}
/* Sections visibles pour un onglet. « Tout » = les 4 sections
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
function visibleKeys(tab) {
tab = parseTab(tab);
if (tab === "all") return APPS.map(function (a) { return a.key; });
return [tab];
}
/* Troncature d'affichage : liste complète dans l'onglet propre à
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
function pageItems(items, full) {
items = items || [];
return full ? items : items.slice(0, PER_APP_LIMIT);
}
/* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */
function buildSearchUrl(q, tab) {
var parts = [];
if (q) parts.push("q=" + encodeURIComponent(q));
tab = parseTab(tab);
if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab));
return parts.length ? "?" + parts.join("&") : "";
}
/* Libellés des compteurs d'onglets à partir des états :
* states : { key: { status, total } }.
* Chargement -> "…", ok/vide -> total ("0" honnête),
* erreur/indisponible/inactif -> "" (pas de faux 0).
* Tout : "…" tant qu'une section charge, sinon somme des totaux
* connus, "" si les 4 sections sont en erreur/inactives. */
function tabCounts(states) {
states = states || {};
var out = {};
function labelFor(key) {
var st = states[key] || {};
if (st.status === "loading") return "…";
if (st.status === "ok" || st.status === "empty") return String(st.total || 0);
return "";
}
var allLoading = false;
var allTotal = 0;
var allKnown = false;
APPS.forEach(function (app) {
var st = states[app.key] || {};
out[app.key] = labelFor(app.key);
if (st.status === "loading") allLoading = true;
if (st.status === "ok" || st.status === "empty") {
allKnown = true;
allTotal += st.total || 0;
}
});
out.console = labelFor("console");
out.all = allLoading ? "…" : allKnown ? String(allTotal) : "";
return out;
}
function searchPath(app, q, full) {
if (app === "obsigate") {
return (
"api/search?q=" +
encodeURIComponent(q) +
"&vault=all&limit=" +
(full ? FULL_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).
* full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT
* (comportement historique, conservé par défaut pour les tests). */
function normalize(app, data, base, q, full) {
data = data || {};
var limit = full ? null : PER_APP_LIMIT;
function cut(arr) {
return limit == null ? arr : arr.slice(0, limit);
}
if (app === "obsigate") {
return cut(data.results || []).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 cut(items).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 cut(data.tasks || []).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
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 cut(rows).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 [];
}
/* Normalisation console, défensive : accepte results/discussions/
* items. Un élément sans URL exploitable (http(s), /relatif, #ancre)
* garde url=null et sera rendu en <span>, pas en faux lien profond.
* Retourne { recognized, items } : recognized=false -> l'appelant
* affiche « indisponible », jamais un vide mensonger. */
function normalizeConsole(data, base) {
data = data || {};
var list = null;
if (Array.isArray(data.results)) list = data.results;
else if (Array.isArray(data.discussions)) list = data.discussions;
else if (Array.isArray(data.items)) list = data.items;
if (!list) return { recognized: false, items: [] };
var items = list.map(function (r) {
r = r || {};
var raw = r.url || r.href || r.link || "";
var url = null;
if (/^https?:\/\//.test(raw)) url = raw;
else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, "");
return {
title: r.title || r.name || r.subject || "Sans titre",
snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""),
context: r.subtitle || r.source || r.type || "",
url: url,
};
});
return { recognized: true, items: items };
}
/* 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) {
if (key === "console") return CONSOLE_APP.label;
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,
resolveConsoleBase: resolveConsoleBase,
parseTab: parseTab,
visibleKeys: visibleKeys,
pageItems: pageItems,
buildSearchUrl: buildSearchUrl,
tabCounts: tabCounts,
searchPath: searchPath,
normalize: normalize,
normalizeConsole: normalizeConsole,
nativeSearchUrl: nativeSearchUrl,
PROD: PROD,
DEV: DEV,
PROD_AGENT: PROD_AGENT,
DEV_AGENT: DEV_AGENT,
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
PER_APP_LIMIT: PER_APP_LIMIT,
FULL_LIMIT: FULL_LIMIT,
};
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, full) {
/* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable",
* items (complets), message, q, base, app }.
* full=true (onglet propre à l'app) : tout afficher ; sinon
* PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */
var body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all");
if (toutVoir) {
try {
toutVoir.href =
global.location.pathname + buildSearchUrl(state.q, state.app);
} catch (e) {}
toutVoir.textContent = "Tout voir";
toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app);
}
if (state.status === "loading") {
count.textContent = "…";
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 === "unavailable") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
(state.message ? " (" + esc(state.message) + ")" : "") +
".</p>";
return;
}
if (state.status === "idle") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
return;
}
if (state.status === "empty") {
count.textContent = "0";
body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return;
}
var shown = pageItems(state.items, full);
count.textContent = String(state.items.length);
body.innerHTML = shown
.map(function (item) {
var inner =
'<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>"
: "");
if (item.url) {
return (
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
);
}
return '<span class="rp-item">' + inner + "</span>";
})
.join("");
}
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 consoleBase = resolveConsoleBase({
host: global.location.hostname,
explicit: root.getAttribute("data-base-console"),
agentUrl:
(global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL,
tabHrefs: tabHrefs,
});
var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]);
var sections = {};
allKeys.forEach(function (key) {
var el = document.querySelector('[data-rp-section="' + key + '"]');
if (el) sections[key] = el;
});
var tabButtons = {};
document.querySelectorAll("[data-rp-tab]").forEach(function (btn) {
tabButtons[btn.getAttribute("data-rp-tab")] = btn;
});
var tablist = document.querySelector(".rp-tabs");
/* États par application (+ console). total = items.length. */
var states = {};
allKeys.forEach(function (key) {
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
});
var activeTab = "all";
var currentQ = "";
var runId = 0;
var consoleFetchedQ = null;
var debounceTimer = null;
function statesForCounts() {
var out = {};
allKeys.forEach(function (key) {
out[key] = { status: states[key].status, total: states[key].items.length };
});
return out;
}
function renderTabs() {
var counts = tabCounts(statesForCounts());
Object.keys(tabButtons).forEach(function (key) {
var btn = tabButtons[key];
var selected = key === activeTab;
btn.classList.toggle("active", selected);
btn.setAttribute("aria-selected", selected ? "true" : "false");
btn.tabIndex = selected ? 0 : -1;
var span = btn.querySelector(".rp-tab-count");
if (span) span.textContent = counts[key] || "";
});
}
/* Bascule d'onglet : visibilité des sections, re-rendu (plein dans
* l'onglet propre à l'app, tronqué dans « Tout »), compteurs.
* opts : { push (historique), focusTab }. */
function applyTab(tab, opts) {
opts = opts || {};
activeTab = parseTab(tab);
try {
root.setAttribute("data-rp-tab", activeTab);
} catch (e) {}
var visible = visibleKeys(activeTab);
allKeys.forEach(function (key) {
var sec = sections[key];
if (!sec) return;
var show = visible.indexOf(key) !== -1;
sec.hidden = !show;
if (show) renderSection(sec, states[key], activeTab === key);
var tv = sec.querySelector(".rp-all");
if (tv) tv.hidden = activeTab === key;
});
renderTabs();
if (opts.push) {
try {
global.history.pushState(
null,
"",
global.location.pathname + buildSearchUrl(currentQ, activeTab)
);
} catch (e) {}
}
if (opts.focusTab && tabButtons[activeTab]) {
try {
tabButtons[activeTab].focus();
} catch (e) {}
}
}
function setTab(tab, opts) {
applyTab(tab, opts);
/* La console n'est interrogée qu'une fois affichée. */
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
fetchConsole(currentQ, runId);
}
}
function fetchConsole(q, myRun) {
var sec = sections.console;
if (!sec) return;
consoleFetchedQ = q;
states.console = {
status: "loading",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
renderSection(sec, states.console, true);
renderTabs();
var url = consoleBase + CONSOLE_SEARCH_PATH + "?q=" + encodeURIComponent(q);
fetchSection("console", url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
else if (err.status) msg = "HTTP " + err.status;
else msg = "injoignable (CORS ou réseau)";
states.console = {
status: "unavailable",
items: [],
message: msg,
q: q,
base: consoleBase,
app: "console",
};
} else {
var norm = normalizeConsole(data, consoleBase);
if (!norm.recognized) {
states.console = {
status: "unavailable",
items: [],
message: "format inattendu",
q: q,
base: consoleBase,
app: "console",
};
} else {
states.console = {
status: norm.items.length ? "ok" : "empty",
items: norm.items,
message: "",
q: q,
base: consoleBase,
app: "console",
};
}
}
renderSection(sec, states.console, true);
renderTabs();
});
}
function runSearch(q) {
currentQ = q;
var myRun = ++runId;
if (!q) {
allKeys.forEach(function (key) {
var sec = sections[key];
if (!sec) return;
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
renderSection(sec, states[key], activeTab === key);
});
consoleFetchedQ = null;
renderTabs();
return;
}
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
states[app.key] = {
status: "loading",
items: [],
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
renderSection(sec, states[app.key], activeTab === app.key);
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q, true);
} else {
url = bases[app.key] + searchPath(app.key, q, true);
}
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)";
states[app.key] = {
status: "error",
items: [],
message: msg,
q: q,
base: bases[app.key],
app: app.key,
};
} else {
var items = normalize(app.key, data, bases[app.key], q, true);
states[app.key] = {
status: items.length ? "ok" : "empty",
items: items,
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
}
renderSection(sec, states[app.key], activeTab === app.key);
renderTabs();
});
});
/* Console : seulement si son onglet est affiché ; sinon son
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
if (activeTab === "console") {
fetchConsole(q, myRun);
} else {
states.console = {
status: "idle",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
consoleFetchedQ = null;
renderTabs();
}
}
function scheduleSearch(q, immediate) {
if (debounceTimer) clearTimeout(debounceTimer);
if (immediate) {
runSearch(q);
return;
}
debounceTimer = setTimeout(function () {
runSearch(q);
}, DEBOUNCE_MS);
}
function syncUrl(q) {
try {
global.history.replaceState(
null,
"",
global.location.pathname + buildSearchUrl(q, activeTab)
);
} catch (e) {}
}
function focusSearch() {
try {
input.focus({ preventScroll: true });
} catch (e) {
try {
input.focus();
} catch (e2) {}
}
}
function visibleLinks() {
return Array.prototype.slice
.call(document.querySelectorAll("[data-rp-link]"))
.filter(function (a) {
var s = a.closest ? a.closest("[data-rp-section]") : null;
return !s || !s.hidden;
});
}
input.addEventListener("input", function () {
var q = input.value.trim();
syncUrl(q);
scheduleSearch(q, false);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = visibleLinks()[0];
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;
}
/* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les
* flèches ←/→ du tablist s'en chargent (voir ci-dessous). */
if (target && target.closest && target.closest(".rp-tab")) return;
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
},
true
);
/* « Tout voir » reste sur la page : bascule vers l'onglet. */
root.addEventListener("click", function (e) {
var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null;
if (!all) return;
var sec = all.closest ? all.closest("[data-rp-section]") : null;
if (!sec) return;
e.preventDefault();
setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true });
});
/* Clic sur la barre « Rechercher partout » (bouton
* data-act="palette") pendant qu'on est déjà sur la page :
* replacer le curseur dans la boîte au lieu de recharger. */
document.addEventListener("click", function (e) {
var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null;
if (!p) return;
e.preventDefault();
focusSearch();
try {
input.select();
} catch (err) {}
});
/* Onglets : clic + clavier (←/→, Début/Fin). */
Object.keys(tabButtons).forEach(function (key) {
tabButtons[key].addEventListener("click", function () {
setTab(key, { push: true });
});
});
if (tablist) {
tablist.addEventListener("keydown", function (e) {
var order = TABS;
var idx = order.indexOf(activeTab);
var next = null;
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
else if (e.key === "ArrowLeft")
next = order[(idx - 1 + order.length) % order.length];
else if (e.key === "Home") next = order[0];
else if (e.key === "End") next = order[order.length - 1];
if (!next) return;
e.preventDefault();
setTab(next, { push: true, focusTab: true });
});
}
/* Retour arrière navigateur : restaurer q + onglet. */
global.addEventListener("popstate", function () {
var params = new URLSearchParams(global.location.search);
var q = (params.get("q") || "").trim();
input.value = q;
applyTab(params.get("tab"));
runSearch(q);
});
function moveFocus(delta, e) {
var links = visibleLinks();
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();
var initialTab = parseTab(params.get("tab"));
if (initialQ) input.value = initialQ;
applyTab(initialTab);
runSearch(initialQ);
/* Focus explicite (voir en-tête) : l'attribut autofocus seul ne
* survit pas au chargement des surcouches. */
focusSearch();
try {
if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch);
} catch (e) {}
try {
setTimeout(focusSearch, 0);
} catch (e) {}
global.addEventListener("pageshow", function (e) {
if (e.persisted) focusSearch();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: this
);
+90
View File
@@ -0,0 +1,90 @@
/* 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; }
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
.rp-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
border-bottom: 1px solid #888; margin-bottom: 20px;
}
.rp-tab {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
color: inherit; opacity: .7; white-space: nowrap;
}
.rp-tab:hover { opacity: 1; }
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.rp-tab-count {
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
}
.rp-tab-count:empty { display: none; }
.rp-section[hidden] { display: none; }
.rp-all[hidden] { display: none; }
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
* Cause : recherche.html étend base.html sans layout_class, donc
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
* la largeur disponible, sans toucher aux autres pages (elles ne
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
* la grille n'a qu'une colonne : la page la prend entièrement. */
.layout > .rp-page {
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
}
@media (max-width: 760px) {
.layout > .rp-page { grid-column: 1 / -1; }
}
/* ---- ObsiGate : navigation statique de la page autonome (chantier
* 2+3). La page /recherche est servie hors SPA (route avant le
* catch-all) : elle reprend les classes de l'app (style.css :
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
* .content-area) et cette navigation, calée sur les variables de
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
* la SPA (voir le commentaire dans recherche.html). */
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
.rp-og-nav a {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
.rp-og-nav a[aria-current="page"] {
background: rgba(127, 127, 127, .18); font-weight: 700;
}
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
+146
View File
@@ -0,0 +1,146 @@
<!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 — ObsiGate</title>
<!-- Gabarit ObsiGate (chantier 2+3) : la page /recherche est servie
hors SPA (route déclarée avant le catch-all, comme /admin.html),
elle reprend donc les feuilles de l'app (style.css + barre) et
la structure de index.html (barre de fusion, en-tête, sidebar,
zone de contenu) au lieu de la page nue précédente.
Jetons {{URL_*}} remplacés par la route /recherche (même contrat
que le fragment de barre) ; servie brute via /static, la page
retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée.
La barre ci-dessous est une copie statique du snippet figé de
index.html (commons v0.1.6) : les onglets sont des liens réels,
le bouton central replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Les menus, le panneau Spark, les
alertes et le domaine restent des comportements de la SPA et ne
sont pas rejoués ici — l'autre travailleur (onglet Console Spark
de la barre) devra reporter son ajout dans cette copie au
re-vendoring, comme pour tout hôte.
Sidebar statique (navigation de l'app) : les vaults, tags et
récents dynamiques vivent dans la SPA, pas sur cette page. -->
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/draco-barre.css">
<link rel="stylesheet" href="/static/recherche.css">
</head>
<body class="has-draco-bar rp-og">
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="{{URL_OBSIGATE}}">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="{{URL_FLOWDECK}}">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="{{URL_TIKTAK}}">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="{{URL_KANBANTE}}">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
<div id="app" class="app-container">
<header class="header">
<div class="header-left">
<div class="header-logo">ObsiGate</div>
<a href="/">Retour à l'application</a>
</div>
</header>
<div class="main-body">
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation ObsiGate">
<nav class="rp-og-nav">
<a href="/">Tableau de bord</a>
<a href="/recherche" aria-current="page">Rechercher partout</a>
<p class="rp-og-note">La navigation par vaults (fichiers, tags, récents) vit dans l'application ObsiGate.</p>
</nav>
</aside>
<div class="content-wrapper">
<div class="content-area">
<main class="rp-page" id="recherche" data-host="obsigate"
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
data-base-kanbante="{{URL_KANBANTE}}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
</div>
</div>
</div>
</div>
<script src="/static/js/recherche.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.81.3",
"version": "2.81.4",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* ObsiGate — tests de la logique pure de la page « Rechercher partout »
* (fusion #68), sans DOM ni jsdom : résolution des bases, liens
* profonds et normalisation des 4 formats d'API.
*
* Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js
* est "type": "module") ; ses helpers sont exposés via
* globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs
*/
import { strict as assert } from "node:assert";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js");
await import(pathToFileURL(JS_FILE).href);
const rp = globalThis.RecherchePartout;
assert.ok(rp, "RecherchePartout exposé");
let n = 0;
const ok = (cond, label) => {
assert.ok(cond, label);
n += 1;
};
ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu");
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
const b = rp.resolveBases({
host: "og-dev.dracodev.net",
explicit: { tiktak: "https://tiktak-perso.example/" },
barUrls: {},
tabHrefs: {},
});
ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire");
ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
ok(
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
"path ObsiGate",
);
const 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[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md",
"lien profond ObsiGate",
);
ok(
rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") ===
"https://tiktak-dev.dracodev.net/search?q=q",
"repli TikTak",
);
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
/* parseTab */
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
ok(rp.parseTab("console") === "console", "onglet console valide");
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
let c = rp.tabCounts({
obsigate: { status: "loading", total: 0 },
flowdeck: { status: "ok", total: 3 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.obsigate === "…", "compteur … pendant le chargement");
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
ok(c.all === "…", "Tout = … tant qu'une section charge");
c = rp.tabCounts({
obsigate: { status: "ok", total: 5 },
flowdeck: { status: "ok", total: 2 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "ok", total: 4 },
});
ok(c.all === "7", "Tout = somme des totaux connus");
ok(c.console === "4", "compteur console");
c = rp.tabCounts({
obsigate: { status: "error", total: 0 },
flowdeck: { status: "error", total: 0 },
tiktak: { status: "error", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
/* buildSearchUrl : état onglet partageable */
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
/* searchPath complet (onglet) vs tronqué (Tout) */
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
/* normalize complet vs tronqué */
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
console.log(`recherche.test.mjs : ${n} assertions OK`);
+69
View File
@@ -0,0 +1,69 @@
"""Tests de la recherche fédérée ObsiGate (fusion #68).
Fonctions pures de backend/recherche_cors.py uniquement : aucune base,
aucun serveur.
"""
from backend.recherche_cors import (
cors_headers_for,
is_dev_host,
is_prod_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",
"agent": "https://agent-dev.dracodev.net",
"gitea": "https://git.dracodev.net",
}
def test_is_dev_host():
assert is_dev_host("og-dev.dracodev.net") is True
assert is_dev_host("og.dracodev.net") is False
assert is_dev_host("x-dev.dracodev.net.evil") is False
assert is_prod_host("og.dracodev.net") is True
assert is_prod_host("og-dev.dracodev.net") is False
def test_origin_of():
assert origin_of("https://og-dev.dracodev.net") == "https://og-dev.dracodev.net"
assert origin_of("https://og-dev.dracodev.net/chemin") == "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_sister_origins_ajoute_env_de_la_requete():
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
assert "https://og-dev.dracodev.net" not in sister_origins(
{}, request_host="og.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://flowdeck-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None