forked from FroSteel/Planification
Version 3.2.0 (pre-release) — Travail en cours sur la 3.2
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Planning Techniciens — Vue claire",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0-pre",
|
||||
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -47,11 +47,16 @@ const LS_THEME = "planning_theme";
|
||||
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
|
||||
const CACHE_DAYS = 7;
|
||||
|
||||
// Concurrence du fetch en parallèle (fiches + timelines).
|
||||
// Avant v3.1 : 12. Monté à 30 pour afficher les refs plus vite sur les jours
|
||||
// chargés (~34 interventions → 2 vagues au lieu de 3). Si le serveur sature,
|
||||
// redescendre à 20.
|
||||
const FETCH_CONCURRENCY = 30;
|
||||
// Concurrence des fetches en parallèle.
|
||||
// En v3.1.1 : xhr2 (bulles) et fetches fiches tournent SIMULTANÉMENT pour
|
||||
// que les refs arrivent plus vite. Chacun a sa propre concurrency, et le
|
||||
// total reste raisonnable pour le serveur EasyVista.
|
||||
// - xhr2 : petits (~400 o) et rapides → 10 workers suffisent
|
||||
// - fiches : gros (~250 Ko) → 15 workers pour vraiment accélérer
|
||||
// Total max simultané : 25 requêtes, ce qui reste confortable.
|
||||
// Si le serveur renvoie des erreurs ou XML vides → baisser les deux.
|
||||
const FETCH_CONCURRENCY_BULLES = 10;
|
||||
const FETCH_CONCURRENCY_FICHES = 15;
|
||||
|
||||
// ============================================================================
|
||||
// Mapping de catégorie → titre court + couleur
|
||||
@@ -371,75 +376,127 @@ async function loadForDate(isoDate, opts = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Afficher immédiatement depuis le cache si disponible
|
||||
const cached = await readCache(isoDate);
|
||||
if (cached && !opts.forceRefetch) {
|
||||
renderFromData({
|
||||
techs: cached.techs,
|
||||
targetDate: isoDate,
|
||||
captureTime: cached.savedAt || null,
|
||||
source: "cache"
|
||||
});
|
||||
// (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
|
||||
// "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date,
|
||||
// ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît
|
||||
// quand le chargement est vraiment fini (finally).
|
||||
const myToken = startNewRefresh();
|
||||
showAbortButton(true);
|
||||
const t0 = performance.now();
|
||||
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
|
||||
|
||||
// Si cache présent ET pas de refresh explicite demandé, on s'arrête là.
|
||||
// Pas de fetch XML, pas de fetch xhr2, pas de fetch fiches.
|
||||
// Le cache d'un jour précédent reste affiché jusqu'au prochain refresh manuel.
|
||||
if (!opts.doStatusRefresh) {
|
||||
return;
|
||||
try {
|
||||
// 1. Afficher immédiatement depuis le cache si disponible
|
||||
const cached = await readCache(isoDate);
|
||||
if (cached && !opts.forceRefetch) {
|
||||
renderFromData({
|
||||
techs: cached.techs,
|
||||
targetDate: isoDate,
|
||||
captureTime: cached.savedAt || null,
|
||||
source: "cache"
|
||||
});
|
||||
|
||||
// Si cache présent ET pas de refresh explicite demandé, on s'arrête là.
|
||||
if (!opts.doStatusRefresh) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
showLoading();
|
||||
}
|
||||
} else {
|
||||
showLoading();
|
||||
}
|
||||
|
||||
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
|
||||
const fresh = await fetchPlanningForDate(isoDate);
|
||||
if (!fresh) return;
|
||||
if (isRefreshAborted()) return;
|
||||
|
||||
// 3. Fusionner cache + frais
|
||||
const merged = mergeCacheAndFresh(cached, fresh);
|
||||
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
|
||||
const tXml = performance.now();
|
||||
const fresh = await fetchPlanningForDate(isoDate);
|
||||
console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`);
|
||||
if (!fresh) return;
|
||||
if (isRefreshAborted()) return;
|
||||
|
||||
// 4. Afficher immédiatement avec ce qu'on a
|
||||
renderFromData({
|
||||
techs: merged.techs,
|
||||
targetDate: isoDate,
|
||||
captureTime: Date.now(),
|
||||
source: "fresh"
|
||||
});
|
||||
// 3. Fusionner cache + frais
|
||||
const merged = mergeCacheAndFresh(cached, fresh);
|
||||
|
||||
// 5. PHASE BULLES (xhr_2) : fetch planning_xhr_2.php pour chaque intervention
|
||||
const bulleNeeded = [];
|
||||
for (const tech of merged.techs) {
|
||||
for (const iv of tech.interventions) {
|
||||
if (iv.type !== "AL-Intervention") continue;
|
||||
if (iv.infobulle && iv.bulleContact) continue;
|
||||
bulleNeeded.push(iv);
|
||||
}
|
||||
}
|
||||
if (bulleNeeded.length > 0) {
|
||||
console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`);
|
||||
await fetchBullesForInterventions(bulleNeeded);
|
||||
// 4. Afficher immédiatement avec ce qu'on a
|
||||
renderFromData({
|
||||
techs: merged.techs,
|
||||
targetDate: isoDate,
|
||||
captureTime: Date.now(),
|
||||
source: "fresh+bulles"
|
||||
source: "fresh"
|
||||
});
|
||||
console.log(`[load] 1er rendu (sans refs) à ${Math.round(performance.now() - t0)} ms`);
|
||||
|
||||
// 5. PARALLÈLE : xhr2 (lieu/contact) + fetches fiches (ref/statut)
|
||||
// Avant v3.1.1 : séquentiel, on devait attendre les 34 xhr2 avant de
|
||||
// lancer les 34 fiches. Résultat : première ref arrivait après ~1s.
|
||||
// Maintenant : les deux démarrent en même temps, chacun met à jour
|
||||
// la ligne correspondante via le rendu incrémental.
|
||||
const bulleNeeded = [];
|
||||
for (const tech of merged.techs) {
|
||||
for (const iv of tech.interventions) {
|
||||
if (iv.type !== "AL-Intervention") continue;
|
||||
if (iv.infobulle && iv.bulleContact) continue;
|
||||
bulleNeeded.push(iv);
|
||||
}
|
||||
}
|
||||
|
||||
const needFetch = merged.techs.some(tech =>
|
||||
tech.interventions.some(iv =>
|
||||
iv.type === "AL-Intervention" && !iv.ficheTarget
|
||||
)
|
||||
);
|
||||
|
||||
const promises = [];
|
||||
|
||||
if (bulleNeeded.length > 0 && !isRefreshAborted()) {
|
||||
const tBulles = performance.now();
|
||||
console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`);
|
||||
promises.push(
|
||||
fetchBullesForInterventions(bulleNeeded).then(() => {
|
||||
console.log(`[load] xhr2 finis en ${Math.round(performance.now() - tBulles)} ms`);
|
||||
if (!isRefreshAborted()) {
|
||||
renderFromData({
|
||||
techs: merged.techs,
|
||||
targetDate: isoDate,
|
||||
captureTime: Date.now(),
|
||||
source: "fresh+bulles"
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted()) {
|
||||
const tFiches = performance.now();
|
||||
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
|
||||
console.log(`[load] début fetch des ${nFiches} fiches…`);
|
||||
promises.push(
|
||||
refreshStatuses(merged.techs, isoDate).then(() => {
|
||||
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
|
||||
if (!isRefreshAborted()) {
|
||||
await writeCache(isoDate, { techs: merged.techs });
|
||||
}
|
||||
|
||||
if (!isRefreshAborted()) {
|
||||
showRefreshDone();
|
||||
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
|
||||
} else {
|
||||
console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`);
|
||||
}
|
||||
} finally {
|
||||
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
|
||||
// se termine (pas un chargement postérieur que l'utilisateur aurait lancé
|
||||
// entre-temps en naviguant ailleurs).
|
||||
if (currentRefreshToken === myToken) {
|
||||
showAbortButton(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sauvegarder dans le cache
|
||||
await writeCache(isoDate, { techs: merged.techs });
|
||||
|
||||
// 7. Fetch fiches en arrière-plan (pour statut + target/checksum clic)
|
||||
const needFetch = merged.techs.some(tech =>
|
||||
tech.interventions.some(iv =>
|
||||
iv.type === "AL-Intervention" && !iv.ficheTarget
|
||||
)
|
||||
);
|
||||
if (opts.doStatusRefresh || needFetch) {
|
||||
await refreshStatuses(merged.techs, isoDate);
|
||||
}
|
||||
|
||||
showRefreshDone();
|
||||
}
|
||||
|
||||
async function refreshPlanning(opts = {}) {
|
||||
@@ -447,16 +504,9 @@ async function refreshPlanning(opts = {}) {
|
||||
await refreshSessionAndLoad();
|
||||
return;
|
||||
}
|
||||
// Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et
|
||||
// on fait apparaître le bouton "Arrêter". Les refresh auto (12h/15h) et
|
||||
// les navigations de date n'ont pas ce bouton (ils ne passent pas ici).
|
||||
startNewRefresh();
|
||||
showAbortButton(true);
|
||||
try {
|
||||
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
|
||||
} finally {
|
||||
showAbortButton(false);
|
||||
}
|
||||
// Refresh manuel : force le refetch des fiches. Le bouton "Arrêter" est
|
||||
// géré par loadForDate lui-même.
|
||||
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -482,6 +532,18 @@ async function fetchPlanningForDate(isoDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
|
||||
// corps vide — typiquement quand la session vient d'être invalidée, ou
|
||||
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
|
||||
// les deux cas, on traite ça comme une session expirée : inutile de
|
||||
// parser (ça ferait "Document is empty") ni de retry en boucle.
|
||||
if (!resp.xml || resp.xml.length < 20) {
|
||||
console.warn("[viewer] XML planning vide — session probablement invalide");
|
||||
state.session = null;
|
||||
showSessionNeeded();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
|
||||
// contact, lieu, description, formLinks, request_id + checksum)
|
||||
const techs = parsePlanningXml(resp.xml, isoDate);
|
||||
@@ -658,6 +720,7 @@ async function fetchBullesForInterventions(interventions) {
|
||||
|
||||
async function worker() {
|
||||
while (idx < interventions.length) {
|
||||
if (isRefreshAborted()) return;
|
||||
const i = idx++;
|
||||
const iv = interventions[i];
|
||||
try {
|
||||
@@ -685,7 +748,7 @@ async function fetchBullesForInterventions(interventions) {
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
const nWorkers = Math.min(FETCH_CONCURRENCY, interventions.length);
|
||||
const nWorkers = Math.min(FETCH_CONCURRENCY_BULLES, interventions.length);
|
||||
for (let w = 0; w < nWorkers; w++) workers.push(worker());
|
||||
await Promise.all(workers);
|
||||
console.log(`[xhr2] ${ok} OK, ${fail} échecs sur ${interventions.length}`);
|
||||
@@ -826,7 +889,7 @@ async function refreshStatuses(techs, isoDate) {
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// Fetcher avec concurrence = FETCH_CONCURRENCY (30)
|
||||
// Fetcher avec concurrence = FETCH_CONCURRENCY_FICHES (15)
|
||||
// Chaque worker vérifie isRefreshAborted() AVANT de prendre la prochaine
|
||||
// intervention : si l'utilisateur a cliqué "Arrêter", les workers
|
||||
// s'arrêtent proprement dans ~100ms.
|
||||
@@ -840,7 +903,8 @@ async function refreshStatuses(techs, isoDate) {
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker());
|
||||
const nWorkers = Math.min(FETCH_CONCURRENCY_FICHES, toFetch.length);
|
||||
for (let w = 0; w < nWorkers; w++) workers.push(worker());
|
||||
await Promise.all(workers);
|
||||
|
||||
// Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu
|
||||
@@ -917,6 +981,22 @@ async function fetchAndUpdateIntervention(iv) {
|
||||
}
|
||||
iv.commentaireTech = fiche.commentaireTech;
|
||||
|
||||
// ─── Remplacement UNIQUEMENT du texte d'action affiché dans la popup ──────
|
||||
// Le texte de la bulle (planning_xhr_2.php) est parfois tronqué/incomplet.
|
||||
// La fiche contient le texte complet dans AM_ACTION.DESCRIPTION.
|
||||
// SÉCURITÉ : on ne remplace QUE si l'Intervenant de la fiche correspond au
|
||||
// tech de la ligne du planning (car une même fiche peut avoir plusieurs
|
||||
// actions assignées à différents techs, et on fetche la MÊME fiche pour tous).
|
||||
// NB : on NE touche PAS à bulleContact/bulleLieu (ils viennent de la bulle
|
||||
// de base et sont utilisés tels quels ailleurs).
|
||||
if (fiche.actionDescription && fiche.intervenant && iv.techId) {
|
||||
const expectedTechName = TEAM[iv.techId];
|
||||
if (expectedTechName && namesMatch(fiche.intervenant, expectedTechName)) {
|
||||
iv.bulleDescription = fiche.actionDescription;
|
||||
}
|
||||
// Si ça ne matche pas : on garde bulleDescription tel quel (sécurité)
|
||||
}
|
||||
|
||||
// Extraire le checksum CORRECT pour ouvrir la fiche :
|
||||
// - Le target de la FICHE = iv.requestId (vient du XML)
|
||||
// - Il faut trouver le checksum qui est accolé à ce target dans le HTML
|
||||
@@ -994,7 +1074,9 @@ function parseFicheHtml(html) {
|
||||
status: null,
|
||||
rfc: null,
|
||||
categoryLine: null,
|
||||
commentaireTech: null
|
||||
commentaireTech: null,
|
||||
intervenant: null, // Nom du tech assigné à l'action (format "Nom, Prénom")
|
||||
actionDescription: null // Texte complet "Date:... Lieu:... Contact:..." (propre, sans HTML)
|
||||
};
|
||||
|
||||
// STATUS_FR (valeur parfois encodée en \u00XX)
|
||||
@@ -1019,9 +1101,90 @@ function parseFicheHtml(html) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Intervenant assigné (AM_EMPLOYEE.LAST_NAME dans la section "Action") ───
|
||||
// HTML Angular rendu : <div field-label="Intervenant" field-label-id="AM_EMPLOYEE-LAST_NAME">
|
||||
// ... <input ... value="Nom, Prénom" ng-attr-name="...">
|
||||
// On cherche la PREMIÈRE occurrence (celle de l'action courante, pas la timeline).
|
||||
m = html.match(/field-label="Intervenant"\s+field-label-id="AM_EMPLOYEE-LAST_NAME"[\s\S]*?value="([^"]+)"\s+ng-attr-name/);
|
||||
if (m) {
|
||||
out.intervenant = decodeHtmlEntities(m[1]).trim();
|
||||
}
|
||||
|
||||
// ─── Texte complet de l'action (AM_ACTION.DESCRIPTION) ───
|
||||
// HTML Angular : <div field-label-id="AM_ACTION-DESCRIPTION"> ... <div class="fr-element fr-view ...">TEXTE AVEC <br></div>
|
||||
// On extrait le HTML entre les balises, puis on nettoie (br → \n, décode entités, strip tags).
|
||||
m = html.match(/field-label-id="AM_ACTION-DESCRIPTION"[\s\S]*?<div[^>]*class="fr-element fr-view[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
||||
if (m) {
|
||||
const cleaned = cleanActionDescriptionHtml(m[1]);
|
||||
if (cleaned && cleaned.length > 5) {
|
||||
out.actionDescription = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le HTML du champ AM_ACTION.DESCRIPTION pour obtenir un texte propre :
|
||||
* - <br>, <br/>, <br /> → \n
|
||||
* - → espace, > → >, < → <, & → &, " → "
|
||||
* - Supprime toute autre balise HTML résiduelle
|
||||
* - Trim lignes et supprime lignes vides multiples
|
||||
*/
|
||||
function cleanActionDescriptionHtml(html) {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
// Normaliser les retours à la ligne
|
||||
s = s.replace(/<br\s*\/?>/gi, "\n");
|
||||
// Décoder les entités HTML courantes
|
||||
s = s.replace(/ /g, " ")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
// Virer les balises HTML restantes (au cas où)
|
||||
s = s.replace(/<[^>]+>/g, "");
|
||||
// Nettoyer : trim chaque ligne, retirer lignes vides en excès
|
||||
s = s.split("\n").map(l => l.trim()).join("\n");
|
||||
s = s.replace(/\n{3,}/g, "\n\n").trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode les entités HTML dans une chaîne courte (ex: un nom "O'Brien" → "O'Brien").
|
||||
*/
|
||||
function decodeHtmlEntities(s) {
|
||||
if (!s) return s;
|
||||
return s.replace(/ /g, " ")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deux noms de personne en étant tolérant :
|
||||
* - casse
|
||||
* - accents (é/è/ê → e, ç → c, etc.)
|
||||
* - espaces multiples
|
||||
* - espace autour de la virgule
|
||||
* Ex: "Ciuppa, Mathieu" matche "ciuppa,mathieu" ou "CIUPPA , Mathieu"
|
||||
*/
|
||||
function namesMatch(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const norm = s => String(s)
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // retire accents
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*,\s*/g, ",")
|
||||
.trim();
|
||||
return norm(a) === norm(b);
|
||||
}
|
||||
|
||||
function decodeJsonString(s) {
|
||||
return s
|
||||
.replace(/\\r/g, "")
|
||||
|
||||
Reference in New Issue
Block a user