1090 lines
38 KiB
JavaScript
1090 lines
38 KiB
JavaScript
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
|
|
* chantier 2+3 : onglets, « Tout voir », focus).
|
|
*
|
|
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
|
|
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
|
|
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
|
*
|
|
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
|
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
|
|
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
|
|
* de cette application (aucune troncature côté client — la requête
|
|
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
|
|
* jeu complet). « Tout voir » d'une section ne quitte plus la page :
|
|
* il bascule vers l'onglet de l'application (son href reste une URL
|
|
* partageable ?q=&tab= au cas où le JS ne tournerait pas).
|
|
* L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
|
|
* et le retour arrière navigateur le restaure (popstate).
|
|
*
|
|
* Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
|
|
* son application (pas le nombre tronqué affiché dans « Tout »),
|
|
* « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
|
|
* une section en erreur/indisponible (pas de faux 0). L'onglet Tout
|
|
* affiche « … » tant qu'une section charge, sinon la somme des totaux
|
|
* connus (une section en erreur compte pour 0) ; il est vide si les
|
|
* 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
|
|
* Une section injoignable ne casse jamais les autres.
|
|
*
|
|
* Focus : l'attribut autofocus seul ne survit pas au chargement des
|
|
* surcouches (barre, palette, app) — la page appelle donc focus()
|
|
* explicitement en fin d'init (plus un rappel rAF/timeout et un
|
|
* rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
|
|
* data-act="palette") sur cette même page replace aussi le curseur
|
|
* dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
|
|
*
|
|
* Contrat d'API (audité, rien d'inventé) :
|
|
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
|
|
* -> { results: [{ vault, path, title, snippet }] }
|
|
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
|
* - Flowdeck : GET {base}/api/search?q=
|
|
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
|
|
* collections: [{ title, subtitle, url: "/db/<id>" }] }
|
|
* - TikTak : GET {base}/api/search?q=
|
|
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
|
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
|
* - Kanbante : GET {base}/api/search?q=
|
|
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
|
|
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
|
|
* adressable — même limite que la page /search native de Kanbante).
|
|
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
|
|
* (agent-commun, authentifié par le cookie SSO draco_session) ->
|
|
* { q, domain, limite_par_type, resultats[], par_type,
|
|
* non_cherchables[] } ; chaque résultat porte type, id, titre,
|
|
* extrait, statut, domaine, section, objet et lien (relatif à
|
|
* l'origine console). q < 2 caractères -> 422. Types cherchables :
|
|
* discussion, mission, approbation, livrable, activite,
|
|
* planification, objectif, identite (aucun secret). Liens profonds
|
|
* (préfixés par la base console) : discussion
|
|
* /?section=conversation&disc=<id>, mission
|
|
* /?section=missions&objet=<id>, approbation
|
|
* /?section=approbations&objet=<id>, objectif
|
|
* /?section=objectifs&objet=<id>, activite /?activite=<id> ;
|
|
* livrable /?section=livrables, planification /?section=planifie
|
|
* et identite /?section=identites ouvrent la vue sans sélection
|
|
* d'objet. L'onglet appelle avec limite=20 (plafond par type) car
|
|
* il affiche la liste complète, comme les onglets d'applications.
|
|
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
|
|
* section affiche honnêtement « Console Spark : recherche
|
|
* indisponible pour l'instant » — jamais de faux résultats,
|
|
* jamais de faux 0. La base console vient, dans l'ordre, de
|
|
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
|
|
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
|
|
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
|
|
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
|
|
* prod agent.dracodev.net. L'onglet console n'est interrogé que
|
|
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
|
|
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
|
|
* polluer la vue groupée d'un message d'indisponibilité.
|
|
*
|
|
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
|
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
|
* hôte de la requête > dérivation > défaut officiel) ;
|
|
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
|
|
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
|
|
* 4. href des onglets .draco-apptab rendus ;
|
|
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
|
|
*
|
|
* Authentification cross-origin : les appels sœurs partent avec
|
|
* credentials:"include". Le navigateur envoie alors les cookies de
|
|
* chaque origine, dont le cookie SSO partagé draco_session
|
|
* (Domain=.dracodev.net), accepté par les 4 API de recherche
|
|
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
|
|
* et par GET /api/v1/recherche de la console (identity() accepte
|
|
* draco_session). Chaque sœur n'autorise en CORS que les origines
|
|
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
|
|
* côté console, un middleware borné à /api/v1/recherche autorise
|
|
* de même les 8 origines hôtes prod ET dev, avec credentials.
|
|
* 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 PROD_AGENT = "https://agent.dracodev.net/";
|
|
var DEV_AGENT = "https://agent-dev.dracodev.net/";
|
|
var CONSOLE_SEARCH_PATH = "api/v1/recherche";
|
|
var CONSOLE_SEARCH_LIMIT = 20;
|
|
var APPS = [
|
|
{ key: "obsigate", label: "ObsiGate" },
|
|
{ key: "flowdeck", label: "Flowdeck" },
|
|
{ key: "tiktak", label: "TikTak" },
|
|
{ key: "kanbante", label: "Kanbante" },
|
|
];
|
|
var CONSOLE_APP = { key: "console", label: "Console Spark" };
|
|
var CONSOLE_TYPE_LABELS = {
|
|
discussion: "Discussion",
|
|
mission: "Mission",
|
|
approbation: "Approbation",
|
|
livrable: "Livrable",
|
|
activite: "Activité",
|
|
planification: "Planification",
|
|
objectif: "Objectif",
|
|
identite: "Identité",
|
|
};
|
|
/* Lien profond console (relatif à l'origine console) quand le
|
|
* résultat n'en porte pas : même règle que _lien_recherche
|
|
* d'agent-commun (section + objet, activite en section=null). */
|
|
var CONSOLE_VIEW_LINKS = {
|
|
livrable: "/?section=livrables",
|
|
planification: "/?section=planifie",
|
|
identite: "/?section=identites",
|
|
};
|
|
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
|
var COLUMN_LABELS = {
|
|
backlog: "Backlog",
|
|
in_progress: "En cours",
|
|
qa: "QA",
|
|
review: "Relecture",
|
|
todo: "À faire",
|
|
done: "Terminé",
|
|
};
|
|
var TIMEOUT_MS = 8000;
|
|
var DEBOUNCE_MS = 250;
|
|
var PER_APP_LIMIT = 8;
|
|
var FULL_LIMIT = 100;
|
|
|
|
function esc(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&")
|
|
.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;
|
|
}
|
|
|
|
/* Base console : explicite > URL agent injectée par l'hôte >
|
|
* dérivation -dev > href d'onglet de barre console/spark > prod.
|
|
* opts : { host, explicit, agentUrl, tabHrefs:{} } */
|
|
function resolveConsoleBase(opts) {
|
|
opts = opts || {};
|
|
var u = cleanUrl(opts.explicit);
|
|
if (!u) u = cleanUrl(opts.agentUrl);
|
|
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
|
|
if (!u)
|
|
u = cleanUrl(
|
|
opts.tabHrefs &&
|
|
(opts.tabHrefs.console || opts.tabHrefs.spark || opts.tabHrefs.agent)
|
|
);
|
|
if (!u) u = PROD_AGENT;
|
|
return withSlash(u);
|
|
}
|
|
|
|
/* Onglet valide dans l'URL, sinon "all". */
|
|
function parseTab(v) {
|
|
return TABS.indexOf(v) !== -1 ? v : "all";
|
|
}
|
|
|
|
/* Sections visibles pour un onglet. « Tout » = les 4 sections
|
|
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
|
|
function visibleKeys(tab) {
|
|
tab = parseTab(tab);
|
|
if (tab === "all") return APPS.map(function (a) { return a.key; });
|
|
return [tab];
|
|
}
|
|
|
|
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
|
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
|
function pageItems(items, full) {
|
|
items = items || [];
|
|
return full ? items : items.slice(0, PER_APP_LIMIT);
|
|
}
|
|
|
|
/* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */
|
|
function buildSearchUrl(q, tab) {
|
|
var parts = [];
|
|
if (q) parts.push("q=" + encodeURIComponent(q));
|
|
tab = parseTab(tab);
|
|
if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab));
|
|
return parts.length ? "?" + parts.join("&") : "";
|
|
}
|
|
|
|
/* Libellés des compteurs d'onglets à partir des états :
|
|
* states : { key: { status, total } }.
|
|
* Chargement -> "…", ok/vide -> total ("0" honnête),
|
|
* erreur/indisponible/inactif -> "" (pas de faux 0).
|
|
* Tout : "…" tant qu'une section charge, sinon somme des totaux
|
|
* connus, "" si les 4 sections sont en erreur/inactives. */
|
|
function tabCounts(states) {
|
|
states = states || {};
|
|
var out = {};
|
|
function labelFor(key) {
|
|
var st = states[key] || {};
|
|
if (st.status === "loading") return "…";
|
|
if (st.status === "ok" || st.status === "empty") return String(st.total || 0);
|
|
return "";
|
|
}
|
|
var allLoading = false;
|
|
var allTotal = 0;
|
|
var allKnown = false;
|
|
APPS.forEach(function (app) {
|
|
var st = states[app.key] || {};
|
|
out[app.key] = labelFor(app.key);
|
|
if (st.status === "loading") allLoading = true;
|
|
if (st.status === "ok" || st.status === "empty") {
|
|
allKnown = true;
|
|
allTotal += st.total || 0;
|
|
}
|
|
});
|
|
out.console = labelFor("console");
|
|
out.all = allLoading ? "…" : allKnown ? String(allTotal) : "";
|
|
return out;
|
|
}
|
|
|
|
function searchPath(app, q, full) {
|
|
if (app === "obsigate") {
|
|
return (
|
|
"api/search?q=" +
|
|
encodeURIComponent(q) +
|
|
"&vault=all&limit=" +
|
|
(full ? FULL_LIMIT : PER_APP_LIMIT)
|
|
);
|
|
}
|
|
return "api/search?q=" + encodeURIComponent(q);
|
|
}
|
|
|
|
function snippetOf(s, n) {
|
|
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
|
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
|
|
}
|
|
|
|
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
|
* base : base résolue de l'app (avec / final). q : requête (liens TikTak).
|
|
* full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT
|
|
* (comportement historique, conservé par défaut pour les tests). */
|
|
function normalize(app, data, base, q, full) {
|
|
data = data || {};
|
|
var limit = full ? null : PER_APP_LIMIT;
|
|
function cut(arr) {
|
|
return limit == null ? arr : arr.slice(0, limit);
|
|
}
|
|
if (app === "obsigate") {
|
|
return cut(data.results || []).map(function (r) {
|
|
return {
|
|
title: r.title || r.path || "Sans titre",
|
|
snippet: snippetOf(r.snippet || ""),
|
|
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
|
|
url:
|
|
base +
|
|
"#file=" +
|
|
encodeURIComponent(r.vault || "") +
|
|
":" +
|
|
encodeURIComponent(r.path || ""),
|
|
};
|
|
});
|
|
}
|
|
if (app === "flowdeck") {
|
|
var items = (data.pages || []).concat(data.collections || []);
|
|
return cut(items).map(function (r) {
|
|
var rel = String(r.url || "").replace(/^\//, "");
|
|
return {
|
|
title: r.title || "Sans titre",
|
|
snippet: snippetOf(r.excerpt || ""),
|
|
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
|
|
url: base + rel,
|
|
};
|
|
});
|
|
}
|
|
if (app === "tiktak") {
|
|
return cut(data.tasks || []).map(function (t) {
|
|
var ctx = t.status === "done" ? "Terminée" : "Active";
|
|
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
|
return {
|
|
title: t.title || "Sans titre",
|
|
snippet: snippetOf(t.description || ""),
|
|
context: "Tâche · " + ctx,
|
|
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
|
|
};
|
|
});
|
|
}
|
|
if (app === "kanbante") {
|
|
var rows = [];
|
|
(data.groups || []).forEach(function (g) {
|
|
(g.rows || []).forEach(function (row) {
|
|
rows.push({ group: g.full_name, row: row });
|
|
});
|
|
});
|
|
return cut(rows).map(function (x) {
|
|
var issue = x.row.issue || {};
|
|
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
|
return {
|
|
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
|
|
snippet: snippetOf(issue.body || ""),
|
|
context: x.group + (col ? " · " + col : ""),
|
|
url: base + "board/" + x.group,
|
|
};
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/* Normalisation console : format réel GET /api/v1/recherche,
|
|
* { resultats: [{ type, id, titre, extrait, statut, domaine,
|
|
* section, objet, lien }] }. Le lien relatif est préfixé par la
|
|
* base console ; sans lien, il est reconstruit depuis type/id
|
|
* (même règle que la console) ; sans lien reconstructible,
|
|
* url=null et le résultat est rendu en <span>, pas en faux lien.
|
|
* Les anciennes formes tolérées (results/discussions/items, avec
|
|
* url/href/link) restent acceptées en repli, pour ne pas casser
|
|
* un déploiement console antérieur. Retourne { recognized,
|
|
* items } : recognized=false -> l'appelant affiche
|
|
* « indisponible », jamais un vide mensonger. */
|
|
function consoleLinkFor(r) {
|
|
var lien = r.lien || "";
|
|
if (lien) return lien;
|
|
var id = r.id != null ? String(r.id) : "";
|
|
if (r.type === "discussion")
|
|
return "/?section=conversation&disc=" + encodeURIComponent(id);
|
|
if (r.type === "mission")
|
|
return "/?section=missions&objet=" + encodeURIComponent(id);
|
|
if (r.type === "approbation")
|
|
return "/?section=approbations&objet=" + encodeURIComponent(id);
|
|
if (r.type === "objectif")
|
|
return "/?section=objectifs&objet=" + encodeURIComponent(id);
|
|
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
|
|
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
|
|
if (r.section)
|
|
return (
|
|
"/?section=" +
|
|
encodeURIComponent(r.section) +
|
|
(r.objet != null
|
|
? "&objet=" + encodeURIComponent(String(r.objet))
|
|
: "")
|
|
);
|
|
return "";
|
|
}
|
|
|
|
function normalizeConsole(data, base) {
|
|
data = data || {};
|
|
if (Array.isArray(data.resultats)) {
|
|
var items = data.resultats.map(function (r) {
|
|
r = r || {};
|
|
var rel = consoleLinkFor(r);
|
|
var url = null;
|
|
if (/^https?:\/\//.test(rel)) url = rel;
|
|
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
|
|
url = base + String(rel).replace(/^\//, "");
|
|
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
|
|
var context = label;
|
|
if (r.statut) context += " · " + r.statut;
|
|
if (r.domaine) context += " · " + r.domaine;
|
|
return {
|
|
title: r.titre || r.title || "Sans titre",
|
|
snippet: snippetOf(r.extrait || r.snippet || ""),
|
|
context: context,
|
|
url: url,
|
|
};
|
|
});
|
|
return { recognized: true, items: items };
|
|
}
|
|
var list = null;
|
|
if (Array.isArray(data.results)) list = data.results;
|
|
else if (Array.isArray(data.discussions)) list = data.discussions;
|
|
else if (Array.isArray(data.items)) list = data.items;
|
|
if (!list) return { recognized: false, items: [] };
|
|
var legacy = list.map(function (r) {
|
|
r = r || {};
|
|
var raw = r.url || r.href || r.link || "";
|
|
var url = null;
|
|
if (/^https?:\/\//.test(raw)) url = raw;
|
|
else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, "");
|
|
return {
|
|
title: r.title || r.name || r.subject || "Sans titre",
|
|
snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""),
|
|
context: r.subtitle || r.source || r.type || "",
|
|
url: url,
|
|
};
|
|
});
|
|
return { recognized: true, items: legacy };
|
|
}
|
|
|
|
/* Recherche native de l'app (repli quand la section est en erreur). */
|
|
function nativeSearchUrl(app, base, q) {
|
|
if (app === "tiktak" || app === "kanbante") {
|
|
return base + "search?q=" + encodeURIComponent(q || "");
|
|
}
|
|
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
|
|
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
|
|
return base;
|
|
}
|
|
|
|
function nativeSearchLabel(app) {
|
|
if (app === "tiktak" || app === "kanbante") {
|
|
return "Ouvrir la recherche dans " + appLabel(app);
|
|
}
|
|
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
|
}
|
|
|
|
function appLabel(key) {
|
|
if (key === "console") return CONSOLE_APP.label;
|
|
for (var i = 0; i < APPS.length; i++) {
|
|
if (APPS[i].key === key) return APPS[i].label;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
var api = {
|
|
esc: esc,
|
|
isDevHost: isDevHost,
|
|
resolveBases: resolveBases,
|
|
resolveConsoleBase: resolveConsoleBase,
|
|
parseTab: parseTab,
|
|
visibleKeys: visibleKeys,
|
|
pageItems: pageItems,
|
|
buildSearchUrl: buildSearchUrl,
|
|
tabCounts: tabCounts,
|
|
searchPath: searchPath,
|
|
normalize: normalize,
|
|
normalizeConsole: normalizeConsole,
|
|
nativeSearchUrl: nativeSearchUrl,
|
|
PROD: PROD,
|
|
DEV: DEV,
|
|
PROD_AGENT: PROD_AGENT,
|
|
DEV_AGENT: DEV_AGENT,
|
|
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
|
|
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
|
|
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
|
|
consoleLinkFor: consoleLinkFor,
|
|
PER_APP_LIMIT: PER_APP_LIMIT,
|
|
FULL_LIMIT: FULL_LIMIT,
|
|
};
|
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
global.RecherchePartout = api;
|
|
|
|
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
|
|
if (typeof document === "undefined") return;
|
|
|
|
function fetchSection(app, url, onDone) {
|
|
var ctrl = null;
|
|
var timer = null;
|
|
try {
|
|
ctrl = new AbortController();
|
|
timer = setTimeout(function () {
|
|
ctrl.abort();
|
|
}, TIMEOUT_MS);
|
|
} catch (e) {
|
|
ctrl = null;
|
|
}
|
|
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
|
|
if (ctrl) opts.signal = ctrl.signal;
|
|
fetch(url, opts)
|
|
.then(function (resp) {
|
|
if (!resp.ok) {
|
|
var err = new Error("HTTP " + resp.status);
|
|
err.status = resp.status;
|
|
throw err;
|
|
}
|
|
return resp.json();
|
|
})
|
|
.then(function (data) {
|
|
if (timer) clearTimeout(timer);
|
|
onDone(null, data);
|
|
})
|
|
.catch(function (err) {
|
|
if (timer) clearTimeout(timer);
|
|
onDone(err, null);
|
|
});
|
|
}
|
|
|
|
function renderSection(section, state, full) {
|
|
/* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable",
|
|
* items (complets), message, q, base, app }.
|
|
* full=true (onglet propre à l'app) : tout afficher ; sinon
|
|
* PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */
|
|
var body = section.querySelector(".rp-body");
|
|
var count = section.querySelector(".rp-count");
|
|
var toutVoir = section.querySelector(".rp-all");
|
|
if (toutVoir) {
|
|
try {
|
|
toutVoir.href =
|
|
global.location.pathname + buildSearchUrl(state.q, state.app);
|
|
} catch (e) {}
|
|
toutVoir.textContent = "Tout voir";
|
|
toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app);
|
|
}
|
|
if (state.status === "loading") {
|
|
count.textContent = "…";
|
|
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
|
return;
|
|
}
|
|
if (state.status === "error") {
|
|
count.textContent = "";
|
|
body.innerHTML =
|
|
'<p class="rp-state rp-error">' +
|
|
esc(state.message) +
|
|
' — <a href="' +
|
|
esc(nativeSearchUrl(state.app, state.base, state.q)) +
|
|
'">' +
|
|
esc(nativeSearchLabel(state.app)) +
|
|
"</a></p>";
|
|
return;
|
|
}
|
|
if (state.status === "unavailable") {
|
|
count.textContent = "";
|
|
body.innerHTML =
|
|
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
|
|
(state.message ? " (" + esc(state.message) + ")" : "") +
|
|
".</p>";
|
|
return;
|
|
}
|
|
if (state.status === "idle") {
|
|
count.textContent = "";
|
|
body.innerHTML =
|
|
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
|
return;
|
|
}
|
|
if (state.status === "empty") {
|
|
count.textContent = "0";
|
|
body.innerHTML =
|
|
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
|
return;
|
|
}
|
|
var shown = pageItems(state.items, full);
|
|
count.textContent = String(state.items.length);
|
|
body.innerHTML = shown
|
|
.map(function (item) {
|
|
var inner =
|
|
'<span class="rp-title">' +
|
|
esc(item.title) +
|
|
"</span>" +
|
|
(item.context
|
|
? '<span class="rp-context">' + esc(item.context) + "</span>"
|
|
: "") +
|
|
(item.snippet
|
|
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
|
: "");
|
|
if (item.url) {
|
|
return (
|
|
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
|
|
);
|
|
}
|
|
return '<span class="rp-item">' + inner + "</span>";
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function init() {
|
|
var root = document.getElementById("recherche");
|
|
if (!root) return;
|
|
var input = document.getElementById("rp-q");
|
|
if (!input) return;
|
|
var hostApp = root.getAttribute("data-host") || "";
|
|
var explicit = {};
|
|
APPS.forEach(function (app) {
|
|
var v = root.getAttribute("data-base-" + app.key);
|
|
if (v) explicit[app.key] = v;
|
|
});
|
|
var barUrls = {};
|
|
try {
|
|
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
|
|
} catch (e) {}
|
|
var tabHrefs = {};
|
|
try {
|
|
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
|
|
var href = tab.getAttribute("href");
|
|
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
|
|
});
|
|
} catch (e) {}
|
|
var bases = resolveBases({
|
|
host: global.location.hostname,
|
|
explicit: explicit,
|
|
barUrls: barUrls,
|
|
tabHrefs: tabHrefs,
|
|
});
|
|
var consoleBase = resolveConsoleBase({
|
|
host: global.location.hostname,
|
|
explicit: root.getAttribute("data-base-console"),
|
|
agentUrl:
|
|
(global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL,
|
|
tabHrefs: tabHrefs,
|
|
});
|
|
var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]);
|
|
var sections = {};
|
|
allKeys.forEach(function (key) {
|
|
var el = document.querySelector('[data-rp-section="' + key + '"]');
|
|
if (el) sections[key] = el;
|
|
});
|
|
var tabButtons = {};
|
|
document.querySelectorAll("[data-rp-tab]").forEach(function (btn) {
|
|
tabButtons[btn.getAttribute("data-rp-tab")] = btn;
|
|
});
|
|
var tablist = document.querySelector(".rp-tabs");
|
|
|
|
/* États par application (+ console). total = items.length. */
|
|
var states = {};
|
|
allKeys.forEach(function (key) {
|
|
states[key] = {
|
|
status: "idle",
|
|
items: [],
|
|
message: "",
|
|
q: "",
|
|
base: key === "console" ? consoleBase : bases[key],
|
|
app: key,
|
|
};
|
|
});
|
|
|
|
var activeTab = "all";
|
|
var currentQ = "";
|
|
var runId = 0;
|
|
var consoleFetchedQ = null;
|
|
var debounceTimer = null;
|
|
|
|
function statesForCounts() {
|
|
var out = {};
|
|
allKeys.forEach(function (key) {
|
|
out[key] = { status: states[key].status, total: states[key].items.length };
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function renderTabs() {
|
|
var counts = tabCounts(statesForCounts());
|
|
Object.keys(tabButtons).forEach(function (key) {
|
|
var btn = tabButtons[key];
|
|
var selected = key === activeTab;
|
|
btn.classList.toggle("active", selected);
|
|
btn.setAttribute("aria-selected", selected ? "true" : "false");
|
|
btn.tabIndex = selected ? 0 : -1;
|
|
var span = btn.querySelector(".rp-tab-count");
|
|
if (span) span.textContent = counts[key] || "";
|
|
});
|
|
}
|
|
|
|
/* Bascule d'onglet : visibilité des sections, re-rendu (plein dans
|
|
* l'onglet propre à l'app, tronqué dans « Tout »), compteurs.
|
|
* opts : { push (historique), focusTab }. */
|
|
function applyTab(tab, opts) {
|
|
opts = opts || {};
|
|
activeTab = parseTab(tab);
|
|
try {
|
|
root.setAttribute("data-rp-tab", activeTab);
|
|
} catch (e) {}
|
|
var visible = visibleKeys(activeTab);
|
|
allKeys.forEach(function (key) {
|
|
var sec = sections[key];
|
|
if (!sec) return;
|
|
var show = visible.indexOf(key) !== -1;
|
|
sec.hidden = !show;
|
|
if (show) renderSection(sec, states[key], activeTab === key);
|
|
var tv = sec.querySelector(".rp-all");
|
|
if (tv) tv.hidden = activeTab === key;
|
|
});
|
|
renderTabs();
|
|
if (opts.push) {
|
|
try {
|
|
global.history.pushState(
|
|
null,
|
|
"",
|
|
global.location.pathname + buildSearchUrl(currentQ, activeTab)
|
|
);
|
|
} catch (e) {}
|
|
}
|
|
if (opts.focusTab && tabButtons[activeTab]) {
|
|
try {
|
|
tabButtons[activeTab].focus();
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
function setTab(tab, opts) {
|
|
applyTab(tab, opts);
|
|
/* La console n'est interrogée qu'une fois affichée. */
|
|
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
|
fetchConsole(currentQ, runId);
|
|
}
|
|
}
|
|
|
|
function fetchConsole(q, myRun) {
|
|
var sec = sections.console;
|
|
if (!sec) return;
|
|
consoleFetchedQ = q;
|
|
states.console = {
|
|
status: "loading",
|
|
items: [],
|
|
message: "",
|
|
q: q,
|
|
base: consoleBase,
|
|
app: "console",
|
|
};
|
|
renderSection(sec, states.console, true);
|
|
renderTabs();
|
|
var url =
|
|
consoleBase +
|
|
CONSOLE_SEARCH_PATH +
|
|
"?q=" +
|
|
encodeURIComponent(q) +
|
|
"&limite=" +
|
|
CONSOLE_SEARCH_LIMIT;
|
|
fetchSection("console", url, function (err, data) {
|
|
if (myRun !== runId) return;
|
|
if (err) {
|
|
var msg;
|
|
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
|
|
else if (err.status === 422)
|
|
msg = "au moins 2 caractères requis";
|
|
else if (err.status) msg = "HTTP " + err.status;
|
|
else msg = "injoignable (CORS ou réseau)";
|
|
states.console = {
|
|
status: "unavailable",
|
|
items: [],
|
|
message: msg,
|
|
q: q,
|
|
base: consoleBase,
|
|
app: "console",
|
|
};
|
|
} else {
|
|
var norm = normalizeConsole(data, consoleBase);
|
|
if (!norm.recognized) {
|
|
states.console = {
|
|
status: "unavailable",
|
|
items: [],
|
|
message: "format inattendu",
|
|
q: q,
|
|
base: consoleBase,
|
|
app: "console",
|
|
};
|
|
} else {
|
|
states.console = {
|
|
status: norm.items.length ? "ok" : "empty",
|
|
items: norm.items,
|
|
message: "",
|
|
q: q,
|
|
base: consoleBase,
|
|
app: "console",
|
|
};
|
|
}
|
|
}
|
|
renderSection(sec, states.console, true);
|
|
renderTabs();
|
|
});
|
|
}
|
|
|
|
function runSearch(q) {
|
|
currentQ = q;
|
|
var myRun = ++runId;
|
|
if (!q) {
|
|
allKeys.forEach(function (key) {
|
|
var sec = sections[key];
|
|
if (!sec) return;
|
|
states[key] = {
|
|
status: "idle",
|
|
items: [],
|
|
message: "",
|
|
q: "",
|
|
base: key === "console" ? consoleBase : bases[key],
|
|
app: key,
|
|
};
|
|
renderSection(sec, states[key], activeTab === key);
|
|
});
|
|
consoleFetchedQ = null;
|
|
renderTabs();
|
|
return;
|
|
}
|
|
APPS.forEach(function (app) {
|
|
var sec = sections[app.key];
|
|
if (!sec) return;
|
|
states[app.key] = {
|
|
status: "loading",
|
|
items: [],
|
|
message: "",
|
|
q: q,
|
|
base: bases[app.key],
|
|
app: app.key,
|
|
};
|
|
renderSection(sec, states[app.key], activeTab === app.key);
|
|
var url;
|
|
if (app.key === hostApp) {
|
|
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
|
url = "/" + searchPath(app.key, q, true);
|
|
} else {
|
|
url = bases[app.key] + searchPath(app.key, q, true);
|
|
}
|
|
fetchSection(app.key, url, function (err, data) {
|
|
if (myRun !== runId) return;
|
|
if (err) {
|
|
var msg;
|
|
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
|
|
else if (err.status === 401 || err.status === 403)
|
|
msg = "Connexion requise sur " + app.label;
|
|
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
|
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
|
states[app.key] = {
|
|
status: "error",
|
|
items: [],
|
|
message: msg,
|
|
q: q,
|
|
base: bases[app.key],
|
|
app: app.key,
|
|
};
|
|
} else {
|
|
var items = normalize(app.key, data, bases[app.key], q, true);
|
|
states[app.key] = {
|
|
status: items.length ? "ok" : "empty",
|
|
items: items,
|
|
message: "",
|
|
q: q,
|
|
base: bases[app.key],
|
|
app: app.key,
|
|
};
|
|
}
|
|
renderSection(sec, states[app.key], activeTab === app.key);
|
|
renderTabs();
|
|
});
|
|
});
|
|
/* Console : seulement si son onglet est affiché ; sinon son
|
|
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
|
if (activeTab === "console") {
|
|
fetchConsole(q, myRun);
|
|
} else {
|
|
states.console = {
|
|
status: "idle",
|
|
items: [],
|
|
message: "",
|
|
q: q,
|
|
base: consoleBase,
|
|
app: "console",
|
|
};
|
|
consoleFetchedQ = null;
|
|
renderTabs();
|
|
}
|
|
}
|
|
|
|
function scheduleSearch(q, immediate) {
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
if (immediate) {
|
|
runSearch(q);
|
|
return;
|
|
}
|
|
debounceTimer = setTimeout(function () {
|
|
runSearch(q);
|
|
}, DEBOUNCE_MS);
|
|
}
|
|
|
|
function syncUrl(q) {
|
|
try {
|
|
global.history.replaceState(
|
|
null,
|
|
"",
|
|
global.location.pathname + buildSearchUrl(q, activeTab)
|
|
);
|
|
} catch (e) {}
|
|
}
|
|
|
|
function focusSearch() {
|
|
try {
|
|
input.focus({ preventScroll: true });
|
|
} catch (e) {
|
|
try {
|
|
input.focus();
|
|
} catch (e2) {}
|
|
}
|
|
}
|
|
|
|
function visibleLinks() {
|
|
return Array.prototype.slice
|
|
.call(document.querySelectorAll("[data-rp-link]"))
|
|
.filter(function (a) {
|
|
var s = a.closest ? a.closest("[data-rp-section]") : null;
|
|
return !s || !s.hidden;
|
|
});
|
|
}
|
|
|
|
input.addEventListener("input", function () {
|
|
var q = input.value.trim();
|
|
syncUrl(q);
|
|
scheduleSearch(q, false);
|
|
});
|
|
input.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter") {
|
|
var first = visibleLinks()[0];
|
|
if (first) {
|
|
e.preventDefault();
|
|
first.click();
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
input.value = "";
|
|
syncUrl("");
|
|
scheduleSearch("", true);
|
|
input.focus();
|
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
|
}
|
|
});
|
|
document.addEventListener(
|
|
"keydown",
|
|
function (e) {
|
|
var target = e.target;
|
|
var typing =
|
|
target &&
|
|
(target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable);
|
|
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
input.focus();
|
|
input.select();
|
|
return;
|
|
}
|
|
/* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les
|
|
* flèches ←/→ du tablist s'en chargent (voir ci-dessous). */
|
|
if (target && target.closest && target.closest(".rp-tab")) return;
|
|
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
|
}
|
|
},
|
|
true
|
|
);
|
|
|
|
/* « Tout voir » reste sur la page : bascule vers l'onglet. */
|
|
root.addEventListener("click", function (e) {
|
|
var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null;
|
|
if (!all) return;
|
|
var sec = all.closest ? all.closest("[data-rp-section]") : null;
|
|
if (!sec) return;
|
|
e.preventDefault();
|
|
setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true });
|
|
});
|
|
|
|
/* Clic sur la barre « Rechercher partout » (bouton
|
|
* data-act="palette") pendant qu'on est déjà sur la page :
|
|
* replacer le curseur dans la boîte au lieu de recharger. */
|
|
document.addEventListener("click", function (e) {
|
|
var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null;
|
|
if (!p) return;
|
|
e.preventDefault();
|
|
focusSearch();
|
|
try {
|
|
input.select();
|
|
} catch (err) {}
|
|
});
|
|
|
|
/* Onglets : clic + clavier (←/→, Début/Fin). */
|
|
Object.keys(tabButtons).forEach(function (key) {
|
|
tabButtons[key].addEventListener("click", function () {
|
|
setTab(key, { push: true });
|
|
});
|
|
});
|
|
if (tablist) {
|
|
tablist.addEventListener("keydown", function (e) {
|
|
var order = TABS;
|
|
var idx = order.indexOf(activeTab);
|
|
var next = null;
|
|
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
|
|
else if (e.key === "ArrowLeft")
|
|
next = order[(idx - 1 + order.length) % order.length];
|
|
else if (e.key === "Home") next = order[0];
|
|
else if (e.key === "End") next = order[order.length - 1];
|
|
if (!next) return;
|
|
e.preventDefault();
|
|
setTab(next, { push: true, focusTab: true });
|
|
});
|
|
}
|
|
|
|
/* Retour arrière navigateur : restaurer q + onglet. */
|
|
global.addEventListener("popstate", function () {
|
|
var params = new URLSearchParams(global.location.search);
|
|
var q = (params.get("q") || "").trim();
|
|
input.value = q;
|
|
applyTab(params.get("tab"));
|
|
runSearch(q);
|
|
});
|
|
|
|
function moveFocus(delta, e) {
|
|
var links = visibleLinks();
|
|
if (!links.length) return;
|
|
var active = document.activeElement;
|
|
var idx = links.indexOf(active);
|
|
var next;
|
|
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
|
|
else next = (idx + delta + links.length) % links.length;
|
|
e.preventDefault();
|
|
links[next].focus();
|
|
}
|
|
|
|
var params = new URLSearchParams(global.location.search);
|
|
var initialQ = (params.get("q") || "").trim();
|
|
var initialTab = parseTab(params.get("tab"));
|
|
if (initialQ) input.value = initialQ;
|
|
applyTab(initialTab);
|
|
runSearch(initialQ);
|
|
|
|
/* Focus explicite (voir en-tête) : l'attribut autofocus seul ne
|
|
* survit pas au chargement des surcouches. */
|
|
focusSearch();
|
|
try {
|
|
if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch);
|
|
} catch (e) {}
|
|
try {
|
|
setTimeout(focusSearch, 0);
|
|
} catch (e) {}
|
|
global.addEventListener("pageshow", function (e) {
|
|
if (e.persisted) focusSearch();
|
|
});
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})(
|
|
typeof window !== "undefined"
|
|
? window
|
|
: typeof globalThis !== "undefined"
|
|
? globalThis
|
|
: this
|
|
);
|