Compare commits

..
49 Commits
Author SHA1 Message Date
Quentin a6ae6ba79e v2026.5.41 — Suppression des hardcodes runtime + UX admin + thème unifié
Suppression des hardcodes :
- Plus aucun hardcode runtime pour groupe EV, domaines, liste de techs,
  absences récurrentes. Tout lu depuis admin_config (chrome.storage.local),
  persiste entre les mises à jour d'extension.
- Au 1er install : aucun tech sélectionné → fetch lève no_team_configured,
  viewer affiche message invitant à configurer Paramètres → Équipe.
- Suppression de la fonction hardcodée isPillonelAbsentFriday() →
  remplacée par isRecurringAbsence(tech, isoDate) générique qui lit
  cfg.recurringAbsences. Label 'Absent le X' calculé depuis le jour.

UX admin :
- Auto-refresh à l'enregistrement (équipe / domaines / heures) → plus
  besoin de recharger l'extension manuellement.
- Bouton ✓ Appliquer pour les heures de la journée + toast de confirmation.
- Toasts au-dessus du flou du panel admin (z-index 11000 > 10000).
- Section Statuts retirée (placeholder lecture-seule).
- Tri équipe : inclus d'abord puis exclus, alphabétique.

Thème unifié :
- Toggle topbar et sélecteur Apparence écrivent dans la même clé cfg.theme.
- Mode 'Auto' résolu en JS via prefers-color-scheme (le CSS n'avait pas
  de bloc @media, ce qui faisait retomber sur clair même en OS sombre).
- Listener matchMedia pour bascule live en mode auto.

Conflits absence/réservation × intervention :
- Si une intervention est planifiée pendant qu'un tech a une absence
  (full-day ou partielle) ou une réservation au même créneau, sa carte
  (row classique + mini-card horizontale) est peinte en rouge plein.

Permissions runtime pour domaines :
- manifest.json : optional_host_permissions [https://*/*].
- chrome.permissions.request() au save d'un domaine custom dans
  Paramètres → EasyVista. Refus → toast d'avertissement.

Synchronisation des heures EV :
- Helpers getDayBounds() côté background. day_start_hour, day_end_hour,
  begin_hour, end_hour des requêtes EV reflètent désormais cfg.dayStart
  et cfg.dayEnd. Avant : 8/18/19 hardcodés.

Vue horizontale :
- Popups au survol/clic limités à dessous/dessus (pas de gauche/droite
  car la sidebar à gauche et la timeline pleine largeur les rendent
  inutilisables).

Ménage de code :
- Suppression de CACHE_DAYS, LS_THEME, commentaire historique sur
  initAdminMenu(). Aucun symbole orphelin restant.

Contact mis à jour : [email protected]
2026-04-27 02:11:14 +02:00
Quentin 012af61fb0 v2026.5.40 — Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie
Onglet Équipe :
- Sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de
  section, détecté automatiquement à l'ouverture du panel via le
  <select id="plan_group_id"> de la page Planning EV. Robuste aux
  ajouts/renommages côté EV.
- ID groupe affiché en italique (ex: ID groupe : 191).
- Refresh auto de la liste d'équipe au changement de groupe.
- Plus de bouton 'Détecter' : tout est auto à l'ouverture.
- Tri double : inclus d'abord, puis exclus, alphabétique dans chaque
  sous-groupe.

Onglet EasyVista :
- Édition manuelle des deux domaines (interne DGNSI / externe Internet).
- Bouton Réinitialiser, normalisation auto des URLs.
- Les défauts restent codés en dur en fallback.

Onglet Statuts retiré (placeholder lecture seule).

Vue horizontale enrichie :
- Barre couleur catégorie + référence + ville sur chaque segment.
- Hauteur 22→32px.

Coulisses :
- Nouveau message 'detectGroups' + fonction detectGroupsFromEV() côté
  background.js.
- detectTeamFromEV() accepte un groupId en argument.
- Le fetch du planning continue d'utiliser group_id=191 codé en dur,
  branchement effectif dans une prochaine version.
2026-04-27 00:57:03 +02:00
FroSteel 79dba86da3 refactor: ranger le code source dans src/ + script build.sh
Réorganisation interne du repo pour clarifier l'arborescence :
- Tous les fichiers source (manifest, viewer.*, background.js, icons/)
  sont maintenant dans src/
- À la racine du repo : uniquement README, LICENSE, CHANGELOG, build.sh,
  .gitignore et le dossier dist/ (généré, dans .gitignore)
- Nouveau script build.sh qui copie src/ → dist/chromium/ et
  dist/firefox/ et génère les archives .zip / .xpi prêtes à distribuer

Aucun changement fonctionnel — la version v2026.5.39 reste identique
côté code source et builds.
2026-04-26 18:15:00 +02:00
FroSteel a5dc0b3365 v2026.5.39 — Séparation matin/après-midi + Apparence (thème, taille, cache, heures) + À propos
Séparation matin / après-midi
- Pill "MATIN" / "APRÈS-MIDI" entre interventions (vue classique), grise
  neutre, ligne 3px épaisse. Affiché aussi entre les absences partielles.
- Si une période est vide, son séparateur n'apparaît pas.

Timeline — coupure midi très visible
- Bande verticale composée d'un trait massif + stripes diagonales (effet
  césure). Visible immédiatement, sans label superflu.

Vue horizontale (sidebar)
- Tout centré horizontalement (align-items + text-align)
- min-height: calc(100vh * --zoom-inv) — sidebar atteint toujours le bas
  de l'écran, même quand le user dézoom le texte
- Bouton "Aujourd'hui" : style identique aux autres boutons (Absence,
  Douchette...), centré
- Boutons d'action (Absence/Douchette/Actualiser/Tout recharger/Vider
  cache/Thème) poussés en bas via margin-top: auto + bordure top de
  séparation visuelle

Section Apparence — refondue + en première position
- Thème : sélecteur Auto / Clair / Sombre
- Durée du cache (jours) : configurable, défaut 7. Lue par viewer (purge
  auto en cas de quota) ET background (au boot). Tooltip au survol qui
  montre l'emplacement physique du cache (adapté browser + OS)
- Taille du texte : slider horizontal avec 5 dots, 5 paliers (-30%, -15%,
  100%, +10%, +20%). Zoom appliqué uniquement au release (pas pendant le
  drag) pour éviter l'effet yo-yo. Couvre TOUS les textes visibles
  (interventions, popups, absences, réservations, "En pompier du...",
  date+heure de la même taille, etc.)
- Heures de la journée : 2 inputs Début/Fin, défaut 8h-18h. Lecture au
  boot via _initDayBoundsFromConfig() qui met à jour DAY_START/END/LEN

Section À propos (nouvelle, dernière du panel)
- Extension : Planification
- Version, Auteur (Quentin Rouiller), Affiliation (Technicien DGNSI —
  Canton de Vaud), Licence MIT, Code source (lien Gitea)
- Description courte mise en avant

Bouton "Vue" (popup user-badge) — plus clair
- Affiche la vue de DESTINATION (pas la vue actuelle)
  - en classique → "Passer en vue Horizontale" + logo ≡
  - en horizontal → "Passer en vue Classique" + logo ⊞

Tooltips
- Apparition : 500ms (cancellable au mouseleave)
- Disparition : 500ms (au lieu de 1000ms)
- Comportement uniforme entre vue classique et horizontale

Stats
- "X tech. dispo" (nouveau) : disponibles = pas absent + pas réservé
  toute la journée. Pompier compte comme disponible.
2026-04-26 02:20:00 +02:00
FroSteel c9363c64b6 v2026.5.38 — Attribution auteur + nettoyage + observabilité
ATTRIBUTION
- En-têtes copyright dans tous les fichiers source (viewer.js, viewer.html,
  viewer.css, background.js)
- @author Quentin Rouiller sur 22 fonctions clés
- Signature "Développé par Quentin Rouiller" en bas du popup user-badge
- description manifest mentionnant DGNSI

NETTOYAGE
- Retrait fonction vide initAdminMenu()
- Retrait classes CSS orphelines (.date-picker-day, .intervention v1)
- Retrait 14× console.log [viewMode] verbeux + 5× console.log [bg]
- extendBtn.onclick → addEventListener (cohérence + cleanup possible)

OBSERVABILITÉ
- Module LOG unifié : préfixe + timestamp + version + niveau
- Handlers globaux window/self.error + unhandledrejection (viewer + bg)
- Toggle "Logs verbeux (debug)" dans le panel admin (Diagnostics)
- Synchronisation viewer ↔ background via chrome.storage.onChanged
- LOG.info muet par défaut, visible quand debug ON

GARDE-FOUS
- sendMessage avec timeout 15s (évite promises pendantes si SW MV3
  oublie sendResponse)
- writeCache avec gestion quota (purge auto entrées > 7 jours puis retry,
  sinon toast user)
- renderFromData wrappé try/catch + null checks DOM
- JSON.parse [timeline] : log warn avec snippet du contenu fautif
- .catch(() => {}) swallowed remplacés par log warn (clipboard, session,
  cache)
- getManifest centralisé dans LOG.version()

BUILDS
- dist/chromium/ et dist/firefox/ prêts à charger en mode dev
- planification-v2026.5.38-chromium.zip (~152 Ko)
- planification-v2026.5.38-firefox.xpi (~152 Ko, à signer sur AMO)
2026-04-26 01:00:00 +02:00
FroSteel 08bf8cb5f5 docs: préciser DGNSI (Canton de Vaud) comme cible et affiliation auteur 2026-04-25 19:30:00 +02:00
FroSteel dd0b5e1a36 docs: clarification du schéma de versionning ANNÉE.MAJEURE.PATCH
Le second chiffre n'est pas un mois mais un compteur de versions majeures
(grosses refontes / ajouts importants). Le troisième est le patch livré
à chaque petite mise à jour dans la majeure courante.
2026-04-25 19:00:00 +02:00
FroSteel 0fbc1997bb Version 2026.5.37 — Refonte vue horizontale (sidebar complète)
- Topbar supprimée, user-badge + titre déplacés en sidebar
- Bouton Aujourd'hui pleine largeur, stats empilées
- Banderole pompier masquée en vue horizontale
2026-04-25 18:00:00 +02:00
FroSteel cd54764dd5 Version 2026.5.36 — Sidebar verticale en vue horizontale (#horizontal-wrapper)
[code interpolé entre v2026.5.35 et v2026.5.37]
2026-04-25 14:00:00 +02:00
FroSteel a92e3429b2 Version 2026.5.35 — Fix popup épinglé position vue horizontale + stats gauche 2026-04-25 10:00:00 +02:00
FroSteel 1ecc60e160 Version 2026.5.34 — Bouton 📌 restauré + badge user cliquable
- _softUnpinPopup refait, _maybeRetryFetchUser, positionTooltipAnchored unifiée
[code interpolé]
2026-04-24 18:00:00 +02:00
FroSteel a5993c54c9 Version 2026.5.33 — Interactions vue horizontale différenciées (hover / clic)
[code interpolé]
2026-04-24 15:00:00 +02:00
FroSteel b0a8102c29 Version 2026.5.32 — Vue horizontale togglable (VIEW_MODE_KEY, _applyViewMode)
[code interpolé]
2026-04-24 12:00:00 +02:00
FroSteel ecb490c55a Version 2026.5.31 — Sarcelle absence récurrente (REJETÉ par utilisateur)
[code interpolé — version revertée par la suite]
2026-04-24 09:00:00 +02:00
FroSteel 7e497de40e Version 2026.5.30 — Absence récurrente cyan + mode compact 24"
[code interpolé]
2026-04-23 17:00:00 +02:00
FroSteel bbdcb8c7de Version 2026.5.29 — Contraste++ + footer QRO/version
[code interpolé]
2026-04-23 15:00:00 +02:00
FroSteel 5a9e465116 Version 2026.5.28 — Ajustements visuels absences
- Retrait pastille .tech-name-dot, 'Maladie/Accident', popups 520px fixe
[code interpolé]
2026-04-23 13:00:00 +02:00
FroSteel 0511c18b07 Version 2026.5.27 — Classification absences (Maladie/Congé/Pompier)
- Topbar une ligne, fermeture auto popups, contrastes améliorés
- ABSENCE_LABELS, couleurs Maladie/Congé/Pompier, badge + barre gauche
[code interpolé]
2026-04-23 11:00:00 +02:00
FroSteel df623da8f4 Version 2026.5.26 — Badge user inconnu cliquable + retry 60s (max 10 essais)
[code interpolé]
2026-04-23 09:00:00 +02:00
FroSteel 1441b0a7a1 Version 2026.5.25 — Bouton ⚙ Paramètres dans popup user-badge
[code interpolé]
2026-04-22 17:00:00 +02:00
FroSteel 5eae40d38b Version 2026.5.24 — Corrections diverses
[code interpolé]
2026-04-22 15:00:00 +02:00
FroSteel e69482add4 Version 2026.5.23 — Reset bulleState.pinned + iv._reloading
[code interpolé v2026.5.22 → v2026.5.35]
2026-04-22 13:00:00 +02:00
FroSteel a382d8f35f Version 2026.5.22 — Régénération tooltip hover après softUnpin 2026-04-22 11:00:00 +02:00
FroSteel 7824990fba Version 2026.5.21 — Ajustements
[code interpolé]
2026-04-22 09:00:00 +02:00
FroSteel e7c5e281d9 Version 2026.5.20 — Safe area popups (topbar + dock)
[code interpolé]
2026-04-21 17:00:00 +02:00
FroSteel c74d52c40c Version 2026.5.19 — Drag popup épinglé
[code interpolé]
2026-04-21 15:00:00 +02:00
FroSteel 8c76085f03 Version 2026.5.18 — Dock pastilles popups épinglés avec couleur catégorie
[code interpolé]
2026-04-21 13:00:00 +02:00
FroSteel f54ccd28d2 Version 2026.5.17 — Popup user-badge avec ligne session (MM:SS)
- Couleur selon seuil
[code interpolé]
2026-04-21 11:00:00 +02:00
FroSteel 72fb565afa Version 2026.5.16 — Passage au versionning par année (YYYY.M.PATCH)
- Format : YYYY.M.PATCH (2026.5.16 succède à 5.0.12)
- Bump du PATCH à chaque livraison
- L'année indique immédiatement la fraîcheur de l'extension
[code interpolé v5.0.12 → v2026.5.22]
2026-04-21 09:00:00 +02:00
FroSteel b3246d3cf2 Version 5.0.12 — Stabilisation finale série 5.0
Dernière version avant passage au système de versionning par année (YYYY.M.PATCH).
2026-04-20 17:00:00 +02:00
FroSteel 8435a2b77e Version 5.0.9 — Stabilisation série 5.0 2026-04-20 13:00:00 +02:00
FroSteel 6ae440cbf1 Version 5.0.0 — Refonte topbar (horloge, menu admin)
- initAppClock : horloge HH:MM au milieu topbar
- initAdminMenu : menu admin caché (5 clics sur titre)
- initSessionTimer : compteur de session EV (tick 1s)
[code interpolé entre v4.3.0 et v5.0.9]
2026-04-20 09:00:00 +02:00
FroSteel f6d549d522 Version 4.3.0 — Tooltip live libéré après épinglage 2026-04-19 18:00:00 +02:00
FroSteel 565075933e Version 4.2.8 — Corrections cumulées 4.2.4-8 2026-04-19 15:00:00 +02:00
FroSteel 7f78493859 Version 4.2.3 — Grande popup timeline persistante (bindTimelinePopover) 2026-04-19 12:00:00 +02:00
FroSteel 0b08ca122b Version 4.2.1 — Démarrage série 4.2 2026-04-19 09:00:00 +02:00
FroSteel 87f561ae10 Version 4.1.14 — moveTooltip devenu no-op (popup statique) 2026-04-18 18:00:00 +02:00
FroSteel be49a89057 Version 4.1.6 — Améliorations tooltip 2026-04-18 15:00:00 +02:00
FroSteel e42b145401 Version 4.1.4 — Corrections mineures tooltip 2026-04-18 12:00:00 +02:00
FroSteel 7201fde2d3 Version 4.1.3 — Introduction tooltips épinglables (pinTooltip) 2026-04-18 09:00:00 +02:00
FroSteel edd6ffc1c3 Version 3.3.0 — Corrections + raffinements
(manifest.json corrigé : était resté à 3.2.0 par oubli)
2026-04-17 18:00:00 +02:00
FroSteel 23244fc4db Version 3.2.0 — Stabilisation 3.2 2026-04-17 16:00:00 +02:00
FroSteel f52095dc4d Version 3.2.0 (pre-release) — Travail en cours sur la 3.2 2026-04-17 14:00:00 +02:00
FroSteel 94877cb816 Version 3.1.0 — Améliorations affichage 2026-04-17 11:00:00 +02:00
FroSteel 8ab62e92d2 Version 3.0.0 — Évolution majeure du viewer 2026-04-17 09:00:00 +02:00
FroSteel 8bc26c326f Version 2.0.1 — Ajustements interface v2 2026-04-16 17:00:00 +02:00
FroSteel d2afbf0dca Version 2.0.0 — Refonte interface et structure 2026-04-16 14:00:00 +02:00
FroSteel 3b1831a83a Version 1.0.0 — Initiale (extension de base sans tooltips avancés)
Première version stable de l'extension Planification : viewer pour planning EasyVista, fetch XML, affichage cards par tech.
2026-04-16 09:30:00 +02:00
FroSteel 43c6e0e487 Initial commit — LICENSE MIT + README + CHANGELOG + .gitignore 2026-04-16 09:00:00 +02:00
16 changed files with 2054 additions and 15976 deletions
+40
View File
@@ -0,0 +1,40 @@
# OS
.DS_Store
Thumbs.db
desktop.ini
# Editors
.vscode/
.idea/
*.swp
*.swo
*~
# Backups
*.bak
*.bak-*
*.orig
*.old
# Build artifacts (les ZIP/XPI livrés ne sont pas dans le repo, ils sont buildés à la demande)
dist/
*.zip
*.xpi
*.crx
# Node (si jamais utilisé pour build)
node_modules/
package-lock.json
npm-debug.log*
# Logs
*.log
rebuild.log
# Dossiers de travail temporaires
extracted/
temp/
tmp/
# Tests
test-output/
+12 -311
View File
@@ -1,312 +1,14 @@
# CHANGELOG — Extension Planification EasyVista Canton de Vaud # CHANGELOG — Extension Planification EasyVista Canton de Vaud
> Ce changelog documente l'évolution de l'extension Chrome/Firefox "Planification" > Ce changelog documente l'évolution de l'extension Chrome/Firefox "Planification"
> développée par Quentin Rouiller pour les coordinateurs DGNSI (Canton de Vaud). > développée par Quentin Rouiller pour les techniciens DGNSI (Canton de Vaud).
> >
> Les versions documentées ci-dessous sont celles dont les détails sont connus. > Les versions documentées ci-dessous sont celles dont les détails sont connus.
> Pour les versions plus anciennes, l'analyse du code source permet de > Pour les versions plus anciennes, Claude Code se basera sur l'analyse du code
> reconstituer un message de version pertinent. > source pour déterminer un message de commit pertinent.
--- ---
## v2026.5.46 — Refonte de la couche données, vue « échelle de temps exacte », session fiabilisée, réservations enrichies
> Version majeure par son contenu : la couche données est entièrement refondue (store unifié par jour, un seul verdict par intervention, synchronisation du dossier partagé en sous-dossier `v2`), une nouvelle disposition « échelle de temps exacte » s'ajoute à la vue horizontale, la détection de session EasyVista est fiabilisée de bout en bout, et les réservations portent enfin leurs informations (n°, lieu, catégorie) sur tous les postes. Le script PowerShell du dossier partagé est désormais publié avec l'extension et téléchargeable depuis les Paramètres.
### Couche données v2 (refonte complète)
- **Un store par jour** (`planv2_<date>`) : liste plate d'interventions dédoublonnées par identité stable (requestId > actionId > réf) — la seule source de vérité ; le rendu, le cache local, le dossier partagé et EasyVista n'en sont que des projections ou des sources.
- **Un seul verdict par intervention** (`live / done / suspended / closed / gone / moved / unknown`) qui remplace les anciens marqueurs dispersés (ghost, statuts de disparition, diagnostics).
- **Une seule fusion** (modèle vie/mort) utilisée partout : EV↔store, local↔dossier, import. Une vie strictement postérieure à une mort lève les tombstones (réapparition EV autoritaire).
- **Dossier partagé** : transport natif inchangé (verrous + écriture atomique + compare-and-swap), fichiers v2 dans le sous-dossier `v2` du partage — les postes en ancienne version n'interfèrent plus. Ordre des sources refondu : EasyVista s'affiche immédiatement, le dossier habille les cartes sans re-télécharger, le fetch ne traite que ce qui manque, l'enrichissement est repoussé au dossier en fin de chargement.
- Les interventions déplacées par l'extension portent un verdict « déplacée » propagé (plus d'analyse inutile), avec fenêtre anti cache-serveur EV.
- Les disparues en attente de verdict restent affichées (grisées, spinner) au lieu de disparaître puis réapparaître.
- Outil réservé à l'auteur (onglet Test) : conversion de l'ancien cache dossier v1 vers le modèle v2, sans modifier les anciens fichiers.
- Les anciens caches (`planning_cache_*`) sont purgés au démarrage.
### Vue horizontale : nouvelle disposition « échelle de temps exacte »
- **Nouvelle option** dans Paramètres → Apparence (exclusive avec « Afficher les pauses » : cocher l'une décoche l'autre). La vue par défaut reste inchangée.
- Le bandeau timeline disparaît : chaque intervention/réservation/absence partielle est posée **en position réelle** sur une bande continue de la plage affichée (position et largeur proportionnelles à l'heure et à la durée).
- **Règle des heures fixe** en haut (reste collée au défilement), **lignes verticales continues** sur toute la hauteur du tableau : trait net à chaque heure, pointillé discret aux demi-heures, césure rayée à midi ; les traits passent derrière les bulles et sautent les techniciens absents.
- **Ligne rouge « maintenant »** continue sur toute la hauteur (jour courant), reposée instantanément à chaque rendu ; elle saute aussi les absents.
- **Chevauchements** : les éléments qui se recouvrent s'empilent en sous-lignes de même hauteur — la zone du technicien s'agrandit seulement dans ce cas ; tolérance de 10 minutes pour ne pas empiler de simples enchaînements.
- **Cartes** : fond neutre, le bord gauche porte la couleur de catégorie et sert de **poignée** (cliquer = fenêtre de modification, glisser = déplacer ; présente sur toutes les cartes, déplacement bloqué pour les faites/clôturées, réservations et absences). Numéro d'intervention en tête, ville + adresse groupées ; cartes étroites : texte compact (ville + fin d'adresse, « Rue de l'Université 5 » → « Université 5 »), jamais vides tant qu'il reste de la place.
- **Réservations** : affichent sous l'intitulé le lieu renseigné à la création, sinon le nom de la personne qui a réservé.
- **Zones libres** : survol = « Libre HH:MMHH:MM » (y compris sur les journées totalement vides), clic droit = Réservation/Absence à l'heure du curseur, dépôt du drag précis (pas de 30 min) avec marqueur vert sur toute la bande.
- Alignement règle/cartes mesuré au pixel réel (indépendant du zoom et de la taille de texte).
### Session EasyVista fiabilisée (fin des faux « déconnecté »)
- **Cookie réécrit ≠ supprimé** : le navigateur émet toujours une « suppression » technique juste avant de reposer un cookie mis à jour — l'extension basculait à tort en « session expirée » à chaque rafraîchissement du PHPSESSID (permission cookies active). Désormais ignoré ; une vraie suppression attend 3 s (relog possible) puis vérifie au serveur avant d'alarmer.
- **Expiration sur preuve uniquement** : une erreur transitoire (réseau, HTTP 5xx) ne déclenche plus la bannière — seule une preuve le fait (page de login à la sonde, temps restant nul, plus aucun PHPSESSID) ; sinon nouvel essai dans 30 s.
- **Onglet EasyVista facultatif** avec la permission cookies : le cookie suffit aux requêtes — fermer l'onglet EV ne « perd » plus la session.
- **Contre-vérification HTTP** : avant de classer un 401/403/404 en « session expirée », l'endpoint d'autorité `session_time` est sondé ; si la session vit, l'erreur est reclassée « EV inaccessible » (transitoire).
- **Compteur calé sur le serveur** : recalage périodique sur la vraie valeur EasyVista (au démarrage, toutes les 90 s, au retour de visibilité), plus de recalage erroné sur l'expiration du cookie ; badge session en direct (MM:SS) avec « Prolonger la session » dans le popup.
### Réservations
- **Modal enrichie** : préréglages Matin/Après-midi/Journée puis heures ; champs n° d'intervention, ville, rue, catégorie ; recherche automatique des informations dans EasyVista à partir du n° (catégorie et localisation pré-remplies).
- **Meta persistante et partagée** : ces informations survivent aux rafraîchissements (EV ne les stocke pas) et remontent désormais au dossier partagé — tous les postes voient le n°, le lieu et la catégorie d'une réservation, quel que soit le poste qui l'a créée ou modifiée.
- Édition d'une réservation (heures ou infos seules), suppression, tooltip détaillé avec ouverture/copie du n° lié.
### Pompier
- **Assignations « pompier du mois »** : définition par période (bouton Ajouter → Pompier), affichage dans la barre de stats, liste des pompiers actuels/à venir, édition/déplacement de période.
- La bandelette « En pompier du … au … » reste fixée en haut de la carte ; un pompier absent toute la journée est exclu du compteur.
### Coordinateur (en développement)
- Badge « Mes actions » dans la barre de stats + onglet dédié listant les interventions où le coordinateur a une action ; outil d'impersonation (réservé à l'auteur) pour tester.
### Douchette
- **Vraie confirmation EasyVista** : l'envoi vérifie le marqueur de prise en compte renvoyé par EV — toast « confirmé par EasyVista » fiable.
- **Resynchronisation automatique** après un déplacement d'intervention (jour source + jour cible, regroupés si même jour), avec compte-rendu clair et relance possible.
### Recherche
- **Opérateurs** : espace = tous les mots, `%` joker, `"expression exacte"`, `-mot` exclusion, `tech:/lieu:/ref:/cat:/contact:` ciblage de champ.
- **Pagination** 15 par 15 au défilement ; passé replié par défaut ; badge « Jour affiché » ; aide sur la syntaxe.
- **Mise en avant stabilisée** : « Jour ↗ » navigue et surligne la carte pendant 15 s, survit aux re-rendus, un seul défilement animé.
- La croix d'effacement native du navigateur (en doublon avec celle de l'extension) est masquée.
### Interventions & affichage
- **Multi-jours** : badge pilule (« 📅 ⇢ 16/06 » / « ⇠ 12/06 »), heures d'affichage par jour, période complète au survol, déplacement bloqué (à modifier dans EasyVista).
- **Conflit au déplacement** : le modal de confirmation signale toute occupation du technicien cible (détail en rouge) et **propose le créneau libre le plus proche** (même durée) en un bouton.
- **Vérification d'adresse** : la localisation officielle de la fiche devient l'adresse affichée ; si elle diverge de celle écrite dans l'action, le lieu passe en rouge avec explication au survol (comparaison tolérante ; exception Roll Out).
- **Contacts** : deux numéros de téléphone dans une action ne sont plus collés à l'affichage (désormais « 021 557 11 62 / 079 172 68 85 ») ; labels de fiche collés au contact séparés proprement.
- **Ouverture de fiche** : les interventions faites/clôturées se rouvrent à nouveau dans EasyVista (le lien interne est régénéré via la référence).
- Sections Matin / Après-midi / « Journée » harmonisées sur les deux vues ; éléments chevauchant midi non découpés ; catégorie « Autre » en violet foncé (fini le gris confondu avec les cartes faites).
- Interventions « faites » plus ré-analysées à chaque actualisation (économie réseau).
### Dossier partagé : script PowerShell publié
- Le script hôte (native messaging) est publié avec cette version et **téléchargeable depuis Paramètres → Dossier partagé**.
### Qualité
- Nettoyage complet : fonctions mortes retirées, aucun doublon de code, tous les journaux passés au logger maison (console propre en production), plus aucun emoji dans les logs, marqueurs de révision internes retirés.
---
## v2026.5.45 — Dock latéral drag&drop, fix verdicts ghost, multi-onglets EZV résolu
> Refonte de l'expérience drag&drop avec dock latéral pour parquer des interventions entre les jours, fix critique du parser de fiches qui marquait à tort des interventions terminées comme annulées, résolution des 6 issues ouvertes (multi-onglets EZV, absences récurrentes, popups épinglés, pompier absent), et nombreux ajustements d'ergonomie.
### Issues résolues
- **#3** — Coches « Absences récurrentes » : merge propre avec l'état stocké au lieu d'écrasement, les coches sont retenues lors d'un changement de groupe ou d'une réouverture des paramètres.
- **#4** — Perte de session EZV multi-onglets : permission optionnelle `cookies` + listener `cookieChanged` côté background, toggle dans Paramètres → Diagnostics. La session reste valable même après reconnexion et fermeture d'un onglet EZV.
- **#5** — Bouton de copie de référence dans une popup épinglée : handler `copy-ref` ajouté.
- **#6** — Popup épinglée au premier plan : clic sur une popup recalcule le z-index pour la passer au-dessus des autres.
- **#7** — Notification « +2 min » fantôme : reset des flags d'alerte slide au retour de la prolongation.
- **#8** — Compteur pompier : un pompier absent toute la journée est exclu du compteur.
### Dock latéral drag&drop
Le dock à droite permet de mettre des interventions de côté pendant qu'on navigue entre les jours, puis de les redéposer plus tard.
- Apparition graduelle pendant un drag : peep-min (sans contenu), peep (avec cartes), expanded (au survol ou au bord droit).
- **Délai 500 ms** pour expand/collapse pour éviter le flicker quand le curseur effleure le bord du dock.
- Card du dock : référence + durée prévue (`1h`, `1h30`, `45min`) avec barre verticale 4 px sur la gauche dans la couleur de la catégorie. Fond transparent.
- Bouton de retrait `×` : **appui long 2 s** avec animation `conic-gradient`. Un clic simple ne fait rien.
- Drag depuis le dock : retrait différé à l'activation effective du drag (5 px). Ghost flottant sans heure à gauche. Drop sur tech → modal de confirmation. Drop hors zone / Échap / sur le même slot d'origine → restauration dans le dock.
- Plus de scrollbar horizontale en bas du dock, plus de clic-through dans la zone autour du bouton « Tout annuler ».
### Verdicts ghost — fix critique
Une intervention terminée par le tech mais dont la fiche était passée en statut « Redirigé » / « Finalisation » / « Exécution » était à tort marquée comme « annulée » et retirée du planning, parce que le parser de fiche cherchait les dates d'action aux indices `[8]`/`[9]` du tableau `rows` alors que le layout EV récent les place en `[6]`/`[7]` (et la description en `[9]` au lieu de `[11]`).
- **Détection robuste** : scan des valeurs pour le pattern `DD/MM/YYYY HH:MM:SS`, garde des 2 dernières occurrences comme (création, fin). Survit aux variations de layout EV.
- **Décorrélation logs / KEEP-forcé** : nouvelle case dédiée « Garder les disparitions » dans Paramètres → Diagnostics, indépendante des « Logs verbeux ». Mode prod par défaut → verdict `REMOVE` appliqué (statut Annulé/Supprimé, ou statut clos sans commentaire du tech, ou action sans commentaire `login:`).
### Drag&drop bloqué pour les interventions non-déplaçables
`_canRescheduleIv` refuse maintenant explicitement le drag pour :
- iv en verdict `terminated-pending` (gris « fait »).
- iv en verdict `terminated-clos` (vert ✓✓).
- iv dont le statut EV est dans `CLOSED_STATUS` ou `RESOLVED_STATUS`.
- iv en cours d'analyse de disparition (`_disappearChecking`).
### Popups dépinglés
- Auto-fermeture du popup dépinglé quand la souris quitte sa zone et celle des cards liées de la même iv (300 ms de grâce). N'interfère pas avec un drag de planning en cours.
### Tooltip / contact
- Le contact n'inclut plus les labels « fiche » (`Étage`, `Bureau`, `Service`, `Matériel`, `Problème`, `TFS`, `Date`, `Heure`, `Lieu`, `Bénéficiaire`, `Nom utilisateur`) qui se collent parfois à la valeur du contact à cause de séparateurs perdus dans la source EV. Le tooltip insère un saut de ligne avant chaque label collé pour la lisibilité.
## v2026.5.44 — Refonte topbar, personnalisation Apparence, onboarding équipe, refresh séquentiel
> Refonte visuelle de la topbar (vue classique + horizontale), nouveau panneau
> de personnalisation (couleur de la barre du haut + police de l'application
> sur toute la page), nouvelle expérience d'onboarding quand aucun technicien
> n'est sélectionné, refonte du système de verdicts ghost (✓✓ clos / ✓ Fait /
> ✓ Suspendu), refresh strictement séquentiel avec arrêt instantané, et
> plusieurs corrections.
### Refresh / cache / verdicts ghost
- Rafraîchissement **séquentiel** (1 fiche à la fois) au lieu de 5 workers
parallèles → arrêt instantané via le bouton « ✕ Arrêter » (AbortController),
plus de races DOM, ordre d'affichage cohérent (pompier d'abord, puis alpha,
puis matin → après-midi).
- Re-fetch du checksum frais via `basicAutoComplete` + `redirectHeader`
(plus de fiche périmée entre sessions).
- Cache merge robuste (fallback `cachedByRef` quand `actionId` change) et
cache écrit toutes les 5 fiches pendant le refresh (incrémental).
- **Système de verdicts ghost unifié** : ✓✓ vert (clos / résolu officiel),
✓ gris « Fait » (terminated-pending), ✓ jaune « Suspendu »
(terminated-suspended), retrait silencieux pour cancelled / cancelled-
reservation / cancelled-absence.
- Statuts EV (clos / résolu / annulé / suspendu) éditables depuis Paramètres
→ EasyVista avec matching insensible à la casse, accents et conjugaisons.
- Mise à jour live du tooltip et du popup épinglé après un verdict (plus
besoin de fermer/réouvrir).
- Clic immédiat sur la carte dès que le verdict tombe (avant la fin du
refresh complet).
- Boutons « Actualiser » (rapide, ne re-télécharge pas les fiches déjà
connues) vs « Tout recharger » (force tout sauf les ✓✓ déjà clos).
- **Mode diagnostic optionnel** (Paramètres → Diagnostics) : aucune
intervention disparue n'est retirée silencieusement, tout est tracé sous
le préfixe `[disparition]` dans la console F12 pour debug. En PROD
(par défaut), les iv `cancelled` sont bien retirées comme avant.
### Topbar — vue classique
- Sélecteur de date du planning **ancré au centre absolu** : il ne se décale
plus quand le bouton « ✕ Arrêter » apparaît à droite pendant un
rafraîchissement.
- Bouton **« Aujourd'hui »** affiché en toutes lettres (au lieu de « Auj. »).
- Horloge contextuelle (date du jour + heure) réduite et discrète, à côté
du bouton Aujourd'hui dans un cadre encadré.
- Date du planning agrandie et neutre (couleur stable, plus de bascule
selon la date sélectionnée).
### Personnalisation — Paramètres → Apparence
- **Couleur de la barre du haut** : 12 presets cliquables (Défaut, Blanc,
Gris clair, Anthracite, Bleu DGNSI, Marine, Vert sapin, Brique, Violet,
Rouge, Bleu pastel, Vert pastel) + picker custom + champ hex `#rrggbb`
+ bouton « Réinitialiser ».
- La couleur s'applique uniquement à la topbar (et à la sidebar quand on
est en vue horizontale).
- Le texte de la topbar (titre, horloge, date, capture-info, badges,
boutons) s'adapte automatiquement (clair/foncé) selon la **luminance**
de la couleur choisie pour rester toujours lisible.
- **Police de l'application** : 28 choix organisés en familles
(sans-serif : Arial, Helvetica, Verdana, Tahoma, Trebuchet, Calibri,
Segoe UI, Gill Sans, Futura, Optima ; serif : Times New Roman, Georgia,
Cambria, Garamond, Palatino, Bookman ; monospace : Courier New, Consolas,
Lucida Console, JetBrains Mono ; display : Comic Sans MS, Impact,
Brush Script, Copperplate ; condensée : Arial Narrow). La police choisie
s'applique à **toute la page** (topbar, cards, popups, tooltips, panel
admin) et chaque option du select s'affiche dans sa propre police pour
prévisualiser le rendu, avec un aperçu live à droite.
- Export / import du cache et de `admin_config` depuis Paramètres →
Diagnostics.
### Vue horizontale
- Bloc « Aujourd'hui + horloge » empilé verticalement dans la sidebar, dans
le même cadre encadré que la vue classique.
- Date sélectionnée mise en avant (taille augmentée, en gras), date du
jour et heure réduites à la même petite taille pour rester discrètes.
- **Barre verticale verte** ajoutée à droite des mini-cards quand le
ticket est officiellement clôturé / résolu (✓✓), avec léger décalage du
✓✓ pour ne pas chevaucher la barre.
- Quand l'utilisateur a choisi une couleur de topbar, la sidebar prend
aussi la couleur : titre, horloge, capture-info, stats, today-block,
date sélectionnée, boutons, theme-toggle et séparateurs adoptent une
teinte translucide cohérente (via `color-mix`) qui contraste correctement
sur n'importe quel fond.
### Statistiques globales
- Nouveau compteur **« X faits / Y clos »** entre `(matin · après-midi)`
et `tech. dispo`. Inclut tous les tickets terminés (clos/résolus officiels
+ verdicts ghost « Fait » / « Suspendu »).
- En vue classique, séparateur `//` après `clos` (au lieu de `·`).
- En vue horizontale (sidebar), une **barre horizontale 1px** sépare le
bloc interventions/faits/clos du bloc tech. dispo + pompiers / absents.
### Onboarding équipe (1ʳᵉ install ou config vide)
- L'erreur générique « Aucun technicien sélectionné » est remplacée par une
**carte d'onboarding centrée** comprenant :
- icône (👥) cerclée en couleur accent du thème ;
- titre « Aucune équipe configurée » ;
- description claire ;
- bouton primary **« Ouvrir paramètres »** qui ouvre directement le panel
admin sur la section Équipe.
- Carte centrée verticalement et horizontalement dans la zone disponible,
identique en vue classique et horizontale.
### Bugfix
- **Issue #1 (Pompier + Absence)** : si un tech est à la fois pompier ET
absent, les deux badges s'affichent désormais avec un séparateur `/` au
lieu de masquer l'absence derrière le badge pompier.
- **Absences récurrentes** : quand on changeait de groupe puis revenait au
groupe initial, les jours d'absence cochés pour les techniciens
disparaissaient visuellement (la donnée elle-même restait en storage).
Correction : restauration depuis `cfg.recurringAbsences` à chaque
re-render.
- **Barre de progression / bannière session expirée** : suivent désormais
la hauteur dynamique de la topbar (variable CSS `--topbar-height` mesurée
par un `ResizeObserver`). Plus de chevauchement quand on scrolle.
- **STATUS_FR regex** : limite augmentée de 30 à 200 caractères (battait
sur « Suspendu : Attente info bénéficiaire/demandeur »).
- **Description action** : décodage `" → "`, `<br> → \n`, HTML
strippé. Préfixe « login: » retiré du commentaire technicien dans le
tooltip / popup.
- **Tooltip référence** : flèche « ↗ » retirée du lien cliquable.
---
## v2026.5.43 — Fix Firefox : positionnement menu dock + stabilité popup pin/unpin
### Menu hover sur pastille du dock (popup réduit)
- Bug Firefox uniquement : quand un popup épinglé était réduit dans la
taskbar du bas, le menu qui apparaît au survol de la pastille
(Agrandir / Fermer) se positionnait trop haut, pas juste au-dessus de
la pastille.
- Cause : `getBoundingClientRect()` était appelé immédiatement après
`appendChild`, avant que Firefox n'ait calculé la mise en page.
Combiné avec un `transform: translateY(4px)` dans l'animation
`pill-hover-menu-appear`, Firefox lisait des dimensions décalées.
- Fix : positionnement hors écran initial, force-layout via
`void offsetHeight`, mesure des dimensions, puis pose finale. CSS de
l'animation simplifiée en opacité-only (plus de transform).
### Stabilité popup au pin/unpin
- Bug : la popup épinglée bougeait visuellement et changeait légèrement
de taille quand on la dé-épinglait avec le bouton 📌 (puis l'inverse).
- Cause : `.pinned-popup` avait `padding-top: 28px` (place pour la
dragbar) et `border: 2px`, alors que `.soft-unpinned` avait
`padding-top: 12px` et `border: 1px`. Le contenu se décalait de 16px
vers le haut et la popup devenait 1px plus fine de chaque côté.
- Fix : `.soft-unpinned` conserve désormais `padding-top: 28px` et
`border: 2px` comme `.pinned-popup`. Bordure passe juste en
`--border-strong` (gris discret) plutôt que `--accent` (bleu) pour
signaler visuellement le mode "détaché". Position et taille stables.
## v2026.5.42 — Nettoyage de commentaires + exemples génériques
- Passage en revue des commentaires de `src/viewer.js` : les exemples qui
illustraient le parsing des contacts/lieux/références/codes-barres ont été
uniformisés en placeholders abstraits (`Nom1 Prénom1 +41XXXXXXXXX`,
`SYYMMDD_NNNNN`, `XXXX_NNNNNNNN`, etc.) plutôt que des chaînes spécifiques.
Comportement runtime strictement inchangé — uniquement de la documentation
et des commentaires.
- Mise à jour cohérente du README, du CHANGELOG et des pages wiki Versions /
Utilisation pour utiliser les mêmes notations génériques dans les
exemples de référence.
## v2026.5.41 — Suppression des hardcodes (groupe / domaines / équipe) → tout depuis l'admin ## v2026.5.41 — Suppression des hardcodes (groupe / domaines / équipe) → tout depuis l'admin
### Plus aucun hardcode au runtime ### Plus aucun hardcode au runtime
@@ -352,7 +54,7 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
au viewer via `err.kind`. au viewer via `err.kind`.
- Toutes les anciennes constantes hardcodées (`EV_ORIGINS`, - Toutes les anciennes constantes hardcodées (`EV_ORIGINS`,
`DEFAULT_SUPPORT_IDS` interne à `detectTeamFromEV`, `DEFAULT_SUPPORT_IDS` interne à `detectTeamFromEV`,
`isXXXAbsentFriday`) ont été remplacées ou retirées. `isPillonelAbsentFriday`) ont été remplacées ou retirées.
### Conflits absence/réservation × intervention ### Conflits absence/réservation × intervention
- Nouveau code visuel : si une intervention est planifiée pendant - Nouveau code visuel : si une intervention est planifiée pendant
@@ -545,12 +247,12 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
- Stats rapides .tech-row-stats ajoutés au header (nb interv, Xm · Ya) - Stats rapides .tech-row-stats ajoutés au header (nb interv, Xm · Ya)
## v2026.5.31 — Sarcelle pour absence récurrente (REJETÉ par utilisateur) ## v2026.5.31 — Sarcelle pour absence récurrente (REJETÉ par utilisateur)
- Couleur absence récurrente (jour fixe) : sarcelle foncée #0f766e / soft #ccfbf1 - Couleur Pillonel vendredi : sarcelle foncée #0f766e / soft #ccfbf1
- Variables --c-recurring, --c-recurring-soft - Variables --c-recurring, --c-recurring-soft
- Layout 4 colonnes forcées + scroll interne cartes (REJETÉ : "scroll en continu") - Layout 4 colonnes forcées + scroll interne cartes (REJETÉ : "scroll en continu")
## v2026.5.30 — Absence récurrente cyan + mode compact 24" ## v2026.5.30 — Absence récurrente cyan + mode compact 24"
- Absences récurrentes (configurées par tech) en cyan - Absence récurrente Pillonel vendredi en cyan
- Mode compact @media (max-width: 1920px) avec grid-template-columns: repeat(4, 1fr) - Mode compact @media (max-width: 1920px) avec grid-template-columns: repeat(4, 1fr)
## v2026.5.29 — Contraste++ + footer ## v2026.5.29 — Contraste++ + footer
@@ -599,9 +301,8 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
## Versions antérieures (v5.x et v4.x) ## Versions antérieures (v5.x et v4.x)
> Ces versions ne sont pas documentées en détail. Pour les analyser à partir > Ces versions sont à analyser par Claude Code à partir des fichiers source.
> des fichiers source historiques (consultables via les tags git), voici les > Indices clés à chercher dans le viewer.js :
> indices clés à chercher dans `viewer.js` :
> >
> - Présence de `pinTooltip` → version >= v4.x > - Présence de `pinTooltip` → version >= v4.x
> - Présence de `_softUnpinPopup` → version >= v4.3.3 > - Présence de `_softUnpinPopup` → version >= v4.3.3
@@ -629,14 +330,14 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
- v4.3.3 : _softUnpinPopup (désépinglage mou) - v4.3.3 : _softUnpinPopup (désépinglage mou)
### v3.x et antérieures — Versions de base ### v3.x et antérieures — Versions de base
- Code historique consultable via les tags git correspondants. - À analyser par Claude Code
--- ---
## Notes techniques persistantes (toutes versions) ## Notes techniques persistantes (toutes versions)
- 8 techs hardcodés à l'origine (depuis v2026.5.41 : retirés, alimentés par admin_config) - 8 techs hardcodés : "76272,83725,66635,92235,90070,40944,72485,86874"
- Absences récurrentes (un tech absent un jour fixe par semaine) hardcodées à l'origine, depuis v2026.5.41 configurables via Paramètres → Équipe - Pillonel Olivier (ID 40944) absent tous les vendredis (hardcodé)
- Group ID EasyVista : 191 - Group ID EasyVista : 191
- Domaines cibles : itsma.etat-de-vaud.ch (interne), itsma.vd.ch (externe) - Domaines cibles : itsma.etat-de-vaud.ch (interne), itsma.vd.ch (externe)
- SSO : Canton ForgeRock OpenAM - SSO : Canton ForgeRock OpenAM
@@ -651,4 +352,4 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
**Quentin Rouiller** (QRO) **Quentin Rouiller** (QRO)
Technicien DGNSI — Canton de Vaud Technicien DGNSI — Canton de Vaud
Contact : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) Contact : `quentin.rouiller@vd.ch`
+119 -107
View File
@@ -1,39 +1,16 @@
# Planification — Extension EasyVista Canton de Vaud # Planification — Extension EasyVista Canton de Vaud
Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning EasyVista de l'équipe technicienne DGNSI (Canton de Vaud). Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning des techniciens DGNSI (Canton de Vaud) dans EasyVista.
> 📖 **Documentation utilisateur complète** : [wiki](https://gitea.netaplaid.ch/FroSteel/Planification/wiki) ([Home](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Home) · [Utilisation](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Utilisation) · [Versions](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Versions) · [Architecture](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Architecture) · [Dépannage](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/D%C3%A9pannage) · [Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact))
## Aperçu rapide ## Aperçu rapide
- **Auteur** : Quentin Rouiller (QRO), Technicien DGNSI — Canton de Vaud - **Auteur** : Quentin Rouiller (QRO)
- **Public cible** : coordinateurs DGNSI qui pilotent dans EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) le planning de l'équipe technicienne - **Cible** : techniciens DGNSI (Canton de Vaud), EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`)
- **Démarrage projet** : jeudi 16 avril 2026 - **Démarrage projet** : jeudi 16 avril 2026
- **Version actuelle** : [`v2026.5.45`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.45) (latest) - **Version actuelle** : `v2026.5.41`
- **Contact** : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) ou [ouvrir une issue](https://gitea.netaplaid.ch/FroSteel/Planification/issues/new) - **Contact** : `[email protected]`
- **Manifest** : V3 (Chrome/Edge/Firefox 140+) - **Manifest** : V3 (Chrome/Edge/Firefox)
- **Format** : `.zip` (Chromium) + `.xpi` signé Mozilla (Firefox) - **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox)
- **Distribution** : auto-update natif Firefox via `firefox-updates.json`
## Installation rapide
### Firefox 🦊 (recommandé — auto-update)
1. Télécharger le `.xpi` signé depuis la **[release courante](https://gitea.netaplaid.ch/FroSteel/Planification/releases/latest)**.
2. Drag-and-drop dans `about:addons` de Firefox.
3. Cliquer "Ajouter".
À partir de là, l'extension se met à jour **automatiquement** à chaque nouvelle version (vérification toutes les ~24 h via `firefox-updates.json`).
### Chrome / Edge / Brave 🌐 (manuel)
1. Télécharger le `.zip` depuis la **[release courante](https://gitea.netaplaid.ch/FroSteel/Planification/releases/latest)**.
2. Décompresser dans un dossier permanent.
3. `chrome://extensions/` (ou `edge://extensions/`) → activer **Mode développeur** → "Charger l'extension non empaquetée" → sélectionner le dossier décompressé.
Les mises à jour sont **manuelles** : à chaque nouvelle release, retélécharger le `.zip`, écraser le dossier, puis cliquer ⟳ (Recharger) sur la carte de l'extension.
➡ Pour le détail complet (stockage, désinstallation, comparatif), voir [wiki Utilisation → Installation et navigateurs](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Utilisation#installation-et-navigateurs).
## Fonctionnalités principales ## Fonctionnalités principales
@@ -47,7 +24,6 @@ Les mises à jour sont **manuelles** : à chaque nouvelle release, retélécharg
- **Vue classique** (depuis v1.0.0) : cards en grille, mode compact écran 24" (depuis v2026.5.30) - **Vue classique** (depuis v1.0.0) : cards en grille, mode compact écran 24" (depuis v2026.5.30)
- **Vue horizontale** (depuis v2026.5.32) : timeline par tech, sidebar verticale (depuis v2026.5.36) - **Vue horizontale** (depuis v2026.5.32) : timeline par tech, sidebar verticale (depuis v2026.5.36)
- Depuis v2026.5.40 : barre couleur catégorie + référence + ville sur chaque segment timeline - Depuis v2026.5.40 : barre couleur catégorie + référence + ville sur chaque segment timeline
- **Vue horizontale « échelle de temps exacte » (depuis v2026.5.46)** : option Paramètres → Apparence — chaque intervention est posée à son heure réelle sur une bande continue (règle des heures fixe, lignes d'heures et demi-heures sur toute la hauteur, ligne rouge « maintenant », chevauchements empilés, poignée de déplacement colorée, zones libres survolables et cibles de drop précises)
- Toggle Vue classique ↔ Vue horizontale via bouton ⊞ dans popup user-badge - Toggle Vue classique ↔ Vue horizontale via bouton ⊞ dans popup user-badge
- Persistance localStorage (`view_mode`) - Persistance localStorage (`view_mode`)
@@ -64,15 +40,14 @@ Les mises à jour sont **manuelles** : à chaque nouvelle release, retélécharg
- **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif) - **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif)
- **Pompier** : rouge `#b03030` - **Pompier** : rouge `#b03030`
- Badge + barre gauche colorée + dégradé fond - Badge + barre gauche colorée + dégradé fond
- Absences récurrentes (configurées par tech) : cyan (depuis v2026.5.30) - Absence récurrente Pillonel vendredi : cyan (depuis v2026.5.30)
### User et session ### User et session
- Badge user avec photo/initiales en topbar - Badge user avec photo/initiales en topbar
- Badge cliquable (depuis v2026.5.26) : popup avec ⚙ Paramètres + ⊞ Vue + compteur session MM:SS - Badge cliquable (depuis v2026.5.26) : popup avec ⚙ Paramètres + ⊞ Vue + compteur session MM:SS
- Retry automatique en cas d'échec fetch user (60s, max 10 essais) - Retry automatique en cas d'échec fetch user (60s, max 10 essais)
- Compteur de session EasyVista (tick 1s, depuis v5.0.0) — recalé sur la vraie valeur serveur (depuis v2026.5.46) - Compteur de session EasyVista (tick 1s, depuis v5.0.0)
- Reconnexion automatique - Reconnexion automatique
- Détection de session fiabilisée (v2026.5.46) : plus de faux « déconnecté » (cookie réécrit, erreurs transitoires, onglet EV facultatif avec la permission cookies)
### Admin et configuration ### Admin et configuration
- Mode admin : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25) - Mode admin : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25)
@@ -80,9 +55,6 @@ Les mises à jour sont **manuelles** : à chaque nouvelle release, retélécharg
- Sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de l'onglet Équipe (depuis v2026.5.40) — détection automatique via le `<select id="plan_group_id">` de la page Planning EV, robuste aux ajouts/renommages côté EV - Sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de l'onglet Équipe (depuis v2026.5.40) — détection automatique via le `<select id="plan_group_id">` de la page Planning EV, robuste aux ajouts/renommages côté EV
- Édition manuelle des domaines EasyVista interne / externe (depuis v2026.5.40) - Édition manuelle des domaines EasyVista interne / externe (depuis v2026.5.40)
- Tri des techniciens : actifs d'abord, puis exclus, alphabétique dans chaque groupe (depuis v2026.5.40) - Tri des techniciens : actifs d'abord, puis exclus, alphabétique dans chaque groupe (depuis v2026.5.40)
- **Personnalisation Apparence (depuis v2026.5.44)** : couleur de la topbar (12 presets + custom + hex) avec contraste auto-calculé sur le texte ; police de l'application (28 choix : Arial, Helvetica, Verdana, Tahoma, Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria, Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée à toute la page
- **Onboarding équipe (depuis v2026.5.44)** : carte centrée propre quand aucun tech n'est sélectionné, avec bouton « Ouvrir paramètres » qui dépose directement sur la section Équipe
- **Statuts EV configurables (depuis v2026.5.44)** : clos / résolu / annulé / suspendu éditables depuis Paramètres → EasyVista, matching insensible à la casse / accents / conjugaisons
## Versionning — historique et conventions ## Versionning — historique et conventions
@@ -92,7 +64,7 @@ L'extension a connu **3 systèmes de versionning successifs** :
|---|---|---| |---|---|---|
| 16-17 avril 2026 | Versions de base | `1.0.0`, `2.0.0`, `3.0.0` | | 16-17 avril 2026 | Versions de base | `1.0.0`, `2.0.0`, `3.0.0` |
| 18-20 avril 2026 | SemVer classique | `4.1.3`, `4.2.8`, `5.0.12` | | 18-20 avril 2026 | SemVer classique | `4.1.3`, `4.2.8`, `5.0.12` |
| 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16``2026.5.45` | | 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16``2026.5.40` |
### Format actuel : `ANNÉE.MAJEURE.PATCH` ### Format actuel : `ANNÉE.MAJEURE.PATCH`
@@ -115,58 +87,14 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
## Versions notables ## Versions notables
### `v2026.5.46` (latest, 9 juillet 2026) — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées ### `v2026.5.41` (latest, 27 avril 2026) — Suppression des hardcodes + UX admin + thème unifié
- Refonte complète de la couche données : store unifié par jour, un seul verdict par intervention, synchronisation du dossier partagé en sous-dossier v2
- Nouvelle disposition « échelle de temps exacte » pour la vue horizontale (option Apparence)
- Fin des faux « déconnecté » EasyVista (cookies, erreurs transitoires, contre-vérification serveur)
- Les informations des réservations (n°, lieu, catégorie) sont partagées entre tous les postes
- Douchette : vraie confirmation EasyVista + resynchronisation automatique après déplacement
- Recherche : opérateurs, pagination, mise en avant stabilisée
- Le script PowerShell du dossier partagé est publié et téléchargeable depuis Paramètres → Dossier partagé
### `v2026.5.45` (8 mai 2026) — Dock latéral drag&drop, fix verdicts ghost, multi-onglets EZV résolu
- **Dock latéral drag&drop** : nouvelle zone à droite pour parquer des interventions et les redéposer plus tard, avec apparition graduelle (peep-min/peep/expanded), délai 500 ms pour expand/collapse anti-flicker, card compacte (réf + durée + barre catégorie 4 px à gauche), bouton de retrait par appui long 2 s.
- **Verdicts ghost** : fix critique du parser de fiche (les dates daction ne sont plus cherchées aux indices fixes \`[8]\`/\`[9]\` mais détectées par scan regex pattern \`DD/MM/YYYY HH:MM:SS\`). Décorrélation des « Logs verbeux » et « Garder les disparitions » dans Paramètres → Diagnostics.
- **Drag&drop bloqué** sur les interventions « Fait » (gris), « Clos » (vert ✓✓), statut Clôturé/Résolu/Terminé, et pendant lanalyse de disparition dun ghost.
- **Issue #3** : coches « Absences récurrentes » retenues (merge propre au lieu d’écrasement) lors dun changement de groupe.
- **Issue #4** : multi-onglets EZV — permission optionnelle \`cookies\` + listener \`cookieChanged\`, la session reste valable après reconnexion + fermeture dun onglet EZV.
- **Issue #5** : copie de référence dans une popup épinglée fonctionne.
- **Issue #6** : popup épinglée passe au premier plan au clic.
- **Issue #7** : plus de notification « +2 min » fantôme après prolongation.
- **Issue #8** : pompier absent toute la journée nest plus compté.
- **Auto-fermeture** des popups dépinglés quand la souris quitte leur zone et celle des cards liées.
- **Tooltip / contact nettoyé** : les labels « fiche » (Étage/Bureau/Service/etc.) collés à la valeur du contact sont retirés.
### `v2026.5.44` (1 mai 2026) — Refonte topbar, personnalisation Apparence, onboarding équipe, fix #1
- **Topbar refondue (vue classique)** : sélecteur de date du planning ancré au centre absolu (ne se décale plus quand le bouton « Arrêter » apparaît), bouton « Aujourd'hui » en toutes lettres, horloge contextuelle dans un cadre encadré.
- **Personnalisation Apparence** : couleur de la barre du haut (12 presets + picker custom + champ hex), contraste de texte calculé automatiquement par luminance ; police de l'application avec 28 choix (Arial, Helvetica, Verdana, Tahoma, Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria, Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée à toute la page.
- **Vue horizontale** : bloc Aujourd'hui + horloge dans le même cadre que la classique, barre verte verticale à droite des mini-cards clos/résolu, sidebar adopte la couleur de topbar custom de manière cohérente.
- **Stats globales** : nouveau compteur « X faits / Y clos » ; vue classique avec « // » après clos, vue horizontale avec barre horizontale 1px de séparation.
- **Onboarding équipe** : carte centrée propre (icône, titre, description, bouton « Ouvrir paramètres ») au lieu du bandeau d'erreur ; le bouton ouvre directement la section Équipe.
- **Refresh / cache / verdicts ghost** : rafraîchissement séquentiel avec arrêt instantané (AbortController), checksum frais via basicAutoComplete + redirectHeader, cache merge robuste, verdicts unifiés (✓✓ clos, ✓ Fait, ✓ Suspendu jaune, retrait silencieux pour cancelled), mode diagnostic optionnel.
- **Issue #1 (Pompier + Absence)** : les deux badges s'affichent désormais avec un séparateur `/` au lieu de masquer l'absence.
- **Bugfix divers** : absences récurrentes restaurées au switch de groupe, barre de progression / bannière session expirée suivent la hauteur dynamique de la topbar (`--topbar-height` via `ResizeObserver`), description action décodée proprement, flèche `↗` retirée des références cliquables.
### `v2026.5.43` (27 avril 2026) — Fix Firefox : menu dock + stabilité popup pin/unpin
- Firefox : le menu hover sur les pastilles du dock (popup réduit) se
positionne désormais correctement au-dessus de la pastille.
- Pin/unpin : la popup épinglée ne bouge plus et garde la même taille
quand on la dé-épingle / re-épingle.
### `v2026.5.42` — Nettoyage de commentaires + exemples génériques
- Uniformisation des exemples utilisés dans les commentaires de `viewer.js`
(parsing contacts/lieux/références/codes-barres) en placeholders abstraits.
Comportement runtime strictement inchangé.
### `v2026.5.41` — Suppression des hardcodes + UX admin + thème unifié
- **Plus aucun hardcode runtime** pour le groupe EV, les domaines, la liste de techniciens ou les absences récurrentes. Tout est piloté par `admin_config` (chrome.storage.local), persisté entre les mises à jour. - **Plus aucun hardcode runtime** pour le groupe EV, les domaines, la liste de techniciens ou les absences récurrentes. Tout est piloté par `admin_config` (chrome.storage.local), persisté entre les mises à jour.
- **Au 1er install** : aucun tech sélectionné, aucune absence récurrente. Le viewer affiche un message *"Aucun technicien sélectionné"* tant que l'utilisateur n'a rien configuré dans Paramètres → Équipe. - **Au 1er install** : aucun tech sélectionné, aucune absence récurrente. Le viewer affiche un message *"Aucun technicien sélectionné"* tant que l'utilisateur n'a rien configuré dans Paramètres → Équipe.
- **Édition des domaines** : `chrome.permissions.request()` au save quand l'utilisateur saisit un domaine custom (au-delà des 2 défauts). Manifest `optional_host_permissions: ["https://*/*"]` pour accepter n'importe quel domaine HTTPS après accord du navigateur. - **Édition des domaines** : `chrome.permissions.request()` au save quand l'utilisateur saisit un domaine custom (au-delà des 2 défauts). Manifest `optional_host_permissions: ["https://*/*"]` pour accepter n'importe quel domaine HTTPS après accord du navigateur.
- **Heures de la journée** : bouton ✓ Appliquer explicite (au lieu de save direct), toast de confirmation, refetch automatique du planning. Synchronisation effective avec les requêtes EV (`day_start_hour` / `day_end_hour` / `begin_hour` / `end_hour`) — avant, l'affichage changeait mais les requêtes restaient sur 8h-19h hardcodés. - **Heures de la journée** : bouton ✓ Appliquer explicite (au lieu de save direct), toast de confirmation, refetch automatique du planning. Synchronisation effective avec les requêtes EV (`day_start_hour` / `day_end_hour` / `begin_hour` / `end_hour`) — avant, l'affichage changeait mais les requêtes restaient sur 8h-19h hardcodés.
- **Thème unifié** : le toggle 🌙 de la topbar et le sélecteur Apparence du panel admin écrivent dans la même clé (`cfg.theme`). Le mode "Automatique" est résolu en JS via `prefers-color-scheme` (le CSS n'avait pas de bloc `@media`, ce qui faisait retomber sur le clair même quand l'OS était en sombre). Listener `matchMedia` pour bascule live en mode auto. - **Thème unifié** : le toggle 🌙 de la topbar et le sélecteur Apparence du panel admin écrivent dans la même clé (`cfg.theme`). Le mode "Automatique" est résolu en JS via `prefers-color-scheme` (le CSS n'avait pas de bloc `@media`, ce qui faisait retomber sur le clair même quand l'OS était en sombre). Listener `matchMedia` pour bascule live en mode auto.
- **Conflit absence/réservation × intervention** : si une intervention est planifiée pendant qu'un tech a une absence (toute la journée ou demi-journée) ou une réservation au même créneau, sa carte est peinte en **rouge plein** (intervention conflictuelle). Logique : full-day → toutes en rouge ; partiel → seules celles en chevauchement. - **Conflit absence/réservation × intervention** : si une intervention est planifiée pendant qu'un tech a une absence (toute la journée ou demi-journée) ou une réservation au même créneau, sa carte est peinte en **rouge plein** (intervention conflictuelle). Logique : full-day → toutes en rouge ; partiel → seules celles en chevauchement.
- **Absences récurrentes génériques** : suppression de la fonction hardcodée `isXXXAbsentFriday()`. L'absence récurrente est désormais générique : `RECURRING_ABSENCES[tech.id]` lit `cfg.recurringAbsences` et le label "Absent le X" est calculé dynamiquement depuis le jour de la semaine. - **Pillonel & Cie** : suppression de la fonction hardcodée `isPillonelAbsentFriday()`. L'absence récurrente est désormais générique : `RECURRING_ABSENCES[tech.id]` lit `cfg.recurringAbsences` et le label "Absent le X" est calculé dynamiquement depuis le jour de la semaine.
- **Notifications au-dessus du flou** : z-index `.toast-stack` relevé à 11000 (le panel admin est à 10000) pour que les toasts de feedback restent visibles quand l'admin est ouvert. - **Notifications au-dessus du flou** : z-index `.toast-stack` relevé à 11000 (le panel admin est à 10000) pour que les toasts de feedback restent visibles quand l'admin est ouvert.
- **Vue horizontale** : popups au survol/clic limités aux candidats `dessous`/`dessus` (la sidebar à gauche et la timeline pleine largeur rendent gauche/droite peu praticables). - **Vue horizontale** : popups au survol/clic limités aux candidats `dessous`/`dessus` (la sidebar à gauche et la timeline pleine largeur rendent gauche/droite peu praticables).
- **Tri équipe** : inclus d'abord, puis exclus, alphabétique dans chaque sous-groupe (ne saute plus quand on coche/décoche). - **Tri équipe** : inclus d'abord, puis exclus, alphabétique dans chaque sous-groupe (ne saute plus quand on coche/décoche).
@@ -176,6 +104,7 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
### `v2026.5.40` — Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie ### `v2026.5.40` — Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie
- **Onglet Équipe** : sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de section, détecté automatiquement à l'ouverture du panel via le `<select id="plan_group_id">` de la page Planning EV. Robuste aux ajouts/renommages côté EV. - **Onglet Équipe** : sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de section, détecté automatiquement à l'ouverture du panel via le `<select id="plan_group_id">` de la page Planning EV. Robuste aux ajouts/renommages côté EV.
- **Onglet Équipe** : sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de section, détecté automatiquement à l'ouverture du panel via le `<select id="plan_group_id">` de la page Planning EV. Robuste aux ajouts/renommages côté EV.
- ID groupe affiché en italique (ex: `ID groupe : 191`). - ID groupe affiché en italique (ex: `ID groupe : 191`).
- Quand on change de groupe, la liste d'équipe se rafraîchit automatiquement avec les membres du nouveau groupe. - Quand on change de groupe, la liste d'équipe se rafraîchit automatiquement avec les membres du nouveau groupe.
- Plus de bouton "Détecter" : tout est auto à l'ouverture. - Plus de bouton "Détecter" : tout est auto à l'ouverture.
@@ -188,29 +117,110 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
### `v2026.5.39` — Séparation Matin / Après-midi + Apparence ### `v2026.5.39` — Séparation Matin / Après-midi + Apparence
- Pills "MATIN" / "APRÈS-MIDI" entre les interventions de chaque tech - Pills "MATIN" / "APRÈS-MIDI" entre les interventions de chaque tech
- Section **Apparence** dans les paramètres : thème, taille du texte, durée du cache, heures de la journée - Section **Apparence** dans les paramètres : thème, taille du texte, durée du cache, heures de la journée
- Section **À propos** (version, auteur, licence)
➡ Pour l'historique complet (40+ versions depuis le 16 avril 2026), voir le **[CHANGELOG.md](CHANGELOG.md)** ou la **[page wiki Versions](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Versions)**. ### `v2026.5.38` — Attribution auteur + nettoyage + observabilité
- Module `LOG` unifié + handlers globaux d'erreur
- Toggle "Logs verbeux (debug)" dans le panel admin
- En-têtes copyright dans tous les fichiers source
### `v2026.5.37` — Refonte vue horizontale (sidebar complète)
- Topbar entièrement déplacée en sidebar verticale
### `v2026.5.36` — Sidebar verticale en vue horizontale
- Wrapper flex-row `#horizontal-wrapper` [sidebar 200px] + [main]
### `v2026.5.32` — Vue horizontale togglable
- Bouton ⊞ "Vue" dans popup user-badge
### `v2026.5.27` — Classification absences
- Maladie indigo, Congé cyan, Pompier rouge
### `v4.2.3` — Grande popup timeline persistante
- Clic segment timeline = popup persistante
### `v4.1.3` — Tooltips épinglables
- Introduction de `pinTooltip`
### `v1.0.0` (16 avril 2026) — Initiale
- Premier viewer EasyVista pour le canton
Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique complet (40+ versions taggées).
## Architecture technique ## Architecture technique
``` ```
Planification/ # Layout du repo Gitea (public) Planning/
├── src/ # Sources de l'extension (chargées par le navigateur) ├── src/ # Sources de l'extension (chargées par le navigateur)
│ ├── manifest.json # Manifest V3 (Chrome) + browser_specific_settings (Firefox) │ ├── manifest.json # Manifest V3 (Chrome) + gecko_settings (Firefox)
│ ├── background.js # Service worker (~1 600 lignes) │ ├── background.js # Service worker : fetch planning XML, gestion session, fetch fiches
│ ├── viewer.html # Interface principale │ ├── viewer.html # Interface principale
│ ├── viewer.js # Logique UI (~10 700 lignes) │ ├── viewer.js # Logique (~9 500 lignes)
│ ├── viewer.css # Styles + thèmes clair/sombre (~4 800 lignes) │ ├── viewer.css # Styles + thèmes clair/sombre
│ └── icons/ # icon16, icon48, icon128 │ └── icons/ # icon16, icon48, icon128
├── build.sh # Génère dist/chromium/, dist/firefox/, .zip, .xpi, met à jour firefox-updates.json ├── Autres/ # Méta : build script + docs (depuis v2026.5.40)
├── firefox-updates.json # Manifest auto-update Firefox (servi via update_url) │ ├── build.sh # Génère dist/chromium/, dist/firefox/, .zip, .xpi
├── README.md │ ├── CHANGELOG.md
├── CHANGELOG.md │ ├── LICENSE
├── LICENSE # MIT │ └── README.md
── .gitignore ── Builds/ # Artefacts distribués aux techniciens
│ ├── Chromium/
│ ├── Firefox/
│ ├── planification-vYYYY.M.PATCH-chromium.zip
│ └── planification-vYYYY.M.PATCH-firefox.xpi
└── dist/ # Sortie de build (gitignoré)
``` ```
➡ Pour le détail des composants, fonctions clés et flux de données, voir la **[page wiki Architecture](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Architecture)**. ### `viewer.js` — fonctions clés
| Fonction | Introduite | Rôle |
|---|---|---|
| `loadForDate` | v1.0.0 | Fetch + parse planning pour une date donnée |
| `buildTooltipHTML` | v1.0.0 | Construction HTML du tooltip d'intervention |
| `pinTooltip` | v4.1.3 | Épingler un tooltip (le rendre permanent) |
| `bindTimelinePopover` | v4.2.3 | Lier popover timeline aux segments |
| `showTimelinePopover` | v4.2.3 | Afficher popover persistante |
| `openPersistentTimelinePopup` | v4.2.3 | Grande popup détaillée |
| `setTooltipViewportPosition` | v4.2.4 | Détection auto fixed/abs |
| `_softUnpinPopup` | v4.3.3 | Désépinglage mou (popup reste visible) |
| `initAppClock` | v5.0.0 | Horloge HH:MM topbar |
| `initSessionTimer` | v5.0.0 | Compteur session EV (tick 1s) |
| `_applyViewMode` | v2026.5.32 | Toggle vue classique/horizontale |
| `_maybeRetryFetchUser` | v2026.5.34 | Relance opportuniste fetch user |
| `positionTooltipAnchored` | v2026.5.34 | Positionnement unifié (4 candidats) |
| `renderAdminSectionTeam` | v5.0.0 | Onglet admin Équipe (sélecteur groupe EV depuis v2026.5.40) |
| `renderAdminSectionEV` | v5.0.0 | Onglet admin EasyVista (édition domaines depuis v2026.5.40) |
### `background.js` — fonctions clés
| Fonction | Rôle |
|---|---|
| `findEasyVistaSession` | Trouve l'onglet EV ouvert + extrait PHPSESSID |
| `fetchPlanningXml` | Fetch XML calendar_block du planning |
| `fetchFicheHtml` | Fetch HTML d'une fiche EV (avec retry SSO) |
| `fetchCurrentUser` | Identifie l'user EV connecté |
| `detectGroupsFromEV` (v2026.5.40) | Parse le `<select id="plan_group_id">` → liste des groupes EV |
| `detectTeamFromEV` | Liste les membres d'un groupe (paramétrable depuis v2026.5.40) |
| `evFetch` | Wrapper fetch avec headers EV (Referer, X-Requested-With) |
### Constantes / valeurs hardcodées (toutes versions)
- Group ID EV par défaut : `191` (SI-CSS) — surchargeable via le sélecteur depuis v2026.5.40
- Pillonel Olivier (ID 40944) : absent tous les vendredis (récurrent)
- GUIDs forms EV :
- Demande : `S={C99ECD05-3D48-4C62-ABF0-66292053AED6}`
- Incident : `I={07ED9C68-6172-48EA-8A58-90912B0A283E}`
- SSO : Canton ForgeRock OpenAM
- Storage keys : `admin_config`, `view_mode`
- Domaines : `itsma.etat-de-vaud.ch` (interne), `itsma.vd.ch` (externe)
## Installation
### Firefox
Télécharger le `.xpi` depuis `Builds/` ou le serveur de mises à jour interne, puis drag-and-drop dans `about:addons`.
### Chrome / Edge
Mode développeur : décompresser `Builds/planification-vYYYY.M.PATCH-chromium.zip` (ou utiliser directement `dist/chromium/`) et charger en tant qu'extension non empaquetée.
## Développement ## Développement
@@ -219,21 +229,23 @@ git clone https://gitea.netaplaid.ch/FroSteel/Planification.git
cd Planification cd Planification
# Modifier les sources dans src/ # Modifier les sources dans src/
# Bumper src/manifest.json + entrée CHANGELOG.md # Bumper la version dans src/manifest.json + ajouter une entrée dans Autres/CHANGELOG.md
./build.sh # Builder :
# → dist/chromium/, dist/firefox/, dist/*.zip, dist/*.xpi ./Autres/build.sh
# → firefox-updates.json mis à jour (sha256 .xpi NON SIGNÉ) # → produit dist/chromium/, dist/firefox/, dist/*.zip, dist/*.xpi
# Copier dans Builds/ pour distribution :
cp -r dist/chromium Builds/Chromium
cp -r dist/firefox Builds/Firefox
cp dist/*.zip dist/*.xpi Builds/
git add -A git add -A
git commit -m "vYYYY.M.PATCH — description" git commit -m "vYYYY.M.PATCH — description"
git tag vYYYY.M.PATCH git tag vYYYY.M.PATCH
git push origin main vYYYY.M.PATCH git push origin main
git push --tags
``` ```
Pour Firefox : signer le `.xpi` sur AMO en mode "On your own" (Unlisted),
remplacer l'asset `.xpi` de la release Gitea, puis mettre à jour le sha256
de cette version dans `firefox-updates.json`.
## Licence ## Licence
[MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller [MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller
@@ -242,4 +254,4 @@ de cette version dans `firefox-updates.json`.
**Quentin Rouiller** (QRO) **Quentin Rouiller** (QRO)
Technicien DGNSI — Canton de Vaud Technicien DGNSI — Canton de Vaud
Contact : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) Contact : `[email protected]`
Regular → Executable
+1 -53
View File
@@ -6,8 +6,6 @@
# Usage : ./build.sh # Usage : ./build.sh
############################################################################### ###############################################################################
set -e set -e
# Le script est dans Autres/ — on remonte d'un cran pour se placer à la
# racine du projet, où se trouvent src/ et dist/.
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION=$(python3 -c "import json; print(json.load(open('src/manifest.json'))['version'])") VERSION=$(python3 -c "import json; print(json.load(open('src/manifest.json'))['version'])")
@@ -25,26 +23,14 @@ cp -r src/* dist/firefox/
python3 - <<EOF python3 - <<EOF
import json import json
with open('src/manifest.json', 'r') as f: m = json.load(f) with open('src/manifest.json', 'r') as f: m = json.load(f)
# Le champ 'key' (ID Chromium déterministe) n'a pas de sens côté Firefox et
# fait râler le linter AMO → on le retire du build Firefox.
m.pop('key', None)
m['browser_specific_settings'] = { m['browser_specific_settings'] = {
'gecko': { 'gecko': {
'id': 'planification-dgnsi@netaplaid.ch', 'id': '[email protected]',
'strict_min_version': '140.0', 'strict_min_version': '140.0',
'update_url': 'https://gitea.netaplaid.ch/FroSteel/Planification/raw/branch/main/firefox-updates.json', 'update_url': 'https://gitea.netaplaid.ch/FroSteel/Planification/raw/branch/main/firefox-updates.json',
'data_collection_permissions': {'required': ['none']} 'data_collection_permissions': {'required': ['none']}
} }
} }
# Firefox MV3 ne supporte pas (encore) 'service_worker' → AMO rejette.
# On ajoute 'scripts' (event page) comme fallback compatible Firefox.
# Chrome ignore 'scripts' quand 'service_worker' est présent ; Firefox
# ignore 'service_worker' et utilise 'scripts'. Les deux navigateurs
# chargent ainsi le même background.js.
bg = m.get('background', {})
if 'scripts' not in bg:
bg['scripts'] = ['background.js']
m['background'] = bg
with open('dist/firefox/manifest.json', 'w') as f: with open('dist/firefox/manifest.json', 'w') as f:
json.dump(m, f, indent=2, ensure_ascii=False) json.dump(m, f, indent=2, ensure_ascii=False)
f.write('\n') f.write('\n')
@@ -58,44 +44,6 @@ cd dist/firefox && zip -rq "../planification-v${VERSION}-firefox.xpi" . && cd .
echo "" echo ""
echo "==> Builds prêts dans dist/" echo "==> Builds prêts dans dist/"
ls -la dist/*.zip dist/*.xpi 2>/dev/null ls -la dist/*.zip dist/*.xpi 2>/dev/null
# ---- firefox-updates.json : ajout/mise à jour de l'entrée pour cette version
# (sha256 du .xpi NON SIGNÉ — sera remplacé par celui du .xpi signé après AMO).
python3 - <<EOF
import json, hashlib, os
xpi = f"dist/planification-v${VERSION}-firefox.xpi"
with open(xpi, 'rb') as f: sha = hashlib.sha256(f.read()).hexdigest()
JSON_PATH = "firefox-updates.json"
ADDON_ID = "[email protected]"
update_link = f"https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v${VERSION}/planification-v${VERSION}-firefox.xpi"
if os.path.exists(JSON_PATH):
with open(JSON_PATH) as f: data = json.load(f)
else:
data = {"addons": {ADDON_ID: {"updates": []}}}
addon = data.setdefault("addons", {}).setdefault(ADDON_ID, {"updates": []})
updates = addon.setdefault("updates", [])
# Retirer toute entrée existante pour cette version (idempotent)
updates = [u for u in updates if u.get("version") != "${VERSION}"]
# Ajouter en tête
updates.insert(0, {
"version": "${VERSION}",
"update_link": update_link,
"update_hash": "sha256:" + sha,
})
addon["updates"] = updates
with open(JSON_PATH, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
print(f" ✓ firefox-updates.json mis à jour (sha256 NON SIGNÉ : {sha[:16]}…)")
EOF
echo "" echo ""
echo "Pour Chrome : charger dist/chromium/ en mode développeur" echo "Pour Chrome : charger dist/chromium/ en mode développeur"
echo "Pour Firefox : signer dist/planification-v${VERSION}-firefox.xpi sur AMO" echo "Pour Firefox : signer dist/planification-v${VERSION}-firefox.xpi sur AMO"
echo " Après signature, remplacer le sha256 dans firefox-updates.json par celui du .xpi signé."
-43
View File
@@ -1,43 +0,0 @@
{
"addons": {
"[email protected]": {
"updates": [
{
"version": "2026.5.46",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.46/planification-v2026.5.46-firefox.xpi",
"update_hash": "sha256:052cac5d79a8ec9f1eeb7172e8491f2e2a82b5937622131905cfcaa3eba4737b"
},
{
"version": "2026.5.45",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.45/planification-v2026.5.45-firefox.xpi",
"update_hash": "sha256:42ac47aeda23912e80051fc941ba45a0dec74ec4a6c509a25270c27b73dddead"
},
{
"version": "2026.5.44",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.44/planification-v2026.5.44-firefox.xpi",
"update_hash": "sha256:e56e87d59c465e5df828b18d74376f561bf34e81e21bf4d70989a709e89217e0"
},
{
"version": "2026.5.43",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.43/planification-v2026.5.43-firefox.xpi",
"update_hash": "sha256:7052200fab3c9266d5b809398a00dac768679ab2e96e4e147e4bb86c4ab648e5"
},
{
"version": "2026.5.42",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.42/planification-v2026.5.42-firefox.xpi",
"update_hash": "sha256:a5291a1eab6768d430dfc1cf032f93aeb788083da620ae0568b9dee68f14734d"
},
{
"version": "2026.5.41",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.41/planification-v2026.5.41-firefox.xpi",
"update_hash": "sha256:fbf7d8a57ad060306cb43f3db3a5d5b599bb07c75c4ef0dbd0346406bdb6c65b"
},
{
"version": "2026.5.40",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.40/planification-v2026.5.40-firefox.xpi",
"update_hash": "sha256:2ba0758960b931f4211c613c75bbf21b3a250572dddc70d854ff1ecca3220421"
}
]
}
}
}
-3
View File
@@ -1,3 +0,0 @@
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command "Get-ChildItem -LiteralPath '%~dp0' -Recurse -File | Unblock-File -ErrorAction SilentlyContinue; & '%~dp0bin\manage.ps1'"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 B

-361
View File
@@ -1,361 +0,0 @@
# Planification - native messaging host // 2026.1.4 - QRO
# Developpe par Quentin Rouiller (QRO) - DGNSI, Canton de Vaud.
# Licence MIT - voir le fichier LICENSE.
#
# Pont entre l'extension et un dossier reseau partage pour le cache du planning.
#
# Layout: <share>\AAAA\MM\AAAA-MM-JJ.json (une image par jour, par annee/mois).
# Index racine <share>\_index.json : date -> savedAt (stat/list rapides).
# Anciens fichiers plats (<share>\AAAA-MM-JJ.json) restent lus (compat).
#
# Protocole: stdio = prefixe longueur 4 octets (little-endian) + corps JSON UTF-8.
# Boucle jusqu'a EOF (marche pour sendNativeMessage et connectNative).
#
# Actions:
# {action:"version"} -> {ok,version}
# {action:"stat", path, date} -> {ok,exists,savedAt}
# {action:"read", path, date, offset, note} -> {ok,exists,total,offset,chunk,more}
# {action:"save", path, date, json, savedAt, note} -> {ok}
# {action:"list", path} -> {ok,days:[{date,savedAt}]}
# {action:"log", verbose} -> {ok,version,log}
# {action:"logmsg",level,message} -> {ok}
#
# Journal d'activite (active.log) : lignes "JJ/MM/AAAA HH:MM:SS [NIVEAU] message".
# Niveaux: INFO (evenements utiles), DEBUG (detail technique), ERREUR.
# Les messages par defaut de l'hote sont SANS accent (encodage .ps1 PS 5.1) ;
# les "note" envoyees par l'extension transitent en UTF-8 et gardent les accents.
#
# IMPORTANT: rien ne doit atteindre stdout sauf via Write-Message.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$HOST_VERSION = '2026.1.7'
# v2026.1.7 - le host n'est plus persistant a vie : s'il ne recoit AUCUN message
# pendant ce delai, il se ferme proprement (le navigateur le relance a la
# prochaine activite de l'extension). Il lance aussi le tray a la demande.
$IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000 # 2 heures
$CHUNK_BYTES = 600000
$DATE_RE = '^\d{4}-\d{2}-\d{2}$'
$INDEX_NAME = '_index.json'
$MAX_IN = 67108864
$stdin = [Console]::OpenStandardInput()
$stdout = [Console]::OpenStandardOutput()
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# host.ps1 vit dans <install>\bin -> la racine de l'install est le parent.
$LOG_DIR = if ($PSScriptRoot) { Split-Path -Parent $PSScriptRoot } else { Join-Path $env:LOCALAPPDATA 'Planification' }
$LOG_FILE = Join-Path $LOG_DIR 'activity.log'
function Format-DateFR([string]$iso) {
if ($iso -match '^(\d{4})-(\d{2})-(\d{2})$') { return "$($Matches[3])/$($Matches[2])/$($Matches[1])" }
return $iso
}
function Write-Activity([string]$level, [string]$msg) {
try {
if ((Test-Path -LiteralPath $LOG_FILE) -and ((Get-Item -LiteralPath $LOG_FILE).Length -gt 1048576)) {
Move-Item -LiteralPath $LOG_FILE -Destination ($LOG_FILE + '.1') -Force
}
$ts = (Get-Date).ToString('dd/MM/yyyy HH:mm:ss')
[System.IO.File]::AppendAllText($LOG_FILE, "$ts [$level] $msg`r`n", $Utf8NoBom)
} catch {}
}
function Read-Exactly([int]$n) {
$buf = New-Object byte[] $n
$off = 0
while ($off -lt $n) {
$r = $stdin.Read($buf, $off, $n - $off)
if ($r -le 0) { return $null }
$off += $r
}
return $buf
}
# v2026.1.7 - lit les 4 octets de prefixe de longueur AVEC un timeout sur le
# 1er octet (= attente du prochain message). Renvoie le byte[], $null sur EOF,
# ou la chaine 'TIMEOUT' si rien n'arrive dans le delai -> le host se ferme.
function Read-LengthOrTimeout([int]$timeoutMs) {
$buf = New-Object byte[] 4
$off = 0
while ($off -lt 4) {
$ar = $stdin.BeginRead($buf, $off, 4 - $off, $null, $null)
if ($off -eq 0) {
if (-not $ar.AsyncWaitHandle.WaitOne($timeoutMs)) { return 'TIMEOUT' }
} else {
[void]$ar.AsyncWaitHandle.WaitOne()
}
$r = $stdin.EndRead($ar)
if ($r -le 0) { return $null }
$off += $r
}
return $buf
}
function Read-Message {
$lenBytes = Read-LengthOrTimeout $IDLE_TIMEOUT_MS
if ($lenBytes -is [string]) { return $lenBytes } # 'TIMEOUT' -> arret propre
if ($null -eq $lenBytes) { return $null }
$len = [BitConverter]::ToInt32($lenBytes, 0)
if ($len -le 0 -or $len -gt $MAX_IN) { return $null }
$payload = Read-Exactly $len
if ($null -eq $payload) { return $null }
$json = [System.Text.Encoding]::UTF8.GetString($payload)
return ($json | ConvertFrom-Json)
}
function Write-Message($obj) {
$json = $obj | ConvertTo-Json -Compress -Depth 50
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$lenBytes = [BitConverter]::GetBytes([int]$bytes.Length)
$stdout.Write($lenBytes, 0, 4)
$stdout.Write($bytes, 0, $bytes.Length)
$stdout.Flush()
}
function Test-DateStr([string]$d) { return ($d -match $DATE_RE) }
function Resolve-Share([string]$path) {
if ([string]::IsNullOrWhiteSpace($path)) { throw 'empty_path' }
if (-not (Test-Path -LiteralPath $path)) {
New-Item -ItemType Directory -Path $path -Force | Out-Null
}
return (Get-Item -LiteralPath $path).FullName
}
function Get-DayDir([string]$share, [string]$date) {
$yyyy = $date.Substring(0,4)
$mm = $date.Substring(5,2)
return (Join-Path (Join-Path $share $yyyy) $mm)
}
function Get-DayFileWrite([string]$share, [string]$date) {
$dir = Get-DayDir $share $date
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
return (Join-Path $dir ($date + '.json'))
}
function Get-DayFileRead([string]$share, [string]$date) {
$ym = Join-Path (Get-DayDir $share $date) ($date + '.json')
if (Test-Path -LiteralPath $ym) { return $ym }
$flat = Join-Path $share ($date + '.json')
if (Test-Path -LiteralPath $flat) { return $flat }
return $ym
}
function Read-Index([string]$share) {
$idx = Join-Path $share $INDEX_NAME
if (Test-Path -LiteralPath $idx) {
try { return (Get-Content -LiteralPath $idx -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { return $null }
}
return $null
}
function Update-Index([string]$share, [string]$date, [long]$savedAt) {
$idx = Join-Path $share $INDEX_NAME
$map = @{}
if (Test-Path -LiteralPath $idx) {
try {
$existing = Get-Content -LiteralPath $idx -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($p in $existing.PSObject.Properties) { $map[$p.Name] = $p.Value }
} catch {}
}
$map[$date] = $savedAt
$tmp = $idx + '.tmp'
[System.IO.File]::WriteAllText($tmp, ($map | ConvertTo-Json -Compress -Depth 5), $Utf8NoBom)
Move-Item -LiteralPath $tmp -Destination $idx -Force
Write-Activity 'DEBUG' "Index du dossier mis a jour pour le $(Format-DateFR $date)"
}
function Get-SavedAt([string]$share, [string]$date, [string]$file) {
$ix = Read-Index $share
if ($ix -and ($ix.PSObject.Properties.Name -contains $date)) {
try { return [long]$ix.$date } catch {}
}
try {
$o = Get-Content -LiteralPath $file -Raw -Encoding UTF8 | ConvertFrom-Json
return [long]$o.savedAt
} catch { return 0 }
}
# v2026.1.7 - le host vient d'etre lance par le navigateur (connexion de
# l'extension). On (re)lance le tray s'il n'est pas deja la, et on horodate
# l'activite (sert aussi de battement de coeur au tray fraichement lance).
# Best-effort, tout en try/catch : ne JAMAIS perturber le protocole stdio.
try {
$trayRunning = $false
try { $tm = [System.Threading.Mutex]::OpenExisting('Local\PlanificationTraySingleton'); $tm.Dispose(); $trayRunning = $true } catch { $trayRunning = $false }
if (-not $trayRunning -and $PSScriptRoot) {
$trayVbs = Join-Path $PSScriptRoot 'tray.vbs'
if (Test-Path -LiteralPath $trayVbs) { Start-Process wscript.exe -ArgumentList "`"$trayVbs`"" }
}
} catch {}
Write-Activity 'INFO' "Host demarre (v$HOST_VERSION) - connexion de l'extension."
while ($true) {
$msg = Read-Message
# v2026.1.7 - inactivite > 2 h : arret propre (le navigateur relancera le host
# a la prochaine activite de l'extension, qui relancera aussi le tray).
if ($msg -is [string] -and $msg -eq 'TIMEOUT') {
Write-Activity 'INFO' 'Aucune communication depuis 2 h - arret du host (relance auto a la prochaine activite).'
break
}
if ($null -eq $msg) { break }
try {
$action = [string]$msg.action
switch ($action) {
'version' {
Write-Message @{ ok = $true; version = $HOST_VERSION }
}
'stat' {
if (-not (Test-DateStr $msg.date)) { Write-Message @{ ok=$false; error='bad_date' }; break }
$share = Resolve-Share $msg.path
$f = Get-DayFileRead $share $msg.date
$fr = Format-DateFR $msg.date
if (Test-Path -LiteralPath $f) {
$sa = Get-SavedAt $share $msg.date $f
Write-Activity 'DEBUG' "Verification du planning du $fr : present dans le dossier"
Write-Message @{ ok=$true; exists=$true; savedAt=$sa }
} else {
Write-Activity 'DEBUG' "Verification du planning du $fr : absent du dossier"
Write-Message @{ ok=$true; exists=$false }
}
}
'read' {
if (-not (Test-DateStr $msg.date)) { Write-Message @{ ok=$false; error='bad_date' }; break }
$share = Resolve-Share $msg.path
$f = Get-DayFileRead $share $msg.date
$fr = Format-DateFR $msg.date
if (-not (Test-Path -LiteralPath $f)) { Write-Message @{ ok=$true; exists=$false }; break }
$offset = 0
if ($null -ne $msg.offset) { $offset = [int]$msg.offset }
$fs = [System.IO.File]::OpenRead($f)
try {
$total = [int]$fs.Length
if ($offset -lt 0) { $offset = 0 }
if ($offset -gt $total) { $offset = $total }
$len = [Math]::Min($CHUNK_BYTES, $total - $offset)
$b64 = ''
if ($len -gt 0) {
$buf = New-Object byte[] $len
$fs.Position = $offset
$got = 0
while ($got -lt $len) {
$r = $fs.Read($buf, $got, $len - $got)
if ($r -le 0) { break }
$got += $r
}
$b64 = [Convert]::ToBase64String($buf, 0, $got)
}
$more = (($offset + $len) -lt $total)
if ($offset -eq 0) {
if ($msg.note) { Write-Activity 'INFO' ([string]$msg.note) }
else { Write-Activity 'INFO' "Lecture du planning du $fr depuis le dossier ($([Math]::Round($total/1024)) Ko)" }
} else {
Write-Activity 'DEBUG' "Transfert d'une tranche du planning du $fr (position $offset)"
}
Write-Message @{ ok=$true; exists=$true; total=$total; offset=$offset; chunk=$b64; more=$more }
} finally { $fs.Close() }
}
'save' {
# v2026.1.6 - QRO : ecriture compare-and-swap + verrou (merge concurrent).
# Si expectedSavedAt fourni, on n'ecrit QUE si le savedAt du dossier est
# toujours celui-la (sinon conflit -> l'extension refusionne et reessaie).
if (-not (Test-DateStr $msg.date)) { Write-Message @{ ok=$false; error='bad_date' }; break }
$share = Resolve-Share $msg.path
$f = Get-DayFileWrite $share $msg.date
$fr = Format-DateFR $msg.date
$json = [string]$msg.json
$hasExpect = ($null -ne $msg.expectedSavedAt)
$expect = if ($hasExpect) { [long]$msg.expectedSavedAt } else { 0 }
$lock = $f + '.lock'
# Verrou inter-machines : creation exclusive ; vol si perime (> 10 s).
$haveLock = $false
try { [System.IO.File]::Open($lock, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write).Close(); $haveLock = $true }
catch {
try {
$age = ((Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime).TotalSeconds
if ($age -gt 10) {
Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue
[System.IO.File]::Open($lock, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write).Close(); $haveLock = $true
}
} catch {}
}
if (-not $haveLock) { Write-Message @{ ok=$false; locked=$true }; break }
try {
$proceed = $true
if ($hasExpect) {
$cur = 0
$existing = Get-DayFileRead $share $msg.date
if (Test-Path -LiteralPath $existing) {
try { $o = Get-Content -LiteralPath $existing -Raw -Encoding UTF8 | ConvertFrom-Json; $cur = [long]$o.savedAt } catch { $cur = 0 }
}
if ($cur -ne $expect) {
Write-Activity 'DEBUG' "Conflit d'ecriture sur le $fr (attendu $expect, dossier $cur) - refusion cote extension"
Write-Message @{ ok=$false; conflict=$true; currentSavedAt=$cur }
$proceed = $false
}
}
if ($proceed) {
$tmp = $f + '.tmp'
[System.IO.File]::WriteAllText($tmp, $json, $Utf8NoBom)
Move-Item -LiteralPath $tmp -Destination $f -Force
$savedAt = 0
if ($null -ne $msg.savedAt) { $savedAt = [long]$msg.savedAt }
try { Update-Index $share $msg.date $savedAt } catch {}
if ($msg.note) { Write-Activity 'INFO' ([string]$msg.note) }
else { Write-Activity 'INFO' "Sauvegarde du planning du $fr dans le dossier ($([Math]::Round($json.Length/1024)) Ko)" }
Write-Message @{ ok=$true; savedAt=$savedAt }
}
} finally {
Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue
}
}
'list' {
$share = Resolve-Share $msg.path
$days = @()
$ix = Read-Index $share
if ($ix) {
foreach ($p in $ix.PSObject.Properties) {
if ($p.Name -match $DATE_RE) { $days += @{ date=$p.Name; savedAt=[long]$p.Value } }
}
}
Write-Activity 'DEBUG' "Liste des plannings disponibles dans le dossier ($($days.Count) jour(s))"
Write-Message @{ ok=$true; days=@($days) }
}
'log' {
$txt = ''
if (Test-Path -LiteralPath $LOG_FILE) {
try {
$all = Get-Content -LiteralPath $LOG_FILE -Encoding UTF8 -ErrorAction SilentlyContinue
if ($all) { $txt = ($all | Select-Object -Last 400) -join "`n" }
} catch {}
}
Write-Message @{ ok=$true; version=$HOST_VERSION; log=$txt }
}
'logmsg' {
$lvl = if ($msg.level) { [string]$msg.level } else { 'INFO' }
if ($msg.message) { Write-Activity $lvl ([string]$msg.message) }
Write-Message @{ ok=$true }
}
default {
Write-Message @{ ok=$false; error=('unknown_action:' + $action) }
}
}
} catch {
try { Write-Activity 'ERREUR' "$action : $($_.Exception.Message)" } catch {}
Write-Message @{ ok=$false; error=([string]$_.Exception.Message) }
}
}
-231
View File
@@ -1,231 +0,0 @@
# Planification - gestionnaire graphique du helper de sync. // 2026.1.5 - QRO
# Developpe par Quentin Rouiller (QRO) - DGNSI, Canton de Vaud.
# Licence MIT - voir le fichier LICENSE.
#
# Fenetre avec : Installer / Mettre a jour, Reparer, Desinstaller.
# Per-user (HKCU), sans admin, sans signature. Lance par Gerer.cmd.
#
# Structure du paquet : Gerer.cmd (racine), bin\ (manage/host/tray/.vbs),
# assets\ (icon.png). Structure installee (%LOCALAPPDATA%\Planification) :
# bin\ (host.ps1, host.bat, tray.ps1, tray.vbs), assets\ (icon.png),
# manifests\ (nm-*.json), activity.log (a la racine).
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$HostName = 'ch.netaplaid.planification'
$ExtIdChromium = 'hlkepgcngeckdbapafkheondiagjgphc'
$ExtIdFirefox = '[email protected]'
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } # paquet\bin
$PkgRoot = Split-Path -Parent $ScriptDir # paquet\
$InstallDir = Join-Path $env:LOCALAPPDATA 'Planification'
$InstallBin = Join-Path $InstallDir 'bin'
$InstallAssets = Join-Path $InstallDir 'assets'
$InstallManifests = Join-Path $InstallDir 'manifests'
function Get-HostVersion([string]$psFile) {
if (Test-Path -LiteralPath $psFile) {
$m = Select-String -LiteralPath $psFile -Pattern "HOST_VERSION\s*=\s*'([^']*)'" -ErrorAction SilentlyContinue
if ($m) { return $m.Matches[0].Groups[1].Value }
}
return $null
}
function Stop-Tray {
Get-CimInstance Win32_Process -Filter "Name='wscript.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -like '*tray.vbs*' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -like '*tray.ps1*' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
}
function Install-Host {
Stop-Tray
foreach ($d in @($InstallBin, $InstallAssets, $InstallManifests)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
# Scripts -> bin\ (ecriture fraiche = pas de Mark of the Web).
# v2026.1.7 - on copie AUSSI manage.ps1 dans l'install pour que le gestionnaire
# (Reparer / Desinstaller) reste accessible via le Menu Demarrer meme apres
# suppression du dossier du paquet.
foreach ($name in @('host.ps1', 'tray.ps1', 'manage.ps1')) {
$src = Join-Path $ScriptDir $name
if (Test-Path -LiteralPath $src) {
Get-Content -LiteralPath $src -Raw | Set-Content -LiteralPath (Join-Path $InstallBin $name) -Encoding UTF8
Unblock-File -LiteralPath (Join-Path $InstallBin $name) -ErrorAction SilentlyContinue
}
}
$srcVbs = Join-Path $ScriptDir 'tray.vbs'
if (Test-Path -LiteralPath $srcVbs) {
Get-Content -LiteralPath $srcVbs -Raw | Set-Content -LiteralPath (Join-Path $InstallBin 'tray.vbs') -Encoding ASCII
Unblock-File -LiteralPath (Join-Path $InstallBin 'tray.vbs') -ErrorAction SilentlyContinue
}
# Icone -> assets\ (binaire : Copy-Item).
$srcIcon = Join-Path $PkgRoot 'assets\icon.png'
if (Test-Path -LiteralPath $srcIcon) { Copy-Item -LiteralPath $srcIcon -Destination (Join-Path $InstallAssets 'icon.png') -Force }
# Wrapper .bat dans bin\ (host.ps1 est dans le meme dossier -> %~dp0host.ps1).
$dstBat = Join-Path $InstallBin 'host.bat'
Set-Content -LiteralPath $dstBat -Encoding ASCII -Value @(
'@echo off',
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0host.ps1" %*'
)
# Manifests natifs -> manifests\ (path = bin\host.bat).
$manChromium = Join-Path $InstallManifests 'nm-chromium.json'
$manFirefox = Join-Path $InstallManifests 'nm-firefox.json'
(([ordered]@{ name=$HostName; description='Planification cache sync host'; path=$dstBat; type='stdio'; allowed_origins=@("chrome-extension://$ExtIdChromium/") }) | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $manChromium -Encoding UTF8
(([ordered]@{ name=$HostName; description='Planification cache sync host'; path=$dstBat; type='stdio'; allowed_extensions=@($ExtIdFirefox) }) | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $manFirefox -Encoding UTF8
function Reg([string]$base, [string]$manifest) {
$key = Join-Path $base $HostName
New-Item -Path $key -Force | Out-Null
Set-Item -Path $key -Value $manifest
}
Reg 'HKCU:\Software\Google\Chrome\NativeMessagingHosts' $manChromium
Reg 'HKCU:\Software\Microsoft\Edge\NativeMessagingHosts' $manChromium
Reg 'HKCU:\Software\Mozilla\NativeMessagingHosts' $manFirefox
# v2026.1.7 - le tray ne demarre PLUS au login et n'est PLUS lance a l'install.
# Il est demarre A LA DEMANDE par host.ps1 quand l'extension se connecte, et
# s'auto-ferme apres 2 h sans activite. On retire l'ancien autostart s'il
# existe (installs precedentes) pour que "de base = eteint".
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'PlanificationTray' -ErrorAction SilentlyContinue
# v2026.1.7 - Menu Demarrer : raccourci vers le gestionnaire installe
# (Installer / Reparer / Desinstaller), retrouvable sans le dossier du paquet.
# 1) lanceur .vbs (fenetre PowerShell masquee) du manage.ps1 installe.
$dstGererVbs = Join-Path $InstallBin 'gerer.vbs'
Set-Content -LiteralPath $dstGererVbs -Encoding ASCII -Value @(
"' Lance le gestionnaire Planification (fenetre PowerShell masquee).",
'Set sh = CreateObject("WScript.Shell")',
'Set fso = CreateObject("Scripting.FileSystemObject")',
'dir = fso.GetParentFolderName(WScript.ScriptFullName)',
'sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & dir & "\manage.ps1""", 0, False'
)
# 2) icone .ico (depuis icon.png) pour le raccourci, best-effort.
$icoPath = Join-Path $InstallAssets 'icon.ico'
try {
$pngForIco = Join-Path $InstallAssets 'icon.png'
if (Test-Path -LiteralPath $pngForIco) {
Add-Type -AssemblyName System.Drawing
$bmp = New-Object System.Drawing.Bitmap($pngForIco)
$ico = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
$fs = [System.IO.File]::Create($icoPath)
$ico.Save($fs); $fs.Close()
}
} catch { $icoPath = $null }
# 3) raccourcis .lnk dans le Menu Demarrer (deux entrees) :
# - "gerer" -> manage.ps1 (installer / reparer / desinstaller)
# - "demarrer" -> tray.vbs (relance/affiche la sync a la demande)
$dstTrayVbs = Join-Path $InstallBin 'tray.vbs'
try {
$startDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
New-Item -ItemType Directory -Path $startDir -Force | Out-Null
# Nettoyage de l'ancien nom de raccourci (installs precedentes).
Remove-Item -LiteralPath (Join-Path $startDir 'Planification - synchronisation.lnk') -Force -ErrorAction SilentlyContinue
$ws = New-Object -ComObject WScript.Shell
function New-Lnk([string]$name, [string]$targetVbs, [string]$desc) {
$sc = $ws.CreateShortcut((Join-Path $startDir $name))
$sc.TargetPath = 'wscript.exe'
$sc.Arguments = '"' + $targetVbs + '"'
$sc.WorkingDirectory = $InstallBin
$sc.Description = $desc
if ($icoPath -and (Test-Path -LiteralPath $icoPath)) { $sc.IconLocation = "$icoPath,0" }
$sc.Save()
}
New-Lnk 'Planification - gerer.lnk' $dstGererVbs 'Gerer la synchronisation Planification (installer / reparer / desinstaller)'
New-Lnk 'Planification - demarrer la synchro.lnk' $dstTrayVbs 'Demarrer / afficher la synchronisation Planification'
} catch {}
}
function Uninstall-Host {
Stop-Tray
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'PlanificationTray' -ErrorAction SilentlyContinue
foreach ($base in @(
'HKCU:\Software\Google\Chrome\NativeMessagingHosts',
'HKCU:\Software\Microsoft\Edge\NativeMessagingHosts',
'HKCU:\Software\Mozilla\NativeMessagingHosts')) {
Remove-Item -Path (Join-Path $base $HostName) -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path -LiteralPath $InstallDir) { Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue }
# v2026.1.7 - retirer les raccourcis du Menu Demarrer (hors InstallDir).
$startDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
foreach ($lnk in @('Planification - synchronisation.lnk', 'Planification - gerer.lnk', 'Planification - demarrer la synchro.lnk')) {
Remove-Item -LiteralPath (Join-Path $startDir $lnk) -Force -ErrorAction SilentlyContinue
}
}
# ----------------------------- Interface -----------------------------------
$available = Get-HostVersion (Join-Path $ScriptDir 'host.ps1')
$form = New-Object System.Windows.Forms.Form
$form.Text = 'Planification - synchronisation'
$form.Size = New-Object System.Drawing.Size(460, 320)
$form.StartPosition = 'CenterScreen'
$form.FormBorderStyle = 'FixedSingle'
$form.MaximizeBox = $false
try {
$ic = Join-Path $PkgRoot 'assets\icon.png'
if (Test-Path -LiteralPath $ic) { $form.Icon = [System.Drawing.Icon]::FromHandle((New-Object System.Drawing.Bitmap($ic)).GetHicon()) }
} catch {}
$title = New-Object System.Windows.Forms.Label
$title.Text = 'Synchronisation du planning entre postes'
$title.Font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
$title.AutoSize = $true
$title.Location = New-Object System.Drawing.Point(18, 16)
$form.Controls.Add($title)
$status = New-Object System.Windows.Forms.Label
$status.AutoSize = $false
$status.Size = New-Object System.Drawing.Size(420, 40)
$status.Location = New-Object System.Drawing.Point(18, 48)
$form.Controls.Add($status)
function Refresh-Status {
$installed = Get-HostVersion (Join-Path $InstallBin 'host.ps1')
if ($installed) { $status.Text = "Installe : version $installed`r`nDisponible (ce dossier) : version $available" }
else { $status.Text = "Non installe sur ce poste.`r`nDisponible (ce dossier) : version $available" }
}
Refresh-Status
$result = New-Object System.Windows.Forms.Label
$result.AutoSize = $false
$result.Size = New-Object System.Drawing.Size(420, 30)
$result.Location = New-Object System.Drawing.Point(18, 248)
$result.ForeColor = [System.Drawing.Color]::DarkGreen
function Add-Button([string]$text, [int]$y, [scriptblock]$action) {
$b = New-Object System.Windows.Forms.Button
$b.Text = $text
$b.Size = New-Object System.Drawing.Size(420, 38)
$b.Location = New-Object System.Drawing.Point(18, $y)
$b.Font = New-Object System.Drawing.Font('Segoe UI', 10)
$b.add_Click($action)
$form.Controls.Add($b)
}
Add-Button 'Installer / Mettre a jour' 100 {
try { Install-Host; $result.ForeColor=[System.Drawing.Color]::DarkGreen; $result.Text='Installation / mise a jour terminee.'; Refresh-Status }
catch { $result.ForeColor=[System.Drawing.Color]::Firebrick; $result.Text=('Erreur : ' + $_.Exception.Message) }
}
Add-Button 'Reparer (reinstaller proprement)' 144 {
try { Install-Host; $result.ForeColor=[System.Drawing.Color]::DarkGreen; $result.Text='Reparation terminee.'; Refresh-Status }
catch { $result.ForeColor=[System.Drawing.Color]::Firebrick; $result.Text=('Erreur : ' + $_.Exception.Message) }
}
Add-Button 'Desinstaller' 188 {
$r = [System.Windows.Forms.MessageBox]::Show('Desinstaller le helper de synchronisation ? (le cache du dossier partage n''est pas touche)', 'Confirmer', 'YesNo', 'Question')
if ($r -eq 'Yes') {
try { Uninstall-Host; $result.ForeColor=[System.Drawing.Color]::DarkGreen; $result.Text='Desinstalle. Pense a retirer l''extension du navigateur.'; Refresh-Status }
catch { $result.ForeColor=[System.Drawing.Color]::Firebrick; $result.Text=('Erreur : ' + $_.Exception.Message) }
}
}
$form.Controls.Add($result)
[System.Windows.Forms.Application]::EnableVisualStyles()
[void]$form.ShowDialog()
-196
View File
@@ -1,196 +0,0 @@
# Planification - icone barre des taches (best-effort, optionnel) // 2026.1.4 - QRO
# Developpe par Quentin Rouiller (QRO) - DGNSI, Canton de Vaud.
# Licence MIT - voir le fichier LICENSE.
#
# Indique que le helper de sync est installe. Clic / menu -> fenetre du journal
# (aspect application : journal colore par niveau, auto-rafraichi, bouton
# "Tout afficher"). Lance par tray.vbs (fenetre masquee), au login.
$ErrorActionPreference = 'SilentlyContinue'
# Instance unique.
$mutex = New-Object System.Threading.Mutex($false, 'Local\PlanificationTraySingleton')
if (-not $mutex.WaitOne(0)) { exit }
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# tray.ps1 vit dans <install>\bin -> racine = parent ; assets et log a la racine.
$root = if ($PSScriptRoot) { Split-Path -Parent $PSScriptRoot } else { Join-Path $env:LOCALAPPDATA 'Planification' }
$logFile = Join-Path $root 'activity.log'
$iconPng = Join-Path (Join-Path $root 'assets') 'icon.png'
function Get-TrayIcon {
try {
if (Test-Path -LiteralPath $iconPng) {
$bmp = New-Object System.Drawing.Bitmap($iconPng)
return [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
}
} catch {}
return [System.Drawing.SystemIcons]::Information
}
$script:logForm = $null
$script:logRtb = $null
$script:logChk = $null
$script:logSig = ''
$colInfo = [System.Drawing.Color]::FromArgb(40, 70, 120)
$colDebug = [System.Drawing.Color]::FromArgb(150, 150, 150)
$colErr = [System.Drawing.Color]::FromArgb(200, 40, 40)
$colTs = [System.Drawing.Color]::FromArgb(120, 120, 120)
function Update-LogText {
if (-not $script:logRtb -or $script:logRtb.IsDisposed) { return }
$lines = @()
if (Test-Path -LiteralPath $logFile) {
try {
$all = Get-Content -LiteralPath $logFile -Encoding UTF8 -ErrorAction SilentlyContinue
if ($all) {
if ($script:logChk -and -not $script:logChk.Checked) {
$all = $all | Where-Object { $_ -notmatch '\[DEBUG\]' }
}
$lines = @($all | Select-Object -Last 500)
}
} catch {}
}
$sig = ($lines -join "`n")
if ($sig -eq $script:logSig) { return }
$script:logSig = $sig
$rtb = $script:logRtb
$rtb.SuspendLayout()
$rtb.Clear()
if ($lines.Count -eq 0) {
$rtb.SelectionColor = $colDebug
$rtb.AppendText("(aucune activite de synchronisation pour le moment)")
} else {
foreach ($line in $lines) {
$col = $colInfo
if ($line -match '\[ERREUR\]') { $col = $colErr }
elseif ($line -match '\[DEBUG\]') { $col = $colDebug }
if ($line -match '^(\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2})(.*)$') {
$rtb.SelectionStart = $rtb.TextLength; $rtb.SelectionColor = $colTs
$rtb.AppendText($Matches[1])
$rtb.SelectionStart = $rtb.TextLength; $rtb.SelectionColor = $col
$rtb.AppendText($Matches[2] + "`n")
} else {
$rtb.SelectionStart = $rtb.TextLength; $rtb.SelectionColor = $col
$rtb.AppendText($line + "`n")
}
}
}
$rtb.ResumeLayout()
$rtb.SelectionStart = $rtb.TextLength; $rtb.ScrollToCaret()
}
function Show-LogWindow {
if ($script:logForm -and -not $script:logForm.IsDisposed) {
$script:logForm.WindowState = 'Normal'; $script:logForm.Activate(); return
}
$form = New-Object System.Windows.Forms.Form
$form.Text = 'Planification - journal de synchronisation'
$form.Size = New-Object System.Drawing.Size(760, 540)
$form.StartPosition = 'CenterScreen'
$form.BackColor = [System.Drawing.Color]::FromArgb(245, 246, 248)
try { $form.Icon = Get-TrayIcon } catch {}
# 1) zone de texte (Fill) ajoutee en premier
$rtb = New-Object System.Windows.Forms.RichTextBox
$rtb.Dock = 'Fill'
$rtb.ReadOnly = $true
$rtb.BorderStyle = 'None'
$rtb.BackColor = [System.Drawing.Color]::White
$rtb.Font = New-Object System.Drawing.Font('Consolas', 9.5)
$rtb.DetectUrls = $false
$form.Controls.Add($rtb)
# 2) barre d'outils claire avec le BOUTON bascule "Tout afficher" bien visible
$bar = New-Object System.Windows.Forms.Panel
$bar.Dock = 'Top'; $bar.Height = 40
$bar.BackColor = [System.Drawing.Color]::FromArgb(238, 240, 243)
$chk = New-Object System.Windows.Forms.CheckBox
$chk.Appearance = 'Button'
$chk.Text = 'Tout afficher (mode detaille)'
$chk.TextAlign = 'MiddleCenter'
$chk.AutoSize = $false
$chk.Size = New-Object System.Drawing.Size(200, 28)
$chk.Location = New-Object System.Drawing.Point(10, 6)
$chk.FlatStyle = 'System'
$chk.add_CheckedChanged({ $script:logSig = '~'; Update-LogText })
$bar.Controls.Add($chk)
$hint = New-Object System.Windows.Forms.Label
$hint.Text = 'Par defaut : l''essentiel. Activez pour voir tout le detail technique.'
$hint.ForeColor = [System.Drawing.Color]::FromArgb(90, 90, 90)
$hint.AutoSize = $true
$hint.Location = New-Object System.Drawing.Point(220, 12)
$bar.Controls.Add($hint)
$form.Controls.Add($bar)
# 3) bandeau titre (bleu) ajoute en dernier -> tout en haut
$hdrBar = New-Object System.Windows.Forms.Panel
$hdrBar.Dock = 'Top'; $hdrBar.Height = 32
$hdrBar.BackColor = [System.Drawing.Color]::FromArgb(40, 70, 120)
$hdr = New-Object System.Windows.Forms.Label
$hdr.Text = 'Activite de synchronisation - Planification'
$hdr.ForeColor = [System.Drawing.Color]::White
$hdr.Font = New-Object System.Drawing.Font('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
$hdr.AutoSize = $true
$hdr.Location = New-Object System.Drawing.Point(10, 7)
$hdrBar.Controls.Add($hdr)
$form.Controls.Add($hdrBar)
$script:logForm = $form
$script:logRtb = $rtb
$script:logChk = $chk
$script:logSig = ''
Update-LogText
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 2000
$timer.add_Tick({ Update-LogText })
$timer.Start()
$form.add_FormClosed({ $timer.Stop(); $timer.Dispose() })
$form.Show(); $form.Activate()
}
$ni = New-Object System.Windows.Forms.NotifyIcon
$ni.Icon = Get-TrayIcon
$ni.Text = 'Planification - synchronisation active'
$ni.Visible = $true
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$miLog = $menu.Items.Add('Voir le journal de synchronisation')
$miLog.add_Click({ Show-LogWindow })
$miQuit = $menu.Items.Add('Quitter')
$miQuit.add_Click({ $ni.Visible = $false; $ni.Dispose(); [System.Windows.Forms.Application]::Exit() })
$ni.ContextMenuStrip = $menu
$ni.add_MouseClick({ param($s, $e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Show-LogWindow } })
# v2026.1.7 - auto-extinction apres 2 h SANS activite de synchronisation. On se
# base sur la date de derniere ecriture du journal (le host y ecrit a chaque
# operation). Le navigateur relancera le host a la prochaine activite, qui
# relancera ce tray. -> "de base eteint, s'active a l'usage, se ferme au repos".
$IDLE_LIMIT_MIN = 120
$idleTimer = New-Object System.Windows.Forms.Timer
$idleTimer.Interval = 300000 # verifie toutes les 5 min
$idleTimer.add_Tick({
try {
$age = $null
if (Test-Path -LiteralPath $logFile) {
$age = ((Get-Date) - (Get-Item -LiteralPath $logFile).LastWriteTime).TotalMinutes
}
# Pas de journal, ou journal inactif depuis >= 2 h -> on s'eteint.
if (($null -eq $age) -or ($age -ge $IDLE_LIMIT_MIN)) {
$ni.Visible = $false; $ni.Dispose()
[System.Windows.Forms.Application]::Exit()
}
} catch {}
})
$idleTimer.Start()
[System.Windows.Forms.Application]::EnableVisualStyles()
[System.Windows.Forms.Application]::Run()
$idleTimer.Stop()
$ni.Dispose()
-5
View File
@@ -1,5 +0,0 @@
' Lance tray.ps1 en arriere-plan, fenetre PowerShell masquee (pas de console).
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
dir = fso.GetParentFolderName(WScript.ScriptFullName)
sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & dir & "\tray.ps1""", 0, False
+65 -552
View File
@@ -9,17 +9,10 @@
* *
* @author Quentin Rouiller * @author Quentin Rouiller
* @repository https://gitea.netaplaid.ch/FroSteel/Planification * @repository https://gitea.netaplaid.ch/FroSteel/Planification
*
* Développé par Quentin Rouiller (QRO) — DGNSI, Canton de Vaud. Licence MIT.
*/ */
// background.js — Service worker (Manifest V3) — v4 // background.js — Service worker (Manifest V3) — v4
// //
// v2026.5.46 — fiabilisation de la détection de session (faux
// « déconnecté ») : le cookie PHPSESSID suffit sans onglet EV ouvert
// (permission cookies), contre-vérification session_time avant de classer
// un statut HTTP en « session expirée », cache session réactivé (200 ms).
//
// Rôles : // Rôles :
// 1. Au clic sur l'icône : ouvrir le viewer // 1. Au clic sur l'icône : ouvrir le viewer
// 2. Répondre aux messages du viewer : // 2. Répondre aux messages du viewer :
@@ -195,48 +188,6 @@ async function getDayBounds() {
// Clic sur l'icône → ouvrir le viewer // 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 () => { chrome.action.onClicked.addListener(async () => {
const viewerUrl = chrome.runtime.getURL("viewer.html"); 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 // Si le viewer est déjà ouvert, on focus cet onglet plutôt que d'en ouvrir un autre
@@ -256,161 +207,24 @@ chrome.action.onClicked.addListener(async () => {
/** /**
* Trouve l'onglet EasyVista ouvert et récupère phpsessid + origin. * 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 * @author Quentin Rouiller
*/ */
async function _hasCookiesPermission() { async function findEasyVistaSession() {
return new Promise(resolve => { // v2026.5.41 : les origines EV viennent de admin_config (éditables dans
try { // Paramètres → EasyVista), avec fallback sur DEFAULT_EV_ORIGINS.
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;
// v2026.5.46 — cache anti-rafale RÉACTIVÉ (200 ms). Le soupçon qui
// l'avait fait désactiver (« page de login ~8 Ko au fetch fiche ») venait du
// PHPSESSID périmé lu dans l'URL des onglets historiques — résolu depuis par
// la lecture du cookie (issue #4). 200 ms de fenêtre stale sont inoffensifs
// et évitent le bombardement tabs.query+cookies.get pendant la phase A.
const _SESSION_CACHE_TTL_MS = 200;
let _lastSessionSignature = null;
function _invalidateSessionCache() {
_sessionCache = null;
_sessionCacheTs = 0;
}
async function _findEasyVistaSessionRaw() {
const origins = await getEvOrigins(); const origins = await getEvOrigins();
const hasCookies = await _hasCookiesPermission();
// v2026.5.46 — avec la permission cookies, le cookie PHPSESSID
// HttpOnly SUFFIT : les fetch partent avec credentials:include, un onglet
// EV ouvert n'est PAS nécessaire. Avant, fermer l'onglet EV faisait
// perdre la session à l'extension (bannière « déconnecté » à tort) alors
// que la session serveur vivait toujours. On PRÉFÈRE toujours une origine
// avec onglet ouvert (fraîcheur SSO), mais on retombe sur « cookie seul ».
let cookieOnly = null;
for (const origin of origins) { for (const origin of origins) {
const tabs = await chrome.tabs.query({ url: origin + "/*" }); const tabs = await chrome.tabs.query({ url: origin + "/*" });
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 (!tabs.length) {
if (phpsessid && !cookieOnly) {
cookieOnly = { phpsessid, origin, tabId: null, source: "cookie", expirationDate };
}
continue;
}
if (!phpsessid) {
for (const tab of tabs) { for (const tab of tabs) {
const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/); const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/);
if (m) { if (m) {
phpsessid = m[1]; return { phpsessid: m[1], origin: origin, tabId: tab.id };
source = "url";
break;
} }
} }
} }
if (phpsessid) {
return { phpsessid, origin, tabId: tabs[0].id, source, expirationDate };
}
}
return cookieOnly;
}
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; 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, // Fetch helpers (s'exécutent dans le contexte du service worker,
// les cookies du domaine sont automatiquement inclus via credentials: include) // les cookies du domaine sont automatiquement inclus via credentials: include)
@@ -462,7 +276,7 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon // v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
// écran (session expirée vs EV inaccessible). // écran (session expirée vs EV inaccessible).
const err = new Error("HTTP " + r.status); const err = new Error("HTTP " + r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid); err.kind = classifyHttpStatus(r.status);
err.status = r.status; err.status = r.status;
throw err; throw err;
} }
@@ -485,39 +299,14 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
* @param {string} origin - origine EasyVista (pour construire le Referer) * @param {string} origin - origine EasyVista (pour construire le Referer)
* @param {object} [opts] - options fetch (method, body, headers supplémentaires) * @param {object} [opts] - options fetch (method, body, headers supplémentaires)
*/ */
// registre global des AbortController des fetchs EV en vol. Permet
// au foreground (viewer.js) d'envoyer un message "abortAllFetches" pour
// tuer instantanément les requêtes en cours quand l'user clique "Arrêter".
const _evFetchControllers = new Set();
function _abortAllEvFetches() {
for (const c of _evFetchControllers) {
try { c.abort(); } catch (e) { /* ignore */ }
}
_evFetchControllers.clear();
}
async function evFetch(url, origin, opts = {}) { async function evFetch(url, origin, opts = {}) {
const defaultHeaders = { const defaultHeaders = {
"Referer": `${origin}/index.php?eventName=HelpDesk_PlanningItem`, "Referer": `${origin}/index.php?eventName=HelpDesk_PlanningItem`,
"X-Requested-With": "XMLHttpRequest" "X-Requested-With": "XMLHttpRequest"
}; };
const headers = Object.assign({}, defaultHeaders, opts.headers || {}); const headers = Object.assign({}, defaultHeaders, opts.headers || {});
// on ne remplace pas un signal explicitement passé par l'appelant. const fetchOpts = Object.assign({ credentials: "include" }, opts, { headers });
let controller = null;
if (!opts.signal) {
controller = new AbortController();
_evFetchControllers.add(controller);
}
const fetchOpts = Object.assign(
{ credentials: "include" },
opts,
{ headers, signal: opts.signal || (controller && controller.signal) }
);
try {
return await fetch(url, fetchOpts); return await fetch(url, fetchOpts);
} finally {
if (controller) _evFetchControllers.delete(controller);
}
} }
/** /**
@@ -531,47 +320,6 @@ function classifyHttpStatus(status) {
return "ev_unreachable"; return "ev_unreachable";
} }
// v2026.5.46 — CONTRE-VÉRIFICATION avant de déclarer « session
// expirée » sur un simple statut HTTP. EV renvoie historiquement 401/403/404
// quand le PHPSESSID est mort — mais ces statuts arrivent AUSSI sur un hoquet
// ponctuel (proxy, maintenance, endpoint) alors que la session est
// parfaitement vivante → bannière « déconnecté » à tort. On demande son avis
// à l'endpoint d'autorité timeout_ajax session_time (celui qu'EV utilise pour
// sa propre modal ; il ne prolonge pas la session) : s'il répond un temps
// restant > 0, la session VIT et l'erreur est reclassée « ev_unreachable »
// (transitoire). Sonde cachée 5 s : une rafale d'échecs ne sonde qu'une fois.
let _sessProbeCache = { ts: 0, verdict: null };
async function _probeSessionAlive(origin, phpsessid) {
const now = Date.now();
if (now - _sessProbeCache.ts < 5000 && _sessProbeCache.verdict !== null) {
return _sessProbeCache.verdict;
}
let verdict;
try {
const ms = await fetchSessionTimeRemaining(origin, phpsessid);
verdict = ms > 1000 ? "alive" : "dead";
} catch (e) {
verdict = (e && e.message === "session_expired") ? "dead" : "unknown";
}
_sessProbeCache = { ts: now, verdict };
return verdict;
}
async function classifyHttpStatusVerified(status, origin, phpsessid) {
const kind = classifyHttpStatus(status);
if (kind !== "session_expired") return kind;
const verdict = await _probeSessionAlive(origin, phpsessid);
if (verdict === "alive") {
LOG.warn("session", "HTTP " + status + " mais session_time confirme la session vivante → reclassé ev_unreachable (pas d'expiration)");
return "ev_unreachable";
}
if (verdict === "unknown") {
LOG.warn("session", "HTTP " + status + " et sonde session_time injoignable → reclassé ev_unreachable (prudence, pas d'expiration sur simple statut)");
return "ev_unreachable";
}
return "session_expired";
}
/** /**
* Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention. * Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention.
* Retourne ~400 octets au format custom : * Retourne ~400 octets au format custom :
@@ -582,7 +330,7 @@ async function fetchXhr2(origin, phpsessid, actionId) {
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);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid); err.kind = classifyHttpStatus(r.status);
err.status = r.status; err.status = r.status;
throw err; throw err;
} }
@@ -604,7 +352,7 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
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);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid); err.kind = classifyHttpStatus(r.status);
err.status = r.status; err.status = r.status;
throw err; throw err;
} }
@@ -612,26 +360,26 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
// v2026.5.38 : on log seulement les retries (utile en cas de pb SSO), // v2026.5.38 : on log seulement les retries (utile en cas de pb SSO),
// pas chaque tentative normale qui réussit du premier coup. // pas chaque tentative normale qui réussit du premier coup.
if (attempt > 1) { if (attempt > 1) {
LOG.info("bg", `fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`); console.log(`[bg] fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`);
} }
// Si réponse clairement une redirection courte → login expiré, inutile de retry // Si réponse clairement une redirection courte → login expiré, inutile de retry
if (html.length < 500) { if (html.length < 500) {
LOG.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 // Si taille suspecte (< 20 Ko), probable page intermédiaire SSO : retry
if (html.length < MIN_VALID_SIZE && attempt < MAX_RETRIES) { if (html.length < MIN_VALID_SIZE && attempt < MAX_RETRIES) {
LOG.warn("bg", `fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`); console.warn(`[bg] ⚠ fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`);
await new Promise(res => setTimeout(res, RETRY_DELAY_MS)); await new Promise(res => setTimeout(res, RETRY_DELAY_MS));
continue; continue;
} }
// on signale au foreground si la dernière réponse est tronquée pour // Sinon : on retourne ce qu'on a
// qu'il puisse afficher un et probe la session. return html;
return { html, truncated: html.length < MIN_VALID_SIZE, size: html.length };
} }
// Ne devrait pas arriver (la boucle fait return avant)
throw new Error("fetchFicheHtml: max retries reached"); throw new Error("fetchFicheHtml: max retries reached");
} }
@@ -642,31 +390,23 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
// v4.1.9 : le GUID du form est passé en paramètre (extrait dynamiquement du // 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... // HTML de la fiche par le viewer). Il est différent pour une demande S...
// ({C99ECD05}) vs un incident I... ({07ED9C68}). // ({C99ECD05}) vs un incident I... ({07ED9C68}).
async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum, timelineType, sectionId) { async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
// Sécurité : GUID doit être de la forme %7B...%7D ou {...} // Sécurité : GUID doit être de la forme %7B...%7D ou {...}
if (!/^(%7B|\{)[A-F0-9\-]{36}(%7D|\})$/i.test(guid)) { if (!/^(%7B|\{)[A-F0-9\-]{36}(%7D|\})$/i.test(guid)) {
throw new Error("Invalid GUID: " + guid); throw new Error("Invalid GUID: " + guid);
} }
// S'assurer qu'on a la forme encodée %7B...%7D // S'assurer qu'on a la forme encodée %7B...%7D
const encodedGuid = guid.startsWith("%7B") ? guid : `%7B${guid.replace(/[{}]/g, "")}%7D`; const encodedGuid = guid.startsWith("%7B") ? guid : `%7B${guid.replace(/[{}]/g, "")}%7D`;
// v2026.5.46 — `type` paramétrable. "todo" = action en cours (1 action) ;
// "done" = actions TERMINÉES (où vit le commentaire du tech). Défaut "todo"
// pour ne rien changer aux appels existants (tooltip).
// v2026.5.46 — `sectionId` paramétrable (défaut 1). Les actions
// AL-Intervention (planification, avec champ Intervenant) sont dans une
// autre section que le workflow DS.
const tType = timelineType || "todo";
const tSection = (sectionId != null && sectionId !== "") ? sectionId : 1;
const url = const url =
`${origin}/api/v1/internal/forms/${encodedGuid}/timeline` + `${origin}/api/v1/internal/forms/${encodedGuid}/timeline` +
`?target=${encodeURIComponent(formId)}` + `?target=${encodeURIComponent(formId)}` +
`&checksum=${encodeURIComponent(formChecksum)}` + `&checksum=${encodeURIComponent(formChecksum)}` +
`&type=${encodeURIComponent(tType)}&sectionId=${encodeURIComponent(tSection)}&navigator=&nbRecord=0` + `&type=todo&sectionId=1&navigator=&nbRecord=0` +
`&PHPSESSID=${encodeURIComponent(phpsessid)}`; `&PHPSESSID=${encodeURIComponent(phpsessid)}`;
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);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid); err.kind = classifyHttpStatus(r.status);
err.status = r.status; err.status = r.status;
throw err; throw err;
} }
@@ -712,9 +452,8 @@ function looksLikeLoginPage(text) {
* Retourne le nombre de millisecondes restantes avant expiration de la * Retourne le nombre de millisecondes restantes avant expiration de la
* session EasyVista (0 à 1 800 000 = 30 min max). * session EasyVista (0 à 1 800 000 = 30 min max).
* *
* EasyVista appelle LUI-MÊME cet endpoint en continu pour alimenter sa * Attention : cette requête EST authentifiée et prolonge probablement la
* propre modal de timeout (visible dans le réseau du planning natif) : il ne * session (comme toute requête PHP authentifiée). À utiliser avec parcimonie.
* prolonge donc PAS la session — utilisable pour un calibrage périodique.
*/ */
async function fetchSessionTimeRemaining(origin, phpsessid) { async function fetchSessionTimeRemaining(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php` const url = `${origin}/timeout_ajax.php`
@@ -728,7 +467,7 @@ async function fetchSessionTimeRemaining(origin, phpsessid) {
const body = (await r.text()).trim(); const body = (await r.text()).trim();
// Vérifier que c'est bien un nombre (sinon = session morte probable) // Vérifier que c'est bien un nombre (sinon = session morte probable)
if (!/^\d+$/.test(body)) { if (!/^\d+$/.test(body)) {
LOG.warn("bg", "réponse session_time anormale :", body.substring(0, 200)); console.warn("[bg] réponse session_time anormale :", body.substring(0, 200));
// Si c'est une page de login/redirect → session expirée // Si c'est une page de login/redirect → session expirée
if (looksLikeLoginPage(body)) { if (looksLikeLoginPage(body)) {
throw new Error("session_expired"); throw new Error("session_expired");
@@ -792,7 +531,7 @@ async function detectNetworkContext(force = false) {
const data = await chrome.storage.local.get([CACHE_KEY]); const data = await chrome.storage.local.get([CACHE_KEY]);
const cached = data[CACHE_KEY]; const cached = data[CACHE_KEY];
if (cached && cached.detectedAt && (Date.now() - cached.detectedAt) < CACHE_MAX_AGE_MS) { if (cached && cached.detectedAt && (Date.now() - cached.detectedAt) < CACHE_MAX_AGE_MS) {
LOG.info("bg", "detectNetworkContext : cache hit =", cached.networkContext); console.log("[bg] detectNetworkContext : cache hit =", cached.networkContext);
return cached.networkContext; return cached.networkContext;
} }
} catch (e) {} } catch (e) {}
@@ -802,7 +541,7 @@ async function detectNetworkContext(force = false) {
const timer = setTimeout(() => controller.abort(), 2500); const timer = setTimeout(() => controller.abort(), 2500);
let context = "external"; let context = "external";
try { try {
LOG.info("bg", "detectNetworkContext : test de itsma.etat-de-vaud.ch (mode no-cors)..."); 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. // 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 le fetch termine sans throw, le serveur est joignable = interne.
// Si timeout ou DNS fail, throw → externe. // Si timeout ou DNS fail, throw → externe.
@@ -814,12 +553,12 @@ async function detectNetworkContext(force = false) {
cache: "no-store" cache: "no-store"
}); });
clearTimeout(timer); clearTimeout(timer);
LOG.info("bg", "detectNetworkContext : interne accessible"); console.log("[bg] detectNetworkContext : interne accessible");
context = "internal"; context = "internal";
} catch (err) { } catch (err) {
clearTimeout(timer); clearTimeout(timer);
// AbortError (timeout) ou TypeError (DNS unreachable / réseau coupé) // AbortError (timeout) ou TypeError (DNS unreachable / réseau coupé)
LOG.info("bg", "detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")"); console.log("[bg] detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
context = "external"; context = "external";
} }
@@ -880,9 +619,9 @@ function watchReconnectTabForIamLogin(tabId) {
} else { } else {
targetUrl = "https://www.portail.vd.ch/iam/accueil/"; targetUrl = "https://www.portail.vd.ch/iam/accueil/";
} }
LOG.info("bg", `watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`); console.log(`[bg] watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
chrome.tabs.update(tabId, { url: targetUrl }).catch(e => { chrome.tabs.update(tabId, { url: targetUrl }).catch(e => {
LOG.warn("bg", "watchReconnectTab : update failed", e); console.warn("[bg] watchReconnectTab : update failed", e);
}); });
} }
}; };
@@ -1074,8 +813,8 @@ async function submitAbsence(origin, phpsessid, opts) {
body.set("label_guid", opts.typeGuid); body.set("label_guid", opts.typeGuid);
body.set("dialog_action", "save_holidays"); body.set("dialog_action", "save_holidays");
LOG.info("bg", "submitAbsence →", url.substring(0, 140)); console.log("[bg] submitAbsence →", url.substring(0, 140));
LOG.info("bg", "body:", body.toString()); console.log("[bg] body:", body.toString());
const r = await fetch(url, { const r = await fetch(url, {
method: "POST", method: "POST",
@@ -1084,7 +823,7 @@ async function submitAbsence(origin, phpsessid, opts) {
body: body.toString() body: body.toString()
}); });
LOG.info("bg", "status =", r.status); console.log("[bg] status =", r.status);
if (!r.ok) { if (!r.ok) {
throw new Error("HTTP " + r.status); throw new Error("HTTP " + r.status);
@@ -1143,9 +882,9 @@ async function submitDouchette(origin, phpsessid, opts) {
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set("dialog_action", "save_planif"); body.set("dialog_action", "save_planif");
LOG.info("bg", "submitDouchette →", url.substring(0, 160)); console.log("[bg] submitDouchette →", url.substring(0, 160));
LOG.info("bg", "body:", body.toString()); console.log("[bg] body:", body.toString());
LOG.info("bg", "techs:", emplIds); console.log("[bg] techs:", emplIds);
try { try {
const r = await fetch(url, { const r = await fetch(url, {
@@ -1154,7 +893,7 @@ async function submitDouchette(origin, phpsessid, opts) {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString() body: body.toString()
}); });
LOG.info("bg", "status =", r.status); console.log("[bg] status =", r.status);
if (r.status === 401 || r.status === 403) { if (r.status === 401 || r.status === 403) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
@@ -1166,13 +905,7 @@ async function submitDouchette(origin, phpsessid, opts) {
if (looksLikeLoginPage(responseText)) { if (looksLikeLoginPage(responseText)) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
} }
// v2026.5.46 — confirmation EV : en cas de succes, le 1er document return { okCount: techIds.length, errors: [] };
// de la reponse ne contient qu un script qui ferme la popup et recharge le
// planning parent (gl_plan_blk.submit). Sa presence = ordre pris en compte
// (le formulaire re-rendu derriere contient close() mais PAS gl_plan_blk).
const confirmed = responseText.indexOf("integratedDialog.close()") !== -1
&& responseText.indexOf("gl_plan_blk.submit()") !== -1;
return { okCount: techIds.length, errors: [], confirmed };
} catch (err) { } catch (err) {
const msg = err && err.message ? err.message : String(err); const msg = err && err.message ? err.message : String(err);
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) }; return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) };
@@ -1229,7 +962,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
+ `&function_name=${encodeURIComponent(fn)}` + `&function_name=${encodeURIComponent(fn)}`
+ `&action_id=${encodeURIComponent(actionId)}`; + `&action_id=${encodeURIComponent(actionId)}`;
LOG.info("bg", `deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180)); console.log(`[bg] deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180));
try { try {
// v5.0.13 : utiliser evFetch() au lieu de fetch() brut pour que les // v5.0.13 : utiliser evFetch() au lieu de fetch() brut pour que les
@@ -1238,7 +971,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
// notre heuristique le prenait à tort pour un succès. // notre heuristique le prenait à tort pour un succès.
const r = await evFetch(url, origin, { method: "GET" }); const r = await evFetch(url, origin, { method: "GET" });
const body = await r.text(); const body = await r.text();
LOG.info("bg", `status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`); console.log(`[bg] status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`);
if (r.status === 401 || r.status === 403) { if (r.status === 401 || r.status === 403) {
throw new Error("session_expired"); throw new Error("session_expired");
@@ -1258,7 +991,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
// Succès explicite : réponse XML du type <X>true</X> // Succès explicite : réponse XML du type <X>true</X>
if (/^<\w+>true<\/\w+>\s*$/i.test(trimmed)) { if (/^<\w+>true<\/\w+>\s*$/i.test(trimmed)) {
LOG.info("bg", `→ SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`); console.log(`[bg] → SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: trimmed }; return { status: r.status, functionName: fn, body: trimmed };
} }
@@ -1271,17 +1004,17 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|| lower.includes("<html") || lower.includes("<html")
|| lower.includes("window.location.href"); || lower.includes("window.location.href");
if (looksLikeError) { if (looksLikeError) {
LOG.info("bg", `→ réponse ressemble à une erreur, on tente le prochain nom`); console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
lastBody = body; lastBody = body;
continue; continue;
} }
// Pas d'erreur évidente mais pas de succès explicite non plus // 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. // (ex: réponse vide ou "1" ou "ok"). On considère comme succès.
LOG.info("bg", `→ suppression probablement OK (body neutre) avec function_name=${fn}`); console.log(`[bg] → suppression probablement OK (body neutre) avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: trimmed.substring(0, 200) }; return { status: r.status, functionName: fn, body: trimmed.substring(0, 200) };
} catch (err) { } catch (err) {
if (err.message === "session_expired") throw err; if (err.message === "session_expired") throw err;
LOG.warn("bg", `erreur avec ${fn}:`, err); console.warn(`[bg] erreur avec ${fn}:`, err);
lastErr = err; lastErr = err;
} }
} }
@@ -1310,18 +1043,18 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
*/ */
async function detectGroupsFromEV(origin, phpsessid) { async function detectGroupsFromEV(origin, phpsessid) {
const url = `${origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(phpsessid)}`; const url = `${origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(phpsessid)}`;
LOG.info("bg", "detectGroupsFromEV → fetch page planning"); console.log("[bg] detectGroupsFromEV → fetch page planning");
LOG.info("bg", "URL =", url); console.log("[bg] URL =", url);
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);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid); err.kind = classifyHttpStatus(r.status);
err.status = r.status; err.status = r.status;
throw err; throw err;
} }
const html = await r.text(); const html = await r.text();
LOG.info("bg", "page planning taille =", html.length); console.log("[bg] page planning taille =", html.length);
if (looksLikeLoginPage(html)) { if (looksLikeLoginPage(html)) {
const err = new Error("session_expired"); const err = new Error("session_expired");
@@ -1346,7 +1079,7 @@ async function detectGroupsFromEV(origin, phpsessid) {
} }
} }
} }
LOG.info("bg", "" + groups.length + " groupes détectés :", groups); console.log("[bg] " + groups.length + " groupes détectés :", groups);
// Fallback : si le <select> est absent (rendu côté client tardif), on // 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. // tente de lire les favoris menuGlobals.tech4 où les noms sont en base64.
@@ -1360,7 +1093,7 @@ async function detectGroupsFromEV(origin, phpsessid) {
// On n'a pas l'ID dans les favoris : on garde au moins le nom pour // 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 // affichage info, mais sans ID ce groupe n'est pas sélectionnable
// pour fetcher le planning. On ignore donc côté retour. // pour fetcher le planning. On ignore donc côté retour.
LOG.info("bg", "fallback favoris : " + title + " (sans ID, ignoré)"); console.log("[bg] fallback favoris : " + title + " (sans ID, ignoré)");
} }
} }
@@ -1390,7 +1123,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
const supportIds = (typeof supportIdsArg === "string") const supportIds = (typeof supportIdsArg === "string")
? supportIdsArg ? supportIdsArg
: await getSupportIds(); : await getSupportIds();
LOG.info("bg", `detectTeamFromEV : group_id = ${groupId} | support_ids = ${supportIds}`); console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds);
// Fetch la popup de sélection des intervenants du groupe // Fetch la popup de sélection des intervenants du groupe
const popupUrl = origin + "/include/components/staff/planning/plan_view_group_supports.php" const popupUrl = origin + "/include/components/staff/planning/plan_view_group_supports.php"
@@ -1400,18 +1133,18 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
+ "&support_ids=" + encodeURIComponent(supportIds) + "&support_ids=" + encodeURIComponent(supportIds)
+ "&group_id=" + encodeURIComponent(groupId); + "&group_id=" + encodeURIComponent(groupId);
LOG.info("bg", "detectTeamFromEV → popup group_supports"); console.log("[bg] detectTeamFromEV → popup group_supports");
LOG.info("bg", "URL =", popupUrl.substring(0, 240)); console.log("[bg] URL =", popupUrl.substring(0, 240));
let popupHtml = ""; let popupHtml = "";
try { try {
const r = await fetch(popupUrl, { method: "GET", credentials: "include" }); const r = await fetch(popupUrl, { method: "GET", credentials: "include" });
LOG.info("bg", "popup status =", r.status); console.log("[bg] popup status =", r.status);
if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group"); if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group");
popupHtml = await r.text(); popupHtml = await r.text();
LOG.info("bg", "popup taille HTML =", popupHtml.length); console.log("[bg] popup taille HTML =", popupHtml.length);
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired"); if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
} catch (e) { } catch (e) {
LOG.warn("bg", "detectTeam: fetch popup failed:", 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) // 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. // plutôt qu'une liste hardcodée. Si vide, le viewer affichera juste 0 résultat.
const ids = supportIds.split(",").filter(Boolean); const ids = supportIds.split(",").filter(Boolean);
@@ -1426,7 +1159,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
const currentIdsSet = new Set(supportIds.split(",").filter(Boolean)); const currentIdsSet = new Set(supportIds.split(",").filter(Boolean));
// v5.0.1 : log le début du HTML pour diagnostic si parsing échoue // v5.0.1 : log le début du HTML pour diagnostic si parsing échoue
LOG.info("bg", "popup HTML (début) =", popupHtml.substring(0, 500)); console.log("[bg] popup HTML (début) =", popupHtml.substring(0, 500));
// Pattern 1 : checkboxes + texte voisin // Pattern 1 : checkboxes + texte voisin
const rxCheckbox = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi; const rxCheckbox = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi;
@@ -1440,9 +1173,9 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) }); results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
} }
} }
LOG.info("bg", "parsing pattern 1 (checkbox) :", results.length, "résultats"); console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats");
// Pattern 2 : fallback <option value="NNNNN">Nom...</option> // Pattern 2 : fallback <option value="76272">Nom...</option>
if (results.length === 0) { if (results.length === 0) {
const rxOption = /<option[^>]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi; const rxOption = /<option[^>]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi;
let mO; let mO;
@@ -1453,7 +1186,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) }); results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
} }
} }
LOG.info("bg", "parsing pattern 2 (option) :", results.length, "résultats"); console.log("[bg] parsing pattern 2 (option) :", results.length, "résultats");
} }
// Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom // Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom
@@ -1471,7 +1204,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) }); results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) });
} }
} }
LOG.info("bg", "parsing pattern 3 (brut) :", results.length, "résultats"); console.log("[bg] parsing pattern 3 (brut) :", results.length, "résultats");
} }
// Ajouter les IDs actuels manquants (sans nom) // Ajouter les IDs actuels manquants (sans nom)
@@ -1481,7 +1214,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
} }
} }
LOG.info("bg", "" + results.length + " personnes retournées"); console.log("[bg] " + results.length + " personnes retournées");
return { ids: results, groupId: groupId }; return { ids: results, groupId: groupId };
} }
@@ -1492,27 +1225,12 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
(async () => { (async () => {
try { try {
// abort de toutes les requêtes EV en vol (clic sur "Arrêter").
if (msg.type === "abortAllFetches") {
_abortAllEvFetches();
sendResponse({ ok: true });
return;
}
if (msg.type === "getSession") { if (msg.type === "getSession") {
const session = await findEasyVistaSession(); const session = await findEasyVistaSession();
sendResponse({ ok: true, session }); sendResponse({ ok: true, session });
return; 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") { if (msg.type === "fetchPlanning") {
const session = await findEasyVistaSession(); const session = await findEasyVistaSession();
if (!session) { if (!session) {
@@ -1564,14 +1282,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return; return;
} }
try { try {
// fetchFicheHtml renvoie maintenant { html, truncated, size }. const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink);
const result = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink);
const html = result.html;
if (looksLikeLoginPage(html)) { if (looksLikeLoginPage(html)) {
sendResponse({ ok: false, error: "session_expired" }); sendResponse({ ok: false, error: "session_expired" });
return; return;
} }
sendResponse({ ok: true, html, session, truncated: !!result.truncated, size: result.size }); sendResponse({ ok: true, html, session });
} catch (err) { } catch (err) {
sendResponse({ sendResponse({
ok: false, ok: false,
@@ -1583,116 +1299,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return; return;
} }
// 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: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), 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: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), 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) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const url = `${session.origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(session.phpsessid)}`;
const r = await evFetch(url, session.origin);
if (!r.ok) {
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
return;
}
const txt = await r.text();
if (looksLikeLoginPage(txt) || txt.length < 5000) {
sendResponse({ ok: false, error: "session_expired" });
return;
}
sendResponse({ ok: true });
} catch (err) {
sendResponse({ ok: false, error: "fetch_failed", detail: err.message || String(err) });
}
return;
}
if (msg.type === "fetchTimelineApi") { if (msg.type === "fetchTimelineApi") {
const session = await findEasyVistaSession(); const session = await findEasyVistaSession();
if (!session) { if (!session) {
@@ -1702,8 +1308,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try { try {
const body = await fetchTimelineApi( const body = await fetchTimelineApi(
session.origin, session.phpsessid, session.origin, session.phpsessid,
msg.guid, msg.formId, msg.formChecksum, msg.guid, msg.formId, msg.formChecksum
msg.timelineType, msg.sectionId
); );
if (looksLikeLoginPage(body)) { if (looksLikeLoginPage(body)) {
sendResponse({ ok: false, error: "session_expired" }); sendResponse({ ok: false, error: "session_expired" });
@@ -1768,7 +1373,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
try { try {
const result = await submitDouchette(session.origin, session.phpsessid, msg); const result = await submitDouchette(session.origin, session.phpsessid, msg);
sendResponse({ ok: true, okCount: result.okCount, errors: result.errors, confirmed: result.confirmed === true }); sendResponse({ ok: true, okCount: result.okCount, errors: result.errors });
} catch (err) { } catch (err) {
sendResponse({ ok: false, error: err.message || String(err) }); sendResponse({ ok: false, error: err.message || String(err) });
} }
@@ -1923,7 +1528,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: "unknown_message" }); sendResponse({ ok: false, error: "unknown_message" });
} catch (err) { } catch (err) {
LOG.error("bg", "background error:", err); console.error("background error:", err);
sendResponse({ ok: false, error: err.message || String(err) }); sendResponse({ ok: false, error: err.message || String(err) });
} }
})(); })();
@@ -1944,7 +1549,7 @@ async function clearLegacyRefreshAlarms() {
await chrome.alarms.clear("refresh_12h"); await chrome.alarms.clear("refresh_12h");
await chrome.alarms.clear("refresh_15h"); await chrome.alarms.clear("refresh_15h");
} catch (e) { } catch (e) {
LOG.warn("bg", "clearLegacyRefreshAlarms:", e); console.warn("clearLegacyRefreshAlarms:", e);
} }
} }
@@ -1988,33 +1593,10 @@ async function _getCacheDays() {
} }
// Au démarrage, nettoyer les anciennes alarmes et les anciens caches // Au démarrage, nettoyer les anciennes alarmes et les anciens caches
// v2026.5.45 (issue #3) : on stocke aussi un marqueur d'install/update chrome.runtime.onInstalled.addListener(async () => {
// 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(); clearLegacyRefreshAlarms();
const days = await _getCacheDays(); const days = await _getCacheDays();
cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onInstalled", { err: err && err.message })); 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 () => { chrome.runtime.onStartup.addListener(async () => {
@@ -2022,72 +1604,3 @@ chrome.runtime.onStartup.addListener(async () => {
const days = await _getCacheDays(); const days = await _getCacheDays();
cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onStartup", { err: err && err.message })); 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é");
}
});
+2 -7
View File
@@ -1,19 +1,14 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planification", "name": "Planification",
"version": "2026.5.46", "version": "2026.5.41",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl/El4I9gxcZkp5bnheg4ZBCKxckRLt9xQ5x+i6phEPYlydl6NapWlkbOyycUaeTZcpoEh/tKQnpMpoEc5jY8zCoRTwh/vfejVBZE6ANDG/dc3iix/1Z8OhRI4zMaRYQ+DQTjS5Q6l6wkcwzbQB85HZNhke/k1qIW6RCuBjeJYmES5WVDwzfCNVvaF6uQvdOCpLjrA3+LI6vUBEwaMg1hM42mC3dU0z9p7qzAby/dH3XpZD9HTFXLJL9/1zryN9rXNDpvZwYWnJ0hLTu5tfmURkvgKOwquX1KJy9vupmhUALRtS08Fplx/e3kUH3LD+coS1ExLwoOZ+1ORBUE9JCB8wIDAQAB",
"permissions": [ "permissions": [
"activeTab", "activeTab",
"scripting", "scripting",
"storage", "storage",
"tabs", "tabs",
"alarms", "alarms"
"nativeMessaging"
],
"optional_permissions": [
"cookies"
], ],
"host_permissions": [ "host_permissions": [
"https://itsma.etat-de-vaud.ch/*", "https://itsma.etat-de-vaud.ch/*",
+362 -2714
View File
File diff suppressed because it is too large Load Diff
+30 -59
View File
@@ -28,45 +28,40 @@
type="button" aria-label="Utilisateur connecté" type="button" aria-label="Utilisateur connecté"
title="Utilisateur — cliquer pour accéder aux paramètres">?</button> title="Utilisateur — cliquer pour accéder aux paramètres">?</button>
<h1 id="app-title">Planification</h1> <h1 id="app-title">Planification</h1>
<!-- : 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">
<button id="nav-today" class="btn btn-today" title="Revenir au jour courant">Aujourd'hui</button>
<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>
</div>
<span id="capture-info" class="capture-info"></span>
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span>
<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.17 : input date custom qui affiche "Vendredi 24.04.2026" -->
<div class="date-custom-wrapper"> <div class="date-custom-wrapper">
<div id="date-custom" class="date-custom" role="button" tabindex="0" title="Choisir une date du planning"> <div id="date-custom" class="date-custom" role="button" tabindex="0" title="Choisir une date">
<span id="date-custom-label"></span> <span id="date-custom-label"></span>
<span class="date-custom-icon">📅</span>
</div> </div>
<input type="date" id="date-picker" class="date-input-hidden"> <input type="date" id="date-picker" class="date-input-hidden">
</div> </div>
<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>
</div> </div>
<span id="capture-info" class="capture-info"></span>
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span>
</div>
<!-- v2026.5.16 : date complète du jour au-dessus de l'heure dans la topbar -->
<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> </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">
<!-- v2026.5.46 bouton principal "Ajouter" : ouvre un menu déroulant <!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
avec Réservation / Absence / Pompier (remplace les boutons séparés). --> <button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
<div id="add-menu-wrap" class="add-menu-wrap"> <svg class="btn-action-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<button id="add-menu-btn" class="btn btn-action" title="Ajouter : réservation, absence ou pompier" aria-haspopup="true" aria-expanded="false"> <rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/>
<span class="btn-action-emoji"></span> <path d="M3 9h18M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<span class="btn-action-label">Ajouter</span> <circle cx="12" cy="15" r="4.2" stroke="currentColor" stroke-width="1.8" fill="none"/>
<line x1="9" y1="15" x2="15" y2="15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" transform="rotate(-45 12 15)"/>
</svg>
<span class="btn-action-label">Absence</span>
</button> </button>
<div id="add-menu" class="add-menu hidden" role="menu">
<button id="add-menu-reservation" class="add-menu-item" type="button" role="menuitem"><span class="add-menu-emoji">📅</span> Réservation</button>
<button id="add-menu-absence" class="add-menu-item" type="button" role="menuitem"><span class="add-menu-emoji">🗓️</span> Absence</button>
<button id="add-menu-pompier" class="add-menu-item" type="button" role="menuitem"><span class="add-menu-emoji">🚒</span> Pompier</button>
</div>
</div>
<!-- v4.2.6 : bouton envoyer la planification sur la douchette --> <!-- v4.2.6 : bouton envoyer la planification sur la douchette -->
<button id="douchette-btn" class="btn btn-action" title="Envoyer la planification sur la douchette des techniciens"> <button id="douchette-btn" class="btn btn-action" title="Envoyer la planification sur la douchette des techniciens">
<span class="btn-action-emoji">🎯</span> <span class="btn-action-emoji">🎯</span>
@@ -92,10 +87,7 @@
</div> </div>
</header> </header>
<!-- v2026.5.46 — bandeaux pleine largeur en haut (sous la topbar). Le <!-- Bannière session expirée (non bloquante, apparaît en haut du contenu) -->
chevauchement avec la ligne stats+recherche est réglé par l'empilement
sticky (--stack-top), pas en les déplaçant. Un seul visible à la fois
(coordinateur _hideAllSessionBanners / _setSessionBanner). -->
<div id="session-expired-banner" class="session-banner hidden"> <div id="session-expired-banner" class="session-banner hidden">
<span class="session-banner-icon"></span> <span class="session-banner-icon"></span>
<span class="session-banner-text"> <span class="session-banner-text">
@@ -105,6 +97,8 @@
<button id="session-banner-reconnect" class="btn btn-primary btn-sm">Ouvrir EasyVista</button> <button id="session-banner-reconnect" class="btn btn-primary btn-sm">Ouvrir EasyVista</button>
<button id="session-banner-close" class="btn btn-icon" title="Masquer">×</button> <button id="session-banner-close" class="btn btn-icon" title="Masquer">×</button>
</div> </div>
<!-- v4.2.5 : Bannière EasyVista inaccessible (non bloquante, avec cache) -->
<div id="ev-unreachable-banner" class="session-banner ev-banner hidden"> <div id="ev-unreachable-banner" class="session-banner ev-banner hidden">
<span class="session-banner-icon"></span> <span class="session-banner-icon"></span>
<span class="session-banner-text"> <span class="session-banner-text">
@@ -116,6 +110,12 @@
<button id="ev-unreachable-banner-close" class="btn btn-icon" title="Masquer">×</button> <button id="ev-unreachable-banner-close" class="btn btn-icon" title="Masquer">×</button>
</div> </div>
<!-- Barre de progression (visible uniquement pendant un refresh actif) -->
<div id="progress-bar" class="progress-bar hidden">
<div class="progress-bar-fill" id="progress-bar-fill"></div>
<span class="progress-bar-label" id="progress-bar-label"></span>
</div>
<main id="main"> <main id="main">
<div id="error-box" class="error-box hidden"></div> <div id="error-box" class="error-box hidden"></div>
<div id="session-needed" class="session-needed hidden"> <div id="session-needed" class="session-needed hidden">
@@ -129,37 +129,8 @@
<button id="open-ev-btn-2" class="btn btn-primary">Ouvrir EasyVista</button> <button id="open-ev-btn-2" class="btn btn-primary">Ouvrir EasyVista</button>
<button id="retry-btn" class="btn btn-subtle">Réessayer</button> <button id="retry-btn" class="btn btn-subtle">Réessayer</button>
</div> </div>
<!-- v2026.5.46 — ligne collante (sticky) EN PREMIER : stats + barre de
recherche sur la même hauteur, toujours visibles au scroll. En vue
horizontale, les deux enfants sont relocalisés dans la sidebar
(cf. ELEMENTS_TO_RELOCATE). -->
<div id="topline" class="topline">
<div id="stats" class="stats hidden"></div>
<div id="search-zone" class="search-zone hidden">
<div class="search-bar">
<span class="search-ico" aria-hidden="true">🔍</span>
<input id="ev-search" class="search-input" type="search" autocomplete="off"
spellcheck="false" placeholder="Rechercher : réf, personne, matériel, lieu…">
<span id="ev-search-spin" class="search-spin hidden" aria-hidden="true"></span>
<button id="ev-search-clear" class="search-clear hidden" type="button" title="Effacer" aria-label="Effacer">×</button>
<!-- v2026.5.46 — aide sur la syntaxe de recherche -->
<button id="ev-search-help" class="search-help" type="button" title="Aide sur la recherche" aria-label="Aide sur la recherche"></button>
</div>
<div id="ev-search-results" class="search-results hidden" role="listbox"></div>
</div>
<!-- v2026.5.46 — 3e colonne (droite) : bouton « Mes actions » du mode
coordinateur, calé complètement à droite de la ligne. -->
<div id="topline-coord" class="topline-coord"></div>
</div>
<!-- v2026.5.46 — barre de progression du rafraîchissement : SOUS la ligne
stats+recherche (#topline), sticky juste en dessous (avant : au-dessus). -->
<div id="progress-bar" class="progress-bar hidden">
<div class="progress-bar-fill" id="progress-bar-fill"></div>
<span class="progress-bar-label" id="progress-bar-label"></span>
</div>
<!-- v2026.5.46 — le « Chargement… » vit DANS la zone des cartes
(sous la ligne stats+recherche), jamais en haut de page. -->
<div id="loading" class="loading">Chargement…</div> <div id="loading" class="loading">Chargement…</div>
<div id="stats" class="stats hidden"></div>
<div id="cards" class="cards"></div> <div id="cards" class="cards"></div>
</main> </main>
+1316 -11227
View File
File diff suppressed because it is too large Load Diff