v2026.5.16 — Passage au schéma de versionning ANNÉE.MAJEURE.PATCH + faux input date custom (Mardi 24.04.2026)

This commit is contained in:
Quentin Rouiller
2026-04-23 13:03:58 +02:00
parent 763e63d9c6
commit ea5a42c5e1
5 changed files with 291 additions and 26 deletions
+93 -2
View File
@@ -157,6 +157,16 @@ async function fetchXhr2(origin, phpsessid, actionId) {
async function fetchFicheHtml(origin, phpsessid, formLink) { async function fetchFicheHtml(origin, phpsessid, formLink) {
const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); console.log("[bg] fetchFicheHtml →", url.substring(0, 120));
// 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); const r = await evFetch(url, origin);
if (!r.ok) { if (!r.ok) {
const err = new Error("HTTP " + r.status); const err = new Error("HTTP " + r.status);
@@ -165,11 +175,26 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
throw err; throw err;
} }
const html = await r.text(); const html = await r.text();
console.log("[bg] fiche status =", r.status, "| taille =", html.length); console.log(`[bg] fiche status = ${r.status} | taille = ${html.length}${attempt > 1 ? ` (tentative ${attempt}/${MAX_RETRIES})` : ""}`);
// Si réponse clairement une redirection courte → login expiré, inutile de retry
if (html.length < 500) { if (html.length < 500) {
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html)); console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
}
return 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, // v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche,
@@ -375,6 +400,67 @@ function originForContext(context) {
: "https://itsma.vd.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é // v4.2 : récupération de l'utilisateur connecté
// ============================================================================ // ============================================================================
@@ -1098,6 +1184,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
url: `${origin}/`, // racine → EV redirige vers SSO si besoin url: `${origin}/`, // racine → EV redirige vers SSO si besoin
active: true 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 }); sendResponse({ ok: true, tabId: tab.id, origin });
} catch (err) { } catch (err) {
sendResponse({ ok: false, error: err.message || String(err) }); sendResponse({ ok: false, error: err.message || String(err) });
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planification", "name": "Planification",
"version": "5.0.15", "version": "2026.5.16",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", "description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.",
"permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
"host_permissions": [ "host_permissions": [
+94 -3
View File
@@ -320,6 +320,19 @@ html, body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-wrap: nowrap;
}
/* v2026.5.16 : nom court du jour (Mardi, Lundi, ...) à gauche du date-picker */
.date-picker-day {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
padding: 0 6px 0 2px;
min-width: 58px;
text-align: right;
white-space: nowrap;
user-select: none;
} }
.btn-nav { .btn-nav {
@@ -1937,18 +1950,36 @@ body.modal-open {
/* ───────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────
v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes) v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes)
───────────────────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────────────────── */
/* v2026.5.16 : app-clock contient maintenant 2 lignes empilées :
- app-clock-date : "Mardi 21 avril 2026" (petit)
- app-clock-time : "12:34" (grand) */
.app-clock { .app-clock {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.1;
color: var(--text);
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.app-clock-date {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.3px;
text-transform: capitalize;
}
.app-clock-time {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--text);
letter-spacing: 1px; letter-spacing: 1px;
pointer-events: none;
user-select: none;
} }
.topbar { position: sticky; /* déja défini plus haut */ } .topbar { position: sticky; /* déja défini plus haut */ }
/* topbar doit être en position: relative parent pour que .app-clock absolute /* topbar doit être en position: relative parent pour que .app-clock absolute
@@ -2407,3 +2438,63 @@ header.topbar::before {
.banner-reconnect-failed .banner-btn-primary:hover { .banner-reconnect-failed .banner-btn-primary:hover {
background: #f8d7da; background: #f8d7da;
} }
/* ==========================================================================
v2026.5.16 : responsive topbar
========================================================================== */
/* Breakpoint medium : entre 1000 et 1300px, on compacte un peu */
@media (max-width: 1300px) {
.app-clock-date { font-size: 11px; }
.app-clock-time { font-size: 20px; }
.topbar-right .btn-action .btn-action-label,
.topbar-right .btn-refresh .btn-refresh-label {
font-size: 12px;
}
}
/* Breakpoint small : moins de 1000px, on masque les labels de boutons action
et on réduit encore l'horloge. Les icônes restent, titres restent. */
@media (max-width: 1000px) {
.topbar { padding: 8px 14px; gap: 8px; }
.topbar h1 { font-size: 16px; }
.app-clock { font-size: smaller; }
.app-clock-date { font-size: 10px; }
.app-clock-time { font-size: 18px; }
.btn-action .btn-action-label,
.btn-refresh .btn-refresh-label {
display: none;
}
.btn-action, .btn-refresh {
padding: 6px 10px;
}
.capture-info { display: none; }
}
/* Breakpoint très petit : moins de 720px, on cache la date complète (garde
juste l'heure) et on autorise le wrap total */
@media (max-width: 720px) {
.topbar {
flex-wrap: wrap;
padding: 6px 10px;
}
.app-clock {
position: static;
transform: none;
margin: 0 auto;
}
.app-clock-date { display: none; }
.topbar-left { flex-wrap: wrap; }
.date-nav { margin-top: 4px; }
.date-picker-day { min-width: 46px; font-size: 12px; }
.topbar-right { flex-wrap: wrap; justify-content: flex-end; }
}
/* Breakpoint minuscule : masque aussi les labels de refresh, boutons deviennent
vraiment iconifiés */
@media (max-width: 520px) {
.app-clock-time { font-size: 16px; }
.topbar h1 { font-size: 14px; }
.btn-today { padding: 4px 6px; font-size: 11px; }
.btn-nav { min-width: 26px; padding: 4px 6px; }
}
+7 -2
View File
@@ -16,6 +16,8 @@
<h1 id="app-title">Planification</h1> <h1 id="app-title">Planification</h1>
<div class="date-nav"> <div class="date-nav">
<button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button> <button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button>
<!-- v2026.5.16 : nom court du jour (Lun, Mar, ...) avant le date-picker -->
<span id="date-picker-day" class="date-picker-day"></span>
<input type="date" id="date-picker" class="date-input"> <input type="date" id="date-picker" class="date-input">
<button id="nav-next" class="btn btn-nav" title="Jour suivant" aria-label="Jour suivant"></button> <button id="nav-next" class="btn btn-nav" title="Jour suivant" aria-label="Jour suivant"></button>
<button id="nav-today" class="btn btn-today" title="Aujourd'hui">Auj.</button> <button id="nav-today" class="btn btn-today" title="Aujourd'hui">Auj.</button>
@@ -23,8 +25,11 @@
<span id="capture-info" class="capture-info"></span> <span id="capture-info" class="capture-info"></span>
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span> <span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span>
</div> </div>
<!-- v5.0.0 : horloge au milieu, format HH:MM, mise à jour toutes les min --> <!-- v2026.5.16 : date complète du jour au-dessus de l'heure dans la topbar -->
<div id="app-clock" class="app-clock" title="Heure actuelle"></div> <div id="app-clock" class="app-clock" title="Date et heure actuelles">
<div id="app-clock-date" class="app-clock-date"></div>
<div id="app-clock-time" class="app-clock-time"></div>
</div>
<!-- v5.0.9 : compteur de session EasyVista (visible < 5 min restantes) --> <!-- v5.0.9 : compteur de session EasyVista (visible < 5 min restantes) -->
<div id="app-session" class="app-session hidden"></div> <div id="app-session" class="app-session hidden"></div>
<div class="topbar-right"> <div class="topbar-right">
+86 -8
View File
@@ -246,6 +246,7 @@ async function init() {
// Initialiser la date = aujourd'hui // Initialiser la date = aujourd'hui
state.currentDate = todayISO(); state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate; document.getElementById("date-picker").value = state.currentDate;
updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi"
// v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant) // v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant)
detectNetworkContextAsync(); detectNetworkContextAsync();
@@ -799,11 +800,37 @@ function initAppFooter() {
function initAppClock() { function initAppClock() {
const el = document.getElementById("app-clock"); const el = document.getElementById("app-clock");
if (!el) return; if (!el) return;
const dateEl = document.getElementById("app-clock-date");
const timeEl = document.getElementById("app-clock-time");
// v2026.5.16 : format "Mardi 21 avril 2026"
const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
const MOIS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
];
let lastDateStr = "";
const tick = () => { const tick = () => {
const d = new Date(); const d = new Date();
const h = String(d.getHours()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0");
el.textContent = `${h}:${m}`; const timeStr = `${h}:${m}`;
if (timeEl) timeEl.textContent = timeStr;
else el.textContent = timeStr; // fallback si ancien markup
// Date complète : actualisée seulement si elle a changé (évite reflow inutile)
if (dateEl) {
const jour = JOURS[d.getDay()];
const num = d.getDate();
const mois = MOIS[d.getMonth()];
const annee = d.getFullYear();
const dateStr = `${jour} ${num} ${mois} ${annee}`;
if (dateStr !== lastDateStr) {
dateEl.textContent = dateStr;
lastDateStr = dateStr;
}
}
// v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now" // v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now"
updateNowLine(); updateNowLine();
}; };
@@ -812,6 +839,21 @@ function initAppClock() {
setInterval(tick, 30 * 1000); setInterval(tick, 30 * 1000);
} }
// v2026.5.16 : met à jour le label court du jour affiché à gauche du
// date-picker (ex: "Mardi", "Lundi"). Appelé à chaque changement de date.
const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
function updateDatePickerDayLabel(isoDate) {
const el = document.getElementById("date-picker-day");
if (!el) return;
if (!isoDate) { el.textContent = ""; return; }
try {
const d = isoToDate(isoDate);
el.textContent = DAY_NAMES_FULL[d.getDay()];
} catch (e) {
el.textContent = "";
}
}
// v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible // v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible
// UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge // UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge
// + après chaque render (cf renderFromData). // + après chaque render (cf renderFromData).
@@ -2155,6 +2197,7 @@ async function loadForDate(isoDate, opts = {}) {
state.currentDate = isoDate; state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate; document.getElementById("date-picker").value = isoDate;
updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté
if (!state.session) { if (!state.session) {
// v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session" // v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session"
@@ -5366,7 +5409,10 @@ function splitOneContact(raw) {
// Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en // Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour // +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
// éviter de matcher des codes postaux ou autres nombres. // éviter de matcher des codes postaux ou autres nombres.
const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g; // v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou
// d'un underscore (identifiants style XXXX_NNNNNNNN, ABC123456,
// SERIAL_0123456789). On ajoute un lookbehind négatif (?<![A-Za-z_]).
const rxLong = /(?<![A-Za-z_])(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// SHORT : numéro interne court (5 chiffres). // SHORT : numéro interne court (5 chiffres).
// - v4.1.20 : accepte "12345Texte" (pas de séparateur après) // - v4.1.20 : accepte "12345Texte" (pas de séparateur après)
// - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro, // - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro,
@@ -5415,6 +5461,26 @@ function splitOneContact(raw) {
} }
name = cleanContactName(name); name = cleanContactName(name);
// v2026.5.16 : dernier garde-fou — rejeter les "noms" qui ressemblent
// à des fragments de description technique plutôt qu'à des vrais contacts.
// Exemples rejetés :
// - "1x" (quantité isolée)
// - "1x pc" (quantité + type matériel)
// - "pc XNNNNNN" (type + numéro de série)
// - "XXXX_NNNNNNNN" (identifiant matériel)
// Critères d'un vrai nom : contient au moins un mot qui commence par une
// majuscule ET n'est pas juste un identifiant technique.
if (name) {
const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // XXXX_NNNNNNNN
const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc"
const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx"
const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name);
if (looksLikeIdentifier || startsWithQuantity || hasOnlyTechTokens || (noCapitalWord && !phone)) {
name = null;
}
}
return { name, phone }; return { name, phone };
} }
@@ -5513,22 +5579,34 @@ function splitLieu(raw) {
// Retirer un / final (avec ou sans espaces) // Retirer un / final (avec ou sans espaces)
s = s.replace(/\s*\/\s*$/, "").trim(); s = s.replace(/\s*\/\s*$/, "").trim();
if (!s) return { ville: null, adresse: null }; if (!s) return { ville: null, adresse: null };
const idx = s.indexOf("/");
// v2026.5.16 : le format EasyVista peut avoir jusqu'à 3 parties séparées
// par "/" : VILLE / ADRESSE / PRÉCISIONS (étage, bureau, indications).
// Exemple : "LAUSANNE / Av. de Beaulieu 19 / 4eme en face de l'ascenseur"
// On ne garde que VILLE + ADRESSE. Les précisions (3e partie et suivantes)
// sont strippées — elles alourdissent la carte et sont disponibles dans
// le tooltip détaillé.
const parts = s.split("/").map(p => p.trim()).filter(Boolean);
let ville, adresse; let ville, adresse;
if (idx < 0) { if (parts.length === 0) {
return { ville: null, adresse: null };
} else if (parts.length === 1) {
// Pas de slash : tout est l'adresse
ville = null; ville = null;
adresse = s; adresse = parts[0];
} else { } else {
ville = s.substring(0, idx).trim(); // 2+ parties : ville = 1ère, adresse = 2e, on ignore le reste
adresse = s.substring(idx + 1).trim(); ville = parts[0];
adresse = parts[1];
} }
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue, // Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd) // Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
if (adresse) { if (adresse) {
adresse = adresse.replace( adresse = adresse.replace(
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi, /\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
(match) => { (match) => {
// Conserver la casse existante si déjà majuscule, sinon capitaliser
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match; if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase(); return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
} }