Compare commits

..
15 changed files with 9351 additions and 2410 deletions
+82
View File
@@ -9,6 +9,88 @@
---
## 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.
+14 -2
View File
@@ -47,6 +47,7 @@ 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 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
- **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
- Persistance localStorage (`view_mode`)
@@ -69,8 +70,9 @@ Les mises à jour sont **manuelles** : à chaque nouvelle release, retélécharg
- Badge user avec photo/initiales en topbar
- 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)
- Compteur de session EasyVista (tick 1s, depuis v5.0.0)
- Compteur de session EasyVista (tick 1s, depuis v5.0.0) — recalé sur la vraie valeur serveur (depuis v2026.5.46)
- 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
- Mode admin : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25)
@@ -113,7 +115,17 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
## Versions notables
### `v2026.5.45` (latest, 8 mai 2026) — Dock latéral drag&drop, fix verdicts ghost, multi-onglets EZV résolu
### `v2026.5.46` (latest, 9 juillet 2026) — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées
- 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.
Executable → Regular
+3
View File
@@ -25,6 +25,9 @@ cp -r src/* dist/firefox/
python3 - <<EOF
import json
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'] = {
'gecko': {
'id': '[email protected]',
+5
View File
@@ -2,6 +2,11 @@
"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",
+3
View File
@@ -0,0 +1,3 @@
@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.

After

Width:  |  Height:  |  Size: 444 B

+361
View File
@@ -0,0 +1,361 @@
# 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
@@ -0,0 +1,231 @@
# 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
@@ -0,0 +1,196 @@
# 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
@@ -0,0 +1,5 @@
' 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
+153 -74
View File
@@ -9,10 +9,17 @@
*
* @author Quentin Rouiller
* @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
//
// 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 :
// 1. Au clic sur l'icône : ouvrir le viewer
// 2. Répondre aux messages du viewer :
@@ -306,11 +313,12 @@ async function _readPhpsessidFromCookie(origin) {
let _sessionCache = null;
let _sessionCacheTs = 0;
// Cache désactivé temporairement (TTL=0) — soupçon que la durée de
// fenêtre stale puisse causer une réponse "page de login" (~8 Ko) lors
// d'un fetch fiche. À remettre à 200 après confirmation que ce n'était
// pas le souci.
const _SESSION_CACHE_TTL_MS = 0;
// 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() {
@@ -322,9 +330,16 @@ async function _findEasyVistaSessionRaw() {
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) {
const tabs = await chrome.tabs.query({ url: origin + "/*" });
if (!tabs.length) continue;
let phpsessid = null;
let expirationDate = null;
@@ -339,6 +354,13 @@ async function _findEasyVistaSessionRaw() {
}
}
if (!tabs.length) {
if (phpsessid && !cookieOnly) {
cookieOnly = { phpsessid, origin, tabId: null, source: "cookie", expirationDate };
}
continue;
}
if (!phpsessid) {
for (const tab of tabs) {
const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/);
@@ -354,7 +376,7 @@ async function _findEasyVistaSessionRaw() {
return { phpsessid, origin, tabId: tabs[0].id, source, expirationDate };
}
}
return null;
return cookieOnly;
}
async function findEasyVistaSession() {
@@ -378,11 +400,11 @@ async function findEasyVistaSession() {
if (sig !== _lastSessionSignature) {
if (result) {
LOG.warn("session",
`🔑 PHPSESSID via ${result.source.toUpperCase()} (${result.phpsessid.slice(0, 8)}…) sur ${result.origin}`,
`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)");
LOG.warn("session", "Aucun PHPSESSID disponible (pas d'onglet EZV ouvert ou cookie absent)");
}
_lastSessionSignature = sig;
}
@@ -440,7 +462,7 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
// écran (session expirée vs EV inaccessible).
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
err.status = r.status;
throw err;
}
@@ -509,6 +531,47 @@ function classifyHttpStatus(status) {
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.
* Retourne ~400 octets au format custom :
@@ -519,7 +582,7 @@ async function fetchXhr2(origin, phpsessid, actionId) {
const r = await evFetch(url, origin);
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
err.status = r.status;
throw err;
}
@@ -541,7 +604,7 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
const r = await evFetch(url, origin);
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
err.status = r.status;
throw err;
}
@@ -549,24 +612,24 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
// v2026.5.38 : on log seulement les retries (utile en cas de pb SSO),
// pas chaque tentative normale qui réussit du premier coup.
if (attempt > 1) {
console.log(`[bg] fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`);
LOG.info("bg", `fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`);
}
// Si réponse clairement une redirection courte → login expiré, inutile de retry
if (html.length < 500) {
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
LOG.warn("bg", "fiche très courte, contenu =", JSON.stringify(html));
return html;
}
// Si taille suspecte (< 20 Ko), probable page intermédiaire SSO : retry
if (html.length < MIN_VALID_SIZE && attempt < MAX_RETRIES) {
console.warn(`[bg] ⚠ fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`);
LOG.warn("bg", `fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`);
await new Promise(res => setTimeout(res, RETRY_DELAY_MS));
continue;
}
// on signale au foreground si la dernière réponse est tronquée pour
// qu'il puisse afficher un et probe la session.
// qu'il puisse afficher un et probe la session.
return { html, truncated: html.length < MIN_VALID_SIZE, size: html.length };
}
throw new Error("fetchFicheHtml: max retries reached");
@@ -579,23 +642,31 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
// 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...
// ({C99ECD05}) vs un incident I... ({07ED9C68}).
async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum, timelineType, sectionId) {
// Sécurité : GUID doit être de la forme %7B...%7D ou {...}
if (!/^(%7B|\{)[A-F0-9\-]{36}(%7D|\})$/i.test(guid)) {
throw new Error("Invalid GUID: " + guid);
}
// S'assurer qu'on a la forme encodée %7B...%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 =
`${origin}/api/v1/internal/forms/${encodedGuid}/timeline` +
`?target=${encodeURIComponent(formId)}` +
`&checksum=${encodeURIComponent(formChecksum)}` +
`&type=todo&sectionId=1&navigator=&nbRecord=0` +
`&type=${encodeURIComponent(tType)}&sectionId=${encodeURIComponent(tSection)}&navigator=&nbRecord=0` +
`&PHPSESSID=${encodeURIComponent(phpsessid)}`;
const r = await evFetch(url, origin);
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
err.status = r.status;
throw err;
}
@@ -641,8 +712,9 @@ function looksLikeLoginPage(text) {
* Retourne le nombre de millisecondes restantes avant expiration de la
* session EasyVista (0 à 1 800 000 = 30 min max).
*
* Attention : cette requête EST authentifiée et prolonge probablement la
* session (comme toute requête PHP authentifiée). À utiliser avec parcimonie.
* EasyVista appelle LUI-MÊME cet endpoint en continu pour alimenter sa
* propre modal de timeout (visible dans le réseau du planning natif) : il ne
* prolonge donc PAS la session — utilisable pour un calibrage périodique.
*/
async function fetchSessionTimeRemaining(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
@@ -656,7 +728,7 @@ async function fetchSessionTimeRemaining(origin, phpsessid) {
const body = (await r.text()).trim();
// Vérifier que c'est bien un nombre (sinon = session morte probable)
if (!/^\d+$/.test(body)) {
console.warn("[bg] réponse session_time anormale :", body.substring(0, 200));
LOG.warn("bg", "réponse session_time anormale :", body.substring(0, 200));
// Si c'est une page de login/redirect → session expirée
if (looksLikeLoginPage(body)) {
throw new Error("session_expired");
@@ -720,7 +792,7 @@ async function detectNetworkContext(force = false) {
const data = await chrome.storage.local.get([CACHE_KEY]);
const cached = data[CACHE_KEY];
if (cached && cached.detectedAt && (Date.now() - cached.detectedAt) < CACHE_MAX_AGE_MS) {
console.log("[bg] detectNetworkContext : cache hit =", cached.networkContext);
LOG.info("bg", "detectNetworkContext : cache hit =", cached.networkContext);
return cached.networkContext;
}
} catch (e) {}
@@ -730,7 +802,7 @@ async function detectNetworkContext(force = false) {
const timer = setTimeout(() => controller.abort(), 2500);
let context = "external";
try {
console.log("[bg] detectNetworkContext : test de itsma.etat-de-vaud.ch (mode no-cors)...");
LOG.info("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.
// Si le fetch termine sans throw, le serveur est joignable = interne.
// Si timeout ou DNS fail, throw → externe.
@@ -742,12 +814,12 @@ async function detectNetworkContext(force = false) {
cache: "no-store"
});
clearTimeout(timer);
console.log("[bg] detectNetworkContext : interne accessible");
LOG.info("bg", "detectNetworkContext : interne accessible");
context = "internal";
} catch (err) {
clearTimeout(timer);
// AbortError (timeout) ou TypeError (DNS unreachable / réseau coupé)
console.log("[bg] detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
LOG.info("bg", "detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
context = "external";
}
@@ -808,9 +880,9 @@ function watchReconnectTabForIamLogin(tabId) {
} else {
targetUrl = "https://www.portail.vd.ch/iam/accueil/";
}
console.log(`[bg] watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
LOG.info("bg", `watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
chrome.tabs.update(tabId, { url: targetUrl }).catch(e => {
console.warn("[bg] watchReconnectTab : update failed", e);
LOG.warn("bg", "watchReconnectTab : update failed", e);
});
}
};
@@ -1002,8 +1074,8 @@ async function submitAbsence(origin, phpsessid, opts) {
body.set("label_guid", opts.typeGuid);
body.set("dialog_action", "save_holidays");
console.log("[bg] submitAbsence →", url.substring(0, 140));
console.log("[bg] body:", body.toString());
LOG.info("bg", "submitAbsence →", url.substring(0, 140));
LOG.info("bg", "body:", body.toString());
const r = await fetch(url, {
method: "POST",
@@ -1012,7 +1084,7 @@ async function submitAbsence(origin, phpsessid, opts) {
body: body.toString()
});
console.log("[bg] status =", r.status);
LOG.info("bg", "status =", r.status);
if (!r.ok) {
throw new Error("HTTP " + r.status);
@@ -1071,9 +1143,9 @@ async function submitDouchette(origin, phpsessid, opts) {
const body = new URLSearchParams();
body.set("dialog_action", "save_planif");
console.log("[bg] submitDouchette →", url.substring(0, 160));
console.log("[bg] body:", body.toString());
console.log("[bg] techs:", emplIds);
LOG.info("bg", "submitDouchette →", url.substring(0, 160));
LOG.info("bg", "body:", body.toString());
LOG.info("bg", "techs:", emplIds);
try {
const r = await fetch(url, {
@@ -1082,7 +1154,7 @@ async function submitDouchette(origin, phpsessid, opts) {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString()
});
console.log("[bg] status =", r.status);
LOG.info("bg", "status =", r.status);
if (r.status === 401 || r.status === 403) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
@@ -1094,7 +1166,13 @@ async function submitDouchette(origin, phpsessid, opts) {
if (looksLikeLoginPage(responseText)) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
}
return { okCount: techIds.length, errors: [] };
// v2026.5.46 — confirmation EV : en cas de succes, le 1er document
// 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) {
const msg = err && err.message ? err.message : String(err);
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) };
@@ -1151,7 +1229,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
+ `&function_name=${encodeURIComponent(fn)}`
+ `&action_id=${encodeURIComponent(actionId)}`;
console.log(`[bg] deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180));
LOG.info("bg", `deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180));
try {
// v5.0.13 : utiliser evFetch() au lieu de fetch() brut pour que les
@@ -1160,7 +1238,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
// notre heuristique le prenait à tort pour un succès.
const r = await evFetch(url, origin, { method: "GET" });
const body = await r.text();
console.log(`[bg] status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`);
LOG.info("bg", `status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`);
if (r.status === 401 || r.status === 403) {
throw new Error("session_expired");
@@ -1180,7 +1258,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
// Succès explicite : réponse XML du type <X>true</X>
if (/^<\w+>true<\/\w+>\s*$/i.test(trimmed)) {
console.log(`[bg] → SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`);
LOG.info("bg", `→ SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: trimmed };
}
@@ -1193,17 +1271,17 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
|| lower.includes("<html")
|| lower.includes("window.location.href");
if (looksLikeError) {
console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
LOG.info("bg", `→ réponse ressemble à une erreur, on tente le prochain nom`);
lastBody = body;
continue;
}
// 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.
console.log(`[bg] → suppression probablement OK (body neutre) avec function_name=${fn}`);
LOG.info("bg", `→ suppression probablement OK (body neutre) avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: trimmed.substring(0, 200) };
} catch (err) {
if (err.message === "session_expired") throw err;
console.warn(`[bg] erreur avec ${fn}:`, err);
LOG.warn("bg", `erreur avec ${fn}:`, err);
lastErr = err;
}
}
@@ -1232,18 +1310,18 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
*/
async function detectGroupsFromEV(origin, phpsessid) {
const url = `${origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] detectGroupsFromEV → fetch page planning");
console.log("[bg] URL =", url);
LOG.info("bg", "detectGroupsFromEV → fetch page planning");
LOG.info("bg", "URL =", url);
const r = await evFetch(url, origin);
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
err.status = r.status;
throw err;
}
const html = await r.text();
console.log("[bg] page planning taille =", html.length);
LOG.info("bg", "page planning taille =", html.length);
if (looksLikeLoginPage(html)) {
const err = new Error("session_expired");
@@ -1268,7 +1346,7 @@ async function detectGroupsFromEV(origin, phpsessid) {
}
}
}
console.log("[bg] " + groups.length + " groupes détectés :", groups);
LOG.info("bg", "" + groups.length + " groupes détectés :", groups);
// 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.
@@ -1282,7 +1360,7 @@ async function detectGroupsFromEV(origin, phpsessid) {
// 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
// pour fetcher le planning. On ignore donc côté retour.
console.log("[bg] fallback favoris : " + title + " (sans ID, ignoré)");
LOG.info("bg", "fallback favoris : " + title + " (sans ID, ignoré)");
}
}
@@ -1312,7 +1390,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
const supportIds = (typeof supportIdsArg === "string")
? supportIdsArg
: await getSupportIds();
console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds);
LOG.info("bg", `detectTeamFromEV : group_id = ${groupId} | support_ids = ${supportIds}`);
// Fetch la popup de sélection des intervenants du groupe
const popupUrl = origin + "/include/components/staff/planning/plan_view_group_supports.php"
@@ -1322,18 +1400,18 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
+ "&support_ids=" + encodeURIComponent(supportIds)
+ "&group_id=" + encodeURIComponent(groupId);
console.log("[bg] detectTeamFromEV → popup group_supports");
console.log("[bg] URL =", popupUrl.substring(0, 240));
LOG.info("bg", "detectTeamFromEV → popup group_supports");
LOG.info("bg", "URL =", popupUrl.substring(0, 240));
let popupHtml = "";
try {
const r = await fetch(popupUrl, { method: "GET", credentials: "include" });
console.log("[bg] popup status =", r.status);
LOG.info("bg", "popup status =", r.status);
if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group");
popupHtml = await r.text();
console.log("[bg] popup taille HTML =", popupHtml.length);
LOG.info("bg", "popup taille HTML =", popupHtml.length);
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
} catch (e) {
console.warn("[bg] detectTeam: fetch popup failed:", e);
LOG.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)
// plutôt qu'une liste hardcodée. Si vide, le viewer affichera juste 0 résultat.
const ids = supportIds.split(",").filter(Boolean);
@@ -1348,7 +1426,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
const currentIdsSet = new Set(supportIds.split(",").filter(Boolean));
// v5.0.1 : log le début du HTML pour diagnostic si parsing échoue
console.log("[bg] popup HTML (début) =", popupHtml.substring(0, 500));
LOG.info("bg", "popup HTML (début) =", popupHtml.substring(0, 500));
// Pattern 1 : checkboxes + texte voisin
const rxCheckbox = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi;
@@ -1362,7 +1440,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
}
}
console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats");
LOG.info("bg", "parsing pattern 1 (checkbox) :", results.length, "résultats");
// Pattern 2 : fallback <option value="NNNNN">Nom...</option>
if (results.length === 0) {
@@ -1375,7 +1453,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
}
}
console.log("[bg] parsing pattern 2 (option) :", results.length, "résultats");
LOG.info("bg", "parsing pattern 2 (option) :", results.length, "résultats");
}
// Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom
@@ -1393,7 +1471,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) });
}
}
console.log("[bg] parsing pattern 3 (brut) :", results.length, "résultats");
LOG.info("bg", "parsing pattern 3 (brut) :", results.length, "résultats");
}
// Ajouter les IDs actuels manquants (sans nom)
@@ -1403,7 +1481,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
}
}
console.log("[bg] " + results.length + " personnes retournées");
LOG.info("bg", "" + results.length + " personnes retournées");
return { ids: results, groupId: groupId };
}
@@ -1526,10 +1604,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
`&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")}`);
`reschedule actionId=${msg.actionId} → tech=${msg.employeeId} ${msg.date} ${msg.hour}:${String(msg.minute).padStart(2,"0")}`);
const r = await evFetch(url, session.origin);
if (!r.ok) {
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
return;
}
const txt = await r.text();
@@ -1567,14 +1645,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
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}`);
`updateActionTimes actionId=${msg.actionId} ${msg.startDate} ${msg.startTime}${msg.endTime}`);
const r = await evFetch(url, session.origin, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
});
if (!r.ok) {
sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status });
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
return;
}
const txt = await r.text();
@@ -1600,7 +1678,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
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: classifyHttpStatus(r.status), httpStatus: r.status });
sendResponse({ ok: false, error: await classifyHttpStatusVerified(r.status, session.origin, session.phpsessid), httpStatus: r.status });
return;
}
const txt = await r.text();
@@ -1624,7 +1702,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const body = await fetchTimelineApi(
session.origin, session.phpsessid,
msg.guid, msg.formId, msg.formChecksum
msg.guid, msg.formId, msg.formChecksum,
msg.timelineType, msg.sectionId
);
if (looksLikeLoginPage(body)) {
sendResponse({ ok: false, error: "session_expired" });
@@ -1689,7 +1768,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
try {
const result = await submitDouchette(session.origin, session.phpsessid, msg);
sendResponse({ ok: true, okCount: result.okCount, errors: result.errors });
sendResponse({ ok: true, okCount: result.okCount, errors: result.errors, confirmed: result.confirmed === true });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
@@ -1844,7 +1923,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: "unknown_message" });
} catch (err) {
console.error("background error:", err);
LOG.error("bg", "background error:", err);
sendResponse({ ok: false, error: err.message || String(err) });
}
})();
@@ -1865,7 +1944,7 @@ async function clearLegacyRefreshAlarms() {
await chrome.alarms.clear("refresh_12h");
await chrome.alarms.clear("refresh_15h");
} catch (e) {
console.warn("clearLegacyRefreshAlarms:", e);
LOG.warn("bg", "clearLegacyRefreshAlarms:", e);
}
}
@@ -1953,7 +2032,7 @@ 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)");
LOG.warn("cookies", "listener PHPSESSID activé (permission cookies accordée)");
chrome.cookies.onChanged.addListener((info) => {
try {
if (!info || !info.cookie) return;
@@ -1965,9 +2044,9 @@ function _attachCookiesListener() {
// é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`);
LOG.warn("cookies", `PHPSESSID supprimé sur ${dom} (cause=${info.cause}) → session morte`);
} else {
LOG.warn("cookies", `🍪 PHPSESSID ${phpsessid8} sur ${dom} (cause=${info.cause})`);
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.
@@ -1997,18 +2076,18 @@ function _attachCookiesListener() {
(async () => {
try {
const has = await _hasCookiesPermission();
LOG.warn("cookies", `🔧 permission cookies au boot : ${has ? "ACCORDÉE → lecture cookie active" : "non accordée → fallback URL"}`);
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");
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é");
LOG.warn("cookies", "permission cookies RETIRÉE → fallback URL réactivé");
}
});
+4 -2
View File
@@ -1,14 +1,16 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "2026.5.45",
"version": "2026.5.46",
"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": [
"activeTab",
"scripting",
"storage",
"tabs",
"alarms"
"alarms",
"nativeMessaging"
],
"optional_permissions": [
"cookies"
+1244 -188
View File
File diff suppressed because it is too large Load Diff
+46 -19
View File
@@ -54,16 +54,19 @@
<!-- v5.0.9 : compteur de session EasyVista (visible < 5 min restantes) -->
<div id="app-session" class="app-session hidden"></div>
<div class="topbar-right">
<!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
<button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
<svg class="btn-action-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/>
<path d="M3 9h18M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<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>
<!-- v2026.5.46 bouton principal "Ajouter" : ouvre un menu déroulant
avec Réservation / Absence / Pompier (remplace les boutons séparés). -->
<div id="add-menu-wrap" class="add-menu-wrap">
<button id="add-menu-btn" class="btn btn-action" title="Ajouter : réservation, absence ou pompier" aria-haspopup="true" aria-expanded="false">
<span class="btn-action-emoji"></span>
<span class="btn-action-label">Ajouter</span>
</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 -->
<button id="douchette-btn" class="btn btn-action" title="Envoyer la planification sur la douchette des techniciens">
<span class="btn-action-emoji">🎯</span>
@@ -89,7 +92,10 @@
</div>
</header>
<!-- Bannière session expirée (non bloquante, apparaît en haut du contenu) -->
<!-- v2026.5.46 — bandeaux pleine largeur en haut (sous la topbar). Le
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">
<span class="session-banner-icon"></span>
<span class="session-banner-text">
@@ -99,8 +105,6 @@
<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>
</div>
<!-- v4.2.5 : Bannière EasyVista inaccessible (non bloquante, avec cache) -->
<div id="ev-unreachable-banner" class="session-banner ev-banner hidden">
<span class="session-banner-icon"></span>
<span class="session-banner-text">
@@ -112,12 +116,6 @@
<button id="ev-unreachable-banner-close" class="btn btn-icon" title="Masquer">×</button>
</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">
<div id="error-box" class="error-box hidden"></div>
<div id="session-needed" class="session-needed hidden">
@@ -131,8 +129,37 @@
<button id="open-ev-btn-2" class="btn btn-primary">Ouvrir EasyVista</button>
<button id="retry-btn" class="btn btn-subtle">Réessayer</button>
</div>
<div id="loading" class="loading">Chargement…</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="cards" class="cards"></div>
</main>
+6841 -1962
View File
File diff suppressed because it is too large Load Diff