Public Access
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:
+354
-8
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user