d7b680fb3f
Bug Firefox uniquement : positionnement du menu hover des pastilles du
dock (popup réduit) corrigé. La cause était que getBoundingClientRect()
était appelé immédiatement après appendChild sans que Firefox n'ait fini
de calculer la mise en page, combiné à un transform: translateY dans
l'animation d'apparition du menu. Fix : positionnement hors écran initial,
force-layout via offsetHeight, puis pose finale. Animation CSS simplifiée
en opacité-only.
Stabilité popup au pin/unpin (tous navigateurs) : la popup épinglée
bougeait de 16px et changeait légèrement de taille quand on la
dé-épinglait via le bouton 📌. Cause : .pinned-popup avait padding-top
28px + border 2px alors que .soft-unpinned avait padding-top 12px + border
1px. Fix : .soft-unpinned conserve désormais les mêmes dimensions, juste
la couleur de bordure change (--border-strong gris au lieu de --accent
bleu) pour signaler le mode détaché.
1607 lines
63 KiB
JavaScript
1607 lines
63 KiB
JavaScript
/**
|
|
* Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
|
|
*
|
|
* Service worker (Manifest V3) : récupération session EV, fetch planning XML,
|
|
* fetch fiches détaillées, gestion cache.
|
|
*
|
|
* Copyright (c) 2026 Quentin Rouiller
|
|
* Licensed under the MIT License — see LICENSE file in the project root.
|
|
*
|
|
* @author Quentin Rouiller
|
|
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
|
|
*/
|
|
|
|
// 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.
|
|
|
|
// ============================================================================
|
|
// v2026.5.38 : Observabilité — logger unifié + handlers globaux SW
|
|
// ============================================================================
|
|
// Le service worker MV3 est endormi/relancé fréquemment, donc important d'avoir
|
|
// un format de log compact et reproductible. Les handlers `error` et
|
|
// `unhandledrejection` sur `self` capturent ce qui passe à travers les
|
|
// try/catch (ex: une promise oubliée dans un setTimeout).
|
|
// ============================================================================
|
|
|
|
// Clef chrome.storage.local pour le mode debug — toggle depuis le panel admin
|
|
// du viewer. Le viewer envoie un message {type:"setDebugLogs"} qu'on traite
|
|
// plus bas pour sync, et on lit aussi au boot.
|
|
const DEBUG_LOGS_KEY = "debug_logs";
|
|
|
|
const LOG = (() => {
|
|
let _version = "?";
|
|
try {
|
|
if (chrome && chrome.runtime && chrome.runtime.getManifest) {
|
|
_version = chrome.runtime.getManifest().version || "?";
|
|
}
|
|
} catch (e) {}
|
|
|
|
let _debug = false;
|
|
// Lecture initiale (asynchrone, mais on s'en fout : on commence muet
|
|
// et qd la valeur arrive on update). Le SW peut être tué/relancé donc
|
|
// on relit à chaque démarrage.
|
|
try {
|
|
chrome.storage.local.get([DEBUG_LOGS_KEY]).then(obj => {
|
|
_debug = !!obj[DEBUG_LOGS_KEY];
|
|
}).catch(() => {});
|
|
} catch (e) {}
|
|
|
|
const _stamp = () => new Date().toISOString().substring(11, 23);
|
|
const _format = (level, prefix, msg, ctx) => {
|
|
const head = `[${_stamp()}][v${_version}][bg/${prefix}][${level}]`;
|
|
if (ctx !== undefined) return [head, msg, ctx];
|
|
return [head, msg];
|
|
};
|
|
|
|
return {
|
|
info: (prefix, msg, ctx) => {
|
|
if (!_debug) return;
|
|
console.log(..._format("INFO", prefix, msg, ctx));
|
|
},
|
|
warn: (prefix, msg, ctx) => console.warn (..._format("WARN", prefix, msg, ctx)),
|
|
error:(prefix, msg, ctx) => console.error(..._format("ERROR", prefix, msg, ctx)),
|
|
exception: (prefix, msg, err, extra) => {
|
|
const ctx = {
|
|
name: err && err.name,
|
|
message: err && err.message,
|
|
stack: err && err.stack,
|
|
extra: extra
|
|
};
|
|
console.error(..._format("ERROR", prefix, msg, ctx));
|
|
},
|
|
setDebug: (on) => {
|
|
_debug = !!on;
|
|
try { chrome.storage.local.set({ [DEBUG_LOGS_KEY]: _debug }); } catch (e) {}
|
|
console.log(..._format("INFO", "logger", `mode debug = ${_debug ? "ON" : "OFF"}`));
|
|
},
|
|
isDebug: () => _debug,
|
|
version: () => _version
|
|
};
|
|
})();
|
|
|
|
// Si le viewer toggle pendant qu'on est endormi/relancé, on capte le
|
|
// changement chrome.storage et on update le flag local.
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area === "local" && changes[DEBUG_LOGS_KEY]) {
|
|
const newVal = !!changes[DEBUG_LOGS_KEY].newValue;
|
|
LOG.setDebug(newVal);
|
|
}
|
|
});
|
|
|
|
self.addEventListener("error", (event) => {
|
|
LOG.exception("global", "uncaught error in service worker",
|
|
event.error || new Error(event.message || "unknown"),
|
|
{ filename: event.filename, lineno: event.lineno, colno: event.colno });
|
|
});
|
|
|
|
self.addEventListener("unhandledrejection", (event) => {
|
|
const reason = event.reason;
|
|
LOG.exception("global", "unhandled promise rejection in service worker",
|
|
reason instanceof Error ? reason : new Error(String(reason)));
|
|
});
|
|
|
|
LOG.info("boot", "service worker démarré", { version: LOG.version() });
|
|
|
|
// ============================================================================
|
|
// v2026.5.41 : Configuration runtime — lue depuis admin_config (chrome.storage.local).
|
|
//
|
|
// Les domaines EV et le group_id ont des défauts (filet de sécurité 1er install).
|
|
// La liste de techniciens, elle, n'a AUCUN défaut : tant que l'utilisateur n'a
|
|
// rien coché dans Paramètres → Équipe, l'extension ne fetche aucun planning et
|
|
// invite à configurer.
|
|
//
|
|
// chrome.storage.local survit aux mises à jour d'extension → la sélection de
|
|
// l'utilisateur est conservée d'une version à l'autre.
|
|
// ============================================================================
|
|
const DEFAULT_EV_ORIGINS = [
|
|
"https://itsma.etat-de-vaud.ch", // interne DGNSI
|
|
"https://itsma.vd.ch" // externe Internet
|
|
];
|
|
const DEFAULT_GROUP_ID = "191"; // SI-CSS
|
|
|
|
/**
|
|
* Lit admin_config depuis chrome.storage.local. Retourne {} si absent ou
|
|
* en cas d'erreur.
|
|
*/
|
|
async function getAdminConfig() {
|
|
try {
|
|
const stored = await chrome.storage.local.get("admin_config");
|
|
return stored.admin_config || {};
|
|
} catch (e) {
|
|
LOG.warn("config", "getAdminConfig failed, using defaults", e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Origines EV à surveiller, depuis admin_config ou défaut. */
|
|
async function getEvOrigins() {
|
|
const cfg = await getAdminConfig();
|
|
const o = Array.isArray(cfg.evOrigins) ? cfg.evOrigins.filter(Boolean) : [];
|
|
return o.length >= 1 ? o : DEFAULT_EV_ORIGINS;
|
|
}
|
|
|
|
/** Group ID effectif (défaut SI-CSS). */
|
|
async function getGroupId() {
|
|
const cfg = await getAdminConfig();
|
|
return cfg.groupId || DEFAULT_GROUP_ID;
|
|
}
|
|
|
|
/**
|
|
* Support IDs effectifs (CSV des clés de cfg.team).
|
|
* Retourne "" si aucun tech sélectionné — l'appelant doit alors signaler
|
|
* à l'utilisateur d'aller configurer son équipe.
|
|
*/
|
|
async function getSupportIds() {
|
|
const cfg = await getAdminConfig();
|
|
const ids = Object.keys(cfg.team || {}).filter(Boolean);
|
|
return ids.join(",");
|
|
}
|
|
|
|
/**
|
|
* Plage horaire d'affichage (heures pleines).
|
|
* Lue depuis admin_config (Paramètres → Apparence → Heures de la journée),
|
|
* défaut 8h-18h. Utilisée pour les paramètres day_start_hour / day_end_hour /
|
|
* begin_hour / end_hour des requêtes EV. Le viewer utilise déjà ces mêmes
|
|
* valeurs pour dessiner la timeline (cf. _initDayBoundsFromConfig dans viewer.js).
|
|
*/
|
|
async function getDayBounds() {
|
|
const cfg = await getAdminConfig();
|
|
const start = (typeof cfg.dayStart === "number" && cfg.dayStart >= 0 && cfg.dayStart <= 23) ? cfg.dayStart : 8;
|
|
const end = (typeof cfg.dayEnd === "number" && cfg.dayEnd > start && cfg.dayEnd <= 24) ? cfg.dayEnd : 18;
|
|
return { start, end };
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Trouve l'onglet EasyVista ouvert et récupère phpsessid + origin.
|
|
*
|
|
* @author Quentin Rouiller
|
|
*/
|
|
async function findEasyVistaSession() {
|
|
// v2026.5.41 : les origines EV viennent de admin_config (éditables dans
|
|
// Paramètres → EasyVista), avec fallback sur DEFAULT_EV_ORIGINS.
|
|
const origins = await getEvOrigins();
|
|
for (const origin of 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.
|
|
*
|
|
* @author Quentin Rouiller
|
|
*/
|
|
async function fetchPlanningXml(origin, phpsessid, unixDate) {
|
|
// v2026.5.41 : groupId vient de admin_config (défaut SI-CSS).
|
|
// techIds vient de admin_config — si vide, on lève une erreur claire pour
|
|
// que le viewer affiche "Aucun technicien sélectionné" plutôt qu'un planning vide.
|
|
const groupId = await getGroupId();
|
|
const techIds = await getSupportIds();
|
|
if (!techIds) {
|
|
const err = new Error("no_team_configured");
|
|
err.kind = "no_team_configured";
|
|
throw err;
|
|
}
|
|
// v2026.5.41 : heures synchronisées avec admin_config (Paramètres → Apparence).
|
|
// EV utilise day_end_hour exclusif (la plage rendue va jusqu'à end-1:59),
|
|
// donc on envoie end+1 pour que la dernière heure pleine soit incluse.
|
|
const { start, end } = await getDayBounds();
|
|
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=${start}` +
|
|
`&day_end_hour=${end + 1}`;
|
|
// v2026.5.38 : on retire les logs verbose à chaque fetch (URL/status/taille).
|
|
// En cas de souci, le throw plus bas porte assez d'info pour debug.
|
|
const r = await evFetch(url, origin);
|
|
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;
|
|
}
|
|
return await r.text();
|
|
}
|
|
|
|
/**
|
|
* 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 <script> de
|
|
* redirection (CSRF check), soit une page de login, même avec une session
|
|
* valide.
|
|
*
|
|
* Observé dans les captures réseau du navigateur :
|
|
* Referer: https://itsma.etat-de-vaud.ch/index.php?eventName=HelpDesk_PlanningItem
|
|
* Sec-Fetch-Site: same-origin
|
|
* X-Requested-With: XMLHttpRequest (parfois)
|
|
*
|
|
* @param {string} url - URL complète à fetcher
|
|
* @param {string} origin - origine EasyVista (pour construire le Referer)
|
|
* @param {object} [opts] - options fetch (method, body, headers supplémentaires)
|
|
*/
|
|
async function evFetch(url, origin, opts = {}) {
|
|
const defaultHeaders = {
|
|
"Referer": `${origin}/index.php?eventName=HelpDesk_PlanningItem`,
|
|
"X-Requested-With": "XMLHttpRequest"
|
|
};
|
|
const headers = Object.assign({}, defaultHeaders, opts.headers || {});
|
|
const fetchOpts = Object.assign({ credentials: "include" }, opts, { headers });
|
|
return await fetch(url, fetchOpts);
|
|
}
|
|
|
|
/**
|
|
* 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 evFetch(url, origin);
|
|
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)}`;
|
|
|
|
// v2026.5.16 : juste après une reconnexion SSO, EasyVista retourne parfois
|
|
// une page intermédiaire tronquée (~8 Ko au lieu de ~250 Ko), le temps que
|
|
// les cookies SSO/Kerberos se propagent. On fait jusqu'à 3 tentatives avec
|
|
// 1.5s entre chaque si on détecte une taille suspecte.
|
|
const MAX_RETRIES = 3;
|
|
const RETRY_DELAY_MS = 1500;
|
|
const MIN_VALID_SIZE = 20000; // < 20 Ko = probablement page intermédiaire
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
const r = await evFetch(url, origin);
|
|
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();
|
|
// 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})`);
|
|
}
|
|
|
|
// 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));
|
|
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...`);
|
|
await new Promise(res => setTimeout(res, RETRY_DELAY_MS));
|
|
continue;
|
|
}
|
|
|
|
// Sinon : on retourne ce qu'on a
|
|
return html;
|
|
}
|
|
// Ne devrait pas arriver (la boucle fait return avant)
|
|
throw new Error("fetchFicheHtml: max retries reached");
|
|
}
|
|
|
|
// 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 evFetch(url, origin);
|
|
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"
|
|
// ============================================================================
|
|
|
|
/**
|
|
* v5.0.9 : détecte plusieurs patterns de session invalide :
|
|
* 1. Page de login classique EasyVista (customer_login, my.policy)
|
|
* 2. Script de redirection court : <script>window.location.href = "..."</script>
|
|
* (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 && /<script[^>]*>\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`;
|
|
// v2026.5.38 : log retiré (appelé toutes les minutes, polluait la console).
|
|
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");
|
|
}
|
|
return parseInt(body, 10);
|
|
}
|
|
|
|
/**
|
|
* 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`;
|
|
// v2026.5.38 : log retiré (déclenché par bouton "prolonger", l'UI affiche déjà un toast).
|
|
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");
|
|
}
|
|
return parseInt(body, 10);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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">}
|
|
*
|
|
* @author Quentin Rouiller
|
|
*/
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* v2026.5.16 : surveille un onglet ouvert pour détecter si le Windows SSO
|
|
* a échoué et rediriger vers la bonne page.
|
|
*
|
|
* Quand la session portail Canton est expirée, EasyVista redirige vers
|
|
* https://portail.etat-de-vaud.ch/iamlogin/?spEntityID=...
|
|
* (page de login manuel moche). On préfère rediriger vers
|
|
* https://portail.etat-de-vaud.ch/iam/accueil/
|
|
* qui déclenche le Windows Kerberos SSO automatique.
|
|
*
|
|
* @param {number} tabId - ID de l'onglet à surveiller
|
|
*/
|
|
function watchReconnectTabForIamLogin(tabId) {
|
|
let redirected = false;
|
|
const timeoutMs = 60000; // surveille max 60s
|
|
|
|
const listener = (updatedTabId, changeInfo, tab) => {
|
|
if (updatedTabId !== tabId) return;
|
|
if (redirected) return;
|
|
const url = changeInfo.url || (tab && tab.url) || "";
|
|
if (!url) return;
|
|
|
|
// Détecter la page de login manuel
|
|
// Patterns : portail.etat-de-vaud.ch/iamlogin/ ou www.portail.vd.ch/iamlogin/
|
|
if (/\/iamlogin\//i.test(url) && /portail\./i.test(url)) {
|
|
redirected = true;
|
|
// Choisir le domaine de redirection :
|
|
// - si on voit portail.etat-de-vaud.ch → rester sur interne
|
|
// - si on voit www.portail.vd.ch → rester sur externe
|
|
let targetUrl;
|
|
if (/portail\.etat-de-vaud\.ch/i.test(url)) {
|
|
targetUrl = "https://portail.etat-de-vaud.ch/iam/accueil/";
|
|
} else {
|
|
targetUrl = "https://www.portail.vd.ch/iam/accueil/";
|
|
}
|
|
console.log(`[bg] watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
|
|
chrome.tabs.update(tabId, { url: targetUrl }).catch(e => {
|
|
console.warn("[bg] watchReconnectTab : update failed", e);
|
|
});
|
|
}
|
|
};
|
|
|
|
chrome.tabs.onUpdated.addListener(listener);
|
|
|
|
// Stop la surveillance après 60s pour ne pas accumuler des listeners morts
|
|
setTimeout(() => {
|
|
try {
|
|
chrome.tabs.onUpdated.removeListener(listener);
|
|
} catch (e) {}
|
|
}, timeoutMs);
|
|
|
|
// Si l'onglet est fermé, stop aussi
|
|
const closeListener = (closedTabId) => {
|
|
if (closedTabId === tabId) {
|
|
try { chrome.tabs.onUpdated.removeListener(listener); } catch (e) {}
|
|
try { chrome.tabs.onRemoved.removeListener(closeListener); } catch (e) {}
|
|
}
|
|
};
|
|
chrome.tabs.onRemoved.addListener(closeListener);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 :
|
|
* - <title>...Nom, Prénom...</title>
|
|
* - é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é.
|
|
*
|
|
* @author Quentin Rouiller
|
|
*/
|
|
async function fetchCurrentUser(origin, phpsessid) {
|
|
const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`;
|
|
// v2026.5.40 : on passe par evFetch() qui ajoute les headers Referer +
|
|
// X-Requested-With attendus par EV. Sans ça, /index.php renvoyait une
|
|
// page intermédiaire SSO sans le bloc .profile-info → user_null perpétuel.
|
|
const resp = await evFetch(url, origin, {
|
|
method: "GET",
|
|
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) {
|
|
LOG.warn("currentUser", "fetch /index.php non-OK", { status: resp.status });
|
|
return null;
|
|
}
|
|
const html = await resp.text();
|
|
if (looksLikeLoginPage(html)) {
|
|
LOG.warn("currentUser", "page de login detectee — session probablement intermediaire SSO");
|
|
return null;
|
|
}
|
|
LOG.info("currentUser", "HTML index.php recu", { taille: html.length, hasProfileInfo: /class=["']profile-info["']/.test(html) });
|
|
|
|
// 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 :
|
|
// <span class="profile-info">
|
|
// <span class="h5" title="Nom, Prénom">Nom, Prénom</span>
|
|
// <span class="h6" title="3.3 DGNSI-ServiceDesk">3.3 DGNSI-ServiceDesk</span>
|
|
// ...
|
|
// </span>
|
|
// Le title du <a> parent contient aussi "Nom, Prénom / Service / Société".
|
|
const patterns = [
|
|
// 1) Le plus fiable : span class="h5" dans profile-info (structure EV 2026)
|
|
/<span\s+class=["']profile-info["'][^>]*>\s*<span\s+class=["']h5["'][^>]*title=["']([^"']{2,80})["']/i,
|
|
// 2) Fallback : span class="h5" avec title= même hors profile-info
|
|
/<span\s+class=["']h5["'][^>]*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(
|
|
/<span\s+class=["']profile-info["'][^>]*>[\s\S]{0,500}?<span\s+class=["']h6["'][^>]*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) {
|
|
// v2026.5.40 : log diagnostic pour comprendre pourquoi l'extraction echoue
|
|
LOG.warn("currentUser", "aucun nom/service/login extrait du HTML",
|
|
{
|
|
taille: html.length,
|
|
hasH5: /class=["']h5["']/.test(html),
|
|
hasProfileInfo: /class=["']profile-info["']/.test(html),
|
|
hasEvDropdown: /class=["'][^"']*ev-employee-dropdown/.test(html),
|
|
snippet: html.substring(0, 200).replace(/\s+/g, " ")
|
|
});
|
|
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é");
|
|
|
|
// v2026.5.41 : heures synchronisées avec admin_config.
|
|
const { start, end } = await getDayBounds();
|
|
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=${start}`
|
|
+ `&end_hour=${end}`
|
|
+ `&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é");
|
|
|
|
// v2026.5.41 : heures synchronisées avec admin_config.
|
|
const { start, end } = await getDayBounds();
|
|
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=${start}`
|
|
+ `&end_hour=${end}`
|
|
+ `&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.14 : confirmé par capture Network réelle — EasyVista utilise
|
|
// "Planning_delete_absence" pour TOUS les types d'entrée planning (absences,
|
|
// réservations, événements, etc.). Réponse XML : <Planning_delete_absence>true</...>
|
|
// On met donc ce nom en PREMIER pour tout, et on garde les autres en fallback.
|
|
const fnNames = [
|
|
"Planning_delete_absence", // ← le seul qui marche vraiment côté EV
|
|
// Fallbacks historiques (au cas où EV change un jour) :
|
|
"Planning_delete_reservation",
|
|
"delete_absence",
|
|
"delete_reservation",
|
|
"fc_delete_absence",
|
|
"fc_delete_reservation"
|
|
];
|
|
|
|
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 <script> de redirection CSRF qui ne ressemble pas à une erreur et
|
|
// 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)}`);
|
|
|
|
if (r.status === 401 || r.status === 403) {
|
|
throw new Error("session_expired");
|
|
}
|
|
if (!r.ok) {
|
|
lastErr = new Error("HTTP " + r.status);
|
|
continue; // tente le prochain
|
|
}
|
|
if (looksLikeLoginPage(body)) {
|
|
throw new Error("session_expired");
|
|
}
|
|
|
|
// v5.0.14 : détection explicite du succès XML observé dans les captures
|
|
// réseau : <Planning_delete_absence>true</Planning_delete_absence>
|
|
const trimmed = (body || "").trim();
|
|
const lower = trimmed.toLowerCase();
|
|
|
|
// 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}`);
|
|
return { status: r.status, functionName: fn, body: trimmed };
|
|
}
|
|
|
|
// Détection d'échec : <X>false</X>, erreurs, html, redirect, etc.
|
|
const looksLikeError = /^<\w+>false<\/\w+>\s*$/i.test(trimmed)
|
|
|| lower.includes("error")
|
|
|| lower.includes("erreur")
|
|
|| lower.includes("unknown function")
|
|
|| lower.includes("fonction inconnue")
|
|
|| lower.includes("<html")
|
|
|| lower.includes("window.location.href");
|
|
if (looksLikeError) {
|
|
console.log(`[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}`);
|
|
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);
|
|
lastErr = err;
|
|
}
|
|
}
|
|
|
|
// Aucun n'a fonctionné
|
|
throw new Error("Aucun endpoint de suppression n'a fonctionné. "
|
|
+ (lastBody ? "Dernière réponse : " + lastBody.substring(0, 100) : "")
|
|
+ (lastErr ? " | " + lastErr.message : ""));
|
|
}
|
|
|
|
// ============================================================================
|
|
// v2026.5.41 : Détection des GROUPES EasyVista (SI-CSS, SI-EXT, …)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Fetch le HTML de la page planning et extrait la liste des groupes depuis
|
|
* `<select id="plan_group_id">`. C'est la source autoritative : ce sont les
|
|
* `group_id` exacts qu'EasyVista lui-même utilise pour le widget de
|
|
* changement de groupe sur la page planning. Robuste aux ajouts/renommages :
|
|
* si le DGNSI ajoute un 3e groupe ou renomme SI-CSS, ça apparaît tout seul.
|
|
*
|
|
* Retourne { groups: [{id, name}, ...] }. Liste vide si le fetch échoue ou
|
|
* si EV renvoie une page de login / redirection JS.
|
|
*
|
|
* @author Quentin Rouiller
|
|
*/
|
|
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);
|
|
|
|
const r = await evFetch(url, origin);
|
|
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] page planning taille =", html.length);
|
|
|
|
if (looksLikeLoginPage(html)) {
|
|
const err = new Error("session_expired");
|
|
err.kind = "session_expired";
|
|
throw err;
|
|
}
|
|
|
|
// Parser le <select id="plan_group_id">…</select> et ses <option>.
|
|
// On accepte plan_group_id avant ou après le name=, et on tolère les
|
|
// attributs supplémentaires (onchange, class, etc.).
|
|
const groups = [];
|
|
const selectMatch = html.match(/<select[^>]*\bid=["']plan_group_id["'][^>]*>([\s\S]*?)<\/select>/i);
|
|
if (selectMatch) {
|
|
const inner = selectMatch[1];
|
|
const rxOpt = /<option[^>]*\bvalue=["'](\d+)["'][^>]*>([^<]+)<\/option>/gi;
|
|
let m;
|
|
while ((m = rxOpt.exec(inner)) !== null) {
|
|
const id = m[1];
|
|
const name = (m[2] || "").trim();
|
|
if (id && name && !groups.some(g => g.id === id)) {
|
|
groups.push({ id, name });
|
|
}
|
|
}
|
|
}
|
|
console.log("[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.
|
|
// Ces favoris listent les filtres "SI-CSS"/"SI-EXT" — utile si le HTML
|
|
// initial ne contient pas encore le <select>.
|
|
if (groups.length === 0) {
|
|
const rxFav = /"TITLE"\s*:\s*"([^"]+)"[\s\S]{0,400}?"q2_value_selected"\s*:\s*"([A-Za-z0-9+/=]+)"/g;
|
|
let mF;
|
|
while ((mF = rxFav.exec(html)) !== null) {
|
|
const title = mF[1];
|
|
// 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é)");
|
|
}
|
|
}
|
|
|
|
return { groups };
|
|
}
|
|
|
|
/**
|
|
* v5.0.1 : Détection de la liste complète des membres du groupe EasyVista.
|
|
*
|
|
* Stratégie :
|
|
* 1) On part des valeurs connues (group_id=191 et support_ids par défaut).
|
|
* Pas besoin de fetcher la page planning HTML (qui souvent ne contient
|
|
* pas ces valeurs accessibles en fetch direct, car EasyVista utilise
|
|
* des redirections JS).
|
|
* 2) Fetch direct /include/components/staff/planning/plan_view_group_supports.php
|
|
* qui retourne le HTML d'une popup listant tous les membres du groupe.
|
|
* 3) Parser ce HTML pour extraire les paires (id, nom).
|
|
*
|
|
* Retourne { ids: [{id, name, alreadyInTeam}], groupId }.
|
|
*/
|
|
async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
|
|
// v2026.5.41 : groupId vient de l'argument, sinon de admin_config (défaut SI-CSS).
|
|
// supportIds vient de l'argument, sinon de admin_config (vide tant que rien n'est
|
|
// sélectionné). Le serveur retourne tous les membres du groupe quoi qu'il arrive ;
|
|
// supportIds sert juste à pré-cocher les techs déjà inclus dans l'équipe.
|
|
const groupId = groupIdArg || await getGroupId();
|
|
const supportIds = (typeof supportIdsArg === "string")
|
|
? supportIdsArg
|
|
: await getSupportIds();
|
|
console.log("[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"
|
|
+ "?PHPSESSID=" + encodeURIComponent(phpsessid)
|
|
+ "&eventName="
|
|
+ "&theme="
|
|
+ "&support_ids=" + encodeURIComponent(supportIds)
|
|
+ "&group_id=" + encodeURIComponent(groupId);
|
|
|
|
console.log("[bg] detectTeamFromEV → popup group_supports");
|
|
console.log("[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);
|
|
if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group");
|
|
popupHtml = await r.text();
|
|
console.log("[bg] popup taille HTML =", popupHtml.length);
|
|
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
|
|
} catch (e) {
|
|
console.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);
|
|
return {
|
|
ids: ids.map(id => ({ id, name: "? (" + id + ")", alreadyInTeam: true })),
|
|
groupId
|
|
};
|
|
}
|
|
|
|
// Parser le HTML. Différents patterns possibles.
|
|
const results = [];
|
|
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));
|
|
|
|
// Pattern 1 : checkboxes + texte voisin
|
|
const rxCheckbox = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi;
|
|
let mC;
|
|
while ((mC = rxCheckbox.exec(popupHtml)) !== null) {
|
|
const id = mC[1];
|
|
const context = mC[2];
|
|
const nameMatch = context.match(/([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]+)/);
|
|
const name = nameMatch ? nameMatch[1].trim() : null;
|
|
if (!results.some(r => r.id === id)) {
|
|
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
|
|
}
|
|
}
|
|
console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats");
|
|
|
|
// Pattern 2 : fallback <option value="NNNNN">Nom...</option>
|
|
if (results.length === 0) {
|
|
const rxOption = /<option[^>]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi;
|
|
let mO;
|
|
while ((mO = rxOption.exec(popupHtml)) !== null) {
|
|
const id = mO[1];
|
|
const name = (mO[2] || "").trim();
|
|
if (!results.some(r => r.id === id)) {
|
|
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
|
|
}
|
|
}
|
|
console.log("[bg] parsing pattern 2 (option) :", results.length, "résultats");
|
|
}
|
|
|
|
// Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom
|
|
if (results.length === 0) {
|
|
// Chercher chaque ID 4-7 chiffres et regarder les 200 caractères qui suivent
|
|
const rxAnyId = /\b(\d{5,7})\b([\s\S]{0,200})/g;
|
|
let mA;
|
|
while ((mA = rxAnyId.exec(popupHtml)) !== null) {
|
|
const id = mA[1];
|
|
// Ignorer les IDs qui ressemblent à des timestamps / hash
|
|
if (id.length > 6 && parseInt(id, 10) > 1000000000) continue;
|
|
const context = mA[2];
|
|
const nameMatch = context.match(/([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]{2,30})/);
|
|
if (nameMatch && !results.some(r => r.id === id)) {
|
|
results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) });
|
|
}
|
|
}
|
|
console.log("[bg] parsing pattern 3 (brut) :", results.length, "résultats");
|
|
}
|
|
|
|
// Ajouter les IDs actuels manquants (sans nom)
|
|
for (const id of currentIdsSet) {
|
|
if (!results.some(r => r.id === id)) {
|
|
results.push({ id, name: "? (" + id + ")", alreadyInTeam: true });
|
|
}
|
|
}
|
|
|
|
console.log("[bg] " + results.length + " personnes retournées");
|
|
return { ids: results, groupId: groupId };
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 === "deletePlanningItem") {
|
|
// v5.0.0 : supprime une absence ou réservation côté EasyVista.
|
|
// Endpoint : /planning_updator_xhr.php?function_name=...&action_id=...
|
|
// Exemples de function_name :
|
|
// - Planning_delete_absence
|
|
// - Planning_delete_reservation
|
|
const session = await findEasyVistaSession();
|
|
if (!session) {
|
|
sendResponse({ ok: false, error: "no_session" });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await deletePlanningItem(
|
|
session.origin, session.phpsessid, msg.actionId, msg.kind
|
|
);
|
|
sendResponse({ ok: true, result });
|
|
} catch (err) {
|
|
sendResponse({ ok: false, error: err.message || String(err) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "detectGroups") {
|
|
// v2026.5.41 : détecte la liste des groupes EV (SI-CSS, SI-EXT, …)
|
|
// depuis le <select id="plan_group_id"> de la page planning.
|
|
const session = await findEasyVistaSession();
|
|
if (!session) {
|
|
sendResponse({ ok: false, error: "no_session" });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await detectGroupsFromEV(session.origin, session.phpsessid);
|
|
sendResponse({ ok: true, groups: result.groups });
|
|
} catch (err) {
|
|
sendResponse({
|
|
ok: false,
|
|
error: err.kind || err.message || String(err)
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "detectTeam") {
|
|
// v5.0.0 : détecte la liste des IDs de techniciens depuis le HTML
|
|
// v5.0.1 : retourne aussi les noms via la popup group_supports
|
|
// v2026.5.41 : accepte msg.groupId pour basculer entre SI-CSS / SI-EXT
|
|
const session = await findEasyVistaSession();
|
|
if (!session) {
|
|
sendResponse({ ok: false, error: "no_session" });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await detectTeamFromEV(
|
|
session.origin, session.phpsessid, msg.groupId, msg.supportIds
|
|
);
|
|
// result = { ids: [{id,name,alreadyInTeam}, ...], groupId }
|
|
sendResponse({ ok: true, members: result.ids, groupId: result.groupId });
|
|
} catch (err) {
|
|
sendResponse({ ok: false, error: err.message || String(err) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "getSessionRemaining") {
|
|
// v5.0.9 : récupère le temps restant avant expiration de la session EV
|
|
const session = await findEasyVistaSession();
|
|
if (!session) {
|
|
sendResponse({ ok: false, error: "no_session" });
|
|
return;
|
|
}
|
|
try {
|
|
const remainingMs = await fetchSessionTimeRemaining(session.origin, session.phpsessid);
|
|
sendResponse({ ok: true, remainingMs, phpsessid: session.phpsessid });
|
|
} catch (err) {
|
|
sendResponse({ ok: false, error: err.message || String(err) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "extendSession") {
|
|
// v5.0.9 : prolonge la session EV à 30 min via keep_connection
|
|
const session = await findEasyVistaSession();
|
|
if (!session) {
|
|
sendResponse({ ok: false, error: "no_session" });
|
|
return;
|
|
}
|
|
try {
|
|
const remainingMs = await extendSessionKeepAlive(session.origin, session.phpsessid);
|
|
sendResponse({ ok: true, remainingMs });
|
|
} catch (err) {
|
|
sendResponse({ ok: false, error: err.message || String(err) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "openEasyVistaLogin") {
|
|
// v5.0.9 : ouvre EasyVista dans un nouvel onglet pour provoquer
|
|
// le SSO Windows automatique (reconnexion transparente).
|
|
// v5.0.11 : URL simplifiée (racine domaine au lieu de eventName=...),
|
|
// et utilise le contexte réseau détecté si l'origine n'est pas fournie.
|
|
let origin = msg.origin;
|
|
if (!origin) {
|
|
const context = await detectNetworkContext();
|
|
origin = originForContext(context);
|
|
}
|
|
try {
|
|
const tab = await chrome.tabs.create({
|
|
url: `${origin}/`, // racine → EV redirige vers SSO si besoin
|
|
active: true
|
|
});
|
|
// v2026.5.16 : surveiller cet onglet — si on tombe sur la page de
|
|
// login manuel portail.etat-de-vaud.ch/iamlogin/, rediriger vers
|
|
// portail.etat-de-vaud.ch/iam/accueil/ qui déclenche le Windows
|
|
// SSO Kerberos automatiquement.
|
|
watchReconnectTabForIamLogin(tab.id);
|
|
sendResponse({ ok: true, tabId: tab.id, origin });
|
|
} catch (err) {
|
|
sendResponse({ ok: false, error: err.message || String(err) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "detectNetwork") {
|
|
// v5.0.11 : détecte si on est en interne ou externe.
|
|
const context = await detectNetworkContext(!!msg.force);
|
|
sendResponse({
|
|
ok: true,
|
|
context, // "internal" | "external"
|
|
origin: originForContext(context) // URL correspondante
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "cleanupOldCaches") {
|
|
const removed = await cleanupOldCaches(msg.daysToKeep || 7);
|
|
sendResponse({ ok: true, removed });
|
|
return;
|
|
}
|
|
|
|
// v2026.5.38 : toggle debug logs (depuis le panel admin du viewer)
|
|
if (msg.type === "setDebugLogs") {
|
|
LOG.setDebug(!!msg.on);
|
|
sendResponse({ ok: true, debug: LOG.isDebug() });
|
|
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;
|
|
}
|
|
|
|
// v2026.5.39 : on lit admin_config pour récupérer cacheDays. Si pas dispo,
|
|
// fallback sur 7 jours.
|
|
async function _getCacheDays() {
|
|
try {
|
|
const o = await chrome.storage.local.get("admin_config");
|
|
const cfg = o && o.admin_config;
|
|
if (cfg && typeof cfg.cacheDays === "number" && cfg.cacheDays > 0) {
|
|
return cfg.cacheDays;
|
|
}
|
|
} catch (e) {
|
|
LOG.warn("cache", "lecture admin_config échouée, fallback 7 jours", { err: e && e.message });
|
|
}
|
|
return 7;
|
|
}
|
|
|
|
// Au démarrage, nettoyer les anciennes alarmes et les anciens caches
|
|
chrome.runtime.onInstalled.addListener(async () => {
|
|
clearLegacyRefreshAlarms();
|
|
const days = await _getCacheDays();
|
|
cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onInstalled", { err: err && err.message }));
|
|
});
|
|
|
|
chrome.runtime.onStartup.addListener(async () => {
|
|
clearLegacyRefreshAlarms();
|
|
const days = await _getCacheDays();
|
|
cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onStartup", { err: err && err.message }));
|
|
});
|