v2026.5.45 — Dock latéral drag&drop, fix verdicts ghost, multi-onglets EZV

Refonte de l'expérience drag&drop avec dock latéral à droite pour parquer
des interventions entre les jours. Fix critique du parser de fiche EV qui
marquait à tort des interventions terminées comme annulées (les dates
d'action sont détectées par scan regex au lieu d'indices fixes [8]/[9]).
Décorrélation des « Logs verbeux » et de la case « Garder les disparitions »
dans Paramètres → Diagnostics.

Issues résolues : #3 #4 #5 #6 #7 #8.

Closes #3 #4 #5 #6 #7 #8
This commit is contained in:
2026-05-08 16:30:47 +02:00
parent c65e943dac
commit 06c0195130
8 changed files with 4560 additions and 396 deletions
+354 -8
View File
@@ -188,6 +188,48 @@ async function getDayBounds() {
// Clic sur l'icône → ouvrir le viewer
// ============================================================================
// ============================================================================
// badge "!" clignotant sur l'icône de l'extension quand la session
// EasyVista va expirer. Permet à l'utilisateur de voir l'alerte même si
// l'onglet planning n'est pas au premier plan.
// ============================================================================
let _expirationBlinkInterval = null;
let _expirationBlinkTimeout = null;
function _setExpirationBadge(active, durationMs) {
// Stop l'état précédent
if (_expirationBlinkInterval) {
clearInterval(_expirationBlinkInterval);
_expirationBlinkInterval = null;
}
if (_expirationBlinkTimeout) {
clearTimeout(_expirationBlinkTimeout);
_expirationBlinkTimeout = null;
}
try { chrome.action.setBadgeText({ text: "" }); } catch (e) {}
if (!active) return;
// Couleur de fond rouge pour le "!"
try { chrome.action.setBadgeBackgroundColor({ color: "#d6443d" }); } catch (e) {}
let on = true;
const tick = () => {
try {
chrome.action.setBadgeText({ text: on ? "!" : "" });
} catch (e) {}
on = !on;
};
tick();
_expirationBlinkInterval = setInterval(tick, 700);
if (durationMs && durationMs > 0) {
_expirationBlinkTimeout = setTimeout(() => {
_setExpirationBadge(false);
}, durationMs);
}
}
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
@@ -207,24 +249,146 @@ chrome.action.onClicked.addListener(async () => {
/**
* Trouve l'onglet EasyVista ouvert et récupère phpsessid + origin.
*
* v2026.5.45 (issue #4) : si la permission optionnelle "cookies" est
* accordée, on lit le PHPSESSID directement depuis le cookie HttpOnly du
* domaine EZV — toujours à jour, immune au PHPSESSID périmé qu'on trouve
* dans l'URL des onglets historiques après un relog. Sinon, fallback sur
* l'ancienne logique URL (compat sans la permission).
*
* @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.
async function _hasCookiesPermission() {
return new Promise(resolve => {
try {
chrome.permissions.contains({ permissions: ["cookies"] }, granted => {
resolve(!!granted);
});
} catch (e) {
resolve(false);
}
});
}
async function _readPhpsessidFromCookie(origin) {
if (!chrome.cookies) return null;
return new Promise(resolve => {
try {
chrome.cookies.get({ url: origin, name: "PHPSESSID" }, c => {
if (!c || !c.value) { resolve(null); return; }
resolve({
value: c.value,
expirationDate: c.expirationDate || null
});
});
} catch (e) {
resolve(null);
}
});
}
// ─── findEasyVistaSession ───────────────────────────────────────────────────
//
// Stratégie unifiée : pour chaque origine EV configurée, on cherche le
// PHPSESSID dans cet ordre :
// 1. cookie HttpOnly (autoritatif, donne aussi expirationDate)
// 2. fallback URL d'un onglet ouvert (?PHPSESSID=…)
// On retourne la première origine qui a un onglet ouvert ET un PHPSESSID.
//
// Anti-rafale : la fonction est appelée par ~16 endroits dans le service
// worker, parfois en parallèle. On cache le résultat 200 ms pour éviter
// le bombardement de chrome.cookies.get + chrome.tabs.query, et de logs
// dupliqués. Le cache est invalidé immédiatement quand un événement
// cookies.onChanged tombe (relog, expiration).
//
// Logs : une seule ligne `LOG.warn` par CHANGEMENT d'état (transition
// cookie↔url, nouveau PHPSESSID, ou perte de session). Pas de log
// répétitif par appel — utiliser `LOG.info` pour les détails internes.
let _sessionCache = null;
let _sessionCacheTs = 0;
// Cache désactivé temporairement (TTL=0) — soupçon que la durée de
// fenêtre stale puisse causer une réponse "page de login" (~8 Ko) lors
// d'un fetch fiche. À remettre à 200 après confirmation que ce n'était
// pas le souci.
const _SESSION_CACHE_TTL_MS = 0;
let _lastSessionSignature = null;
function _invalidateSessionCache() {
_sessionCache = null;
_sessionCacheTs = 0;
}
async function _findEasyVistaSessionRaw() {
const origins = await getEvOrigins();
const hasCookies = await _hasCookiesPermission();
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 };
if (!tabs.length) continue;
let phpsessid = null;
let expirationDate = null;
let source = null;
if (hasCookies) {
const c = await _readPhpsessidFromCookie(origin);
if (c && c.value) {
phpsessid = c.value;
expirationDate = c.expirationDate;
source = "cookie";
}
}
if (!phpsessid) {
for (const tab of tabs) {
const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/);
if (m) {
phpsessid = m[1];
source = "url";
break;
}
}
}
if (phpsessid) {
return { phpsessid, origin, tabId: tabs[0].id, source, expirationDate };
}
}
return null;
}
async function findEasyVistaSession() {
const now = Date.now();
if (_sessionCache !== null && now - _sessionCacheTs < _SESSION_CACHE_TTL_MS) {
return _sessionCache;
}
if (_sessionCache === null && _sessionCacheTs > 0 && now - _sessionCacheTs < _SESSION_CACHE_TTL_MS) {
// Cache négatif (pas de session trouvée à l'instant) — même TTL.
return null;
}
const result = await _findEasyVistaSessionRaw();
_sessionCache = result;
_sessionCacheTs = now;
// Log uniquement si l'état change (source, origine, ou PHPSESSID).
const sig = result
? `${result.source}|${result.origin}|${result.phpsessid.slice(0, 8)}`
: "none";
if (sig !== _lastSessionSignature) {
if (result) {
LOG.warn("session",
`🔑 PHPSESSID via ${result.source.toUpperCase()} (${result.phpsessid.slice(0, 8)}…) sur ${result.origin}`,
{ source: result.source, origin: result.origin,
expirationDate: result.expirationDate || null });
} else {
LOG.warn("session", "❌ Aucun PHPSESSID disponible (pas d'onglet EZV ouvert ou cookie absent)");
}
_lastSessionSignature = sig;
}
return result;
}
// ============================================================================
// Fetch helpers (s'exécutent dans le contexte du service worker,
// les cookies du domaine sont automatiquement inclus via credentials: include)
@@ -1263,6 +1427,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
// badge "!" clignotant sur l'icône de l'extension.
// { active: true, durationMs?: number }
if (msg.type === "setExpirationBadge") {
_setExpirationBadge(!!msg.active, msg.durationMs || 0);
sendResponse({ ok: true });
return;
}
if (msg.type === "fetchPlanning") {
const session = await findEasyVistaSession();
if (!session) {
@@ -1336,6 +1508,88 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// probe rapide de session — fetch un endpoint léger pour vérifier
// que PHPSESSID est toujours valide. Renvoie ok=false/error=session_expired
// si la session est morte.
// (feature reschedule) : déplace une intervention vers un autre
// tech / nouvelle date / nouvel horaire de début. Durée préservée
// automatiquement par EZV (l'API ne change que start_date+hour+minute).
// Args : actionId, employeeId, date (DD/MM/YYYY), hour (0-23), minute (0-59).
if (msg.type === "rescheduleAction") {
const session = await findEasyVistaSession();
if (!session) { sendResponse({ ok: false, error: "no_session" }); return; }
try {
const url = `${session.origin}/planning_updator_xhr.php` +
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}` +
`&function_name=Planning_schedule_action_Employee` +
`&action_id=${encodeURIComponent(msg.actionId)}` +
`&employee_id=${encodeURIComponent(msg.employeeId)}` +
`&date=${encodeURIComponent(msg.date)}` +
`&hour=${encodeURIComponent(msg.hour)}` +
`&minute=${encodeURIComponent(msg.minute)}` +
`&multi_day_mode_act=0`;
LOG.warn("reschedule",
`📅 reschedule actionId=${msg.actionId} → tech=${msg.employeeId} ${msg.date} ${msg.hour}:${String(msg.minute).padStart(2,"0")}`);
const r = await evFetch(url, session.origin);
if (!r.ok) {
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
return;
}
const txt = await r.text();
if (looksLikeLoginPage(txt)) {
sendResponse({ ok: false, error: "session_expired" });
return;
}
sendResponse({ ok: true, response: txt });
} catch (err) {
LOG.warn("reschedule", "rescheduleAction err", { err: err && err.message });
sendResponse({ ok: false, error: err.kind || "fetch_failed", detail: err.message });
}
return;
}
// (feature reschedule) : modifie la durée (heure début/fin) d'une
// action sans changer de tech. POST application/x-www-form-urlencoded
// sur planning_updator_xhr.php avec function_name=fc_save_inspector.
// Args : actionId, suffix (ex "act_<id>_nb_0_date_<DDMMYYYY>"),
// startDate, endDate (DD/MM/YYYY), startTime, endTime (HH:MM).
if (msg.type === "updateActionTimes") {
const session = await findEasyVistaSession();
if (!session) { sendResponse({ ok: false, error: "no_session" }); return; }
try {
const params = new URLSearchParams();
params.set(`start_date_${msg.suffix}`, msg.startDate);
params.set(`start_time_${msg.suffix}`, msg.startTime);
params.set(`end_date_${msg.suffix}`, msg.endDate);
params.set(`end_time_${msg.suffix}`, msg.endTime);
params.set("action_id", msg.actionId);
params.set("suffix_act", msg.suffix);
params.set(`act_absence_${msg.suffix}`, "");
params.set("function_name", "fc_save_inspector");
const body = params.toString();
const url = `${session.origin}/planning_updator_xhr.php` +
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}`;
LOG.warn("reschedule",
`⏱ updateActionTimes actionId=${msg.actionId} ${msg.startDate} ${msg.startTime}${msg.endTime}`);
const r = await evFetch(url, session.origin, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
});
if (!r.ok) {
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
return;
}
const txt = await r.text();
if (looksLikeLoginPage(txt)) {
sendResponse({ ok: false, error: "session_expired" });
return;
}
sendResponse({ ok: true, response: txt });
} catch (err) {
LOG.warn("reschedule", "updateActionTimes err", { err: err && err.message });
sendResponse({ ok: false, error: err.kind || "fetch_failed", detail: err.message });
}
return;
}
if (msg.type === "checkSession") {
const session = await findEasyVistaSession();
if (!session) {
@@ -1655,10 +1909,33 @@ async function _getCacheDays() {
}
// Au démarrage, nettoyer les anciennes alarmes et les anciens caches
chrome.runtime.onInstalled.addListener(async () => {
// v2026.5.45 (issue #3) : on stocke aussi un marqueur d'install/update
// avec previousVersion. Au prochain ouverture du viewer, on affiche un
// toast préventif si la version précédente était < 2026.5.44 (où le bug
// structurel d'écrasement des absences récurrentes existait encore).
chrome.runtime.onInstalled.addListener(async (details) => {
clearLegacyRefreshAlarms();
const days = await _getCacheDays();
cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onInstalled", { err: err && err.message }));
try {
const reason = details && details.reason;
const previousVersion = (details && details.previousVersion) || null;
if (reason === "install" || reason === "update") {
const currentVersion = (chrome.runtime.getManifest && chrome.runtime.getManifest().version) || null;
await chrome.storage.local.set({
_lastInstallEvent: {
reason,
previousVersion,
currentVersion,
ts: Date.now(),
notified: false
}
});
LOG.info("install", "marqueur posé", { reason, previousVersion, currentVersion });
}
} catch (e) {
LOG.warn("install", "écriture _lastInstallEvent échouée", { err: e && e.message });
}
});
chrome.runtime.onStartup.addListener(async () => {
@@ -1666,3 +1943,72 @@ chrome.runtime.onStartup.addListener(async () => {
const days = await _getCacheDays();
cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onStartup", { err: err && err.message }));
});
// v2026.5.45 (issue #4 — bonus) : listener sur les changements de cookie
// EZV. Quand la permission `cookies` est accordée, on s'abonne pour détecter
// EN TEMPS RÉEL les changements de PHPSESSID (relog dans un autre onglet,
// expiration côté serveur, révocation…). On envoie un message broadcast aux
// viewers ouverts pour qu'ils recalibrent leur compteur de session.
function _attachCookiesListener() {
if (!chrome.cookies || !chrome.cookies.onChanged) return;
if (_attachCookiesListener._attached) return;
_attachCookiesListener._attached = true;
LOG.warn("cookies", "🍪 listener PHPSESSID activé (permission cookies accordée)");
chrome.cookies.onChanged.addListener((info) => {
try {
if (!info || !info.cookie) return;
const c = info.cookie;
if (c.name !== "PHPSESSID") return;
const dom = (c.domain || "").replace(/^\./, "");
if (!/itsma\.(etat-de-vaud|vd)\.ch$/.test(dom)) return;
const phpsessid8 = c.value ? c.value.slice(0, 8) + "…" : null;
// événements importants → LOG.warn (toujours visibles).
// Détail (cause, expirationDate, …) → LOG.info (debug only).
if (info.removed) {
LOG.warn("cookies", `🚫 PHPSESSID supprimé sur ${dom} (cause=${info.cause}) → session morte`);
} else {
LOG.warn("cookies", `🍪 PHPSESSID ${phpsessid8} sur ${dom} (cause=${info.cause})`);
}
// Invalide immédiatement le cache findEasyVistaSession() pour que
// le prochain appel reflète le nouveau cookie sans attendre la TTL.
if (typeof _invalidateSessionCache === "function") _invalidateSessionCache();
LOG.info("cookies", " détail onChanged",
{ domain: dom, removed: !!info.removed, cause: info.cause,
phpsessid8, expirationDate: c.expirationDate || null,
exp: c.expirationDate ? new Date(c.expirationDate * 1000).toISOString() : null });
try {
chrome.runtime.sendMessage({
type: "cookieChanged",
domain: dom,
removed: !!info.removed,
cause: info.cause,
phpsessid: c.value || null,
expirationDate: c.expirationDate || null
}).catch(() => { /* viewers fermés → pas grave */ });
} catch (e) { /* idem */ }
} catch (e) {
LOG.warn("cookies", "listener err", { err: e && e.message });
}
});
}
// Au boot du service worker, on log l'état de la permission (toujours visible)
// et on attache le listener si déjà accordée.
(async () => {
try {
const has = await _hasCookiesPermission();
LOG.warn("cookies", `🔧 permission cookies au boot : ${has ? "ACCORDÉE → lecture cookie active" : "non accordée → fallback URL"}`);
if (has) _attachCookiesListener();
} catch (e) { /* silent */ }
})();
chrome.permissions.onAdded && chrome.permissions.onAdded.addListener((p) => {
if (p && p.permissions && p.permissions.includes("cookies")) {
LOG.warn("cookies", "✅ permission cookies ACCORDÉE par l'utilisateur");
_attachCookiesListener();
}
});
chrome.permissions.onRemoved && chrome.permissions.onRemoved.addListener((p) => {
if (p && p.permissions && p.permissions.includes("cookies")) {
LOG.warn("cookies", "🛑 permission cookies RETIRÉE → fallback URL réactivé");
}
});
+4 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "2026.5.44",
"version": "2026.5.45",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
"permissions": [
"activeTab",
@@ -10,6 +10,9 @@
"tabs",
"alarms"
],
"optional_permissions": [
"cookies"
],
"host_permissions": [
"https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*"
+857 -55
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -28,7 +28,7 @@
type="button" aria-label="Utilisateur connecté"
title="Utilisateur — cliquer pour accéder aux paramètres">?</button>
<h1 id="app-title">Planification</h1>
<!-- R13u : bloc "Aujourd'hui + horloge" encadré, suivi DIRECTEMENT
<!-- : bloc "Aujourd'hui + horloge" encadré, suivi DIRECTEMENT
du statut d'actualisation (MAJ + ✓), puis le sélecteur de date
du planning. -->
<div id="today-block" class="today-block">
+3274 -328
View File
File diff suppressed because it is too large Load Diff