forked from FroSteel/Planification
v2026.5.46 — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées
This commit is contained in:
+153
-74
@@ -9,10 +9,17 @@
|
||||
*
|
||||
* @author Quentin Rouiller
|
||||
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
|
||||
*
|
||||
* Développé par Quentin Rouiller (QRO) — DGNSI, Canton de Vaud. Licence MIT.
|
||||
*/
|
||||
|
||||
// background.js — Service worker (Manifest V3) — v4
|
||||
//
|
||||
// v2026.5.46 — fiabilisation de la détection de session (faux
|
||||
// « déconnecté ») : le cookie PHPSESSID suffit sans onglet EV ouvert
|
||||
// (permission cookies), contre-vérification session_time avant de classer
|
||||
// un statut HTTP en « session expirée », cache session réactivé (200 ms).
|
||||
//
|
||||
// Rôles :
|
||||
// 1. Au clic sur l'icône : ouvrir le viewer
|
||||
// 2. Répondre aux messages du viewer :
|
||||
@@ -306,11 +313,12 @@ async function _readPhpsessidFromCookie(origin) {
|
||||
|
||||
let _sessionCache = null;
|
||||
let _sessionCacheTs = 0;
|
||||
// Cache désactivé temporairement (TTL=0) — soupçon que la durée de
|
||||
// fenêtre stale puisse causer une réponse "page de login" (~8 Ko) lors
|
||||
// d'un fetch fiche. À remettre à 200 après confirmation que ce n'était
|
||||
// pas le souci.
|
||||
const _SESSION_CACHE_TTL_MS = 0;
|
||||
// v2026.5.46 — cache anti-rafale RÉACTIVÉ (200 ms). Le soupçon qui
|
||||
// l'avait fait désactiver (« page de login ~8 Ko au fetch fiche ») venait du
|
||||
// PHPSESSID périmé lu dans l'URL des onglets historiques — résolu depuis par
|
||||
// la lecture du cookie (issue #4). 200 ms de fenêtre stale sont inoffensifs
|
||||
// et évitent le bombardement tabs.query+cookies.get pendant la phase A.
|
||||
const _SESSION_CACHE_TTL_MS = 200;
|
||||
let _lastSessionSignature = null;
|
||||
|
||||
function _invalidateSessionCache() {
|
||||
@@ -322,9 +330,16 @@ async function _findEasyVistaSessionRaw() {
|
||||
const origins = await getEvOrigins();
|
||||
const hasCookies = await _hasCookiesPermission();
|
||||
|
||||
// v2026.5.46 — avec la permission cookies, le cookie PHPSESSID
|
||||
// HttpOnly SUFFIT : les fetch partent avec credentials:include, un onglet
|
||||
// EV ouvert n'est PAS nécessaire. Avant, fermer l'onglet EV faisait
|
||||
// perdre la session à l'extension (bannière « déconnecté » à tort) alors
|
||||
// que la session serveur vivait toujours. On PRÉFÈRE toujours une origine
|
||||
// avec onglet ouvert (fraîcheur SSO), mais on retombe sur « cookie seul ».
|
||||
let cookieOnly = null;
|
||||
|
||||
for (const origin of origins) {
|
||||
const tabs = await chrome.tabs.query({ url: origin + "/*" });
|
||||
if (!tabs.length) continue;
|
||||
|
||||
let phpsessid = null;
|
||||
let expirationDate = null;
|
||||
@@ -339,6 +354,13 @@ async function _findEasyVistaSessionRaw() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!tabs.length) {
|
||||
if (phpsessid && !cookieOnly) {
|
||||
cookieOnly = { phpsessid, origin, tabId: null, source: "cookie", expirationDate };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!phpsessid) {
|
||||
for (const tab of tabs) {
|
||||
const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/);
|
||||
@@ -354,7 +376,7 @@ async function _findEasyVistaSessionRaw() {
|
||||
return { phpsessid, origin, tabId: tabs[0].id, source, expirationDate };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return cookieOnly;
|
||||
}
|
||||
|
||||
async function findEasyVistaSession() {
|
||||
@@ -378,11 +400,11 @@ async function findEasyVistaSession() {
|
||||
if (sig !== _lastSessionSignature) {
|
||||
if (result) {
|
||||
LOG.warn("session",
|
||||
`🔑 PHPSESSID via ${result.source.toUpperCase()} (${result.phpsessid.slice(0, 8)}…) sur ${result.origin}`,
|
||||
`PHPSESSID via ${result.source.toUpperCase()} (${result.phpsessid.slice(0, 8)}…) sur ${result.origin}`,
|
||||
{ source: result.source, origin: result.origin,
|
||||
expirationDate: result.expirationDate || null });
|
||||
} else {
|
||||
LOG.warn("session", "❌ Aucun PHPSESSID disponible (pas d'onglet EZV ouvert ou cookie absent)");
|
||||
LOG.warn("session", "Aucun PHPSESSID disponible (pas d'onglet EZV ouvert ou cookie absent)");
|
||||
}
|
||||
_lastSessionSignature = sig;
|
||||
}
|
||||
@@ -440,7 +462,7 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
|
||||
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
|
||||
// écran (session expirée vs EV inaccessible).
|
||||
const err = new Error("HTTP " + r.status);
|
||||
err.kind = classifyHttpStatus(r.status);
|
||||
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
@@ -509,6 +531,47 @@ function classifyHttpStatus(status) {
|
||||
return "ev_unreachable";
|
||||
}
|
||||
|
||||
// v2026.5.46 — CONTRE-VÉRIFICATION avant de déclarer « session
|
||||
// expirée » sur un simple statut HTTP. EV renvoie historiquement 401/403/404
|
||||
// quand le PHPSESSID est mort — mais ces statuts arrivent AUSSI sur un hoquet
|
||||
// ponctuel (proxy, maintenance, endpoint) alors que la session est
|
||||
// parfaitement vivante → bannière « déconnecté » à tort. On demande son avis
|
||||
// à l'endpoint d'autorité timeout_ajax session_time (celui qu'EV utilise pour
|
||||
// sa propre modal ; il ne prolonge pas la session) : s'il répond un temps
|
||||
// restant > 0, la session VIT et l'erreur est reclassée « ev_unreachable »
|
||||
// (transitoire). Sonde cachée 5 s : une rafale d'échecs ne sonde qu'une fois.
|
||||
let _sessProbeCache = { ts: 0, verdict: null };
|
||||
async function _probeSessionAlive(origin, phpsessid) {
|
||||
const now = Date.now();
|
||||
if (now - _sessProbeCache.ts < 5000 && _sessProbeCache.verdict !== null) {
|
||||
return _sessProbeCache.verdict;
|
||||
}
|
||||
let verdict;
|
||||
try {
|
||||
const ms = await fetchSessionTimeRemaining(origin, phpsessid);
|
||||
verdict = ms > 1000 ? "alive" : "dead";
|
||||
} catch (e) {
|
||||
verdict = (e && e.message === "session_expired") ? "dead" : "unknown";
|
||||
}
|
||||
_sessProbeCache = { ts: now, verdict };
|
||||
return verdict;
|
||||
}
|
||||
|
||||
async function classifyHttpStatusVerified(status, origin, phpsessid) {
|
||||
const kind = classifyHttpStatus(status);
|
||||
if (kind !== "session_expired") return kind;
|
||||
const verdict = await _probeSessionAlive(origin, phpsessid);
|
||||
if (verdict === "alive") {
|
||||
LOG.warn("session", "HTTP " + status + " mais session_time confirme la session vivante → reclassé ev_unreachable (pas d'expiration)");
|
||||
return "ev_unreachable";
|
||||
}
|
||||
if (verdict === "unknown") {
|
||||
LOG.warn("session", "HTTP " + status + " et sonde session_time injoignable → reclassé ev_unreachable (prudence, pas d'expiration sur simple statut)");
|
||||
return "ev_unreachable";
|
||||
}
|
||||
return "session_expired";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention.
|
||||
* Retourne ~400 octets au format custom :
|
||||
@@ -519,7 +582,7 @@ async function fetchXhr2(origin, phpsessid, actionId) {
|
||||
const r = await evFetch(url, origin);
|
||||
if (!r.ok) {
|
||||
const err = new Error("HTTP " + r.status);
|
||||
err.kind = classifyHttpStatus(r.status);
|
||||
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
@@ -541,7 +604,7 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
|
||||
const r = await evFetch(url, origin);
|
||||
if (!r.ok) {
|
||||
const err = new Error("HTTP " + r.status);
|
||||
err.kind = classifyHttpStatus(r.status);
|
||||
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
@@ -549,24 +612,24 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
|
||||
// v2026.5.38 : on log seulement les retries (utile en cas de pb SSO),
|
||||
// pas chaque tentative normale qui réussit du premier coup.
|
||||
if (attempt > 1) {
|
||||
console.log(`[bg] fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`);
|
||||
LOG.info("bg", `fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`);
|
||||
}
|
||||
|
||||
// Si réponse clairement une redirection courte → login expiré, inutile de retry
|
||||
if (html.length < 500) {
|
||||
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
|
||||
LOG.warn("bg", "fiche très courte, contenu =", JSON.stringify(html));
|
||||
return html;
|
||||
}
|
||||
|
||||
// Si taille suspecte (< 20 Ko), probable page intermédiaire SSO : retry
|
||||
if (html.length < MIN_VALID_SIZE && attempt < MAX_RETRIES) {
|
||||
console.warn(`[bg] ⚠ fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`);
|
||||
LOG.warn("bg", `fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`);
|
||||
await new Promise(res => setTimeout(res, RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
// on signale au foreground si la dernière réponse est tronquée pour
|
||||
// qu'il puisse afficher un ⚠ et probe la session.
|
||||
// qu'il puisse afficher un et probe la session.
|
||||
return { html, truncated: html.length < MIN_VALID_SIZE, size: html.length };
|
||||
}
|
||||
throw new Error("fetchFicheHtml: max retries reached");
|
||||
@@ -579,23 +642,31 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
|
||||
// v4.1.9 : le GUID du form est passé en paramètre (extrait dynamiquement du
|
||||
// HTML de la fiche par le viewer). Il est différent pour une demande S...
|
||||
// ({C99ECD05}) vs un incident I... ({07ED9C68}).
|
||||
async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
|
||||
async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum, timelineType, sectionId) {
|
||||
// Sécurité : GUID doit être de la forme %7B...%7D ou {...}
|
||||
if (!/^(%7B|\{)[A-F0-9\-]{36}(%7D|\})$/i.test(guid)) {
|
||||
throw new Error("Invalid GUID: " + guid);
|
||||
}
|
||||
// S'assurer qu'on a la forme encodée %7B...%7D
|
||||
const encodedGuid = guid.startsWith("%7B") ? guid : `%7B${guid.replace(/[{}]/g, "")}%7D`;
|
||||
// v2026.5.46 — `type` paramétrable. "todo" = action en cours (1 action) ;
|
||||
// "done" = actions TERMINÉES (où vit le commentaire du tech). Défaut "todo"
|
||||
// pour ne rien changer aux appels existants (tooltip).
|
||||
// v2026.5.46 — `sectionId` paramétrable (défaut 1). Les actions
|
||||
// AL-Intervention (planification, avec champ Intervenant) sont dans une
|
||||
// autre section que le workflow DS.
|
||||
const tType = timelineType || "todo";
|
||||
const tSection = (sectionId != null && sectionId !== "") ? sectionId : 1;
|
||||
const url =
|
||||
`${origin}/api/v1/internal/forms/${encodedGuid}/timeline` +
|
||||
`?target=${encodeURIComponent(formId)}` +
|
||||
`&checksum=${encodeURIComponent(formChecksum)}` +
|
||||
`&type=todo§ionId=1&navigator=&nbRecord=0` +
|
||||
`&type=${encodeURIComponent(tType)}§ionId=${encodeURIComponent(tSection)}&navigator=&nbRecord=0` +
|
||||
`&PHPSESSID=${encodeURIComponent(phpsessid)}`;
|
||||
const r = await evFetch(url, origin);
|
||||
if (!r.ok) {
|
||||
const err = new Error("HTTP " + r.status);
|
||||
err.kind = classifyHttpStatus(r.status);
|
||||
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
@@ -641,8 +712,9 @@ function looksLikeLoginPage(text) {
|
||||
* Retourne le nombre de millisecondes restantes avant expiration de la
|
||||
* session EasyVista (0 à 1 800 000 = 30 min max).
|
||||
*
|
||||
* Attention : cette requête EST authentifiée et prolonge probablement la
|
||||
* session (comme toute requête PHP authentifiée). À utiliser avec parcimonie.
|
||||
* EasyVista appelle LUI-MÊME cet endpoint en continu pour alimenter sa
|
||||
* propre modal de timeout (visible dans le réseau du planning natif) : il ne
|
||||
* prolonge donc PAS la session — utilisable pour un calibrage périodique.
|
||||
*/
|
||||
async function fetchSessionTimeRemaining(origin, phpsessid) {
|
||||
const url = `${origin}/timeout_ajax.php`
|
||||
@@ -656,7 +728,7 @@ async function fetchSessionTimeRemaining(origin, phpsessid) {
|
||||
const body = (await r.text()).trim();
|
||||
// Vérifier que c'est bien un nombre (sinon = session morte probable)
|
||||
if (!/^\d+$/.test(body)) {
|
||||
console.warn("[bg] réponse session_time anormale :", body.substring(0, 200));
|
||||
LOG.warn("bg", "réponse session_time anormale :", body.substring(0, 200));
|
||||
// Si c'est une page de login/redirect → session expirée
|
||||
if (looksLikeLoginPage(body)) {
|
||||
throw new Error("session_expired");
|
||||
@@ -720,7 +792,7 @@ async function detectNetworkContext(force = false) {
|
||||
const data = await chrome.storage.local.get([CACHE_KEY]);
|
||||
const cached = data[CACHE_KEY];
|
||||
if (cached && cached.detectedAt && (Date.now() - cached.detectedAt) < CACHE_MAX_AGE_MS) {
|
||||
console.log("[bg] detectNetworkContext : cache hit =", cached.networkContext);
|
||||
LOG.info("bg", "detectNetworkContext : cache hit =", cached.networkContext);
|
||||
return cached.networkContext;
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -730,7 +802,7 @@ async function detectNetworkContext(force = false) {
|
||||
const timer = setTimeout(() => controller.abort(), 2500);
|
||||
let context = "external";
|
||||
try {
|
||||
console.log("[bg] detectNetworkContext : test de itsma.etat-de-vaud.ch (mode no-cors)...");
|
||||
LOG.info("bg", "detectNetworkContext : test de itsma.etat-de-vaud.ch (mode no-cors)...");
|
||||
// v5.0.11 (fix) : no-cors évite l'erreur CORS de la redirection SSO.
|
||||
// Si le fetch termine sans throw, le serveur est joignable = interne.
|
||||
// Si timeout ou DNS fail, throw → externe.
|
||||
@@ -742,12 +814,12 @@ async function detectNetworkContext(force = false) {
|
||||
cache: "no-store"
|
||||
});
|
||||
clearTimeout(timer);
|
||||
console.log("[bg] detectNetworkContext : interne accessible");
|
||||
LOG.info("bg", "detectNetworkContext : interne accessible");
|
||||
context = "internal";
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
// AbortError (timeout) ou TypeError (DNS unreachable / réseau coupé)
|
||||
console.log("[bg] detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
|
||||
LOG.info("bg", "detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
|
||||
context = "external";
|
||||
}
|
||||
|
||||
@@ -808,9 +880,9 @@ function watchReconnectTabForIamLogin(tabId) {
|
||||
} else {
|
||||
targetUrl = "https://www.portail.vd.ch/iam/accueil/";
|
||||
}
|
||||
console.log(`[bg] watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
|
||||
LOG.info("bg", `watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
|
||||
chrome.tabs.update(tabId, { url: targetUrl }).catch(e => {
|
||||
console.warn("[bg] watchReconnectTab : update failed", e);
|
||||
LOG.warn("bg", "watchReconnectTab : update failed", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1002,8 +1074,8 @@ async function submitAbsence(origin, phpsessid, opts) {
|
||||
body.set("label_guid", opts.typeGuid);
|
||||
body.set("dialog_action", "save_holidays");
|
||||
|
||||
console.log("[bg] submitAbsence →", url.substring(0, 140));
|
||||
console.log("[bg] body:", body.toString());
|
||||
LOG.info("bg", "submitAbsence →", url.substring(0, 140));
|
||||
LOG.info("bg", "body:", body.toString());
|
||||
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -1012,7 +1084,7 @@ async function submitAbsence(origin, phpsessid, opts) {
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
console.log("[bg] status =", r.status);
|
||||
LOG.info("bg", "status =", r.status);
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error("HTTP " + r.status);
|
||||
@@ -1071,9 +1143,9 @@ async function submitDouchette(origin, phpsessid, opts) {
|
||||
const body = new URLSearchParams();
|
||||
body.set("dialog_action", "save_planif");
|
||||
|
||||
console.log("[bg] submitDouchette →", url.substring(0, 160));
|
||||
console.log("[bg] body:", body.toString());
|
||||
console.log("[bg] techs:", emplIds);
|
||||
LOG.info("bg", "submitDouchette →", url.substring(0, 160));
|
||||
LOG.info("bg", "body:", body.toString());
|
||||
LOG.info("bg", "techs:", emplIds);
|
||||
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
@@ -1082,7 +1154,7 @@ async function submitDouchette(origin, phpsessid, opts) {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString()
|
||||
});
|
||||
console.log("[bg] status =", r.status);
|
||||
LOG.info("bg", "status =", r.status);
|
||||
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
|
||||
@@ -1094,7 +1166,13 @@ async function submitDouchette(origin, phpsessid, opts) {
|
||||
if (looksLikeLoginPage(responseText)) {
|
||||
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
|
||||
}
|
||||
return { okCount: techIds.length, errors: [] };
|
||||
// v2026.5.46 — confirmation EV : en cas de succes, le 1er document
|
||||
// de la reponse ne contient qu un script qui ferme la popup et recharge le
|
||||
// planning parent (gl_plan_blk.submit). Sa presence = ordre pris en compte
|
||||
// (le formulaire re-rendu derriere contient close() mais PAS gl_plan_blk).
|
||||
const confirmed = responseText.indexOf("integratedDialog.close()") !== -1
|
||||
&& responseText.indexOf("gl_plan_blk.submit()") !== -1;
|
||||
return { okCount: techIds.length, errors: [], confirmed };
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) };
|
||||
@@ -1151,7 +1229,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|
||||
+ `&function_name=${encodeURIComponent(fn)}`
|
||||
+ `&action_id=${encodeURIComponent(actionId)}`;
|
||||
|
||||
console.log(`[bg] deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180));
|
||||
LOG.info("bg", `deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180));
|
||||
|
||||
try {
|
||||
// v5.0.13 : utiliser evFetch() au lieu de fetch() brut pour que les
|
||||
@@ -1160,7 +1238,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|
||||
// notre heuristique le prenait à tort pour un succès.
|
||||
const r = await evFetch(url, origin, { method: "GET" });
|
||||
const body = await r.text();
|
||||
console.log(`[bg] status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`);
|
||||
LOG.info("bg", `status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`);
|
||||
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
throw new Error("session_expired");
|
||||
@@ -1180,7 +1258,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|
||||
|
||||
// Succès explicite : réponse XML du type <X>true</X>
|
||||
if (/^<\w+>true<\/\w+>\s*$/i.test(trimmed)) {
|
||||
console.log(`[bg] → SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`);
|
||||
LOG.info("bg", `→ SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`);
|
||||
return { status: r.status, functionName: fn, body: trimmed };
|
||||
}
|
||||
|
||||
@@ -1193,17 +1271,17 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|
||||
|| lower.includes("<html")
|
||||
|| lower.includes("window.location.href");
|
||||
if (looksLikeError) {
|
||||
console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
|
||||
LOG.info("bg", `→ réponse ressemble à une erreur, on tente le prochain nom`);
|
||||
lastBody = body;
|
||||
continue;
|
||||
}
|
||||
// Pas d'erreur évidente mais pas de succès explicite non plus
|
||||
// (ex: réponse vide ou "1" ou "ok"). On considère comme succès.
|
||||
console.log(`[bg] → suppression probablement OK (body neutre) avec function_name=${fn}`);
|
||||
LOG.info("bg", `→ suppression probablement OK (body neutre) avec function_name=${fn}`);
|
||||
return { status: r.status, functionName: fn, body: trimmed.substring(0, 200) };
|
||||
} catch (err) {
|
||||
if (err.message === "session_expired") throw err;
|
||||
console.warn(`[bg] erreur avec ${fn}:`, err);
|
||||
LOG.warn("bg", `erreur avec ${fn}:`, err);
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
@@ -1232,18 +1310,18 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|
||||
*/
|
||||
async function detectGroupsFromEV(origin, phpsessid) {
|
||||
const url = `${origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(phpsessid)}`;
|
||||
console.log("[bg] detectGroupsFromEV → fetch page planning");
|
||||
console.log("[bg] URL =", url);
|
||||
LOG.info("bg", "detectGroupsFromEV → fetch page planning");
|
||||
LOG.info("bg", "URL =", url);
|
||||
|
||||
const r = await evFetch(url, origin);
|
||||
if (!r.ok) {
|
||||
const err = new Error("HTTP " + r.status);
|
||||
err.kind = classifyHttpStatus(r.status);
|
||||
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
const html = await r.text();
|
||||
console.log("[bg] page planning taille =", html.length);
|
||||
LOG.info("bg", "page planning taille =", html.length);
|
||||
|
||||
if (looksLikeLoginPage(html)) {
|
||||
const err = new Error("session_expired");
|
||||
@@ -1268,7 +1346,7 @@ async function detectGroupsFromEV(origin, phpsessid) {
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("[bg] " + groups.length + " groupes détectés :", groups);
|
||||
LOG.info("bg", "" + groups.length + " groupes détectés :", groups);
|
||||
|
||||
// Fallback : si le <select> est absent (rendu côté client tardif), on
|
||||
// tente de lire les favoris menuGlobals.tech4 où les noms sont en base64.
|
||||
@@ -1282,7 +1360,7 @@ async function detectGroupsFromEV(origin, phpsessid) {
|
||||
// On n'a pas l'ID dans les favoris : on garde au moins le nom pour
|
||||
// affichage info, mais sans ID ce groupe n'est pas sélectionnable
|
||||
// pour fetcher le planning. On ignore donc côté retour.
|
||||
console.log("[bg] fallback favoris : " + title + " (sans ID, ignoré)");
|
||||
LOG.info("bg", "fallback favoris : " + title + " (sans ID, ignoré)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1312,7 +1390,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
const supportIds = (typeof supportIdsArg === "string")
|
||||
? supportIdsArg
|
||||
: await getSupportIds();
|
||||
console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds);
|
||||
LOG.info("bg", `detectTeamFromEV : group_id = ${groupId} | support_ids = ${supportIds}`);
|
||||
|
||||
// Fetch la popup de sélection des intervenants du groupe
|
||||
const popupUrl = origin + "/include/components/staff/planning/plan_view_group_supports.php"
|
||||
@@ -1322,18 +1400,18 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
+ "&support_ids=" + encodeURIComponent(supportIds)
|
||||
+ "&group_id=" + encodeURIComponent(groupId);
|
||||
|
||||
console.log("[bg] detectTeamFromEV → popup group_supports");
|
||||
console.log("[bg] URL =", popupUrl.substring(0, 240));
|
||||
LOG.info("bg", "detectTeamFromEV → popup group_supports");
|
||||
LOG.info("bg", "URL =", popupUrl.substring(0, 240));
|
||||
let popupHtml = "";
|
||||
try {
|
||||
const r = await fetch(popupUrl, { method: "GET", credentials: "include" });
|
||||
console.log("[bg] popup status =", r.status);
|
||||
LOG.info("bg", "popup status =", r.status);
|
||||
if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group");
|
||||
popupHtml = await r.text();
|
||||
console.log("[bg] popup taille HTML =", popupHtml.length);
|
||||
LOG.info("bg", "popup taille HTML =", popupHtml.length);
|
||||
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
|
||||
} catch (e) {
|
||||
console.warn("[bg] detectTeam: fetch popup failed:", e);
|
||||
LOG.warn("bg", "detectTeam: fetch popup failed:", e);
|
||||
// v2026.5.41 : on retourne les IDs déjà sélectionnés par l'user (s'il y en a)
|
||||
// plutôt qu'une liste hardcodée. Si vide, le viewer affichera juste 0 résultat.
|
||||
const ids = supportIds.split(",").filter(Boolean);
|
||||
@@ -1348,7 +1426,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
const currentIdsSet = new Set(supportIds.split(",").filter(Boolean));
|
||||
|
||||
// v5.0.1 : log le début du HTML pour diagnostic si parsing échoue
|
||||
console.log("[bg] popup HTML (début) =", popupHtml.substring(0, 500));
|
||||
LOG.info("bg", "popup HTML (début) =", popupHtml.substring(0, 500));
|
||||
|
||||
// Pattern 1 : checkboxes + texte voisin
|
||||
const rxCheckbox = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi;
|
||||
@@ -1362,7 +1440,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
|
||||
}
|
||||
}
|
||||
console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats");
|
||||
LOG.info("bg", "parsing pattern 1 (checkbox) :", results.length, "résultats");
|
||||
|
||||
// Pattern 2 : fallback <option value="NNNNN">Nom...</option>
|
||||
if (results.length === 0) {
|
||||
@@ -1375,7 +1453,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
|
||||
}
|
||||
}
|
||||
console.log("[bg] parsing pattern 2 (option) :", results.length, "résultats");
|
||||
LOG.info("bg", "parsing pattern 2 (option) :", results.length, "résultats");
|
||||
}
|
||||
|
||||
// Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom
|
||||
@@ -1393,7 +1471,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) });
|
||||
}
|
||||
}
|
||||
console.log("[bg] parsing pattern 3 (brut) :", results.length, "résultats");
|
||||
LOG.info("bg", "parsing pattern 3 (brut) :", results.length, "résultats");
|
||||
}
|
||||
|
||||
// Ajouter les IDs actuels manquants (sans nom)
|
||||
@@ -1403,7 +1481,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[bg] " + results.length + " personnes retournées");
|
||||
LOG.info("bg", "" + results.length + " personnes retournées");
|
||||
return { ids: results, groupId: groupId };
|
||||
}
|
||||
|
||||
@@ -1526,10 +1604,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
`&minute=${encodeURIComponent(msg.minute)}` +
|
||||
`&multi_day_mode_act=0`;
|
||||
LOG.warn("reschedule",
|
||||
`📅 reschedule actionId=${msg.actionId} → tech=${msg.employeeId} ${msg.date} ${msg.hour}:${String(msg.minute).padStart(2,"0")}`);
|
||||
`reschedule actionId=${msg.actionId} → tech=${msg.employeeId} ${msg.date} ${msg.hour}:${String(msg.minute).padStart(2,"0")}`);
|
||||
const r = await evFetch(url, session.origin);
|
||||
if (!r.ok) {
|
||||
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
|
||||
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
|
||||
return;
|
||||
}
|
||||
const txt = await r.text();
|
||||
@@ -1567,14 +1645,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const url = `${session.origin}/planning_updator_xhr.php` +
|
||||
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}`;
|
||||
LOG.warn("reschedule",
|
||||
`⏱ updateActionTimes actionId=${msg.actionId} ${msg.startDate} ${msg.startTime}→${msg.endTime}`);
|
||||
`updateActionTimes actionId=${msg.actionId} ${msg.startDate} ${msg.startTime}→${msg.endTime}`);
|
||||
const r = await evFetch(url, session.origin, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body
|
||||
});
|
||||
if (!r.ok) {
|
||||
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
|
||||
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
|
||||
return;
|
||||
}
|
||||
const txt = await r.text();
|
||||
@@ -1600,7 +1678,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const url = `${session.origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(session.phpsessid)}`;
|
||||
const r = await evFetch(url, session.origin);
|
||||
if (!r.ok) {
|
||||
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
|
||||
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
|
||||
return;
|
||||
}
|
||||
const txt = await r.text();
|
||||
@@ -1624,7 +1702,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
try {
|
||||
const body = await fetchTimelineApi(
|
||||
session.origin, session.phpsessid,
|
||||
msg.guid, msg.formId, msg.formChecksum
|
||||
msg.guid, msg.formId, msg.formChecksum,
|
||||
msg.timelineType, msg.sectionId
|
||||
);
|
||||
if (looksLikeLoginPage(body)) {
|
||||
sendResponse({ ok: false, error: "session_expired" });
|
||||
@@ -1689,7 +1768,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
try {
|
||||
const result = await submitDouchette(session.origin, session.phpsessid, msg);
|
||||
sendResponse({ ok: true, okCount: result.okCount, errors: result.errors });
|
||||
sendResponse({ ok: true, okCount: result.okCount, errors: result.errors, confirmed: result.confirmed === true });
|
||||
} catch (err) {
|
||||
sendResponse({ ok: false, error: err.message || String(err) });
|
||||
}
|
||||
@@ -1844,7 +1923,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
|
||||
sendResponse({ ok: false, error: "unknown_message" });
|
||||
} catch (err) {
|
||||
console.error("background error:", err);
|
||||
LOG.error("bg", "background error:", err);
|
||||
sendResponse({ ok: false, error: err.message || String(err) });
|
||||
}
|
||||
})();
|
||||
@@ -1865,7 +1944,7 @@ async function clearLegacyRefreshAlarms() {
|
||||
await chrome.alarms.clear("refresh_12h");
|
||||
await chrome.alarms.clear("refresh_15h");
|
||||
} catch (e) {
|
||||
console.warn("clearLegacyRefreshAlarms:", e);
|
||||
LOG.warn("bg", "clearLegacyRefreshAlarms:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1953,7 +2032,7 @@ function _attachCookiesListener() {
|
||||
if (!chrome.cookies || !chrome.cookies.onChanged) return;
|
||||
if (_attachCookiesListener._attached) return;
|
||||
_attachCookiesListener._attached = true;
|
||||
LOG.warn("cookies", "🍪 listener PHPSESSID activé (permission cookies accordée)");
|
||||
LOG.warn("cookies", "listener PHPSESSID activé (permission cookies accordée)");
|
||||
chrome.cookies.onChanged.addListener((info) => {
|
||||
try {
|
||||
if (!info || !info.cookie) return;
|
||||
@@ -1965,9 +2044,9 @@ function _attachCookiesListener() {
|
||||
// événements importants → LOG.warn (toujours visibles).
|
||||
// Détail (cause, expirationDate, …) → LOG.info (debug only).
|
||||
if (info.removed) {
|
||||
LOG.warn("cookies", `🚫 PHPSESSID supprimé sur ${dom} (cause=${info.cause}) → session morte`);
|
||||
LOG.warn("cookies", `PHPSESSID supprimé sur ${dom} (cause=${info.cause}) → session morte`);
|
||||
} else {
|
||||
LOG.warn("cookies", `🍪 PHPSESSID ${phpsessid8} sur ${dom} (cause=${info.cause})`);
|
||||
LOG.warn("cookies", `PHPSESSID ${phpsessid8} sur ${dom} (cause=${info.cause})`);
|
||||
}
|
||||
// Invalide immédiatement le cache findEasyVistaSession() pour que
|
||||
// le prochain appel reflète le nouveau cookie sans attendre la TTL.
|
||||
@@ -1997,18 +2076,18 @@ function _attachCookiesListener() {
|
||||
(async () => {
|
||||
try {
|
||||
const has = await _hasCookiesPermission();
|
||||
LOG.warn("cookies", `🔧 permission cookies au boot : ${has ? "ACCORDÉE → lecture cookie active" : "non accordée → fallback URL"}`);
|
||||
LOG.warn("cookies", `permission cookies au boot : ${has ? "ACCORDÉE → lecture cookie active" : "non accordée → fallback URL"}`);
|
||||
if (has) _attachCookiesListener();
|
||||
} catch (e) { /* silent */ }
|
||||
})();
|
||||
chrome.permissions.onAdded && chrome.permissions.onAdded.addListener((p) => {
|
||||
if (p && p.permissions && p.permissions.includes("cookies")) {
|
||||
LOG.warn("cookies", "✅ permission cookies ACCORDÉE par l'utilisateur");
|
||||
LOG.warn("cookies", "permission cookies ACCORDÉE par l'utilisateur");
|
||||
_attachCookiesListener();
|
||||
}
|
||||
});
|
||||
chrome.permissions.onRemoved && chrome.permissions.onRemoved.addListener((p) => {
|
||||
if (p && p.permissions && p.permissions.includes("cookies")) {
|
||||
LOG.warn("cookies", "🛑 permission cookies RETIRÉE → fallback URL réactivé");
|
||||
LOG.warn("cookies", "permission cookies RETIRÉE → fallback URL réactivé");
|
||||
}
|
||||
});
|
||||
|
||||
+4
-2
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Planification",
|
||||
"version": "2026.5.45",
|
||||
"version": "2026.5.46",
|
||||
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl/El4I9gxcZkp5bnheg4ZBCKxckRLt9xQ5x+i6phEPYlydl6NapWlkbOyycUaeTZcpoEh/tKQnpMpoEc5jY8zCoRTwh/vfejVBZE6ANDG/dc3iix/1Z8OhRI4zMaRYQ+DQTjS5Q6l6wkcwzbQB85HZNhke/k1qIW6RCuBjeJYmES5WVDwzfCNVvaF6uQvdOCpLjrA3+LI6vUBEwaMg1hM42mC3dU0z9p7qzAby/dH3XpZD9HTFXLJL9/1zryN9rXNDpvZwYWnJ0hLTu5tfmURkvgKOwquX1KJy9vupmhUALRtS08Fplx/e3kUH3LD+coS1ExLwoOZ+1ORBUE9JCB8wIDAQAB",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs",
|
||||
"alarms"
|
||||
"alarms",
|
||||
"nativeMessaging"
|
||||
],
|
||||
"optional_permissions": [
|
||||
"cookies"
|
||||
|
||||
+1244
-188
File diff suppressed because it is too large
Load Diff
+47
-20
@@ -54,16 +54,19 @@
|
||||
<!-- v5.0.9 : compteur de session EasyVista (visible < 5 min restantes) -->
|
||||
<div id="app-session" class="app-session hidden"></div>
|
||||
<div class="topbar-right">
|
||||
<!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
|
||||
<button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
|
||||
<svg class="btn-action-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M3 9h18M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="15" r="4.2" stroke="currentColor" stroke-width="1.8" fill="none"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" transform="rotate(-45 12 15)"/>
|
||||
</svg>
|
||||
<span class="btn-action-label">Absence</span>
|
||||
</button>
|
||||
<!-- v2026.5.46 — bouton principal "Ajouter" : ouvre un menu déroulant
|
||||
avec Réservation / Absence / Pompier (remplace les boutons séparés). -->
|
||||
<div id="add-menu-wrap" class="add-menu-wrap">
|
||||
<button id="add-menu-btn" class="btn btn-action" title="Ajouter : réservation, absence ou pompier" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="btn-action-emoji">➕</span>
|
||||
<span class="btn-action-label">Ajouter</span>
|
||||
</button>
|
||||
<div id="add-menu" class="add-menu hidden" role="menu">
|
||||
<button id="add-menu-reservation" class="add-menu-item" type="button" role="menuitem"><span class="add-menu-emoji">📅</span> Réservation</button>
|
||||
<button id="add-menu-absence" class="add-menu-item" type="button" role="menuitem"><span class="add-menu-emoji">🗓️</span> Absence</button>
|
||||
<button id="add-menu-pompier" class="add-menu-item" type="button" role="menuitem"><span class="add-menu-emoji">🚒</span> Pompier</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v4.2.6 : bouton envoyer la planification sur la douchette -->
|
||||
<button id="douchette-btn" class="btn btn-action" title="Envoyer la planification sur la douchette des techniciens">
|
||||
<span class="btn-action-emoji">🎯</span>
|
||||
@@ -89,7 +92,10 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Bannière session expirée (non bloquante, apparaît en haut du contenu) -->
|
||||
<!-- v2026.5.46 — bandeaux pleine largeur en haut (sous la topbar). Le
|
||||
chevauchement avec la ligne stats+recherche est réglé par l'empilement
|
||||
sticky (--stack-top), pas en les déplaçant. Un seul visible à la fois
|
||||
(coordinateur _hideAllSessionBanners / _setSessionBanner). -->
|
||||
<div id="session-expired-banner" class="session-banner hidden">
|
||||
<span class="session-banner-icon">⚠</span>
|
||||
<span class="session-banner-text">
|
||||
@@ -99,8 +105,6 @@
|
||||
<button id="session-banner-reconnect" class="btn btn-primary btn-sm">Ouvrir EasyVista</button>
|
||||
<button id="session-banner-close" class="btn btn-icon" title="Masquer">×</button>
|
||||
</div>
|
||||
|
||||
<!-- v4.2.5 : Bannière EasyVista inaccessible (non bloquante, avec cache) -->
|
||||
<div id="ev-unreachable-banner" class="session-banner ev-banner hidden">
|
||||
<span class="session-banner-icon">⚠</span>
|
||||
<span class="session-banner-text">
|
||||
@@ -112,12 +116,6 @@
|
||||
<button id="ev-unreachable-banner-close" class="btn btn-icon" title="Masquer">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression (visible uniquement pendant un refresh actif) -->
|
||||
<div id="progress-bar" class="progress-bar hidden">
|
||||
<div class="progress-bar-fill" id="progress-bar-fill"></div>
|
||||
<span class="progress-bar-label" id="progress-bar-label"></span>
|
||||
</div>
|
||||
|
||||
<main id="main">
|
||||
<div id="error-box" class="error-box hidden"></div>
|
||||
<div id="session-needed" class="session-needed hidden">
|
||||
@@ -131,8 +129,37 @@
|
||||
<button id="open-ev-btn-2" class="btn btn-primary">Ouvrir EasyVista</button>
|
||||
<button id="retry-btn" class="btn btn-subtle">Réessayer</button>
|
||||
</div>
|
||||
<!-- v2026.5.46 — ligne collante (sticky) EN PREMIER : stats + barre de
|
||||
recherche sur la même hauteur, toujours visibles au scroll. En vue
|
||||
horizontale, les deux enfants sont relocalisés dans la sidebar
|
||||
(cf. ELEMENTS_TO_RELOCATE). -->
|
||||
<div id="topline" class="topline">
|
||||
<div id="stats" class="stats hidden"></div>
|
||||
<div id="search-zone" class="search-zone hidden">
|
||||
<div class="search-bar">
|
||||
<span class="search-ico" aria-hidden="true">🔍</span>
|
||||
<input id="ev-search" class="search-input" type="search" autocomplete="off"
|
||||
spellcheck="false" placeholder="Rechercher : réf, personne, matériel, lieu…">
|
||||
<span id="ev-search-spin" class="search-spin hidden" aria-hidden="true"></span>
|
||||
<button id="ev-search-clear" class="search-clear hidden" type="button" title="Effacer" aria-label="Effacer">×</button>
|
||||
<!-- v2026.5.46 — aide sur la syntaxe de recherche -->
|
||||
<button id="ev-search-help" class="search-help" type="button" title="Aide sur la recherche" aria-label="Aide sur la recherche">❕</button>
|
||||
</div>
|
||||
<div id="ev-search-results" class="search-results hidden" role="listbox"></div>
|
||||
</div>
|
||||
<!-- v2026.5.46 — 3e colonne (droite) : bouton « Mes actions » du mode
|
||||
coordinateur, calé complètement à droite de la ligne. -->
|
||||
<div id="topline-coord" class="topline-coord"></div>
|
||||
</div>
|
||||
<!-- v2026.5.46 — barre de progression du rafraîchissement : SOUS la ligne
|
||||
stats+recherche (#topline), sticky juste en dessous (avant : au-dessus). -->
|
||||
<div id="progress-bar" class="progress-bar hidden">
|
||||
<div class="progress-bar-fill" id="progress-bar-fill"></div>
|
||||
<span class="progress-bar-label" id="progress-bar-label"></span>
|
||||
</div>
|
||||
<!-- v2026.5.46 — le « Chargement… » vit DANS la zone des cartes
|
||||
(sous la ligne stats+recherche), jamais en haut de page. -->
|
||||
<div id="loading" class="loading">Chargement…</div>
|
||||
<div id="stats" class="stats hidden"></div>
|
||||
<div id="cards" class="cards"></div>
|
||||
</main>
|
||||
|
||||
|
||||
+7003
-2124
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user