From 9772ecdcf609c87aea88df98a6f7c9318187c580 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:31 -0400 Subject: [PATCH] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20page=20de=20r?= =?UTF-8?q?=C3=A9sultats=20unifi=C3=A9e=20=E2=80=94=20JS=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/recherche.js | 504 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 static/js/recherche.js diff --git a/static/js/recherche.js b/static/js/recherche.js new file mode 100644 index 0000000..77aec9f --- /dev/null +++ b/static/js/recherche.js @@ -0,0 +1,504 @@ +/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68). + * + * Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque + * hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient + * des attributs data-* de #recherche, rendus côté serveur par l'hôte. + * + * Contrat d'API (audité, rien d'inventé) : + * - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8 + * -> { results: [{ vault, path, title, snippet }] } + * Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile). + * - Flowdeck : GET {base}/api/search?q= + * -> { pages: [{ title, excerpt, subtitle, url: "/pages/" }], + * collections: [{ title, subtitle, url: "/db/" }] } + * - TikTak : GET {base}/api/search?q= + * -> { tasks: [{ id, title, description, status, due_at }], ... } + * Lien profond vérifié : {base}search?q=&task= (page /search). + * - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison) + * -> { groups: [{ full_name, rows: [{ issue, column }] }] } + * Lien profond : {base}board/ (la carte n'a pas d'URL propre + * adressable — même limite que la page /search native de Kanbante). + * + * Résolution des bases sœurs (même ordre que la barre v0.1.6) : + * 1. data-base-* rendus par l'hôte (son propre résolveur : explicite > + * hôte de la requête > dérivation > défaut officiel) ; + * 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ; + * 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ; + * 4. href des onglets .draco-apptab rendus ; + * 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev). + * + * Authentification cross-origin : les appels sœurs partent avec + * credentials:"include". Le navigateur envoie alors les cookies de + * chaque origine, dont le cookie SSO partagé draco_session + * (Domain=.dracodev.net), accepté par les 4 API de recherche + * (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette + * livraison). Chaque sœur n'autorise en CORS que les origines sœurs + * (GET sur l'endpoint de recherche uniquement, pas de "*"). + * Une sœur injoignable / non autorisée ne casse pas les autres : + * sa section affiche l'erreur + un lien vers sa recherche native. + */ +(function (global) { + "use strict"; + + var PROD = { + obsigate: "https://og.dracodev.net/", + flowdeck: "https://flowdeck.dracodev.net/", + tiktak: "https://tiktak.dracodev.net/", + kanbante: "https://kanbante.dracodev.net/", + }; + var DEV = { + obsigate: "https://og-dev.dracodev.net/", + flowdeck: "https://flowdeck-dev.dracodev.net/", + tiktak: "https://tiktak-dev.dracodev.net/", + kanbante: "https://kanbante-dev.dracodev.net/", + }; + var APPS = [ + { key: "obsigate", label: "ObsiGate" }, + { key: "flowdeck", label: "Flowdeck" }, + { key: "tiktak", label: "TikTak" }, + { key: "kanbante", label: "Kanbante" }, + ]; + var COLUMN_LABELS = { + backlog: "Backlog", + in_progress: "En cours", + qa: "QA", + review: "Relecture", + todo: "À faire", + done: "Terminé", + }; + var TIMEOUT_MS = 8000; + var DEBOUNCE_MS = 250; + var PER_APP_LIMIT = 8; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function cleanUrl(u) { + if (!u) return null; + u = String(u).trim(); + if (!u || u.indexOf("{{") !== -1) return null; + return u; + } + + function withSlash(u) { + return u.charAt(u.length - 1) === "/" ? u : u + "/"; + } + + /* Même règle que la barre v0.1.6 : première étiquette en -dev ET + * domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */ + function isDevHost(hostname) { + if (!hostname) return false; + var h = String(hostname).toLowerCase(); + if (h.slice(-13) !== ".dracodev.net") return false; + var first = h.split(".")[0]; + return first.length > 4 && first.slice(-4) === "-dev"; + } + + /* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */ + function resolveBases(opts) { + opts = opts || {}; + var dev = isDevHost(opts.host); + var out = {}; + APPS.forEach(function (app) { + var key = app.key; + var u = cleanUrl(opts.explicit && opts.explicit[key]); + if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]); + if (!u && dev) u = DEV[key]; + if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]); + if (!u) u = PROD[key]; + out[key] = withSlash(u); + }); + return out; + } + + function searchPath(app, q) { + if (app === "obsigate") { + return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT; + } + return "api/search?q=" + encodeURIComponent(q); + } + + function snippetOf(s, n) { + s = String(s == null ? "" : s).replace(/\s+/g, " ").trim(); + return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s; + } + + /* Normalise la réponse d'une app en [{ title, snippet, context, url }]. + * base : base résolue de l'app (avec / final). q : requête (liens TikTak). */ + function normalize(app, data, base, q) { + data = data || {}; + if (app === "obsigate") { + return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) { + return { + title: r.title || r.path || "Sans titre", + snippet: snippetOf(r.snippet || ""), + context: (r.vault || "") + (r.path ? " · " + r.path : ""), + url: + base + + "#file=" + + encodeURIComponent(r.vault || "") + + ":" + + encodeURIComponent(r.path || ""), + }; + }); + } + if (app === "flowdeck") { + var items = (data.pages || []).concat(data.collections || []); + return items.slice(0, PER_APP_LIMIT).map(function (r) { + var rel = String(r.url || "").replace(/^\//, ""); + return { + title: r.title || "Sans titre", + snippet: snippetOf(r.excerpt || ""), + context: r.subtitle || (r.type === "collection" ? "Base" : "Page"), + url: base + rel, + }; + }); + } + if (app === "tiktak") { + return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) { + var ctx = t.status === "done" ? "Terminée" : "Active"; + if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10); + return { + title: t.title || "Sans titre", + snippet: snippetOf(t.description || ""), + context: "Tâche · " + ctx, + url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id, + }; + }); + } + if (app === "kanbante") { + var rows = []; + (data.groups || []).forEach(function (g) { + (g.rows || []).forEach(function (row) { + rows.push({ group: g.full_name, row: row }); + }); + }); + return rows.slice(0, PER_APP_LIMIT).map(function (x) { + var issue = x.row.issue || {}; + var col = COLUMN_LABELS[x.row.column] || x.row.column || ""; + return { + title: "#" + issue.number + " " + (issue.title || "Sans titre"), + snippet: snippetOf(issue.body || ""), + context: x.group + (col ? " · " + col : ""), + url: base + "board/" + x.group, + }; + }); + } + return []; + } + + /* Recherche native de l'app (repli quand la section est en erreur). */ + function nativeSearchUrl(app, base, q) { + if (app === "tiktak" || app === "kanbante") { + return base + "search?q=" + encodeURIComponent(q || ""); + } + /* ObsiGate et Flowdeck n'ont pas de page de recherche adressable : + * leur recherche native vit dans la palette (Ctrl+K) de l'app. */ + return base; + } + + function nativeSearchLabel(app) { + if (app === "tiktak" || app === "kanbante") { + return "Ouvrir la recherche dans " + appLabel(app); + } + return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)"; + } + + function appLabel(key) { + for (var i = 0; i < APPS.length; i++) { + if (APPS[i].key === key) return APPS[i].label; + } + return key; + } + + var api = { + esc: esc, + isDevHost: isDevHost, + resolveBases: resolveBases, + searchPath: searchPath, + normalize: normalize, + nativeSearchUrl: nativeSearchUrl, + PROD: PROD, + DEV: DEV, + }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + global.RecherchePartout = api; + + /* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */ + if (typeof document === "undefined") return; + + function fetchSection(app, url, onDone) { + var ctrl = null; + var timer = null; + try { + ctrl = new AbortController(); + timer = setTimeout(function () { + ctrl.abort(); + }, TIMEOUT_MS); + } catch (e) { + ctrl = null; + } + var opts = { headers: { Accept: "application/json" }, credentials: "include" }; + if (ctrl) opts.signal = ctrl.signal; + fetch(url, opts) + .then(function (resp) { + if (!resp.ok) { + var err = new Error("HTTP " + resp.status); + err.status = resp.status; + throw err; + } + return resp.json(); + }) + .then(function (data) { + if (timer) clearTimeout(timer); + onDone(null, data); + }) + .catch(function (err) { + if (timer) clearTimeout(timer); + onDone(err, null); + }); + } + + function renderSection(section, state) { + /* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */ + var body = section.querySelector(".rp-body"); + var count = section.querySelector(".rp-count"); + var toutVoir = section.querySelector(".rp-all"); + toutVoir.href = nativeSearchUrl(state.app, state.base, state.q); + toutVoir.textContent = "Tout voir"; + toutVoir.title = nativeSearchLabel(state.app); + if (state.status === "loading") { + count.textContent = ""; + body.innerHTML = '

Recherche…

'; + return; + } + if (state.status === "error") { + count.textContent = ""; + body.innerHTML = + '

' + + esc(state.message) + + ' — ' + + esc(nativeSearchLabel(state.app)) + + "

"; + return; + } + if (state.status === "empty") { + count.textContent = ""; + body.innerHTML = + '

Aucun résultat pour « ' + esc(state.q) + " ».

"; + return; + } + count.textContent = String(state.items.length); + body.innerHTML = state.items + .map(function (item) { + return ( + '' + + '' + + esc(item.title) + + "" + + (item.context + ? '' + esc(item.context) + "" + : "") + + (item.snippet + ? '' + esc(item.snippet) + "" + : "") + + "" + ); + }) + .join(""); + } + + function init() { + var root = document.getElementById("recherche"); + if (!root) return; + var input = document.getElementById("rp-q"); + if (!input) return; + var hostApp = root.getAttribute("data-host") || ""; + var explicit = {}; + APPS.forEach(function (app) { + var v = root.getAttribute("data-base-" + app.key); + if (v) explicit[app.key] = v; + }); + var barUrls = {}; + try { + if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls; + } catch (e) {} + var tabHrefs = {}; + try { + document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) { + var href = tab.getAttribute("href"); + if (href) tabHrefs[tab.getAttribute("data-app")] = href; + }); + } catch (e) {} + var bases = resolveBases({ + host: global.location.hostname, + explicit: explicit, + barUrls: barUrls, + tabHrefs: tabHrefs, + }); + var sections = {}; + APPS.forEach(function (app) { + var el = document.querySelector('[data-rp-section="' + app.key + '"]'); + if (el) sections[app.key] = el; + }); + + var currentQ = ""; + var runId = 0; + var debounceTimer = null; + + function runSearch(q) { + currentQ = q; + var myRun = ++runId; + if (!q) { + APPS.forEach(function (app) { + var sec = sections[app.key]; + if (!sec) return; + sec.querySelector(".rp-body").innerHTML = + '

Tapez une recherche ci-dessus.

'; + sec.querySelector(".rp-count").textContent = ""; + sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], ""); + }); + return; + } + APPS.forEach(function (app) { + var sec = sections[app.key]; + if (!sec) return; + renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q }); + var url; + if (app.key === hostApp) { + /* Même origine : pas de CORS, pas de CSP cross-origin. */ + url = "/" + searchPath(app.key, q); + } else { + url = bases[app.key] + searchPath(app.key, q); + } + fetchSection(app.key, url, function (err, data) { + if (myRun !== runId) return; + if (err) { + var msg; + if (err.name === "AbortError") msg = "Délai dépassé (8 s)"; + else if (err.status === 401 || err.status === 403) + msg = "Connexion requise sur " + app.label; + else if (err.status) msg = "Indisponible (HTTP " + err.status + ")"; + else msg = "Injoignable depuis cet hôte (CORS ou réseau)"; + renderSection(sec, { + status: "error", + message: msg, + app: app.key, + base: bases[app.key], + q: q, + }); + return; + } + var items = normalize(app.key, data, bases[app.key], q); + renderSection(sec, { + status: items.length ? "ok" : "empty", + items: items, + app: app.key, + base: bases[app.key], + q: q, + }); + }); + }); + } + + function scheduleSearch(q, immediate) { + if (debounceTimer) clearTimeout(debounceTimer); + if (immediate) { + runSearch(q); + return; + } + debounceTimer = setTimeout(function () { + runSearch(q); + }, DEBOUNCE_MS); + } + + function syncUrl(q) { + try { + var url = new URL(global.location.href); + if (q) url.searchParams.set("q", q); + else url.searchParams.delete("q"); + global.history.replaceState(null, "", url.toString()); + } catch (e) {} + } + + input.addEventListener("input", function () { + var q = input.value.trim(); + syncUrl(q); + scheduleSearch(q, false); + }); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + var first = document.querySelector("[data-rp-link]"); + if (first) { + e.preventDefault(); + first.click(); + } + } else if (e.key === "Escape") { + input.value = ""; + syncUrl(""); + scheduleSearch("", true); + input.focus(); + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + moveFocus(e.key === "ArrowDown" ? 1 : -1, e); + } + }); + document.addEventListener( + "keydown", + function (e) { + var target = e.target; + var typing = + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable); + if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) { + e.preventDefault(); + e.stopPropagation(); + input.focus(); + input.select(); + return; + } + if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) { + moveFocus(e.key === "ArrowDown" ? 1 : -1, e); + } + }, + true + ); + + function moveFocus(delta, e) { + var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]")); + if (!links.length) return; + var active = document.activeElement; + var idx = links.indexOf(active); + var next; + if (idx === -1) next = delta > 0 ? 0 : links.length - 1; + else next = (idx + delta + links.length) % links.length; + e.preventDefault(); + links[next].focus(); + } + + var params = new URLSearchParams(global.location.search); + var initialQ = (params.get("q") || "").trim(); + if (initialQ) { + input.value = initialQ; + runSearch(initialQ); + } else { + runSearch(""); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : this);