Files
flowdeck/static/js/recherche.js
T
bruno 167e93e295
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
Recherche partout (#68, rebase sur main post-#70 et post-#69)
2026-10-28 12:54:49 -04:00

515 lines
17 KiB
JavaScript

/* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u || u.indexOf("{{") !== -1) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) === "/" ? u : u + "/";
}
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
function resolveBases(opts) {
opts = opts || {};
var dev = isDevHost(opts.host);
var out = {};
APPS.forEach(function (app) {
var key = app.key;
var u = cleanUrl(opts.explicit && opts.explicit[key]);
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
if (!u && dev) u = DEV[key];
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
if (!u) u = PROD[key];
out[key] = withSlash(u);
});
return out;
}
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
);