Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf837edfbb | ||
|
|
edd2c24014 | ||
|
|
fc3e61f266 | ||
|
|
842204dadb | ||
|
|
c8be099aed | ||
|
|
1abb9f97de | ||
|
|
cd4fcf78a9 | ||
|
|
79ea841a81 | ||
|
|
46d26ca0c7 | ||
|
|
5c570b485c | ||
|
|
7365d00c4c | ||
|
|
e4be8578ff | ||
|
|
e733f3309d | ||
|
|
371908ed1d | ||
|
|
da205feec1 | ||
|
|
c582856051 | ||
|
|
ecafeecda0 | ||
|
|
7a43ca00ad | ||
|
|
e5e98ed08f |
@@ -1,5 +1,9 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.84.1 (2026-10-28) — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
||||||
|
|
||||||
|
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||||
|
|
||||||
## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4)
|
## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4)
|
||||||
|
|
||||||
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
|
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
|
||||||
|
|||||||
+10
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Littéral de version synchronisé avec le fichier VERSION :
|
# Littéral de version synchronisé avec le fichier VERSION :
|
||||||
# version="7.84.0"
|
# version="7.84.1"
|
||||||
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
||||||
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||||
@@ -250,6 +250,14 @@ app.add_middleware(
|
|||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
|
||||||
|
# après CORSMiddleware donc exécutée avant lui (dernier ajouté = le
|
||||||
|
# plus externe) : les preflights OPTIONS sont répondus ici, avant
|
||||||
|
# tout refus d'origine. La politique CORS globale est inchangée.
|
||||||
|
from app.middleware.recherche_cors import RechercheCorsMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(RechercheCorsMiddleware)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(sso_router)
|
app.include_router(sso_router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
@@ -427,3 +435,4 @@ def http_exception_handler(request: Request, exc: _StarHTTPException):
|
|||||||
return problem_response(request, exc)
|
return problem_response(request, exc)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""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 résolues par
|
||||||
|
``app.services.draco_urls.toutes()`` (explicite > hôte > dérivation
|
||||||
|
> défaut officiel), **plus** les origines prod ou ``-dev``
|
||||||
|
officielles correspondant à l'hôte de la requête — ce second
|
||||||
|
ensemble est nécessaire car ce middleware s'exécute avant le
|
||||||
|
middleware CSP qui pose le contexte d'hôte de la requête pour
|
||||||
|
``draco_urls`` ; sans lui, une page servie par ``flowdeck-dev``
|
||||||
|
verrait ses sœurs ``-dev`` refusées quand la configuration seule
|
||||||
|
ne les déclare pas ;
|
||||||
|
- avec credentials (le cookie SSO ``draco_session``, accepté par
|
||||||
|
``/api/search`` depuis cette livraison, ou la session native).
|
||||||
|
|
||||||
|
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 CORS globale de l'application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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_urls.toutes() 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 static/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 ``draco_urls.toutes()``.
|
||||||
|
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 app.services import draco_urls
|
||||||
|
|
||||||
|
resolved = draco_urls.toutes()
|
||||||
|
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)
|
||||||
@@ -174,9 +174,29 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
request.headers.get("host", ""))
|
request.headers.get("host", ""))
|
||||||
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
||||||
draco_urls.origine(draco_urls.agent_url()))
|
draco_urls.origine(draco_urls.agent_url()))
|
||||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
csp = self.CSP_VALUE.format(
|
||||||
nonce=nonce, host=host, agent=agent
|
nonce=nonce, host=host, agent=agent
|
||||||
)
|
)
|
||||||
|
# Recherche partout (#68) : la page /recherche appelle les
|
||||||
|
# API de recherche des sœurs depuis le navigateur — sa
|
||||||
|
# connect-src doit les nommer, sinon le navigateur bloque
|
||||||
|
# les appels avant même CORS. Les autres pages gardent la
|
||||||
|
# CSP inchangée. `draco_urls` est déjà importé dans ce
|
||||||
|
# dispatch (contexte d'hôte posé plus haut).
|
||||||
|
if request.url.path == "/recherche":
|
||||||
|
try:
|
||||||
|
soeurs = " ".join(sorted({
|
||||||
|
draco_urls.origine(draco_urls.toutes()[k])
|
||||||
|
for k in ("obsigate", "tiktak", "kanbante")
|
||||||
|
}))
|
||||||
|
csp = csp.replace(
|
||||||
|
"connect-src 'self' " + agent + " ",
|
||||||
|
"connect-src 'self' " + agent + " " + soeurs + " ",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
response.headers[self.CSP_HEADER] = csp
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -288,3 +308,4 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
for k in expired:
|
for k in expired:
|
||||||
del self._store[k]
|
del self._store[k]
|
||||||
self._last_prune = now
|
self._last_prune = now
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
||||||
editor pages and databases scoped to the current user's accessible workspaces.
|
editor pages and databases scoped to the current user's accessible workspaces.
|
||||||
|
|
||||||
|
Recherche partout (fusion-trois-apps #68) : la même route sert la page
|
||||||
|
unifiée (``GET /recherche``, coquille dont les résultats sont chargés
|
||||||
|
côté client) et accepte, en plus de la session native, le jeton SSO
|
||||||
|
``draco_session`` (validateur local ``app.services.draco``). Sans
|
||||||
|
session ni jeton valide, 401 — avant cette livraison, l'absence de
|
||||||
|
session donnait une recherche SANS périmètre (``user_id=None`` → ``1=1``
|
||||||
|
dans ``_scope_where``) : corrigé ici.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.services.search import search as search_service
|
from app.services.search import search as search_service
|
||||||
@@ -17,6 +26,18 @@ router = APIRouter(tags=["search"])
|
|||||||
def search(request: Request, q: str = Query(default="")):
|
def search(request: Request, q: str = Query(default="")):
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
user_id = user.get("id") if user and user.get("id") else None
|
user_id = user.get("id") if user and user.get("id") else None
|
||||||
|
if user_id is None:
|
||||||
|
# Repli SSO draco_session : un jeton valide désigne l'utilisateur
|
||||||
|
# lié, ou l'unique utilisateur actif (cas mono-utilisateur).
|
||||||
|
try:
|
||||||
|
from app.services import draco as draco_service
|
||||||
|
|
||||||
|
draco_user = draco_service.user_for_draco(request)
|
||||||
|
user_id = draco_user.get("id") if draco_user else None
|
||||||
|
except Exception:
|
||||||
|
user_id = None
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
data = search_service(q, user_id=user_id)
|
data = search_service(q, user_id=user_id)
|
||||||
return {
|
return {
|
||||||
@@ -25,3 +46,24 @@ def search(request: Request, q: str = Query(default="")):
|
|||||||
"collections": data["collections"],
|
"collections": data["collections"],
|
||||||
"total": len(data["pages"]) + len(data["collections"]),
|
"total": len(data["pages"]) + len(data["collections"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recherche", response_class=HTMLResponse)
|
||||||
|
def recherche_page(request: Request, q: str = Query(default="")):
|
||||||
|
"""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 (API de recherche de chaque app, en
|
||||||
|
parallèle, états par application). La palette Ctrl+K native et
|
||||||
|
``GET /api/search`` restent inchangés par ailleurs.
|
||||||
|
"""
|
||||||
|
from app.services import draco_urls
|
||||||
|
from app.templating import ENV
|
||||||
|
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
bases = draco_urls.toutes()
|
||||||
|
template = ENV.get_template("recherche.html")
|
||||||
|
return HTMLResponse(template.render(
|
||||||
|
request=request, q=q.strip(), user=user,
|
||||||
|
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante")},
|
||||||
|
))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
|
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
|
||||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
|
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
|
||||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||||
passe par une nouvelle version de commons.
|
passe par une nouvelle version de commons.
|
||||||
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
|
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
|
||||||
<span class="draco-avatar" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</span>
|
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||||
@@ -86,8 +86,14 @@
|
|||||||
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
if (act === "palette") { location.href = "/recherche"; return; }
|
||||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
/* v0.1.6 : le toast placeholder "fédération" est remplacé
|
||||||
|
par le menu par défaut du nouveau JS — retourner false le
|
||||||
|
demande explicitement (un onAction existant garde sinon la
|
||||||
|
main). Contenu : Console agent + applications, URL résolues
|
||||||
|
donc dérivées -dev sur un hôte -dev. Nonce CSP du script
|
||||||
|
inchangé. */
|
||||||
|
if (act === "menu") { return false; }
|
||||||
if (act === "spark") {
|
if (act === "spark") {
|
||||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||||
@@ -101,3 +107,4 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title_prefix %}Rechercher partout{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<link rel="stylesheet" href="/static/css/recherche.css">
|
||||||
|
<main class="rp-page" id="recherche" data-host="flowdeck"
|
||||||
|
data-base-obsigate="{{ bases.obsigate }}"
|
||||||
|
data-base-flowdeck="{{ bases.flowdeck }}"
|
||||||
|
data-base-tiktak="{{ bases.tiktak }}"
|
||||||
|
data-base-kanbante="{{ bases.kanbante }}">
|
||||||
|
<h1>Rechercher partout</h1>
|
||||||
|
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
|
||||||
|
<div class="rp-form">
|
||||||
|
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
|
||||||
|
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||||
|
</div>
|
||||||
|
<section class="rp-section" data-rp-section="obsigate">
|
||||||
|
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||||
|
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||||
|
</section>
|
||||||
|
<section class="rp-section" data-rp-section="flowdeck">
|
||||||
|
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||||
|
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||||
|
</section>
|
||||||
|
<section class="rp-section" data-rp-section="tiktak">
|
||||||
|
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||||
|
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||||
|
</section>
|
||||||
|
<section class="rp-section" data-rp-section="kanbante">
|
||||||
|
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||||
|
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/recherche.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
|
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||||
|
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
|
||||||
|
que le menu des applications) ; l'avatar est un vrai <button>.
|
||||||
Jetons repris du prototype validé Console v7.2.
|
Jetons repris du prototype validé Console v7.2.
|
||||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||||
@@ -42,6 +44,29 @@
|
|||||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||||
}
|
}
|
||||||
.draco-apps-menu[hidden] { display: none; }
|
.draco-apps-menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
|
||||||
|
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
|
||||||
|
.draco-more-menu, .draco-compte-menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
|
||||||
|
min-width: 248px; padding: 6px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||||
|
}
|
||||||
|
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
|
||||||
|
.draco-menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||||
|
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||||
|
background: transparent; color: var(--ink); font: inherit; text-align: left;
|
||||||
|
text-decoration: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||||
|
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
|
||||||
|
.draco-compte-nom {
|
||||||
|
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--line); margin-bottom: 6px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
.draco-apps-item {
|
.draco-apps-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: 10px;
|
padding: 8px 10px; border-radius: 10px;
|
||||||
@@ -122,8 +147,10 @@
|
|||||||
}
|
}
|
||||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||||
.draco-avatar {
|
.draco-avatar {
|
||||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||||
background: #2f6fed; color: #fff; font-weight: 700;
|
display: grid; place-items: center; flex: none;
|
||||||
|
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||||
|
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||||
|
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||||
|
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||||
|
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||||
|
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||||
|
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||||
|
.rp-input {
|
||||||
|
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||||
|
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||||
|
.rp-section { margin-bottom: 28px; }
|
||||||
|
.rp-head {
|
||||||
|
display: flex; align-items: baseline; gap: 10px;
|
||||||
|
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||||
|
.rp-count {
|
||||||
|
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||||
|
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||||
|
}
|
||||||
|
.rp-count:empty { display: none; }
|
||||||
|
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||||
|
.rp-state { opacity: .75; margin: 8px 0; }
|
||||||
|
.rp-error { opacity: 1; }
|
||||||
|
.rp-item {
|
||||||
|
display: block; padding: 8px 10px; border-radius: 8px;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||||
|
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||||
|
.rp-title { display: block; font-weight: 600; }
|
||||||
|
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||||
|
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||||
|
*
|
||||||
|
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
|
||||||
|
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
|
||||||
|
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
||||||
|
*
|
||||||
|
* Contrat d'API (audité, rien d'inventé) :
|
||||||
|
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||||
|
* -> { results: [{ vault, path, title, snippet }] }
|
||||||
|
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||||
|
* - Flowdeck : GET {base}/api/search?q=
|
||||||
|
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
|
||||||
|
* collections: [{ title, subtitle, url: "/db/<id>" }] }
|
||||||
|
* - TikTak : GET {base}/api/search?q=
|
||||||
|
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||||
|
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
||||||
|
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
|
||||||
|
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
|
||||||
|
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
|
||||||
|
* adressable — même limite que la page /search native de Kanbante).
|
||||||
|
*
|
||||||
|
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||||
|
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||||
|
* hôte de la requête > dérivation > défaut officiel) ;
|
||||||
|
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
|
||||||
|
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
|
||||||
|
* 4. href des onglets .draco-apptab rendus ;
|
||||||
|
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
|
||||||
|
*
|
||||||
|
* Authentification cross-origin : les appels sœurs partent avec
|
||||||
|
* credentials:"include". Le navigateur envoie alors les cookies de
|
||||||
|
* chaque origine, dont le cookie SSO partagé draco_session
|
||||||
|
* (Domain=.dracodev.net), accepté par les 4 API de recherche
|
||||||
|
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
|
||||||
|
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
|
||||||
|
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||||
|
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||||
|
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
/* globalThis : le fichier est chargé comme script classique dans les
|
||||||
|
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
|
||||||
|
* et importé en ESM par le test ObsiGate (frontend/js est "type":
|
||||||
|
* "module") — où `this` de niveau supérieur vaut undefined. */
|
||||||
|
|
||||||
|
var PROD = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
};
|
||||||
|
var DEV = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||||
|
};
|
||||||
|
var APPS = [
|
||||||
|
{ key: "obsigate", label: "ObsiGate" },
|
||||||
|
{ key: "flowdeck", label: "Flowdeck" },
|
||||||
|
{ key: "tiktak", label: "TikTak" },
|
||||||
|
{ key: "kanbante", label: "Kanbante" },
|
||||||
|
];
|
||||||
|
var COLUMN_LABELS = {
|
||||||
|
backlog: "Backlog",
|
||||||
|
in_progress: "En cours",
|
||||||
|
qa: "QA",
|
||||||
|
review: "Relecture",
|
||||||
|
todo: "À faire",
|
||||||
|
done: "Terminé",
|
||||||
|
};
|
||||||
|
var TIMEOUT_MS = 8000;
|
||||||
|
var DEBOUNCE_MS = 250;
|
||||||
|
var PER_APP_LIMIT = 8;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u) return null;
|
||||||
|
u = String(u).trim();
|
||||||
|
if (!u || u.indexOf("{{") !== -1) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) === "/" ? u : u + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
|
||||||
|
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
|
||||||
|
function isDevHost(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
var h = String(hostname).toLowerCase();
|
||||||
|
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||||
|
var first = h.split(".")[0];
|
||||||
|
return first.length > 4 && first.slice(-4) === "-dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
|
||||||
|
function resolveBases(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var dev = isDevHost(opts.host);
|
||||||
|
var out = {};
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var key = app.key;
|
||||||
|
var u = cleanUrl(opts.explicit && opts.explicit[key]);
|
||||||
|
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
|
||||||
|
if (!u && dev) u = DEV[key];
|
||||||
|
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
|
||||||
|
if (!u) u = PROD[key];
|
||||||
|
out[key] = withSlash(u);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchPath(app, q) {
|
||||||
|
if (app === "obsigate") {
|
||||||
|
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||||
|
}
|
||||||
|
return "api/search?q=" + encodeURIComponent(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snippetOf(s, n) {
|
||||||
|
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
||||||
|
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
||||||
|
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
|
||||||
|
function normalize(app, data, base, q) {
|
||||||
|
data = data || {};
|
||||||
|
if (app === "obsigate") {
|
||||||
|
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||||
|
return {
|
||||||
|
title: r.title || r.path || "Sans titre",
|
||||||
|
snippet: snippetOf(r.snippet || ""),
|
||||||
|
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
|
||||||
|
url:
|
||||||
|
base +
|
||||||
|
"#file=" +
|
||||||
|
encodeURIComponent(r.vault || "") +
|
||||||
|
":" +
|
||||||
|
encodeURIComponent(r.path || ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app === "flowdeck") {
|
||||||
|
var items = (data.pages || []).concat(data.collections || []);
|
||||||
|
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||||
|
var rel = String(r.url || "").replace(/^\//, "");
|
||||||
|
return {
|
||||||
|
title: r.title || "Sans titre",
|
||||||
|
snippet: snippetOf(r.excerpt || ""),
|
||||||
|
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
|
||||||
|
url: base + rel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app === "tiktak") {
|
||||||
|
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
|
||||||
|
var ctx = t.status === "done" ? "Terminée" : "Active";
|
||||||
|
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
||||||
|
return {
|
||||||
|
title: t.title || "Sans titre",
|
||||||
|
snippet: snippetOf(t.description || ""),
|
||||||
|
context: "Tâche · " + ctx,
|
||||||
|
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app === "kanbante") {
|
||||||
|
var rows = [];
|
||||||
|
(data.groups || []).forEach(function (g) {
|
||||||
|
(g.rows || []).forEach(function (row) {
|
||||||
|
rows.push({ group: g.full_name, row: row });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||||
|
var issue = x.row.issue || {};
|
||||||
|
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||||
|
return {
|
||||||
|
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
|
||||||
|
snippet: snippetOf(issue.body || ""),
|
||||||
|
context: x.group + (col ? " · " + col : ""),
|
||||||
|
url: base + "board/" + x.group,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||||
|
function nativeSearchUrl(app, base, q) {
|
||||||
|
if (app === "tiktak" || app === "kanbante") {
|
||||||
|
return base + "search?q=" + encodeURIComponent(q || "");
|
||||||
|
}
|
||||||
|
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
|
||||||
|
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSearchLabel(app) {
|
||||||
|
if (app === "tiktak" || app === "kanbante") {
|
||||||
|
return "Ouvrir la recherche dans " + appLabel(app);
|
||||||
|
}
|
||||||
|
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function appLabel(key) {
|
||||||
|
for (var i = 0; i < APPS.length; i++) {
|
||||||
|
if (APPS[i].key === key) return APPS[i].label;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = {
|
||||||
|
esc: esc,
|
||||||
|
isDevHost: isDevHost,
|
||||||
|
resolveBases: resolveBases,
|
||||||
|
searchPath: searchPath,
|
||||||
|
normalize: normalize,
|
||||||
|
nativeSearchUrl: nativeSearchUrl,
|
||||||
|
PROD: PROD,
|
||||||
|
DEV: DEV,
|
||||||
|
};
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
|
global.RecherchePartout = api;
|
||||||
|
|
||||||
|
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
function fetchSection(app, url, onDone) {
|
||||||
|
var ctrl = null;
|
||||||
|
var timer = null;
|
||||||
|
try {
|
||||||
|
ctrl = new AbortController();
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
ctrl.abort();
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
} catch (e) {
|
||||||
|
ctrl = null;
|
||||||
|
}
|
||||||
|
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
|
||||||
|
if (ctrl) opts.signal = ctrl.signal;
|
||||||
|
fetch(url, opts)
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
var err = new Error("HTTP " + resp.status);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
onDone(null, data);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
onDone(err, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(section, state) {
|
||||||
|
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||||
|
var body = section.querySelector(".rp-body");
|
||||||
|
var count = section.querySelector(".rp-count");
|
||||||
|
var toutVoir = section.querySelector(".rp-all");
|
||||||
|
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
|
||||||
|
toutVoir.textContent = "Tout voir";
|
||||||
|
toutVoir.title = nativeSearchLabel(state.app);
|
||||||
|
if (state.status === "loading") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML =
|
||||||
|
'<p class="rp-state rp-error">' +
|
||||||
|
esc(state.message) +
|
||||||
|
' — <a href="' +
|
||||||
|
esc(nativeSearchUrl(state.app, state.base, state.q)) +
|
||||||
|
'">' +
|
||||||
|
esc(nativeSearchLabel(state.app)) +
|
||||||
|
"</a></p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status === "empty") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML =
|
||||||
|
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
count.textContent = String(state.items.length);
|
||||||
|
body.innerHTML = state.items
|
||||||
|
.map(function (item) {
|
||||||
|
return (
|
||||||
|
'<a class="rp-item" data-rp-link href="' +
|
||||||
|
esc(item.url) +
|
||||||
|
'">' +
|
||||||
|
'<span class="rp-title">' +
|
||||||
|
esc(item.title) +
|
||||||
|
"</span>" +
|
||||||
|
(item.context
|
||||||
|
? '<span class="rp-context">' + esc(item.context) + "</span>"
|
||||||
|
: "") +
|
||||||
|
(item.snippet
|
||||||
|
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||||
|
: "") +
|
||||||
|
"</a>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var root = document.getElementById("recherche");
|
||||||
|
if (!root) return;
|
||||||
|
var input = document.getElementById("rp-q");
|
||||||
|
if (!input) return;
|
||||||
|
var hostApp = root.getAttribute("data-host") || "";
|
||||||
|
var explicit = {};
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var v = root.getAttribute("data-base-" + app.key);
|
||||||
|
if (v) explicit[app.key] = v;
|
||||||
|
});
|
||||||
|
var barUrls = {};
|
||||||
|
try {
|
||||||
|
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
|
||||||
|
} catch (e) {}
|
||||||
|
var tabHrefs = {};
|
||||||
|
try {
|
||||||
|
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
|
||||||
|
var href = tab.getAttribute("href");
|
||||||
|
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
var bases = resolveBases({
|
||||||
|
host: global.location.hostname,
|
||||||
|
explicit: explicit,
|
||||||
|
barUrls: barUrls,
|
||||||
|
tabHrefs: tabHrefs,
|
||||||
|
});
|
||||||
|
var sections = {};
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||||
|
if (el) sections[app.key] = el;
|
||||||
|
});
|
||||||
|
|
||||||
|
var currentQ = "";
|
||||||
|
var runId = 0;
|
||||||
|
var debounceTimer = null;
|
||||||
|
|
||||||
|
function runSearch(q) {
|
||||||
|
currentQ = q;
|
||||||
|
var myRun = ++runId;
|
||||||
|
if (!q) {
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var sec = sections[app.key];
|
||||||
|
if (!sec) return;
|
||||||
|
sec.querySelector(".rp-body").innerHTML =
|
||||||
|
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||||
|
sec.querySelector(".rp-count").textContent = "";
|
||||||
|
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var sec = sections[app.key];
|
||||||
|
if (!sec) return;
|
||||||
|
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
|
||||||
|
var url;
|
||||||
|
if (app.key === hostApp) {
|
||||||
|
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||||
|
url = "/" + searchPath(app.key, q);
|
||||||
|
} else {
|
||||||
|
url = bases[app.key] + searchPath(app.key, q);
|
||||||
|
}
|
||||||
|
fetchSection(app.key, url, function (err, data) {
|
||||||
|
if (myRun !== runId) return;
|
||||||
|
if (err) {
|
||||||
|
var msg;
|
||||||
|
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
|
||||||
|
else if (err.status === 401 || err.status === 403)
|
||||||
|
msg = "Connexion requise sur " + app.label;
|
||||||
|
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
||||||
|
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
||||||
|
renderSection(sec, {
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
app: app.key,
|
||||||
|
base: bases[app.key],
|
||||||
|
q: q,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = normalize(app.key, data, bases[app.key], q);
|
||||||
|
renderSection(sec, {
|
||||||
|
status: items.length ? "ok" : "empty",
|
||||||
|
items: items,
|
||||||
|
app: app.key,
|
||||||
|
base: bases[app.key],
|
||||||
|
q: q,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch(q, immediate) {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
if (immediate) {
|
||||||
|
runSearch(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(function () {
|
||||||
|
runSearch(q);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl(q) {
|
||||||
|
try {
|
||||||
|
var url = new URL(global.location.href);
|
||||||
|
if (q) url.searchParams.set("q", q);
|
||||||
|
else url.searchParams.delete("q");
|
||||||
|
global.history.replaceState(null, "", url.toString());
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
syncUrl(q);
|
||||||
|
scheduleSearch(q, false);
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
var first = document.querySelector("[data-rp-link]");
|
||||||
|
if (first) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.click();
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
input.value = "";
|
||||||
|
syncUrl("");
|
||||||
|
scheduleSearch("", true);
|
||||||
|
input.focus();
|
||||||
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
function (e) {
|
||||||
|
var target = e.target;
|
||||||
|
var typing =
|
||||||
|
target &&
|
||||||
|
(target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable);
|
||||||
|
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||||
|
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
function moveFocus(delta, e) {
|
||||||
|
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||||
|
if (!links.length) return;
|
||||||
|
var active = document.activeElement;
|
||||||
|
var idx = links.indexOf(active);
|
||||||
|
var next;
|
||||||
|
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
|
||||||
|
else next = (idx + delta + links.length) % links.length;
|
||||||
|
e.preventDefault();
|
||||||
|
links[next].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = new URLSearchParams(global.location.search);
|
||||||
|
var initialQ = (params.get("q") || "").trim();
|
||||||
|
if (initialQ) {
|
||||||
|
input.value = initialQ;
|
||||||
|
runSearch(initialQ);
|
||||||
|
} else {
|
||||||
|
runSearch("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})(
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window
|
||||||
|
: typeof globalThis !== "undefined"
|
||||||
|
? globalThis
|
||||||
|
: this
|
||||||
|
);
|
||||||
Vendored
+403
-40
@@ -1,5 +1,10 @@
|
|||||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||||
* v0.1.3 : menu des applications ouvert par la marque ✦).
|
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
|
||||||
|
* v0.1.5 : noms publics par configuration — aucun nom public d'un
|
||||||
|
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||||
|
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||||
|
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||||
|
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
|
||||||
*
|
*
|
||||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||||
@@ -10,19 +15,158 @@
|
|||||||
* current: "tiktak", // app courante
|
* current: "tiktak", // app courante
|
||||||
* domain: "pro", // domaine actif
|
* domain: "pro", // domaine actif
|
||||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||||
|
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||||
|
* // ou { display_name: "Bruno", username: "bruno" }
|
||||||
|
* urls: { // bases des applications
|
||||||
|
* obsigate: "https://og.dracodev.net/",
|
||||||
|
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
* tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
* kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
* agent: "https://agent.dracodev.net/",
|
||||||
|
* spark: "https://spark.dracodev.net/"
|
||||||
|
* },
|
||||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||||
* } // qu'elle ne connaît pas
|
* } // qu'elle ne connaît pas
|
||||||
|
*
|
||||||
|
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
|
||||||
|
* résolution, dans l'ordre STRICT, pour chaque app :
|
||||||
|
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
|
||||||
|
* serveur ou client) depuis SA configuration d'environnement —
|
||||||
|
* explicite, donc prioritaire, même sur un hôte -dev ;
|
||||||
|
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
|
||||||
|
* (première étiquette se terminant par -dev : tiktak-dev,
|
||||||
|
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
|
||||||
|
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
|
||||||
|
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
|
||||||
|
* les noms SANS -dev faute de configuration explicite ;
|
||||||
|
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
|
||||||
|
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
|
||||||
|
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
|
||||||
|
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
|
||||||
|
* partout (config injectée comme href rendu) et ne peut pas être exposé.
|
||||||
|
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
||||||
|
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
|
||||||
|
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
|
||||||
|
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
|
||||||
|
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
|
||||||
|
* Spark (panneau-agent), qui suit le même contrat d'injection.
|
||||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
* ouvre le menu des applications, construit depuis les onglets ;
|
||||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
* c'est le moyen de changer d'app en mobile, où les onglets sont
|
||||||
|
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
|
||||||
|
*
|
||||||
|
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
|
||||||
|
* connu à gérer l'action "menu" via onAction (il déclenche son propre
|
||||||
|
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
|
||||||
|
* pas et le bouton restait mort (relais vers un onAction absent, puis
|
||||||
|
* événement draco:bar sans écouteur). Comportement désormais :
|
||||||
|
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
|
||||||
|
* la console agent (URL agent résolue, donc dérivée -dev sur un
|
||||||
|
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
|
||||||
|
* par clic extérieur, Échap ou choix d'un lien ;
|
||||||
|
* - avec onAction : l'hôte garde la main (comportement historique,
|
||||||
|
* zéro régression pour ObsiGate) ; il peut demander explicitement
|
||||||
|
* le menu par défaut en retournant false pour l'action "menu".
|
||||||
|
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
|
||||||
|
* un « B » codé en dur dans le fragment, est un vrai <button>
|
||||||
|
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
|
||||||
|
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
|
||||||
|
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
|
||||||
|
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
|
||||||
|
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
|
||||||
|
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
|
||||||
|
* mais le menu affiche l'identité même sans gestionnaire hôte.
|
||||||
|
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
|
||||||
|
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||||
|
|
||||||
|
/* Défauts OFFICIELS (production) — les seuls noms publics présents
|
||||||
|
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
|
||||||
|
var DEFAULT_URLS = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
agent: "https://agent.dracodev.net/",
|
||||||
|
spark: "https://spark.dracodev.net/"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
|
||||||
|
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
|
||||||
|
* volontairement absent : aucun hôte spark-dev connu, aucune URL
|
||||||
|
* inventée ; spark retombe sur href rendu puis défaut prod. */
|
||||||
|
var DEV_URLS = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||||
|
agent: "https://agent-dev.dracodev.net/"
|
||||||
|
};
|
||||||
|
|
||||||
|
var APP_LABELS = {
|
||||||
|
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
|
||||||
|
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
|
||||||
|
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
|
||||||
|
* ne déclenche rien). Insensible à la casse. */
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u || typeof u !== "string") return null;
|
||||||
|
if (u.indexOf("{{") !== -1) return null;
|
||||||
|
if (!u.replace(/\s/g, "")) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrls() {
|
||||||
|
var host = "";
|
||||||
|
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
|
||||||
|
var dev = isDevHost(host);
|
||||||
|
var urls = {};
|
||||||
|
Object.keys(DEFAULT_URLS).forEach(function (app) {
|
||||||
|
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
|
||||||
|
var u = cleanUrl(cfg.urls && cfg.urls[app]);
|
||||||
|
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
|
||||||
|
if (!u && dev) u = DEV_URLS[app] || null;
|
||||||
|
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
|
||||||
|
if (!u) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
var href = tab ? tab.getAttribute("href") : null;
|
||||||
|
u = cleanUrl(href);
|
||||||
|
}
|
||||||
|
/* 4. Défaut officiel (production). */
|
||||||
|
if (!u) u = DEFAULT_URLS[app];
|
||||||
|
urls[app] = withSlash(u);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
function applyUrls(urls) {
|
||||||
|
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||||
|
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var resolvedUrls = resolveUrls();
|
||||||
|
|
||||||
function setCounts(counts) {
|
function setCounts(counts) {
|
||||||
Object.keys(counts || {}).forEach(function (k) {
|
Object.keys(counts || {}).forEach(function (k) {
|
||||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||||
@@ -43,26 +187,122 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||||
* Construit depuis les onglets présents dans la barre : mêmes
|
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||||
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||||
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||||
* choix d'une application (la navigation suit son cours). */
|
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
|
||||||
|
function userName() {
|
||||||
|
var u = cfg.user;
|
||||||
|
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
|
||||||
|
if (u && typeof u === "object") {
|
||||||
|
var n = u.display_name || u.name || u.username;
|
||||||
|
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function titleName() {
|
||||||
|
var av = document.querySelector(".draco-avatar");
|
||||||
|
var t = av ? av.getAttribute("title") : null;
|
||||||
|
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function displayName() { return userName() || titleName(); }
|
||||||
|
function syncAvatar() {
|
||||||
|
var av = document.querySelector(".draco-avatar");
|
||||||
|
if (!av) return;
|
||||||
|
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
|
||||||
|
* sans data-act) : le rendre déclencheur du menu compte. */
|
||||||
|
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
|
||||||
|
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
|
||||||
|
var name = displayName();
|
||||||
|
if (name) {
|
||||||
|
av.textContent = name.charAt(0).toUpperCase();
|
||||||
|
av.setAttribute("title", name);
|
||||||
|
av.setAttribute("aria-label", "Compte : " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
|
||||||
|
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||||
|
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||||
|
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||||
|
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
|
||||||
|
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||||
var bar = document.querySelector(".draco-bar");
|
var bar = document.querySelector(".draco-bar");
|
||||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||||
var menu = null;
|
var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
|
||||||
|
var avatarBtn = document.querySelector(".draco-avatar");
|
||||||
|
var appsMenu = null, moreMenu = null, compteMenu = null;
|
||||||
|
|
||||||
function buildMenu() {
|
function setExpanded(btn, open) {
|
||||||
if (!bar || !brand || menu) return;
|
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
menu = document.createElement("div");
|
}
|
||||||
menu.className = "draco-apps-menu";
|
function refreshMenuHrefs(menu) {
|
||||||
menu.hidden = true;
|
if (!menu) return;
|
||||||
menu.setAttribute("role", "menu");
|
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||||
menu.setAttribute("aria-label", "Applications");
|
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function togglePopup(menu, btn, force) {
|
||||||
|
if (!menu) return;
|
||||||
|
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||||
|
menu.hidden = !open;
|
||||||
|
setExpanded(btn, open);
|
||||||
|
}
|
||||||
|
function closeAllPopups() {
|
||||||
|
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||||
|
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
|
||||||
|
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||||
|
}
|
||||||
|
function tabLabel(app) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
|
||||||
|
return fort ? fort.textContent : (APP_LABELS[app] || app);
|
||||||
|
}
|
||||||
|
function tabRole(app) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||||
|
return petit ? petit.textContent : "";
|
||||||
|
}
|
||||||
|
function menuLink(app, nom, role) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.className = "draco-apps-item draco-menu-item";
|
||||||
|
a.href = resolvedUrls[app] || "#";
|
||||||
|
a.dataset.app = app;
|
||||||
|
a.setAttribute("role", "menuitem");
|
||||||
|
var txt = document.createElement("span");
|
||||||
|
txt.className = "draco-apps-item-txt";
|
||||||
|
var n = document.createElement("span");
|
||||||
|
n.className = "draco-apps-item-nom";
|
||||||
|
n.textContent = nom;
|
||||||
|
txt.appendChild(n);
|
||||||
|
if (role) {
|
||||||
|
var r = document.createElement("span");
|
||||||
|
r.className = "draco-apps-item-role";
|
||||||
|
r.textContent = role;
|
||||||
|
txt.appendChild(r);
|
||||||
|
}
|
||||||
|
a.appendChild(txt);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu des applications (v0.1.3) — construit depuis les onglets
|
||||||
|
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
|
||||||
|
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
|
||||||
|
* extérieur, Échap, ou le choix d'une application (la navigation
|
||||||
|
* suit son cours). */
|
||||||
|
function buildAppsMenu() {
|
||||||
|
if (!bar || !brand || appsMenu) return;
|
||||||
|
appsMenu = document.createElement("div");
|
||||||
|
appsMenu.className = "draco-apps-menu";
|
||||||
|
appsMenu.hidden = true;
|
||||||
|
appsMenu.setAttribute("role", "menu");
|
||||||
|
appsMenu.setAttribute("aria-label", "Applications");
|
||||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.className = "draco-apps-item";
|
a.className = "draco-apps-item";
|
||||||
a.href = t.getAttribute("href");
|
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||||
a.dataset.app = t.dataset.app;
|
a.dataset.app = t.dataset.app;
|
||||||
a.setAttribute("role", "menuitem");
|
a.setAttribute("role", "menuitem");
|
||||||
var ico = t.querySelector(".draco-apptab-ico");
|
var ico = t.querySelector(".draco-apptab-ico");
|
||||||
@@ -84,44 +324,167 @@
|
|||||||
a.appendChild(txt);
|
a.appendChild(txt);
|
||||||
var badge = t.querySelector(".draco-badge");
|
var badge = t.querySelector(".draco-badge");
|
||||||
if (badge) a.appendChild(badge.cloneNode(true));
|
if (badge) a.appendChild(badge.cloneNode(true));
|
||||||
menu.appendChild(a);
|
appsMenu.appendChild(a);
|
||||||
});
|
});
|
||||||
bar.appendChild(menu);
|
bar.appendChild(appsMenu);
|
||||||
|
}
|
||||||
|
function toggleAppsMenu(force) {
|
||||||
|
buildAppsMenu();
|
||||||
|
if (!appsMenu) return;
|
||||||
|
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||||
|
togglePopup(appsMenu, brand, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMenu(force) {
|
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
|
||||||
buildMenu();
|
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
|
||||||
if (!menu) return;
|
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
|
||||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
|
||||||
menu.hidden = !open;
|
function buildMoreMenu() {
|
||||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
if (!bar || !moreBtn || moreMenu) return;
|
||||||
|
moreMenu = document.createElement("div");
|
||||||
|
moreMenu.className = "draco-more-menu";
|
||||||
|
moreMenu.hidden = true;
|
||||||
|
moreMenu.setAttribute("role", "menu");
|
||||||
|
moreMenu.setAttribute("aria-label", "Plus d'actions");
|
||||||
|
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
|
||||||
|
var sep = document.createElement("div");
|
||||||
|
sep.className = "draco-menu-sep";
|
||||||
|
sep.setAttribute("aria-hidden", "true");
|
||||||
|
moreMenu.appendChild(sep);
|
||||||
|
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
|
||||||
|
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
|
||||||
|
});
|
||||||
|
bar.appendChild(moreMenu);
|
||||||
|
}
|
||||||
|
function toggleMoreMenu(force) {
|
||||||
|
buildMoreMenu();
|
||||||
|
if (!moreMenu) return;
|
||||||
|
if (force !== false) refreshMenuHrefs(moreMenu);
|
||||||
|
togglePopup(moreMenu, moreBtn, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
|
||||||
|
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
|
||||||
|
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
|
||||||
|
* remplit l'avatar depuis /api/auth/me après chargement). */
|
||||||
|
function buildCompteMenu() {
|
||||||
|
if (!bar || !avatarBtn || compteMenu) return;
|
||||||
|
compteMenu = document.createElement("div");
|
||||||
|
compteMenu.className = "draco-compte-menu";
|
||||||
|
compteMenu.hidden = true;
|
||||||
|
compteMenu.setAttribute("role", "menu");
|
||||||
|
compteMenu.setAttribute("aria-label", "Compte");
|
||||||
|
var head = document.createElement("div");
|
||||||
|
head.className = "draco-compte-nom";
|
||||||
|
compteMenu.appendChild(head);
|
||||||
|
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "draco-menu-item";
|
||||||
|
b.setAttribute("data-compte-act", pair[0]);
|
||||||
|
b.setAttribute("role", "menuitem");
|
||||||
|
b.textContent = pair[1];
|
||||||
|
compteMenu.appendChild(b);
|
||||||
|
});
|
||||||
|
bar.appendChild(compteMenu);
|
||||||
|
}
|
||||||
|
function refreshCompteMenu() {
|
||||||
|
if (!compteMenu) return;
|
||||||
|
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||||
|
var name = displayName();
|
||||||
|
if (head) head.textContent = name || "Compte";
|
||||||
|
}
|
||||||
|
function toggleCompteMenu(force) {
|
||||||
|
buildCompteMenu();
|
||||||
|
if (!compteMenu) return;
|
||||||
|
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||||
|
togglePopup(compteMenu, avatarBtn, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||||
|
* événement draco:bar (contrat historique des actions relayées). */
|
||||||
|
function relay(act, el, ev) {
|
||||||
|
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||||
|
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("click", function (ev) {
|
document.addEventListener("click", function (ev) {
|
||||||
var el = ev.target.closest("[data-act]");
|
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||||
if (el && el.dataset.act === "apps") {
|
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||||
ev.preventDefault();
|
if (compteItem) {
|
||||||
toggleMenu();
|
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||||
|
toggleCompteMenu(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||||
toggleMenu(false);
|
if (el && el.dataset.act === "apps") {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (moreMenu) toggleMoreMenu(false);
|
||||||
|
if (compteMenu) toggleCompteMenu(false);
|
||||||
|
toggleAppsMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el && el.dataset.act === "menu") {
|
||||||
|
if (appsMenu) toggleAppsMenu(false);
|
||||||
|
if (compteMenu) toggleCompteMenu(false);
|
||||||
|
/* Hôte prioritaire : il garde la main, sauf s'il demande
|
||||||
|
* explicitement le menu par défaut en retournant false.
|
||||||
|
* Sans onAction, le menu par défaut s'ouvre (il ne restait
|
||||||
|
* auparavant qu'un événement draco:bar sans écouteur). */
|
||||||
|
if (typeof cfg.onAction === "function") {
|
||||||
|
var handled = cfg.onAction("menu", el, ev);
|
||||||
|
if (handled === false) toggleMoreMenu();
|
||||||
|
} else {
|
||||||
|
toggleMoreMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el && el.dataset.act === "compte") {
|
||||||
|
if (appsMenu) toggleAppsMenu(false);
|
||||||
|
if (moreMenu) toggleMoreMenu(false);
|
||||||
|
toggleCompteMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||||
|
* sur son déclencheur déjà traité ci-dessus). */
|
||||||
|
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||||
|
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
|
||||||
|
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||||
|
closeAllPopups();
|
||||||
}
|
}
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
var act = el.dataset.act;
|
var act = el.dataset.act;
|
||||||
if (act === "domaine") setDomain(el.dataset.dom);
|
if (act === "domaine") setDomain(el.dataset.dom);
|
||||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
relay(act, el, ev);
|
||||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", function (ev) {
|
document.addEventListener("keydown", function (ev) {
|
||||||
if (ev.key === "Escape") toggleMenu(false);
|
if (ev.key === "Escape") closeAllPopups();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
* setUrls(urls) fusionne de nouvelles bases (même contrat que
|
||||||
|
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
|
||||||
|
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
|
||||||
|
* les bases résolues (défauts inclus), pour le panneau Spark et tout
|
||||||
|
* autre consommateur de l'hôte. */
|
||||||
|
window.DRACO_BAR = {
|
||||||
|
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
|
||||||
|
urls: resolvedUrls,
|
||||||
|
setUrls: function (urls) {
|
||||||
|
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||||
|
resolvedUrls = resolveUrls();
|
||||||
|
window.DRACO_BAR.urls = resolvedUrls;
|
||||||
|
applyUrls(resolvedUrls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
buildMenu();
|
applyUrls(resolvedUrls);
|
||||||
|
buildAppsMenu();
|
||||||
|
buildMoreMenu();
|
||||||
|
buildCompteMenu();
|
||||||
|
syncAvatar();
|
||||||
|
refreshCompteMenu();
|
||||||
if (cfg.current) setCurrent(cfg.current);
|
if (cfg.current) setCurrent(cfg.current);
|
||||||
if (cfg.domain) setDomain(cfg.domain);
|
if (cfg.domain) setDomain(cfg.domain);
|
||||||
if (cfg.counts) setCounts(cfg.counts);
|
if (cfg.counts) setCounts(cfg.counts);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/* Tests Node sans navigateur de recherche.js (logique pure).
|
||||||
|
* Usage : node recherche.test.js — sortie 0 si tout passe. */
|
||||||
|
"use strict";
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const rp = require("../static/js/recherche.js");
|
||||||
|
|
||||||
|
let n = 0;
|
||||||
|
function ok(cond, label) {
|
||||||
|
assert.ok(cond, label);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* esc */
|
||||||
|
ok(rp.esc('<a href="x">&') === "<a href="x">&", "esc échappe HTML");
|
||||||
|
|
||||||
|
/* isDevHost : même règle que la barre v0.1.6 */
|
||||||
|
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
|
||||||
|
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
|
||||||
|
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||||
|
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
|
||||||
|
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
|
||||||
|
|
||||||
|
/* resolveBases : ordre explicite > barre > dev > href > prod */
|
||||||
|
let b = rp.resolveBases({
|
||||||
|
host: "tiktak-dev.dracodev.net",
|
||||||
|
explicit: { obsigate: "https://og-perso.example/" },
|
||||||
|
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
|
||||||
|
tabHrefs: { kanbante: "https://kanbante-href.example/" },
|
||||||
|
});
|
||||||
|
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
|
||||||
|
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
|
||||||
|
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
|
||||||
|
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
|
||||||
|
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
|
||||||
|
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
|
||||||
|
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
|
||||||
|
b = rp.resolveBases({});
|
||||||
|
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
|
||||||
|
|
||||||
|
/* searchPath */
|
||||||
|
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
|
||||||
|
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
|
||||||
|
|
||||||
|
/* normalize ObsiGate */
|
||||||
|
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
|
||||||
|
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
|
||||||
|
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
|
||||||
|
|
||||||
|
/* normalize Flowdeck */
|
||||||
|
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
|
||||||
|
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
|
||||||
|
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
|
||||||
|
|
||||||
|
/* normalize TikTak */
|
||||||
|
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
|
||||||
|
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
|
||||||
|
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
|
||||||
|
|
||||||
|
/* normalize Kanbante */
|
||||||
|
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
|
||||||
|
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
|
||||||
|
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
|
||||||
|
|
||||||
|
/* nativeSearchUrl */
|
||||||
|
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
|
||||||
|
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
|
||||||
|
|
||||||
|
console.log("recherche.test.js : " + n + " assertions OK");
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Tests de la recherche fédérée Flowdeck (fusion #68).
|
||||||
|
|
||||||
|
Fonctions pures de app/middleware/recherche_cors.py uniquement :
|
||||||
|
aucune base, aucun serveur.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.middleware.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("flowdeck-dev.dracodev.net") is True
|
||||||
|
assert is_dev_host("flowdeck.dracodev.net") is False
|
||||||
|
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||||
|
assert is_dev_host("flowdeck-dev.dracodev.net:8443") is True
|
||||||
|
assert is_prod_host("flowdeck.dracodev.net") is True
|
||||||
|
assert is_prod_host("flowdeck-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("{{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():
|
||||||
|
# Configuration muette (hors requête) + hôte dev : les -dev passent.
|
||||||
|
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||||
|
{}, request_host="flowdeck-dev.dracodev.net"
|
||||||
|
)
|
||||||
|
assert "https://og.dracodev.net" in sister_origins(
|
||||||
|
{}, request_host="flowdeck.dracodev.net"
|
||||||
|
)
|
||||||
|
assert "https://og-dev.dracodev.net" not in sister_origins(
|
||||||
|
{}, request_host="flowdeck.dracodev.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_headers():
|
||||||
|
headers = cors_headers_for("https://og-dev.dracodev.net", RESOLVED_DEV)
|
||||||
|
assert headers["Access-Control-Allow-Origin"] == "https://og-dev.dracodev.net"
|
||||||
|
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||||
|
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||||
|
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_headers():
|
||||||
|
headers = preflight_headers("https://kanbante-dev.dracodev.net", RESOLVED_DEV)
|
||||||
|
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||||
|
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||||
Reference in New Issue
Block a user