forked from FroSteel/Planification
- Retour visuel au survol d'un créneau libre (zone surlignée + heure choisie qui suit la souris ; bulle fixe plage libre + durée disponible, jamais par-dessus un rendez-vous). - Rendez-vous en erreur (début = fin) affiché en rouge et visible en échelle exacte ; avertissement + blocage à la création par-dessus. Interventions courtes visibles. - Zone du nom du technicien pleine hauteur ; barre des heures collée sous la banderole. - Fiabilité : navigation rapide entre dates, technicien retiré, deadlock de synchro du dossier, jour lu depuis le dossier d'emblée, doublon d'absence, réservations multiples le même jour, barre de progression et message de reconnexion. - Synchro : bouton « Tester la communication », panneau réorganisé, script du poste 2026.1.8 (verrou d'index, icône persistante).
388 lines
16 KiB
PowerShell
388 lines
16 KiB
PowerShell
# 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.8'
|
|
# 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
|
|
# v2026.1.8 - verrou DEDIE a l'index. Plusieurs hotes (plusieurs onglets
|
|
# Planification ouverts, ou plusieurs postes) font un read-modify-write
|
|
# concurrent sur _index.json ; sans verrou, deux Move-Item se recouvrent et la
|
|
# maj d'un hote est perdue (lost update) -> l'index prend du retard sur les
|
|
# fichiers-jour -> conflits CAS a repetition ("refusion" en boucle). On
|
|
# serialise donc la sequence lecture->modif->ecriture. Vol du verrou perime
|
|
# (> 10 s) comme pour les fichiers-jour. Best-effort : si on n'obtient pas le
|
|
# verrou, on renonce (la prochaine ecriture reussie remettra l'index a jour).
|
|
$lock = $idx + '.lock'
|
|
$haveLock = $false
|
|
for ($i = 0; $i -lt 20 -and -not $haveLock; $i++) {
|
|
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 }
|
|
else { Start-Sleep -Milliseconds 50 }
|
|
} catch { Start-Sleep -Milliseconds 50 }
|
|
}
|
|
}
|
|
if (-not $haveLock) { Write-Activity 'DEBUG' "Index non mis a jour pour le $(Format-DateFR $date) (verrou index indisponible)"; return }
|
|
try {
|
|
# lecture DANS le verrou -> on part toujours de la derniere version.
|
|
$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)"
|
|
} finally {
|
|
Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
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) }
|
|
}
|
|
}
|