// background.js — Service worker (Manifest V3) — v4 // // Rôles : // 1. Au clic sur l'icône : ouvrir le viewer // 2. Répondre aux messages du viewer : // - getSession : trouve l'onglet EasyVista ouvert, renvoie {phpsessid, origin} // - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout) // - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol) // - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech // 3. Nettoyer les vieux caches (>7 jours) // (v4.2 : l'auto-refresh 12h/15h a été retiré) // // v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient // directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3, // donc on n'a plus besoin ni de xhr2 en masse, ni de l'API timeline. // Domaines EasyVista reconnus (interne d'abord, externe en fallback) const EV_ORIGINS = [ "https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch" ]; // ============================================================================ // Clic sur l'icône → ouvrir le viewer // ============================================================================ chrome.action.onClicked.addListener(async () => { const viewerUrl = chrome.runtime.getURL("viewer.html"); // Si le viewer est déjà ouvert, on focus cet onglet plutôt que d'en ouvrir un autre const existing = await chrome.tabs.query({ url: viewerUrl + "*" }); if (existing.length > 0) { await chrome.tabs.update(existing[0].id, { active: true }); await chrome.windows.update(existing[0].windowId, { focused: true }); } else { await chrome.tabs.create({ url: viewerUrl }); } }); // ============================================================================ // Trouver l'onglet EasyVista actif et en extraire le PHPSESSID // ============================================================================ async function findEasyVistaSession() { // Chercher tous les onglets sur un domaine EasyVista for (const origin of EV_ORIGINS) { const tabs = await chrome.tabs.query({ url: origin + "/*" }); for (const tab of tabs) { const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/); if (m) { return { phpsessid: m[1], origin: origin, tabId: tab.id }; } } } return null; } // ============================================================================ // Fetch helpers (s'exécutent dans le contexte du service worker, // les cookies du domaine sont automatiquement inclus via credentials: include) // ============================================================================ /** * Fetch du XML retourné par planning_xhr.php?div=calendar_block. * Contient les interventions de nos 8 techs pour la date donnée (~40 ko). * * Ce n'est PAS le HTML de la page Planning — le serveur ne rend pas les données * dans le HTML, elles arrivent via cet endpoint AJAX. */ async function fetchPlanningXml(origin, phpsessid, unixDate) { const techIds = "76272,83725,66635,92235,90070,40944,72485,86874"; const groupId = "191"; const url = `${origin}/planning_xhr.php` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `&div=calendar_block` + `&mode=day` + `&group_id=${groupId}` + `&event_name=HelpDesk_PlanningItem` + `&sql_param=${techIds}` + `&unix_date=${unixDate}` + `&start_date_label=Date` + `&end_date_label=Date` + `&click_here_label=Ici` + `&mail_title=mail` + `&day_start_hour=8` + `&day_end_hour=19`; console.log("[bg] fetchPlanningXml →", url.substring(0, 140)); const r = await evFetch(url, origin); console.log("[bg] status =", r.status); if (!r.ok) { // 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.status = r.status; throw err; } const xml = await r.text(); console.log("[bg] taille XML =", xml.length); return xml; } /** * v5.0.9 : wrapper autour de fetch() qui ajoute systématiquement les * headers de sécurité attendus par EasyVista (Referer, Sec-Fetch-Site, * X-Requested-With). Sans ces headers, EV renvoie soit un * (protection CSRF ou session expirée) * 3. URL de logout : index.php?...&logout=1 * 4. Redirection vers le portail SSO : portail.etat-de-vaud.ch/sso/ * 5. Réponse JSON avec "isLogged": false */ function looksLikeLoginPage(text) { const t = (text || "").substring(0, 3000); if (!t) return false; // Pattern 1 : page de login EV classique if (/customer_login|my\.policy/i.test(t)) return true; // Pattern 2 : script de redirection (< 500 chars = probablement juste ça) if (t.length < 500 && /]*>\s*window\.location\.href\s*=/i.test(t)) return true; // Pattern 3 : URL de logout if (/[?&]logout=1/i.test(t)) return true; // Pattern 4 : redirection vers portail SSO if (/portail\.etat-de-vaud\.ch\/sso\//i.test(t)) return true; // Pattern 5 : JSON isLogged:false if (/"isLogged"\s*:\s*false/i.test(t)) return true; return false; } // ============================================================================ // v5.0.9 : surveillance du timeout de session EasyVista // ============================================================================ /** * GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=session_time * * 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. */ async function fetchSessionTimeRemaining(origin, phpsessid) { const url = `${origin}/timeout_ajax.php` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `&__AJAX_TIMEOUT_FCT__=session_time`; console.log("[bg] fetchSessionTimeRemaining →", url.substring(0, 120)); const r = await evFetch(url, origin); if (!r.ok) { throw new Error("HTTP " + r.status); } 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)); // Si c'est une page de login/redirect → session expirée if (looksLikeLoginPage(body)) { throw new Error("session_expired"); } throw new Error("invalid_response"); } const ms = parseInt(body, 10); console.log(`[bg] session_time = ${ms} ms = ${Math.round(ms/60000)} min`); return ms; } /** * GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=keep_connection * * Prolonge la session à 30 min. Retourne 1800000. */ async function extendSessionKeepAlive(origin, phpsessid) { const url = `${origin}/timeout_ajax.php` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `&__AJAX_TIMEOUT_FCT__=keep_connection`; console.log("[bg] extendSessionKeepAlive →", url.substring(0, 120)); const r = await evFetch(url, origin); if (!r.ok) { throw new Error("HTTP " + r.status); } const body = (await r.text()).trim(); if (!/^\d+$/.test(body)) { if (looksLikeLoginPage(body)) throw new Error("session_expired"); throw new Error("invalid_response"); } const ms = parseInt(body, 10); console.log(`[bg] keep_connection → session prolongée à ${ms} ms`); return ms; } // ============================================================================ // v5.0.11 : détection automatique du contexte réseau (interne / externe) // ============================================================================ /** * Détecte si on est sur le réseau interne (itsma.etat-de-vaud.ch accessible) * ou externe (seul itsma.vd.ch accessible). Fait un fetch avec mode "no-cors" * sur l'URL interne : si ça répond (même redirection SSO), on est interne. * Si ça échoue (DNS unreachable, timeout), on est externe. * * v5.0.11 (fix) : mode "no-cors" pour éviter que l'erreur CORS de la * redirection SSO (itsma.etat-de-vaud.ch → portail.etat-de-vaud.ch/sso/) * soit interprétée comme un échec de connectivité. Au bureau, la redirection * SSO passe → le fetch termine → on sait qu'on est interne. * * Le résultat est mis en cache dans chrome.storage.local pendant 1h pour * éviter de refaire le test à chaque démarrage. * * @param {boolean} force - si true, ignore le cache et refait le test * @returns {Promise<"internal"|"external">} */ async function detectNetworkContext(force = false) { const CACHE_KEY = "network_context_v2"; // v5.0.12 : nouvelle clé pour invalider le cache fautif v5.0.11 const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1h if (!force) { try { 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); return cached.networkContext; } } catch (e) {} } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2500); let context = "external"; try { console.log("[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. await fetch("https://itsma.etat-de-vaud.ch/", { method: "GET", mode: "no-cors", signal: controller.signal, credentials: "omit", cache: "no-store" }); clearTimeout(timer); console.log("[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 + ")"); context = "external"; } // Mettre en cache try { await chrome.storage.local.set({ [CACHE_KEY]: { networkContext: context, detectedAt: Date.now() } }); } catch (e) {} return context; } /** * Retourne l'origine EV à utiliser selon le contexte réseau détecté. */ function originForContext(context) { return context === "internal" ? "https://itsma.etat-de-vaud.ch" : "https://itsma.vd.ch"; } // ============================================================================ // v4.2 : récupération de l'utilisateur connecté // ============================================================================ /** * Essaie de récupérer le nom de l'utilisateur EasyVista connecté en fetchant * la page d'accueil avec la session active. EasyVista n'exposant pas * d'endpoint public simple, on cherche des patterns typiques dans le HTML : * - ...Nom, Prénom... * - éléments avec data-user-name, data-user-login * - balises cachées ou variables JS EV.User.name * - champ "Bienvenue Nom Prénom" * Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si * tout a échoué. */ async function fetchCurrentUser(origin, phpsessid) { const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`; const resp = await fetch(url, { method: "GET", credentials: "include", headers: { "Accept": "text/html,*/*" } }); // v4.2 : cette fonction est lancée en tâche de fond au démarrage. Si la // session est expirée ou EV inaccessible, on retourne juste null — le // planning lui-même déclenchera l'écran d'erreur approprié. if (!resp.ok) return null; const html = await resp.text(); if (looksLikeLoginPage(html)) return null; // v4.2.2 : patterns spécifiques à la structure EasyVista réelle du Canton // de Vaud (identifiés à partir du HTML de la page d'accueil). L'user est // affiché dans un dropdown ".ev-employee-dropdown" avec ces éléments : // // Nom, Prénom // 3.3 DGNSI-ServiceDesk // ... // // Le title du parent contient aussi "Nom, Prénom / Service / Société". const patterns = [ // 1) Le plus fiable : span class="h5" dans profile-info (structure EV 2026) /]*>\s*]*title=["']([^"']{2,80})["']/i, // 2) Fallback : span class="h5" avec title= même hors profile-info /]*title=["']([^"']{2,80})["'][^>]*>\s*([^<]{2,80})<\/span>/i, // 3) Fallback : title= de ev-employee-dropdown (format "Nom, Prénom / Service / Société") /class=["'][^"']*ev-employee-dropdown[^"']*["'][^>]*title=["']([^"'\/]+?)(?:\s*\/\s*[^"']+)?["']/i, // 4) Anciens patterns génériques (autres instances EasyVista éventuelles) /data-user-name\s*=\s*["']([^"']+)["']/i, /data-username\s*=\s*["']([^"']+)["']/i, /data-user-fullname\s*=\s*["']([^"']+)["']/i, /EV\.User\.name\s*=\s*["']([^"']+)["']/, /EV\.User\.fullname\s*=\s*["']([^"']+)["']/, /userFullName\s*[:=]\s*["']([^"']+)["']/, // 5) "Bienvenue" / "Welcome" /(?:Bienvenue|Welcome)[,\s]+(?:M\.?\s+|Mme\s+)?([A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+(?:\s*,?\s+[A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+){0,3})/ ]; let name = null; for (const rx of patterns) { const m = html.match(rx); if (m && m[1]) { const candidate = m[1].trim() .replace(/\s+/g, " ") .replace(/^(?:EasyVista|EV|Accueil|Home|Planning|ITSMA)[\s\-|•]+/i, "") .replace(/[\s\-|•]+(?:EasyVista|EV|ITSMA)$/i, "") .trim(); if (candidate && candidate.length >= 3 && candidate.length <= 80 && /[A-Za-zÀ-ÿ]/.test(candidate) && !/\b(login|connexion|sign\s*in|easyvista|ITSMA)\b/i.test(candidate)) { name = candidate; break; } } } // v4.2.2 : on extrait aussi le service/unité si disponible (h6 à côté du h5) let service = null; const serviceMatch = html.match( /]*>[\s\S]{0,500}?]*title=["']([^"']{2,80})["']/i ); if (serviceMatch && serviceMatch[1]) { service = serviceMatch[1].trim(); } // Login / identifiant court (optionnel) let login = null; const loginPatterns = [ /data-user-login\s*=\s*["']([^"']+)["']/i, /data-login\s*=\s*["']([^"']+)["']/i, /EV\.User\.login\s*=\s*["']([^"']+)["']/, /userLogin\s*[:=]\s*["']([^"']+)["']/ ]; for (const rx of loginPatterns) { const m = html.match(rx); if (m && m[1]) { login = m[1].trim(); break; } } if (!name && !login && !service) return null; return { name, login, service }; } // ============================================================================ // v4.2.6 : Création d'absence // ============================================================================ /** * Envoie un POST vers plan_set_holidays_popup.php pour créer une absence. * Format attendu (analysé depuis le HTML EasyVista) : * Query params : PHPSESSID, MAIN_DIRECTORY, ROOT_DIRECTORY, current_date, * empl_ids, begin_hour, end_hour, plagehoraire * Body : start_date, start_time, end_date, end_time, label_guid, dialog_action * * @param {string} origin - "https://itsma.vd.ch" ou similaire * @param {string} phpsessid * @param {Object} opts - { techIds: string[], startDate: "DD/MM/YYYY", * startTime: "HH:MM:SS", endDate, endTime, * typeGuid, currentDate } */ async function submitAbsence(origin, phpsessid, opts) { const emplIds = (opts.techIds || []).join(","); if (!emplIds) throw new Error("Aucun technicien sélectionné"); const internalurltime = Math.floor(Date.now() / 1000); const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `&internalurltime=${internalurltime}` + `&MAIN_DIRECTORY=${encodeURIComponent("/")}` + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + `¤t_date=${encodeURIComponent(opts.currentDate)}` + `&empl_ids=${encodeURIComponent(emplIds)}` + `&begin_hour=8` + `&end_hour=18` + `&plagehoraire=0`; const body = new URLSearchParams(); body.set("start_date", opts.startDate); body.set("start_time", opts.startTime); body.set("end_date", opts.endDate); body.set("end_time", opts.endTime); 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()); const r = await fetch(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() }); console.log("[bg] status =", r.status); if (!r.ok) { throw new Error("HTTP " + r.status); } const responseText = await r.text(); if (looksLikeLoginPage(responseText)) { throw new Error("session_expired"); } // Succès : on ne sait pas le format exact de la réponse EasyVista, on // considère qu'un HTTP 200 non-login signifie succès. return { status: r.status }; } // ============================================================================ // v4.2.6 : Envoi sur douchette // ============================================================================ /** * Envoie la planification du jour sur la douchette des techs sélectionnés. * * Endpoint identifié (via l'inspection de la page EasyVista) : * POST /include/components/staff/planning/plan_set_tech_planif_popup.php * Query : PHPSESSID, current_date, empl_ids (CSV), begin_hour, end_hour, * plagehoraire * Body : dialog_action=save_planif * * Contrairement à l'absence, un seul POST suffit pour tous les techs (empl_ids * est une CSV), pas besoin de boucler. * * @param {string} origin * @param {string} phpsessid * @param {Object} opts - { techIds, currentDate } * @returns {{ okCount, errors }} */ async function submitDouchette(origin, phpsessid, opts) { const techIds = opts.techIds || []; if (techIds.length === 0) throw new Error("Aucun technicien sélectionné"); const emplIds = techIds.join(","); const internalurltime = Math.floor(Date.now() / 1000); const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `&internalurltime=${internalurltime}` + `&MAIN_DIRECTORY=${encodeURIComponent("/")}` + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + `¤t_date=${encodeURIComponent(opts.currentDate)}` + `&empl_ids=${encodeURIComponent(emplIds)}` + `&begin_hour=8` + `&end_hour=18` + `&plagehoraire=0`; 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); try { const r = await fetch(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() }); console.log("[bg] status =", r.status); if (r.status === 401 || r.status === 403) { return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; } if (!r.ok) { return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "HTTP " + r.status })) }; } const responseText = await r.text(); if (looksLikeLoginPage(responseText)) { return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; } return { okCount: techIds.length, errors: [] }; } catch (err) { const msg = err && err.message ? err.message : String(err); return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) }; } } // ============================================================================ // v5.0.0 : Suppression d'une absence ou d'une réservation // ============================================================================ /** * Supprime un item du planning (absence ou réservation) côté EasyVista. * * v5.0.1 : l'endpoint exact n'est pas totalement certain selon les versions * EasyVista. On essaye plusieurs `function_name` jusqu'à trouver celui qui * marche. Un "status 200" ne garantit pas que ça a été supprimé (l'API peut * répondre 200 même sur un nom de fonction inconnu), mais ça + le reload * post-suppression donne un bon signal : si le ticket est toujours là après * reload, on réessaye avec le nom suivant. * * Pour l'absence, dans le HTML le bouton "Supprimer" appelle : * onclick="g_arr_player[N].delete_absence();" * qui fait probablement un GET /planning_updator_xhr.php?function_name=... * mais le nom exact varie (peut être "delete_absence", "Planning_delete_absence", * "fc_delete_absence", etc.) * * @param {string} origin * @param {string} phpsessid * @param {string} actionId - ID de l'action à supprimer * @param {string} kind - "absence" ou "reservation" */ async function deletePlanningItem(origin, phpsessid, actionId, kind) { if (!actionId) throw new Error("actionId manquant"); // v5.0.1 : plusieurs function_name à tester dans l'ordre (du plus probable // au moins probable). Le premier qui renvoie 200 ET non-login est considéré OK. const fnNames = kind === "reservation" ? [ "Planning_delete_reservation", "delete_reservation", "fc_delete_reservation", "delete_act_reservation", "delete_planning_reservation", "remove_reservation", // v5.0.2 : réservations sont parfois traitées comme absences côté API "Planning_delete_absence", "delete_absence", "fc_delete_absence" ] : [ // v5.0.2 : élargir la liste, on a essayé 3 sans succès. Les variantes // plausibles vues dans les API EasyVista : "Planning_delete_absence", // le plus "officiel" "delete_absence", // le nom JS dans le onclick "fc_delete_absence", // pattern fc_* "delete_act_absence", // parfois "act_" dans les noms "Planning_delete_holiday", // en anglais "delete_holiday", "fc_delete_holiday", "delete_planning_absence", // variation complète "remove_absence" ]; let lastErr = null; let lastBody = null; for (const fn of fnNames) { const url = `${origin}/planning_updator_xhr.php` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `&function_name=${encodeURIComponent(fn)}` + `&action_id=${encodeURIComponent(actionId)}`; console.log(`[bg] deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180)); try { // v5.0.13 : utiliser evFetch() au lieu de fetch() brut pour que les // headers Referer + X-Requested-With soient envoyés — sinon EV renvoie // un