// 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 fetch(url, { credentials: "include" }); 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; } /** * v4.2 : classifie un statut HTTP comme "session_expired" ou "ev_unreachable". * - 401, 403, 404 → session_expired (EV renvoie souvent 404 au lieu de rediriger * vers la page de login quand PHPSESSID n'est plus valide) * - 5xx, autres → ev_unreachable (service down, surcharge, etc.) */ function classifyHttpStatus(status) { if (status === 401 || status === 403 || status === 404) return "session_expired"; return "ev_unreachable"; } /** * Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention. * Retourne ~400 octets au format custom : * @@DESCRIPTION_S@@...@@DESCRIPTION_E@@@@LABEL_S@@... */ async function fetchXhr2(origin, phpsessid, actionId) { const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`; const r = await fetch(url, { credentials: "include" }); if (!r.ok) { const err = new Error("HTTP " + r.status); err.kind = classifyHttpStatus(r.status); err.status = r.status; throw err; } return await r.text(); } async function fetchFicheHtml(origin, phpsessid, formLink) { const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); const r = await fetch(url, { credentials: "include" }); if (!r.ok) { const err = new Error("HTTP " + r.status); err.kind = classifyHttpStatus(r.status); err.status = r.status; throw err; } const html = await r.text(); console.log("[bg] fiche status =", r.status, "| taille =", html.length); return html; } // v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche, // avec pour chaque action : intervenant, ACTION_ID, AM_DONE_BY_ID, description // complète (bien plus riche que le xhr2 tronqué). // Utilisé pour afficher le texte complet de l'action dans le tooltip. // 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) { // 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`; const url = `${origin}/api/v1/internal/forms/${encodedGuid}/timeline` + `?target=${encodeURIComponent(formId)}` + `&checksum=${encodeURIComponent(formChecksum)}` + `&type=todo§ionId=1&navigator=&nbRecord=0` + `&PHPSESSID=${encodeURIComponent(phpsessid)}`; const r = await fetch(url, { credentials: "include" }); if (!r.ok) { const err = new Error("HTTP " + r.status); err.kind = classifyHttpStatus(r.status); err.status = r.status; throw err; } return await r.text(); } // ============================================================================ // Détection "session invalide" // ============================================================================ function looksLikeLoginPage(text) { // La page de login EasyVista contient cette chaîne return /customer_login|my\.policy/i.test((text || "").substring(0, 3000)); } // ============================================================================ // 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 })) }; } } // ============================================================================ // Messages du viewer // ============================================================================ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { (async () => { try { if (msg.type === "getSession") { const session = await findEasyVistaSession(); sendResponse({ ok: true, session }); return; } if (msg.type === "fetchPlanning") { const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { // Fetch XML calendar_block du planning (rapide ~40 ko) const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate); if (looksLikeLoginPage(xml)) { sendResponse({ ok: false, error: "session_expired" }); return; } sendResponse({ ok: true, xml, session }); } catch (err) { // v4.2 : classification de l'erreur pour afficher le bon écran const errorCode = err.kind || ( /network|fetch|typeerror/i.test(err.message) ? "ev_unreachable" : "ev_unreachable" ); sendResponse({ ok: false, error: errorCode, httpStatus: err.status, detail: err.message }); } return; } if (msg.type === "fetchXhr2") { const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId); sendResponse({ ok: true, body }); } catch (err) { sendResponse({ ok: false, error: err.kind || "fetch_failed", httpStatus: err.status, detail: err.message || String(err) }); } return; } if (msg.type === "fetchFiche") { const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); if (looksLikeLoginPage(html)) { sendResponse({ ok: false, error: "session_expired" }); return; } sendResponse({ ok: true, html, session }); } catch (err) { sendResponse({ ok: false, error: err.kind || "fetch_failed", httpStatus: err.status, detail: err.message || String(err) }); } return; } if (msg.type === "fetchTimelineApi") { const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { const body = await fetchTimelineApi( session.origin, session.phpsessid, msg.guid, msg.formId, msg.formChecksum ); if (looksLikeLoginPage(body)) { sendResponse({ ok: false, error: "session_expired" }); return; } sendResponse({ ok: true, body }); } catch (err) { sendResponse({ ok: false, error: err.kind || "fetch_failed", httpStatus: err.status, detail: err.message || String(err) }); } return; } if (msg.type === "fetchCurrentUser") { // v4.2 : essaie d'identifier l'utilisateur EasyVista connecté en // fetchant la page d'accueil et en cherchant dans le HTML un champ // contenant son nom. Si on trouve rien, on renvoie { ok: true, // user: null } pour que l'UI sache qu'on n'a pas pu. const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { const user = await fetchCurrentUser(session.origin, session.phpsessid); sendResponse({ ok: true, user }); } catch (err) { sendResponse({ ok: false, error: String(err) }); } return; } if (msg.type === "submitAbsence") { // v4.2.6 : crée une absence dans EasyVista via POST vers // /include/components/staff/planning/plan_set_holidays_popup.php const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { const result = await submitAbsence(session.origin, session.phpsessid, msg); sendResponse({ ok: true, result }); } catch (err) { sendResponse({ ok: false, error: err.message || String(err) }); } return; } if (msg.type === "submitDouchette") { // v4.2.6 : envoie la planification sur la douchette de chaque tech. // On teste plusieurs URLs possibles (l'endpoint exact n'est pas dans // le HTML statique que nous avons analysé). const session = await findEasyVistaSession(); if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } try { const result = await submitDouchette(session.origin, session.phpsessid, msg); sendResponse({ ok: true, okCount: result.okCount, errors: result.errors }); } catch (err) { sendResponse({ ok: false, error: err.message || String(err) }); } return; } if (msg.type === "cleanupOldCaches") { const removed = await cleanupOldCaches(msg.daysToKeep || 7); sendResponse({ ok: true, removed }); return; } sendResponse({ ok: false, error: "unknown_message" }); } catch (err) { console.error("background error:", err); sendResponse({ ok: false, error: err.message || String(err) }); } })(); // Retourner true pour garder sendResponse asynchrone return true; }); // ============================================================================ // v4.2 : les alarmes d'auto-refresh 12h/15h ont été supprimées. Seul le // nettoyage quotidien des caches > 7 jours reste. // On supprime aussi activement les anciennes alarmes créées par les // versions précédentes pour éviter qu'elles restent programmées. // ============================================================================ async function clearLegacyRefreshAlarms() { try { await chrome.alarms.clear("refresh_12h"); await chrome.alarms.clear("refresh_15h"); } catch (e) { console.warn("clearLegacyRefreshAlarms:", e); } } // ============================================================================ // Nettoyage caches > 7 jours // ============================================================================ async function cleanupOldCaches(daysToKeep) { const all = await chrome.storage.local.get(null); const threshold = new Date(); threshold.setDate(threshold.getDate() - daysToKeep); const thresholdStr = threshold.toISOString().substring(0, 10); // YYYY-MM-DD const toRemove = []; for (const key of Object.keys(all)) { // Nos clés de cache sont planning_cache_YYYY-MM-DD const m = key.match(/^planning_cache_(\d{4}-\d{2}-\d{2})$/); if (m && m[1] < thresholdStr) { toRemove.push(key); } } if (toRemove.length > 0) { await chrome.storage.local.remove(toRemove); } return toRemove.length; } // Au démarrage, nettoyer les anciennes alarmes et les anciens caches chrome.runtime.onInstalled.addListener(() => { clearLegacyRefreshAlarms(); cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); }); chrome.runtime.onStartup.addListener(() => { clearLegacyRefreshAlarms(); cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); });