v2026.5.46 — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées

This commit is contained in:
2026-07-09 16:21:25 +02:00
parent 06c0195130
commit 975a907f2b
15 changed files with 9351 additions and 2410 deletions
+153 -74
View File
@@ -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&sectionId=1&navigator=&nbRecord=0` +
`&type=${encodeURIComponent(tType)}&sectionId=${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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+47 -20
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff