#Requires -Version 5.1 # ===================================================================== # CONFIGURATION # ===================================================================== $GITEA_BASE_URL = "https://git.cadjou.net" $GITEA_ORG = "ScriptPowerShell" # Nom de l'organisation Gitea $GITEA_BRANCH = "main" $LOCAL_CACHE_DIR = "$PSScriptRoot\scripts" $CADMASTER_VERSION = "2.0.1" $CADMASTER_UPDATE_ORG = "DistechControls" # Organisation Gitea hebergeant cadMasterCLI lui-meme $CADMASTER_UPDATE_REPO = "cadMasterCLI" $CADMASTER_CONFIG_PATH = "$PSScriptRoot\cadmastercli.config.json" Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Data # ===================================================================== # I18N : dictionnaire de traduction FR/EN et fonctions associees # ===================================================================== $script:I18N = @{ fr = @{ "app.subtitle" = "Lanceur de scripts PowerShell" "list.header" = "Scripts disponibles" "list.cacheBadge" = "En cache" "list.newBadge" = "Nouveau" "detail.placeholder" = "Selectionnez un script dans la liste a gauche pour afficher ses parametres." "detail.paramsTitle" = "Parametres de :" "detail.readmeOpen" = "Ouvrir README" "detail.readmeClose" = "Fermer README" "detail.mandatoryNote" = "* Champs obligatoires" "detail.launch" = "Lancer le script" "detail.browse" = "Parcourir..." "detail.orPasteIps" = "- ou - coller une liste d'IPs :" "detail.convertCsv" = "Convertir en CSV" "detail.previewCsv" = "Apercu (10 premieres lignes) :" "detail.helpShow" = "[?] Voir l'aide" "detail.helpHide" = "[^] Masquer l'aide" "detail.mandatory" = "Obligatoire" "detail.optional" = "Optionnel" "detail.switchEnable" = "Activer" "detail.noParams" = "Ce script ne necessite aucun parametre." "detail.noReadme" = "Aucune documentation disponible." "detail.updateAvailable" = "Mise a jour disponible - cliquer pour installer" "log.header" = "Journal d'execution" "log.placeholder" = "En attente d'execution..." "log.starting" = "Demarrage du script..." "log.finishedOk" = "Execution terminee avec succes." "log.finishedError" = "Execution terminee avec des erreurs." "msg.errorTitle" = "Erreur" "msg.errorExecTitle" = "Erreur d'execution" "msg.errorExec" = "Erreur lors de l'execution de" "msg.downloadFailed" = "Impossible de telecharger le script" "msg.updateAvailableTitle"= "Mise a jour disponible" "msg.updateAvailableScript" = "Une mise a jour est disponible pour" "msg.updateConfirm" = "Voulez-vous telecharger la derniere version ?" "msg.selfUpdateTitle" = "Mise a jour de cadMasterCLI" "msg.selfUpdateAvailable" = "Une nouvelle version de cadMasterCLI est disponible" "msg.selfUpdateConfirm" = "Voulez-vous l'installer maintenant ? L'application va redemarrer." "msg.offline" = "Impossible de contacter Gitea et le cache local est vide.`n`nVerifiez votre connexion reseau et relancez le script." } en = @{ "app.subtitle" = "PowerShell script launcher" "list.header" = "Available scripts" "list.cacheBadge" = "Cached" "list.newBadge" = "New" "detail.placeholder" = "Select a script from the list on the left to view its parameters." "detail.paramsTitle" = "Parameters for:" "detail.readmeOpen" = "Open README" "detail.readmeClose" = "Close README" "detail.mandatoryNote" = "* Required fields" "detail.launch" = "Run script" "detail.browse" = "Browse..." "detail.orPasteIps" = "- or - paste a list of IPs:" "detail.convertCsv" = "Convert to CSV" "detail.previewCsv" = "Preview (first 10 rows):" "detail.helpShow" = "[?] Show help" "detail.helpHide" = "[^] Hide help" "detail.mandatory" = "Required" "detail.optional" = "Optional" "detail.switchEnable" = "Enable" "detail.noParams" = "This script does not require any parameters." "detail.noReadme" = "No documentation available." "detail.updateAvailable" = "Update available - click to install" "log.header" = "Execution log" "log.placeholder" = "Waiting for execution..." "log.starting" = "Starting script..." "log.finishedOk" = "Execution completed successfully." "log.finishedError" = "Execution finished with errors." "msg.errorTitle" = "Error" "msg.errorExecTitle" = "Execution error" "msg.errorExec" = "Error while running" "msg.downloadFailed" = "Unable to download the script" "msg.updateAvailableTitle"= "Update available" "msg.updateAvailableScript" = "An update is available for" "msg.updateConfirm" = "Do you want to download the latest version?" "msg.selfUpdateTitle" = "cadMasterCLI update" "msg.selfUpdateAvailable" = "A new version of cadMasterCLI is available" "msg.selfUpdateConfirm" = "Do you want to install it now? The application will restart." "msg.offline" = "Unable to reach Gitea and the local cache is empty.`n`nCheck your network connection and restart the script." } } function Get-Text { param([string]$Key) if ($script:I18N[$script:CurrentLang].ContainsKey($Key)) { return $script:I18N[$script:CurrentLang][$Key] } return $Key } function T { param([string]$Key) Get-Text -Key $Key } function Get-CadMasterConfig { if (Test-Path $CADMASTER_CONFIG_PATH) { try { $cfg = Get-Content $CADMASTER_CONFIG_PATH -Raw -Encoding UTF8 | ConvertFrom-Json if ($cfg.Language) { return $cfg.Language } } catch { } } return $null } function Save-CadMasterConfig { param([string]$Language) try { @{ Language = $Language } | ConvertTo-Json | Set-Content -Path $CADMASTER_CONFIG_PATH -Encoding UTF8 } catch { } } $script:CurrentLang = Get-CadMasterConfig if (-not $script:CurrentLang) { $script:CurrentLang = if ([System.Globalization.CultureInfo]::CurrentUICulture.TwoLetterISOLanguageName -eq "fr") { "fr" } else { "en" } } # Note : $script:CurrentLang ne doit JAMAIS etre lu/ecrit directement depuis un # scriptblock passe par .GetNewClosure() (WPF event handlers) : GetNewClosure() # fige une copie privee de toute variable de scope $script: des sa creation, qui # ne se resynchronise plus jamais avec la vraie valeur. Passer systematiquement # par ces fonctions (fonctions racine = resolution dynamique a chaque appel). function Get-CurrentLang { return $script:CurrentLang } function Set-CadMasterLanguage { param([string]$Lang) $script:CurrentLang = $Lang Save-CadMasterConfig -Language $Lang } # ===================================================================== # UTILITAIRES # ===================================================================== function Write-Log { param( [string]$Message, [ValidateSet("INFO", "WARN", "ERROR", "OK")] [string]$Level = "INFO" ) $ts = Get-Date -Format "HH:mm:ss" $tag = switch ($Level) { "INFO" {"[INFO ]"} "WARN" {"[WARN ]"} "ERROR" {"[ERR ]"} "OK" {"[ OK ]"} } $color = switch ($Level) { "INFO" {"Cyan"} "WARN" {"Yellow"} "ERROR" {"Red"} "OK" {"Green"} } Write-Host "$ts $tag $Message" -ForegroundColor $color } function Show-MessageBox { param( [string]$Message, [string]$Title = "cadMasterCLI", [string]$Type = "Info" ) $icon = switch ($Type) { "Warning" { [System.Windows.MessageBoxImage]::Warning } "Error" { [System.Windows.MessageBoxImage]::Error } "Question" { [System.Windows.MessageBoxImage]::Question } default { [System.Windows.MessageBoxImage]::Information } } $buttons = if ($Type -eq "Question") { [System.Windows.MessageBoxButton]::YesNo } else { [System.Windows.MessageBoxButton]::OK } return [System.Windows.MessageBox]::Show($Message, $Title, $buttons, $icon) } # ===================================================================== # FONCTION : Get-ScriptList # Recupere la liste des depots de l'organisation Gitea # ===================================================================== function Get-ScriptList { $page = 1 $limit = 50 $allRepos = @() try { Write-Log "Connexion a Gitea (organisation : $GITEA_ORG)..." do { $url = "$GITEA_BASE_URL/api/v1/orgs/$GITEA_ORG/repos?limit=$limit&page=$page" $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 10 -ErrorAction Stop $allRepos += $response $page++ } while ($response.Count -eq $limit) if ($allRepos.Count -eq 0) { Write-Log "Aucun depot trouve dans l'organisation $GITEA_ORG" "WARN" return @() } Write-Log "$($allRepos.Count) script(s) trouve(s) sur Gitea" "OK" return $allRepos | ForEach-Object { [PSCustomObject]@{ Nom = $_.name Description = $_.description DefaultBranch = if ($_.default_branch) { $_.default_branch } else { $GITEA_BRANCH } } } } catch { Write-Log "API Gitea inaccessible : $($_.Exception.Message)" "ERROR" return $null } } # ===================================================================== # FONCTION : Get-ScriptReadme # Telecharge le README.md a la racine du depot du script # ===================================================================== function Get-ScriptReadme { param( [string]$ScriptName, [string]$Branch = $GITEA_BRANCH ) $url = "$GITEA_BASE_URL/api/v1/repos/$GITEA_ORG/$ScriptName/raw/README.md?ref=$Branch" try { return [string](Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 10 -ErrorAction Stop) } catch { return $null } } # ===================================================================== # FONCTION : Get-ReadmeSummary # Extrait la premiere ligne de description d'un README (conservee pour # usage eventuel en infobulle, non utilisee dans la liste depuis la v2) # ===================================================================== function Get-ReadmeSummary { param([string]$ReadmeContent) if ([string]::IsNullOrWhiteSpace($ReadmeContent)) { return "Aucune description disponible" } $lines = $ReadmeContent -split "`n" $foundTitle = $false foreach ($line in $lines) { $t = $line.Trim() if ($t -eq "") { continue } if ($t -match "^#") { $foundTitle = $true; continue } if ($foundTitle) { return $t -replace "\*\*|__|~~|``", "" } } $first = ($lines | Where-Object { $_.Trim() -ne "" } | Select-Object -First 1) if ($first) { return $first.Trim().TrimStart('#').Trim() } return "Aucune description disponible" } # ===================================================================== # FONCTION : Get-ReadmeSection # Extrait la section FR ou EN d'un README marque avec / . # Si aucun marqueur n'est present, retourne le contenu brut (retro-compatibilite). # Si la langue demandee est absente, retombe sur l'autre langue disponible. # ===================================================================== function Get-ReadmeSection { param( [string]$Markdown, [string]$Lang ) if ([string]::IsNullOrWhiteSpace($Markdown)) { return $Markdown } $pattern = '(?is)(.*?)(?=|$)' $found = [regex]::Matches($Markdown, $pattern) if ($found.Count -eq 0) { return $Markdown } $sections = @{} foreach ($m in $found) { $key = $m.Groups[1].Value.ToUpper() if (-not $sections.ContainsKey($key)) { $sections[$key] = $m.Groups[2].Value.Trim() } } $want = $Lang.ToUpper() if ($sections.ContainsKey($want)) { return $sections[$want] } $other = $sections.Keys | Select-Object -First 1 return $sections[$other] } # ===================================================================== # FONCTION : Get-ScriptLastCommitDate # Recupere la date du dernier commit du depot du script # ===================================================================== function Get-ScriptLastCommitDate { param( [string]$ScriptName, [string]$Branch = $GITEA_BRANCH ) $url = "$GITEA_BASE_URL/api/v1/repos/$GITEA_ORG/$ScriptName/commits?limit=1&sha=$Branch" try { $commits = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 10 -ErrorAction Stop if ($commits -and @($commits).Count -gt 0) { return [DateTime]::Parse($commits[0].commit.committer.date) } } catch { } return $null } # ===================================================================== # FONCTION : Test-ScriptUpdate # Retourne $true si la version Gitea est plus recente que le cache # ===================================================================== function Test-ScriptUpdate { param( [string]$ScriptName, [Nullable[DateTime]]$RemoteDate ) if (-not $RemoteDate) { return $false } $localFile = Get-ChildItem -Path "$LOCAL_CACHE_DIR\$ScriptName\" -Filter "*.ps1" -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $localFile) { return $false } return $RemoteDate.ToUniversalTime() -gt $localFile.LastWriteTimeUtc } # ===================================================================== # FONCTION : Find-ScriptPs1Name # Cherche le nom du fichier .ps1 a la racine du depot # ===================================================================== function Find-ScriptPs1Name { param( [string]$ScriptName, [string]$Branch = $GITEA_BRANCH ) $url = "$GITEA_BASE_URL/api/v1/repos/$GITEA_ORG/$ScriptName/contents/?ref=$Branch" try { $contents = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 10 -ErrorAction Stop $ps1File = $contents | Where-Object { $_.type -eq "file" -and $_.name -like "*.ps1" } | Select-Object -First 1 if ($ps1File) { return $ps1File.name } } catch { } return "$ScriptName.ps1" } # ===================================================================== # FONCTION : Invoke-ScriptDownload # Telecharge la totalite du depot via l'API git/trees recursive # ===================================================================== function Invoke-ScriptDownload { param( [string]$ScriptName, [string]$Branch = $GITEA_BRANCH ) $targetDir = "$LOCAL_CACHE_DIR\$ScriptName" try { $branchInfo = Invoke-RestMethod -Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_ORG/$ScriptName/branches/$Branch" ` -Method Get -TimeoutSec 10 -ErrorAction Stop $treeSha = $branchInfo.commit.id } catch { Write-Log "Impossible de lire la branche $Branch de $ScriptName : $($_.Exception.Message)" "ERROR" return $false } try { $treeData = Invoke-RestMethod -Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_ORG/$ScriptName/git/trees/$treeSha`?recursive=true" ` -Method Get -TimeoutSec 15 -ErrorAction Stop $files = @($treeData.tree | Where-Object { $_.type -eq "blob" }) } catch { Write-Log "Impossible de lister les fichiers du depot $ScriptName : $($_.Exception.Message)" "ERROR" return $false } Write-Log "$($files.Count) fichier(s) a telecharger pour $ScriptName..." if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } $ps1FileName = $null $mainScriptOk = $false foreach ($file in $files) { $filePath = $file.path $localPath = Join-Path $targetDir ($filePath -replace "/", "\") $localDir = Split-Path $localPath -Parent if (-not (Test-Path $localDir)) { New-Item -ItemType Directory -Path $localDir -Force | Out-Null } $rawUrl = "$GITEA_BASE_URL/$GITEA_ORG/$ScriptName/raw/branch/$Branch/$filePath" try { Invoke-WebRequest -Uri $rawUrl -OutFile $localPath -ErrorAction Stop Unblock-File -Path $localPath -ErrorAction SilentlyContinue if ($filePath -like "*.ps1" -and $filePath -notlike "*/*") { $ps1FileName = Split-Path $localPath -Leaf $mainScriptOk = $true } } catch { Write-Log "Erreur telechargement $filePath : $($_.Exception.Message)" "WARN" if ($filePath -like "*.ps1" -and $filePath -notlike "*/*") { Write-Log "CRITIQUE : echec du telechargement du script principal" "ERROR" return $false } } } if (-not $ps1FileName) { $ps1FileName = "$ScriptName.ps1" } $ps1FileName | Set-Content -Path "$targetDir\.ps1name" -Encoding UTF8 Write-Log "$ScriptName telecharge : $($files.Count) fichier(s)." "OK" return $mainScriptOk } # ===================================================================== # FONCTION : Test-CadMasterUpdate / Invoke-CadMasterSelfUpdate # Verifie et applique une mise a jour de cadMasterCLI.ps1 lui-meme, # depuis le depot Gitea dedie $CADMASTER_UPDATE_ORG/$CADMASTER_UPDATE_REPO. # ===================================================================== function Test-CadMasterUpdate { try { $url = "$GITEA_BASE_URL/api/v1/repos/$CADMASTER_UPDATE_ORG/$CADMASTER_UPDATE_REPO/raw/version.txt?ref=$GITEA_BRANCH" $remote = (Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 10 -ErrorAction Stop).ToString().Trim() if ([version]$remote -gt [version]$CADMASTER_VERSION) { return $remote } } catch { } return $null } function Invoke-CadMasterSelfUpdate { param([string]$RemoteVersion) try { $tempFile = Join-Path $env:TEMP "cadMasterCLI_new_$([Guid]::NewGuid().ToString('N')).ps1" $rawUrl = "$GITEA_BASE_URL/$CADMASTER_UPDATE_ORG/$CADMASTER_UPDATE_REPO/raw/branch/$GITEA_BRANCH/cadMasterCLI.ps1" Invoke-WebRequest -Uri $rawUrl -OutFile $tempFile -ErrorAction Stop if (-not (Test-Path $tempFile) -or (Get-Item $tempFile).Length -eq 0) { Write-Log "Fichier telecharge invalide, mise a jour annulee." "ERROR" return $false } $currentPath = $PSCommandPath $relaunchCmd = "Start-Sleep -Seconds 1; Copy-Item -Path `"$tempFile`" -Destination `"$currentPath`" -Force; " + "Remove-Item -Path `"$tempFile`" -Force -ErrorAction SilentlyContinue; " + "Start-Process powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$currentPath'" Start-Process powershell -ArgumentList "-NoProfile", "-WindowStyle", "Hidden", "-Command", $relaunchCmd | Out-Null Write-Log "Redemarrage pour appliquer la mise a jour vers $RemoteVersion..." "OK" return $true } catch { Write-Log "Echec de la mise a jour automatique : $($_.Exception.Message)" "ERROR" return $false } } # ===================================================================== # FONCTION : ConvertTo-WpfDataTable # Convertit les premieres lignes d'un CSV en DataTable pour le DataGrid # ===================================================================== function ConvertTo-WpfDataTable { param([string]$CsvPath) $table = New-Object System.Data.DataTable try { $rows = @(Import-Csv -Path $CsvPath -Encoding UTF8 | Select-Object -First 10) if ($rows.Count -gt 0) { foreach ($prop in $rows[0].PSObject.Properties) { $table.Columns.Add($prop.Name) | Out-Null } foreach ($row in $rows) { $newRow = $table.NewRow() foreach ($prop in $row.PSObject.Properties) { $newRow[$prop.Name] = $prop.Value } $table.Rows.Add($newRow) } } } catch { } return $table } # ===================================================================== # FONCTION : Convert-IpListToCsv # Convertit une liste d'IPs collee en fichier CSV # ===================================================================== function Convert-IpListToCsv { param([string]$IpListText) $ips = $IpListText -split "[,;\s\r\n]+" | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$" } if ($ips.Count -eq 0) { return $null } $csvPath = "$PSScriptRoot\ips_temp.csv" $ips | ForEach-Object { [PSCustomObject]@{ IP = $_ } } | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 Write-Log "$($ips.Count) IP(s) converties -> $csvPath" "OK" return $csvPath } # ===================================================================== # FONCTION : ConvertTo-FriendlyTypeName # Traduit les types .NET en libelles lisibles (FR/EN) # ===================================================================== function ConvertTo-FriendlyTypeName { param( [string]$TypeName, [string]$Lang = $script:CurrentLang ) $map = @{ fr = @{ String = "Texte"; Int32 = "Nombre entier"; Int64 = "Nombre entier"; Double = "Nombre decimal"; Single = "Nombre decimal"; Boolean = "Vrai/Faux"; DateTime = "Date"; FileInfo = "Fichier" } en = @{ String = "Text"; Int32 = "Integer"; Int64 = "Integer"; Double = "Decimal number"; Single = "Decimal number"; Boolean = "True/False"; DateTime = "Date"; FileInfo = "File" } } if ($map[$Lang].ContainsKey($TypeName)) { return $map[$Lang][$TypeName] } return $TypeName } # ===================================================================== # FONCTION : Get-ScriptParamHelp # Extrait les descriptions de parametres depuis le comment-based help # ===================================================================== function Get-ScriptParamHelp { param([string]$ScriptPath) $result = @{} try { $help = Get-Help $ScriptPath -ErrorAction Stop if ($help.parameters -and $help.parameters.parameter) { foreach ($param in @($help.parameters.parameter)) { if ($param.description) { $desc = ($param.description | ForEach-Object { $_.Text }) -join " " if ($desc.Trim()) { $result[$param.name] = $desc.Trim() } } } } } catch { } return $result } # ===================================================================== # FONCTION : ConvertTo-SimpleHtml # Convertit du Markdown GFM en HTML pour WebBrowser WPF. # Supporte : titres h1/h2/h3, gras/italique, code inline, blocs de code, # listes, tableaux GFM, blockquotes, separateurs. # Pas de dependance externe - utilise System.Net.WebUtility. # ===================================================================== function ConvertTo-SimpleHtml { param([string]$Markdown) if ([string]::IsNullOrWhiteSpace($Markdown)) { $emptyMsg = T "detail.noReadme" return "

$emptyMsg

" } $css = "body{font-family:'Segoe UI',sans-serif;font-size:13px;padding:12px 16px;color:#222;line-height:1.6;margin:0}" + "h1{font-size:18px;border-bottom:2px solid #388E3C;padding-bottom:4px;color:#1a1a1a;margin-top:8px}" + "h2{font-size:15px;color:#1565C0;margin-top:16px}" + "h3{font-size:13px;color:#555;margin-top:12px}" + "code{background:#f0f0f0;border-radius:3px;padding:1px 5px;font-family:Consolas,monospace;font-size:12px}" + "pre{background:#f4f4f4;border:1px solid #ddd;border-radius:4px;padding:10px;overflow-x:auto;margin:6px 0}" + "pre code{background:none;padding:0}" + "hr{border:none;border-top:1px solid #ddd;margin:12px 0}" + "ul{padding-left:20px;margin:4px 0}li{margin-bottom:2px}" + "blockquote{border-left:3px solid #388E3C;margin:8px 0;padding:4px 12px;color:#555;background:#f9f9f9}" + "blockquote p{margin:2px 0}" + "table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px}" + "th{background:#1565C0;color:#fff;padding:6px 10px;text-align:left}" + "td{padding:5px 10px;border-bottom:1px solid #eee}" + "tr:nth-child(even) td{background:#f5f5f5}" + "a{color:#1565C0;text-decoration:underline;cursor:pointer}" # Formate gras, italique et code inline sur une chaine deja encodee HTML $applyInline = { param([string]$s) $s = $s -replace "\[([^\]]+)\]\(([^)\s]+)\)", "`$1" $s = $s -replace "\*\*(.+?)\*\*", "`$1" $s = $s -replace "\*(.+?)\*", "`$1" $s = $s -replace "``(.+?)``", "`$1" return $s } # Convertit un buffer de lignes brutes de tableau GFM en HTML $flushTable = { param([System.Collections.Generic.List[string]]$buf) if ($buf.Count -eq 0) { return "" } $html = "" $isHeader = $true foreach ($row in $buf) { if ($row -match "^\s*\|[\s\-\|:]+$") { continue } $cells = $row -split "\|" | Where-Object { $_ -ne "" } | ForEach-Object { $_.Trim() } if ($isHeader) { $html += "" foreach ($c in $cells) { $html += "" } $html += "" $isHeader = $false } else { $html += "" foreach ($c in $cells) { $html += "" } $html += "" } } $html += "
$([System.Net.WebUtility]::HtmlEncode($c))
$([System.Net.WebUtility]::HtmlEncode($c))
" return $html } $sb = New-Object System.Text.StringBuilder $inCodeBlock = $false $inTable = $false $tableBuffer = [System.Collections.Generic.List[string]]::new() $listOpen = $false $bqOpen = $false $sb.Append("") | Out-Null foreach ($line in ($Markdown -split "`n")) { $raw = $line.TrimEnd() # Toggle bloc de code if ($raw -match "^``````") { if ($listOpen) { $sb.Append("") | Out-Null; $listOpen = $false } if ($bqOpen) { $sb.Append("") | Out-Null; $bqOpen = $false } if ($inTable) { $sb.Append((& $flushTable $tableBuffer)) | Out-Null; $tableBuffer.Clear(); $inTable = $false } if ($inCodeBlock) { $sb.Append("") | Out-Null; $inCodeBlock = $false } else { $sb.Append("
")   | Out-Null; $inCodeBlock = $true  }
            continue
        }

        if ($inCodeBlock) {
            $sb.Append([System.Net.WebUtility]::HtmlEncode($raw) + "`n") | Out-Null
            continue
        }

        # Tableau GFM : bufferiser les lignes commencant par |
        if ($raw -match "^\s*\|") {
            if ($listOpen) { $sb.Append("")         | Out-Null; $listOpen = $false }
            if ($bqOpen)   { $sb.Append("") | Out-Null; $bqOpen   = $false }
            $tableBuffer.Add($raw)
            $inTable = $true
            continue
        }
        if ($inTable) {
            $sb.Append((& $flushTable $tableBuffer)) | Out-Null
            $tableBuffer.Clear()
            $inTable = $false
        }

        # Blockquote
        if ($raw -match "^>[ ]?(.*)") {
            if ($listOpen)    { $sb.Append("") | Out-Null; $listOpen = $false }
            if (-not $bqOpen) { $sb.Append("
") | Out-Null; $bqOpen = $true } $content = & $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])) $sb.Append("

$content

") | Out-Null continue } if ($bqOpen) { $sb.Append("
") | Out-Null; $bqOpen = $false } # Fermer la liste si la ligne n'est pas un item if ($listOpen -and $raw -notmatch "^- ") { $sb.Append("") | Out-Null $listOpen = $false } # Elements structurels (detection sur ligne brute avant encodage) if ($raw -match "^# (.+)") { $sb.Append("

$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))

") | Out-Null } elseif ($raw -match "^## (.+)") { $sb.Append("

$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))

") | Out-Null } elseif ($raw -match "^### (.+)") { $sb.Append("

$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))

") | Out-Null } elseif ($raw -match "^---$") { $sb.Append("
") | Out-Null } elseif ($raw -match "^- (.+)") { if (-not $listOpen) { $sb.Append("") | Out-Null } if ($bqOpen) { $sb.Append("") | Out-Null } if ($inCodeBlock){ $sb.Append("
") | Out-Null } $sb.Append("") | Out-Null return $sb.ToString() } # ===================================================================== # FONCTION : Start-ScriptExecution # Lance le script en arriere-plan (Job isole) en redirigeant tous les # flux (y compris Write-Host) vers un fichier de log, lu en direct par # Show-MainWindow via un DispatcherTimer. # ===================================================================== function Start-ScriptExecution { param( [string]$ScriptPath, [hashtable]$ParamValues, [string]$LogFile, [string]$WorkingDirectory ) # Start-Job demarre dans un runspace dont le repertoire courant n'est PAS # herite de celui de cadMasterCLI (contrairement a l'ancienne execution # synchrone) : sans ce Set-Location, les scripts enfants qui ecrivent des # fichiers en chemin relatif (logs, exports) les deposent au mauvais endroit. return Start-Job -ScriptBlock { param($Path, $Params, $Log, $WorkDir) Set-Location -Path $WorkDir & $Path @Params *> $Log } -ArgumentList $ScriptPath, $ParamValues, $LogFile, $WorkingDirectory } # ===================================================================== # FONCTION : Add-LogLine # Ajoute une ligne coloree (selon son niveau INFO/WARN/ERROR/OK detecte # par mot-cle) dans un RichTextBox, pour retrouver la colorisation du # terminal d'origine. Fonction racine (pas de closure) : appelable sans # risque depuis n'importe quel niveau d'imbrication de closures WPF. # ===================================================================== function Add-LogLine { param( [System.Windows.Controls.RichTextBox]$LogBox, [string]$Line, [ValidateSet("Auto", "Info", "Warn", "Error", "Ok")] [string]$Level = "Auto" ) $color = switch ($Level) { "Info" { [System.Windows.Media.Brushes]::LightSkyBlue } "Warn" { [System.Windows.Media.Brushes]::Orange } "Error" { [System.Windows.Media.Brushes]::Tomato } "Ok" { [System.Windows.Media.Brushes]::LightGreen } default { if ($Line -imatch '\[?\s*(ERROR|ERR|FAIL(ED)?)\s*\]?') { [System.Windows.Media.Brushes]::Tomato } elseif ($Line -imatch '\[?\s*(WARN(ING)?)\s*\]?') { [System.Windows.Media.Brushes]::Orange } elseif ($Line -imatch '\[?\s*(OK|SUCCESS|DONE)\s*\]?') { [System.Windows.Media.Brushes]::LightGreen } elseif ($Line -imatch '\[?\s*(INFO)\s*\]?') { [System.Windows.Media.Brushes]::LightSkyBlue } else { [System.Windows.Media.Brushes]::Gainsboro } } } $para = $LogBox.Document.Blocks.LastBlock if (-not $para) { $para = New-Object System.Windows.Documents.Paragraph $para.Margin = New-Object System.Windows.Thickness(0) $LogBox.Document.Blocks.Add($para) } if ($para.Inlines.Count -gt 0) { $para.Inlines.Add((New-Object System.Windows.Documents.LineBreak)) | Out-Null } $run = New-Object System.Windows.Documents.Run($Line) $run.Foreground = $color $para.Inlines.Add($run) | Out-Null } # ===================================================================== # FONCTION : Clear-LogBox # Vide un RichTextBox de logs et y affiche eventuellement un texte initial. # ===================================================================== function Clear-LogBox { param( [System.Windows.Controls.RichTextBox]$LogBox, [string]$InitialLine, [ValidateSet("Auto", "Info", "Warn", "Error", "Ok")] [string]$Level = "Auto" ) $LogBox.Document.Blocks.Clear() if ($InitialLine) { Add-LogLine -LogBox $LogBox -Line $InitialLine -Level $Level } } # ===================================================================== # FONCTION : Update-ScriptBadge # Met a jour les controles visuels (badges/date) d'un item de la liste # des scripts. Les references UI sont stockees directement sur l'objet # (pas de data binding WPF, non fiable sur des PSCustomObject). # ===================================================================== function Update-ScriptBadge { param($ScriptItem) $cacheExists = [bool](Get-ChildItem -Path "$LOCAL_CACHE_DIR\$($ScriptItem.Nom)\" -Filter "*.ps1" -ErrorAction SilentlyContinue) $ScriptItem.UiCacheBadge.Visibility = if ($cacheExists) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } $ScriptItem.UiNewBadge.Visibility = if ($ScriptItem.MajDispo) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } $ScriptItem.UiCacheText.Text = T "list.cacheBadge" $ScriptItem.UiNewText.Text = T "list.newBadge" $ScriptItem.UiDateBlock.Text = if ($ScriptItem.DateGitea) { $ScriptItem.DateGitea.ToString("dd/MM/yyyy HH:mm") } else { "" } } # ===================================================================== # FONCTION : New-FlagIcon # Dessine un mini-drapeau en formes vectorielles WPF (Rectangle/Line). # WPF sur .NET Framework ne sait pas afficher les glyphes emoji couleur # (drapeaux) quelle que soit la police : on dessine donc de vraies formes. # ===================================================================== function New-FlagIcon { param([string]$Country) # "FR" ou "GB" $w = 24 $h = 16 $canvas = New-Object System.Windows.Controls.Canvas $canvas.Width = $w $canvas.Height = $h $canvas.ClipToBounds = $true if ($Country -eq "FR") { $colors = @("#0055A4", "#FFFFFF", "#EF4135") for ($i = 0; $i -lt 3; $i++) { $rect = New-Object System.Windows.Shapes.Rectangle $rect.Width = $w / 3 $rect.Height = $h $rect.Fill = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString($colors[$i])) [System.Windows.Controls.Canvas]::SetLeft($rect, $i * ($w / 3)) [System.Windows.Controls.Canvas]::SetTop($rect, 0) $canvas.Children.Add($rect) | Out-Null } } else { $blueBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#00247D")) $redBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#CF142B")) $whiteBrush = [System.Windows.Media.Brushes]::White $bg = New-Object System.Windows.Shapes.Rectangle $bg.Width = $w; $bg.Height = $h; $bg.Fill = $blueBrush $canvas.Children.Add($bg) | Out-Null # Sautoir blanc (diagonales epaisses), puis rouge (fines, par-dessus) foreach ($def in @( @{ Brush = $whiteBrush; Thickness = 4.4 }, @{ Brush = $redBrush; Thickness = 1.6 } )) { $d1 = New-Object System.Windows.Shapes.Line $d1.X1 = 0; $d1.Y1 = 0; $d1.X2 = $w; $d1.Y2 = $h $d1.Stroke = $def.Brush; $d1.StrokeThickness = $def.Thickness $d2 = New-Object System.Windows.Shapes.Line $d2.X1 = $w; $d2.Y1 = 0; $d2.X2 = 0; $d2.Y2 = $h $d2.Stroke = $def.Brush; $d2.StrokeThickness = $def.Thickness $canvas.Children.Add($d1) | Out-Null $canvas.Children.Add($d2) | Out-Null } # Croix blanche, puis rouge (plus fine, par-dessus) $hWhite = New-Object System.Windows.Shapes.Rectangle $hWhite.Width = $w; $hWhite.Height = 6; $hWhite.Fill = $whiteBrush [System.Windows.Controls.Canvas]::SetLeft($hWhite, 0); [System.Windows.Controls.Canvas]::SetTop($hWhite, ($h - 6) / 2) $vWhite = New-Object System.Windows.Shapes.Rectangle $vWhite.Width = 6; $vWhite.Height = $h; $vWhite.Fill = $whiteBrush [System.Windows.Controls.Canvas]::SetLeft($vWhite, ($w - 6) / 2); [System.Windows.Controls.Canvas]::SetTop($vWhite, 0) $canvas.Children.Add($hWhite) | Out-Null $canvas.Children.Add($vWhite) | Out-Null $hRed = New-Object System.Windows.Shapes.Rectangle $hRed.Width = $w; $hRed.Height = 3; $hRed.Fill = $redBrush [System.Windows.Controls.Canvas]::SetLeft($hRed, 0); [System.Windows.Controls.Canvas]::SetTop($hRed, ($h - 3) / 2) $vRed = New-Object System.Windows.Shapes.Rectangle $vRed.Width = 3; $vRed.Height = $h; $vRed.Fill = $redBrush [System.Windows.Controls.Canvas]::SetLeft($vRed, ($w - 3) / 2); [System.Windows.Controls.Canvas]::SetTop($vRed, 0) $canvas.Children.Add($hRed) | Out-Null $canvas.Children.Add($vRed) | Out-Null } return $canvas } # ===================================================================== # FONCTION : Show-MainWindow # Fenetre unique : liste des scripts a gauche (permanente), panneau # droit dynamique (README + formulaire de parametres + logs d'execution # en direct). Remplace les anciennes Show-ScriptMenu / Show-ParametersWindow. # ===================================================================== function Show-MainWindow { param([array]$Scripts) $greenBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(56, 142, 60)) $grayBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(189, 189, 189)) $w = New-Object System.Windows.Window $w.Title = "cadMasterCLI" $w.Width = 1200 $w.Height = 780 $w.MinWidth = 900 $w.MinHeight = 560 $w.WindowStartupLocation = "CenterScreen" $root = New-Object System.Windows.Controls.DockPanel # --- Barre d'en-tete globale --- $headerBar = New-Object System.Windows.Controls.DockPanel $headerBar.Margin = New-Object System.Windows.Thickness(14, 12, 14, 8) [System.Windows.Controls.DockPanel]::SetDock($headerBar, [System.Windows.Controls.Dock]::Top) $langActiveBorder = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(56, 142, 60)) $langInactiveBorder = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(210, 210, 210)) $langActiveBg = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(232, 245, 233)) $langPanel = New-Object System.Windows.Controls.StackPanel $langPanel.Orientation = "Horizontal" [System.Windows.Controls.DockPanel]::SetDock($langPanel, [System.Windows.Controls.Dock]::Right) $btnFlagFr = New-Object System.Windows.Controls.Button $btnFlagFr.Content = New-FlagIcon -Country "FR" $btnFlagFr.Padding = New-Object System.Windows.Thickness(4) $btnFlagFr.Margin = New-Object System.Windows.Thickness(0, 0, 4, 0) $btnFlagFr.Cursor = [System.Windows.Input.Cursors]::Hand $btnFlagFr.BorderThickness = New-Object System.Windows.Thickness(2) $btnFlagEn = New-Object System.Windows.Controls.Button $btnFlagEn.Content = New-FlagIcon -Country "GB" $btnFlagEn.Padding = New-Object System.Windows.Thickness(4) $btnFlagEn.Cursor = [System.Windows.Input.Cursors]::Hand $btnFlagEn.BorderThickness = New-Object System.Windows.Thickness(2) $langPanel.Children.Add($btnFlagFr) | Out-Null $langPanel.Children.Add($btnFlagEn) | Out-Null $titleStack = New-Object System.Windows.Controls.StackPanel $appTitle = New-Object System.Windows.Controls.TextBlock $appTitle.Text = "cadMasterCLI" $appTitle.FontSize = 18 $appTitle.FontWeight = [System.Windows.FontWeights]::Bold $appSubtitle = New-Object System.Windows.Controls.TextBlock $appSubtitle.FontSize = 11 $appSubtitle.Foreground = [System.Windows.Media.Brushes]::Gray $titleStack.Children.Add($appTitle) | Out-Null $titleStack.Children.Add($appSubtitle) | Out-Null $headerBar.Children.Add($langPanel) | Out-Null $headerBar.Children.Add($titleStack) | Out-Null $root.Children.Add($headerBar) | Out-Null # --- Separateur horizontal entre l'en-tete et le contenu --- $headerSeparator = New-Object System.Windows.Controls.Border $headerSeparator.Height = 1 $headerSeparator.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(221, 221, 221)) $headerSeparator.Margin = New-Object System.Windows.Thickness(14, 0, 14, 8) [System.Windows.Controls.DockPanel]::SetDock($headerSeparator, [System.Windows.Controls.Dock]::Top) $root.Children.Add($headerSeparator) | Out-Null # --- Grille principale : liste (gauche) | splitter | detail (droite) --- $mainGrid = New-Object System.Windows.Controls.Grid $colList = New-Object System.Windows.Controls.ColumnDefinition $colList.Width = New-Object System.Windows.GridLength(300) $colList.MinWidth = 220 $colSplit = New-Object System.Windows.Controls.ColumnDefinition $colSplit.Width = New-Object System.Windows.GridLength(5) $colDetail = New-Object System.Windows.Controls.ColumnDefinition $colDetail.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star) $mainGrid.ColumnDefinitions.Add($colList) | Out-Null $mainGrid.ColumnDefinitions.Add($colSplit) | Out-Null $mainGrid.ColumnDefinitions.Add($colDetail) | Out-Null $root.Children.Add($mainGrid) | Out-Null $w.Content = $root # --- Panneau liste (col 0) --- $listPanel = New-Object System.Windows.Controls.DockPanel $listPanel.Margin = New-Object System.Windows.Thickness(14, 0, 8, 14) [System.Windows.Controls.Grid]::SetColumn($listPanel, 0) $mainGrid.Children.Add($listPanel) | Out-Null $lblList = New-Object System.Windows.Controls.TextBlock $lblList.FontWeight = [System.Windows.FontWeights]::SemiBold $lblList.FontSize = 13 $lblList.Margin = New-Object System.Windows.Thickness(0, 0, 0, 6) [System.Windows.Controls.DockPanel]::SetDock($lblList, [System.Windows.Controls.Dock]::Top) $listPanel.Children.Add($lblList) | Out-Null $lv = New-Object System.Windows.Controls.ListBox $lv.BorderThickness = New-Object System.Windows.Thickness(1) $lv.BorderBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(221, 221, 221)) $listPanel.Children.Add($lv) | Out-Null # --- Construction manuelle des items de la liste (badges Cache/MAJ) --- foreach ($item in $Scripts) { $lvi = New-Object System.Windows.Controls.ListBoxItem $lvi.Tag = $item $card = New-Object System.Windows.Controls.StackPanel $card.Margin = New-Object System.Windows.Thickness(2) $nameBlock = New-Object System.Windows.Controls.TextBlock $nameBlock.Text = $item.Nom $nameBlock.FontWeight = [System.Windows.FontWeights]::Bold $nameBlock.FontSize = 13 $nameBlock.TextWrapping = "Wrap" $card.Children.Add($nameBlock) | Out-Null $dateBlock = New-Object System.Windows.Controls.TextBlock $dateBlock.FontSize = 10 $dateBlock.Foreground = [System.Windows.Media.Brushes]::Gray $dateBlock.Margin = New-Object System.Windows.Thickness(0, 2, 0, 0) $card.Children.Add($dateBlock) | Out-Null $badgeRow = New-Object System.Windows.Controls.WrapPanel $badgeRow.Margin = New-Object System.Windows.Thickness(0, 4, 0, 0) $cacheBadge = New-Object System.Windows.Controls.Border $cacheBadge.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(56, 142, 60)) $cacheBadge.CornerRadius = New-Object System.Windows.CornerRadius(3) $cacheBadge.Padding = New-Object System.Windows.Thickness(5, 1, 5, 1) $cacheBadge.Margin = New-Object System.Windows.Thickness(0, 0, 4, 0) $cacheBadgeText = New-Object System.Windows.Controls.TextBlock $cacheBadgeText.FontSize = 10 $cacheBadgeText.Foreground = [System.Windows.Media.Brushes]::White $cacheBadge.Child = $cacheBadgeText $badgeRow.Children.Add($cacheBadge) | Out-Null $newBadge = New-Object System.Windows.Controls.Border $newBadge.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(245, 124, 0)) $newBadge.CornerRadius = New-Object System.Windows.CornerRadius(3) $newBadge.Padding = New-Object System.Windows.Thickness(5, 1, 5, 1) $newBadgeText = New-Object System.Windows.Controls.TextBlock $newBadgeText.FontSize = 10 $newBadgeText.Foreground = [System.Windows.Media.Brushes]::White $newBadge.Child = $newBadgeText $badgeRow.Children.Add($newBadge) | Out-Null $card.Children.Add($badgeRow) | Out-Null $lvi.Content = $card Add-Member -InputObject $item -Force -NotePropertyMembers @{ UiNameBlock = $nameBlock UiDateBlock = $dateBlock UiCacheBadge = $cacheBadge UiCacheText = $cacheBadgeText UiNewBadge = $newBadge UiNewText = $newBadgeText } Update-ScriptBadge -ScriptItem $item $lv.Items.Add($lvi) | Out-Null } # --- Splitter entre liste et detail (col 1) --- $splitter = New-Object System.Windows.Controls.GridSplitter $splitter.Width = 5 $splitter.HorizontalAlignment = "Stretch" $splitter.VerticalAlignment = "Stretch" $splitter.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(220, 220, 220)) [System.Windows.Controls.Grid]::SetColumn($splitter, 1) $mainGrid.Children.Add($splitter) | Out-Null # --- Panneau detail (col 2), reconstruit a chaque selection --- $detailHost = New-Object System.Windows.Controls.Grid $detailHost.Margin = New-Object System.Windows.Thickness(8, 0, 14, 14) [System.Windows.Controls.Grid]::SetColumn($detailHost, 2) $mainGrid.Children.Add($detailHost) | Out-Null $placeholder = New-Object System.Windows.Controls.TextBlock $placeholder.HorizontalAlignment = "Center" $placeholder.VerticalAlignment = "Center" $placeholder.Foreground = [System.Windows.Media.Brushes]::Gray $placeholder.FontSize = 14 $placeholder.TextWrapping = "Wrap" $placeholder.MaxWidth = 320 # --- Etat d'execution partage (job/timer en cours) --- $execState = @{ Job = $null Timer = $null LogFile = $null LastLen = 0 } $SetDetailPlaceholder = { $detailHost.ColumnDefinitions.Clear() $detailHost.Children.Clear() $placeholder.Text = T "detail.placeholder" $detailHost.Children.Add($placeholder) | Out-Null }.GetNewClosure() $StopRunningExecution = { if ($execState.Timer) { $execState.Timer.Stop() } if ($execState.Job) { Stop-Job -Job $execState.Job -ErrorAction SilentlyContinue Remove-Job -Job $execState.Job -Force -ErrorAction SilentlyContinue } if ($execState.LogFile) { Remove-Item $execState.LogFile -Force -ErrorAction SilentlyContinue } $execState.Job = $null $execState.Timer = $null $execState.LogFile = $null }.GetNewClosure() $UpdateTexts = { $appSubtitle.Text = T "app.subtitle" $isFr = ((Get-CurrentLang) -eq "fr") $btnFlagFr.BorderBrush = if ($isFr) { $langActiveBorder } else { $langInactiveBorder } $btnFlagEn.BorderBrush = if (-not $isFr) { $langActiveBorder } else { $langInactiveBorder } $btnFlagFr.Background = if ($isFr) { $langActiveBg } else { [System.Windows.Media.Brushes]::Transparent } $btnFlagEn.Background = if (-not $isFr) { $langActiveBg } else { [System.Windows.Media.Brushes]::Transparent } $lblList.Text = T "list.header" foreach ($item in $Scripts) { Update-ScriptBadge -ScriptItem $item } if (-not $lv.SelectedItem) { & $SetDetailPlaceholder } }.GetNewClosure() $ShowScriptDetail = { param($ScriptData) # Re-aliasage local : .GetNewClosure() ne capture QUE le scope direct # ou il est appele. Une variable simplement "heritee" d'un niveau de # closure superieur (ex: $lv/$execState captures par Show-MainWindow) # redevient invisible pour un closure cree DANS ce bloc (ex: le clic # du bouton Lancer). Une reassignation locale ici la rend "native" de # CE niveau, et donc a nouveau capturable par les closures imbriquees # ci-dessous (a repeter a chaque niveau d'imbrication supplementaire). $lv = $lv $execState = $execState $grayBrush = $grayBrush $greenBrush = $greenBrush # Auto-reference (pour le bouton de MAJ du README, qui doit rappeler # cette meme fonction) : $MyInvocation.MyCommand.ScriptBlock pointe # sur le scriptblock en cours d'execution, donc pas besoin de passer # par la variable exterieure $ShowScriptDetail (qui, elle, subirait # le meme probleme de capture que $lv/$execState ci-dessus). $selfShowScriptDetail = $MyInvocation.MyCommand.ScriptBlock & $StopRunningExecution $detailHost.ColumnDefinitions.Clear() $detailHost.Children.Clear() $scriptName = $ScriptData.Nom # --- Telechargement initial / proposition de mise a jour --- $localPs1 = Get-ChildItem -Path "$LOCAL_CACHE_DIR\$scriptName\" -Filter "*.ps1" -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName $scriptBranch = $ScriptData.DefaultBranch $justUpdated = $false if (-not $localPs1) { Write-Log "Telechargement initial de $scriptName..." $ok = Invoke-ScriptDownload -ScriptName $scriptName -Branch $scriptBranch if (-not $ok) { Show-MessageBox -Message "$(T 'msg.downloadFailed') '$scriptName'." -Title (T "msg.errorTitle") -Type "Error" & $SetDetailPlaceholder return } $ScriptData.MajDispo = $false $justUpdated = $true } elseif ($ScriptData.MajDispo -and -not $ScriptData.UpdatePromptShown) { $ScriptData.UpdatePromptShown = $true $rep = Show-MessageBox -Message "$(T 'msg.updateAvailableScript') '$scriptName'.`n$(T 'msg.updateConfirm')" ` -Title (T "msg.updateAvailableTitle") -Type "Question" if ($rep -eq "Yes") { Invoke-ScriptDownload -ScriptName $scriptName -Branch $scriptBranch | Out-Null $justUpdated = $true $ScriptData.MajDispo = $false } # Si "Non" : MajDispo reste $true (le badge "Nouveau" persiste tant # que le script n'est pas reellement a jour), mais on ne repropose # plus le popup pour ce script durant cette session de l'app. } Update-ScriptBadge -ScriptItem $ScriptData $ps1NameFile = "$LOCAL_CACHE_DIR\$scriptName\.ps1name" $ps1FileName = if (Test-Path $ps1NameFile) { (Get-Content $ps1NameFile -Encoding UTF8).Trim() } else { "$scriptName.ps1" } $cachedScriptPath = "$LOCAL_CACHE_DIR\$scriptName\$ps1FileName" # --- README : section langue courante (Feature 6) --- # Si l'utilisateur a refuse la mise a jour (ou qu'elle etait deja a # jour), on affiche le README LOCAL - celui qui correspond reellement # au code qui sera execute - plutot que la version Gitea la plus # recente, pour eviter toute confusion entre doc affichee et code lance. $localReadmePath = "$LOCAL_CACHE_DIR\$scriptName\README.md" if (-not $justUpdated -and (Test-Path $localReadmePath)) { $readmeContent = Get-Content $localReadmePath -Raw -Encoding UTF8 } else { $readmeContent = $ScriptData.ReadmeFull if (-not $readmeContent -and (Test-Path $localReadmePath)) { $readmeContent = Get-Content $localReadmePath -Raw -Encoding UTF8 } } $readmeSection = Get-ReadmeSection -Markdown $readmeContent -Lang (Get-CurrentLang) $htmlContent = ConvertTo-SimpleHtml -Markdown $readmeSection # --- Parametres reels du script (Get-Command) --- $commonParams = 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'ErrorVariable', 'WarningVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'InformationAction', 'InformationVariable', 'WhatIf', 'Confirm' $scriptParams = @() try { $cmd = Get-Command $cachedScriptPath -ErrorAction Stop $scriptParams = @($cmd.Parameters.Values | Where-Object { $_.Name -notin $commonParams } | ForEach-Object { $attr = $_.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Select-Object -First 1 $vsAttr = $_.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | Select-Object -First 1 [PSCustomObject]@{ Name = $_.Name TypeName = $_.ParameterType.Name Mandatory = [bool]($attr -and $attr.Mandatory) IsSwitch = ($_.ParameterType -eq [System.Management.Automation.SwitchParameter]) IsPath = ($_.Name -imatch 'Path|File|Csv|Input|Output') -or ($_.ParameterType -eq [System.IO.FileInfo]) IsCsv = ($_.Name -imatch 'Csv|Input') ValidValues = if ($vsAttr) { @($vsAttr.ValidValues) } else { $null } } } | Sort-Object { -[int]$_.Mandatory }, Name) } catch { Write-Log "Impossible de lire les parametres de $scriptName : $($_.Exception.Message)" "WARN" } $paramHelp = Get-ScriptParamHelp -ScriptPath $cachedScriptPath # === Construction du panneau detail : readme | splitter | form+log === $col0 = New-Object System.Windows.Controls.ColumnDefinition $col0.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star) $col0.MinWidth = 0 $col1 = New-Object System.Windows.Controls.ColumnDefinition $col1.Width = New-Object System.Windows.GridLength(5) $col2 = New-Object System.Windows.Controls.ColumnDefinition $col2.Width = New-Object System.Windows.GridLength(480) $col2.MinWidth = 380 $detailHost.ColumnDefinitions.Add($col0) | Out-Null $detailHost.ColumnDefinitions.Add($col1) | Out-Null $detailHost.ColumnDefinitions.Add($col2) | Out-Null $readmeBorder = New-Object System.Windows.Controls.Border $readmeBorder.BorderBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(204, 204, 204)) $readmeBorder.BorderThickness = New-Object System.Windows.Thickness(0, 0, 1, 0) [System.Windows.Controls.Grid]::SetColumn($readmeBorder, 0) $detailHost.Children.Add($readmeBorder) | Out-Null $readmeDock = New-Object System.Windows.Controls.DockPanel $readmeBorder.Child = $readmeDock $btnScriptUpdate = New-Object System.Windows.Controls.Button $btnScriptUpdate.Content = T "detail.updateAvailable" $btnScriptUpdate.Margin = New-Object System.Windows.Thickness(10, 10, 10, 6) $btnScriptUpdate.Padding = New-Object System.Windows.Thickness(10, 6, 10, 6) $btnScriptUpdate.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(245, 124, 0)) $btnScriptUpdate.Foreground = [System.Windows.Media.Brushes]::White $btnScriptUpdate.FontWeight = [System.Windows.FontWeights]::Bold $btnScriptUpdate.Cursor = [System.Windows.Input.Cursors]::Hand $btnScriptUpdate.HorizontalContentAlignment = "Center" $btnScriptUpdate.Visibility = if ($ScriptData.MajDispo) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } [System.Windows.Controls.DockPanel]::SetDock($btnScriptUpdate, [System.Windows.Controls.Dock]::Top) $readmeDock.Children.Add($btnScriptUpdate) | Out-Null $btnScriptUpdate.Add_Click({ $rep = Show-MessageBox -Message "$(T 'msg.updateAvailableScript') '$scriptName'.`n$(T 'msg.updateConfirm')" ` -Title (T "msg.updateAvailableTitle") -Type "Question" if ($rep -eq "Yes") { Invoke-ScriptDownload -ScriptName $scriptName -Branch $scriptBranch | Out-Null $ScriptData.MajDispo = $false Update-ScriptBadge -ScriptItem $ScriptData & $selfShowScriptDetail -ScriptData $ScriptData } }.GetNewClosure()) $wb = New-Object System.Windows.Controls.WebBrowser $readmeDock.Children.Add($wb) | Out-Null # Un clic sur un lien du README ne doit pas naviguer DANS le petit # panneau integre (qui perdrait la doc affichee), mais ouvrir le lien # dans le navigateur par defaut de l'utilisateur. $wb.Add_Navigating({ param($sender, $e) if ($e.Uri -and $e.Uri.IsAbsoluteUri -and ($e.Uri.Scheme -eq "http" -or $e.Uri.Scheme -eq "https")) { $e.Cancel = $true Start-Process -FilePath $e.Uri.AbsoluteUri } }.GetNewClosure()) $wb.NavigateToString($htmlContent) $splitter2 = New-Object System.Windows.Controls.GridSplitter $splitter2.Width = 5 $splitter2.HorizontalAlignment = "Stretch" $splitter2.VerticalAlignment = "Stretch" $splitter2.ResizeBehavior = "PreviousAndNext" $splitter2.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(220, 220, 220)) [System.Windows.Controls.Grid]::SetColumn($splitter2, 1) $detailHost.Children.Add($splitter2) | Out-Null $formPanel = New-Object System.Windows.Controls.DockPanel $formPanel.Margin = New-Object System.Windows.Thickness(12, 0, 0, 0) [System.Windows.Controls.Grid]::SetColumn($formPanel, 2) $detailHost.Children.Add($formPanel) | Out-Null # -- Header du panneau formulaire : titre + toggle README -- $formHeader = New-Object System.Windows.Controls.StackPanel $formHeader.Margin = New-Object System.Windows.Thickness(0, 0, 0, 10) [System.Windows.Controls.DockPanel]::SetDock($formHeader, [System.Windows.Controls.Dock]::Top) $titleBlock = New-Object System.Windows.Controls.TextBlock $titleBlock.Text = "$(T 'detail.paramsTitle') $scriptName" $titleBlock.FontSize = 15 $titleBlock.FontWeight = [System.Windows.FontWeights]::Bold $titleBlock.Margin = New-Object System.Windows.Thickness(0, 0, 0, 6) $formHeader.Children.Add($titleBlock) | Out-Null $readmeVisible = $true $btnReadme = New-Object System.Windows.Controls.Button $btnReadme.Content = T "detail.readmeClose" $btnReadme.HorizontalAlignment = "Left" $btnReadme.Padding = New-Object System.Windows.Thickness(10, 4, 10, 4) $btnReadme.Cursor = [System.Windows.Input.Cursors]::Hand $btnReadme.FontSize = 11 $formHeader.Children.Add($btnReadme) | Out-Null $formPanel.Children.Add($formHeader) | Out-Null $btnReadme.Add_Click({ if ($readmeVisible) { $col0.Width = New-Object System.Windows.GridLength(0) $col1.Width = New-Object System.Windows.GridLength(0) $readmeVisible = $false $btnReadme.Content = T "detail.readmeOpen" } else { $col0.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star) $col1.Width = New-Object System.Windows.GridLength(5) $readmeVisible = $true $btnReadme.Content = T "detail.readmeClose" } }.GetNewClosure()) # -- Footer : Lancer -- $footer = New-Object System.Windows.Controls.DockPanel $footer.Margin = New-Object System.Windows.Thickness(0, 10, 0, 0) [System.Windows.Controls.DockPanel]::SetDock($footer, [System.Windows.Controls.Dock]::Bottom) $btnLancer = New-Object System.Windows.Controls.Button $btnLancer.Content = T "detail.launch" $btnLancer.Width = 160 $btnLancer.Padding = New-Object System.Windows.Thickness(10, 6, 10, 6) $btnLancer.Margin = New-Object System.Windows.Thickness(4, 0, 0, 0) $btnLancer.Cursor = [System.Windows.Input.Cursors]::Hand $btnLancer.Background = $grayBrush $btnLancer.Foreground = [System.Windows.Media.Brushes]::White $btnLancer.FontWeight = [System.Windows.FontWeights]::Bold [System.Windows.Controls.DockPanel]::SetDock($btnLancer, [System.Windows.Controls.Dock]::Right) $statusLbl = New-Object System.Windows.Controls.TextBlock $statusLbl.Text = T "detail.mandatoryNote" $statusLbl.Foreground = [System.Windows.Media.Brushes]::Gray $statusLbl.FontSize = 12 $statusLbl.VerticalAlignment = "Center" $statusLbl.Padding = New-Object System.Windows.Thickness(4, 0, 0, 0) $statusLbl.TextWrapping = "Wrap" $footer.Children.Add($btnLancer) | Out-Null $footer.Children.Add($statusLbl) | Out-Null $formPanel.Children.Add($footer) | Out-Null # -- Zone de logs d'execution (Feature 4), en bas du panneau -- $logHeader = New-Object System.Windows.Controls.TextBlock $logHeader.Text = T "log.header" $logHeader.FontWeight = [System.Windows.FontWeights]::SemiBold $logHeader.FontSize = 12 $logHeader.Margin = New-Object System.Windows.Thickness(0, 10, 0, 4) [System.Windows.Controls.DockPanel]::SetDock($logHeader, [System.Windows.Controls.Dock]::Bottom) $formPanel.Children.Add($logHeader) | Out-Null $logBox = New-Object System.Windows.Controls.RichTextBox $logBox.Height = 160 $logBox.IsReadOnly = $true $logBox.IsDocumentEnabled = $true $logBox.VerticalScrollBarVisibility = "Auto" $logBox.HorizontalScrollBarVisibility = "Disabled" $logBox.FontFamily = New-Object System.Windows.Media.FontFamily("Consolas") $logBox.FontSize = 11 $logBox.Background = [System.Windows.Media.Brushes]::Black $logBox.Foreground = [System.Windows.Media.Brushes]::Gainsboro $logBox.BorderThickness = New-Object System.Windows.Thickness(1) Clear-LogBox -LogBox $logBox -InitialLine (T "log.placeholder") [System.Windows.Controls.DockPanel]::SetDock($logBox, [System.Windows.Controls.Dock]::Bottom) $formPanel.Children.Add($logBox) | Out-Null # -- Zone scrollable des parametres -- $scroll = New-Object System.Windows.Controls.ScrollViewer $scroll.VerticalScrollBarVisibility = "Auto" $paramPanel = New-Object System.Windows.Controls.StackPanel $scroll.Content = $paramPanel $formPanel.Children.Add($scroll) | Out-Null if ($scriptParams.Count -eq 0) { $noParamLbl = New-Object System.Windows.Controls.TextBlock $noParamLbl.Text = T "detail.noParams" $noParamLbl.Foreground = [System.Windows.Media.Brushes]::Gray $noParamLbl.Margin = New-Object System.Windows.Thickness(0, 8, 0, 0) $paramPanel.Children.Add($noParamLbl) | Out-Null } $controlMap = @{} foreach ($p in $scriptParams) { $group = New-Object System.Windows.Controls.StackPanel $group.Margin = New-Object System.Windows.Thickness(0, 6, 0, 12) $friendlyType = ConvertTo-FriendlyTypeName -TypeName $p.TypeName $typeDisplay = if ($p.ValidValues) { $p.ValidValues -join " / " } else { $friendlyType } $lbl = New-Object System.Windows.Controls.TextBlock $lbl.FontSize = 13 $lbl.Margin = New-Object System.Windows.Thickness(0, 0, 0, 4) if ($p.Mandatory) { $lbl.Text = "$($p.Name) * [$typeDisplay] - $(T 'detail.mandatory')" $lbl.FontWeight = [System.Windows.FontWeights]::SemiBold } else { $lbl.Text = "$($p.Name) [$typeDisplay] - $(T 'detail.optional')" $lbl.Foreground = [System.Windows.Media.Brushes]::Gray } $group.Children.Add($lbl) | Out-Null $helpText = $paramHelp[$p.Name] if ($helpText) { $helpBlock = New-Object System.Windows.Controls.TextBlock $helpBlock.Text = $helpText $helpBlock.Foreground = [System.Windows.Media.Brushes]::DimGray $helpBlock.FontSize = 11 $helpBlock.TextWrapping = "Wrap" $helpBlock.Margin = New-Object System.Windows.Thickness(0, 2, 0, 4) $helpBlock.Visibility = [System.Windows.Visibility]::Collapsed $btnHelp = New-Object System.Windows.Controls.Button $btnHelp.Content = T "detail.helpShow" $btnHelp.Background = [System.Windows.Media.Brushes]::Transparent $btnHelp.BorderThickness = New-Object System.Windows.Thickness(0) $btnHelp.Foreground = [System.Windows.Media.Brushes]::SteelBlue $btnHelp.FontSize = 11 $btnHelp.Cursor = [System.Windows.Input.Cursors]::Hand $btnHelp.HorizontalAlignment = "Left" $btnHelp.Margin = New-Object System.Windows.Thickness(0, 0, 0, 2) $btnHelp.Add_Click({ if ($helpBlock.Visibility -eq [System.Windows.Visibility]::Collapsed) { $helpBlock.Visibility = [System.Windows.Visibility]::Visible $btnHelp.Content = T "detail.helpHide" } else { $helpBlock.Visibility = [System.Windows.Visibility]::Collapsed $btnHelp.Content = T "detail.helpShow" } }.GetNewClosure()) $group.Children.Add($btnHelp) | Out-Null $group.Children.Add($helpBlock) | Out-Null } if ($p.ValidValues) { $rbPanel = New-Object System.Windows.Controls.WrapPanel $rbPanel.Orientation = "Horizontal" $rbPanel.Margin = New-Object System.Windows.Thickness(0, 4, 0, 0) $firstRb = $null foreach ($val in $p.ValidValues) { $rb = New-Object System.Windows.Controls.RadioButton $rb.Content = $val $rb.Margin = New-Object System.Windows.Thickness(0, 0, 16, 0) $rb.GroupName = $p.Name $rb.FontSize = 13 if (-not $firstRb) { $rb.IsChecked = $true; $firstRb = $rb } $rbPanel.Children.Add($rb) | Out-Null } $group.Children.Add($rbPanel) | Out-Null $controlMap[$p.Name] = @{ Type = "Radio"; Panel = $rbPanel } } elseif ($p.IsSwitch) { $cb = New-Object System.Windows.Controls.CheckBox $cb.Content = T "detail.switchEnable" $cb.Margin = New-Object System.Windows.Thickness(4, 0, 0, 0) $group.Children.Add($cb) | Out-Null $controlMap[$p.Name] = @{ Type = "Switch"; Control = $cb } } elseif ($p.IsPath) { $dock = New-Object System.Windows.Controls.DockPanel $btnB = New-Object System.Windows.Controls.Button $btnB.Content = T "detail.browse" $btnB.Width = 110 $btnB.Padding = New-Object System.Windows.Thickness(8, 5, 8, 5) $btnB.Margin = New-Object System.Windows.Thickness(4, 0, 0, 0) $btnB.Cursor = [System.Windows.Input.Cursors]::Hand [System.Windows.Controls.DockPanel]::SetDock($btnB, [System.Windows.Controls.Dock]::Right) $txtPath = New-Object System.Windows.Controls.TextBox $txtPath.FontSize = 12 $txtPath.Padding = New-Object System.Windows.Thickness(6) $txtPath.Background = [System.Windows.Media.Brushes]::LightYellow $dock.Children.Add($btnB) | Out-Null $dock.Children.Add($txtPath) | Out-Null $group.Children.Add($dock) | Out-Null $controlMap[$p.Name] = @{ Type = "Path"; Control = $txtPath } if ($p.IsCsv) { $sep = New-Object System.Windows.Controls.TextBlock $sep.Text = T "detail.orPasteIps" $sep.HorizontalAlignment = "Center" $sep.Foreground = [System.Windows.Media.Brushes]::Gray $sep.FontStyle = [System.Windows.FontStyles]::Italic $sep.Margin = New-Object System.Windows.Thickness(0, 8, 0, 4) $group.Children.Add($sep) | Out-Null $txtIp = New-Object System.Windows.Controls.TextBox $txtIp.Height = 65 $txtIp.TextWrapping = "Wrap" $txtIp.AcceptsReturn = $true $txtIp.VerticalScrollBarVisibility = "Auto" $txtIp.FontFamily = New-Object System.Windows.Media.FontFamily("Consolas") $txtIp.FontSize = 12 $txtIp.Padding = New-Object System.Windows.Thickness(6) $group.Children.Add($txtIp) | Out-Null $btnConv = New-Object System.Windows.Controls.Button $btnConv.Content = T "detail.convertCsv" $btnConv.HorizontalAlignment = "Left" $btnConv.Width = 180 $btnConv.Padding = New-Object System.Windows.Thickness(10, 5, 10, 5) $btnConv.Margin = New-Object System.Windows.Thickness(0, 4, 0, 8) $btnConv.Cursor = [System.Windows.Input.Cursors]::Hand $group.Children.Add($btnConv) | Out-Null $lblPrev = New-Object System.Windows.Controls.TextBlock $lblPrev.Text = T "detail.previewCsv" $lblPrev.FontSize = 11 $lblPrev.Foreground = [System.Windows.Media.Brushes]::Gray $lblPrev.Margin = New-Object System.Windows.Thickness(0, 0, 0, 3) $group.Children.Add($lblPrev) | Out-Null $dg = New-Object System.Windows.Controls.DataGrid $dg.Height = 130 $dg.IsReadOnly = $true $dg.AutoGenerateColumns = $true $dg.CanUserAddRows = $false $dg.GridLinesVisibility = "Horizontal" $dg.HeadersVisibility = "Column" $dg.FontSize = 11 $dg.AlternatingRowBackground = [System.Windows.Media.Brushes]::AliceBlue $group.Children.Add($dg) | Out-Null $loadCsv = { param([string]$CsvPath) try { $fl = Get-Content $CsvPath -TotalCount 1 -Encoding UTF8 $delim = if (($fl -split ";").Count -gt ($fl -split ",").Count) { ";" } else { "," } $tbl = New-Object System.Data.DataTable $rows = @(Import-Csv $CsvPath -Encoding UTF8 -Delimiter $delim | Select-Object -First 10) if ($rows.Count -gt 0) { foreach ($col in $rows[0].PSObject.Properties) { $tbl.Columns.Add($col.Name) | Out-Null } foreach ($r in $rows) { $nr = $tbl.NewRow() foreach ($col in $r.PSObject.Properties) { $nr[$col.Name] = $col.Value } $tbl.Rows.Add($nr) } } $dg.ItemsSource = $tbl.DefaultView } catch { } }.GetNewClosure() $btnB.Add_Click({ $dlg = New-Object System.Windows.Forms.OpenFileDialog $dlg.Filter = "Fichiers CSV (*.csv)|*.csv|Tous les fichiers (*.*)|*.*" if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $txtPath.Text = $dlg.FileName & $loadCsv $dlg.FileName } }.GetNewClosure()) $btnConv.Add_Click({ $ipText = $txtIp.Text.Trim() if ([string]::IsNullOrWhiteSpace($ipText)) { return } $ips = $ipText -split "[, ;\t\r\n]+" | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$" } if ($ips.Count -eq 0) { return } $out = "$PSScriptRoot\ips_temp.csv" $ips | ForEach-Object { [PSCustomObject]@{ IP = $_ } } | Export-Csv $out -NoTypeInformation -Encoding UTF8 $txtPath.Text = $out & $loadCsv $out }.GetNewClosure()) } else { $btnB.Add_Click({ $dlg = New-Object System.Windows.Forms.OpenFileDialog $dlg.Filter = "Tous les fichiers (*.*)|*.*" if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $txtPath.Text = $dlg.FileName } }.GetNewClosure()) } } else { $txt = New-Object System.Windows.Controls.TextBox $txt.FontSize = 12 $txt.Padding = New-Object System.Windows.Thickness(6) $txt.Background = if ($p.Mandatory) { [System.Windows.Media.Brushes]::White } else { [System.Windows.Media.Brushes]::WhiteSmoke } $group.Children.Add($txt) | Out-Null $controlMap[$p.Name] = @{ Type = "Text"; Control = $txt } } $paramPanel.Children.Add($group) | Out-Null } $checkCanLaunch = { $allFilled = $true foreach ($n in $controlMap.Keys) { $e = $controlMap[$n] $sp = $scriptParams | Where-Object { $_.Name -eq $n } | Select-Object -First 1 if ($sp -and $sp.Mandatory -and $e.Type -notin @("Switch", "Radio")) { if ([string]::IsNullOrWhiteSpace($e.Control.Text)) { $allFilled = $false; break } } } $btnLancer.Background = if ($allFilled) { $greenBrush } else { $grayBrush } }.GetNewClosure() foreach ($n in $controlMap.Keys) { $e = $controlMap[$n] $sp = $scriptParams | Where-Object { $_.Name -eq $n } | Select-Object -First 1 if ($sp -and $sp.Mandatory -and $e.Type -notin @("Switch", "Radio")) { $e.Control.Add_TextChanged($checkCanLaunch) } } & $checkCanLaunch # -- Lancement asynchrone avec logs en direct (Feature 4) -- $btnLancer.Add_Click({ # Re-aliasage local (voir note en tete de Show-ScriptDetail) : ces # variables doivent redevenir "natives" de CE niveau pour que le # $timer.Add_Tick imbrique plus bas puisse les capturer a son tour. $execState = $execState $lv = $lv $logBox = $logBox $btnLancer = $btnLancer $scriptName = $scriptName $ok = $true foreach ($name in $controlMap.Keys) { $entry = $controlMap[$name] $sp = $scriptParams | Where-Object { $_.Name -eq $name } | Select-Object -First 1 if ($sp -and $sp.Mandatory -and $entry.Type -notin @("Switch", "Radio")) { if ([string]::IsNullOrWhiteSpace($entry.Control.Text)) { $ok = $false; break } } } if (-not $ok) { return } $paramValues = @{} foreach ($name in $controlMap.Keys) { $entry = $controlMap[$name] $value = if ($entry.Type -eq "Switch") { $entry.Control.IsChecked } elseif ($entry.Type -eq "Radio") { $sel = $entry.Panel.Children | Where-Object { $_.IsChecked } | Select-Object -First 1 if ($sel) { $sel.Content } else { $null } } else { $entry.Control.Text } if (-not [string]::IsNullOrWhiteSpace("$value")) { $paramValues[$name] = $value } } $btnLancer.IsEnabled = $false $lv.IsEnabled = $false Clear-LogBox -LogBox $logBox -InitialLine (T "log.starting") -Level Info $logFile = "$env:TEMP\cadmastercli_run_$([Guid]::NewGuid().ToString('N')).log" $execState.LogFile = $logFile $execState.LastLen = 0 $execState.Job = Start-ScriptExecution -ScriptPath $cachedScriptPath -ParamValues $paramValues -LogFile $logFile -WorkingDirectory $PSScriptRoot $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(300) $execState.Timer = $timer $timer.Add_Tick({ if (Test-Path $execState.LogFile) { $content = Get-Content $execState.LogFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ($content -and $content.Length -gt $execState.LastLen) { $delta = $content.Substring($execState.LastLen) foreach ($deltaLine in ($delta -split "`r`n|`n")) { if ($deltaLine -ne "") { Add-LogLine -LogBox $logBox -Line $deltaLine } } $logBox.ScrollToEnd() $execState.LastLen = $content.Length } } if ($execState.Job.State -in @("Completed", "Failed", "Stopped")) { $timer.Stop() $jobFailed = ($execState.Job.State -eq "Failed") Remove-Job -Job $execState.Job -Force -ErrorAction SilentlyContinue Remove-Item $execState.LogFile -Force -ErrorAction SilentlyContinue $execState.Job = $null $execState.Timer = $null $execState.LogFile = $null $btnLancer.IsEnabled = $true $lv.IsEnabled = $true if ($jobFailed) { Add-LogLine -LogBox $logBox -Line (T "log.finishedError") -Level Error Show-MessageBox -Message "$(T 'msg.errorExec') '$scriptName'." -Title (T "msg.errorExecTitle") -Type "Error" } else { Add-LogLine -LogBox $logBox -Line (T "log.finishedOk") -Level Ok } $logBox.ScrollToEnd() } }.GetNewClosure()) $timer.Start() }.GetNewClosure()) }.GetNewClosure() $lv.Add_SelectionChanged({ if ($lv.SelectedItem) { & $ShowScriptDetail -ScriptData $lv.SelectedItem.Tag } }.GetNewClosure()) $btnFlagFr.Add_Click({ Set-CadMasterLanguage -Lang "fr" & $UpdateTexts if ($lv.SelectedItem -and -not $execState.Job) { & $ShowScriptDetail -ScriptData $lv.SelectedItem.Tag } }.GetNewClosure()) $btnFlagEn.Add_Click({ Set-CadMasterLanguage -Lang "en" & $UpdateTexts if ($lv.SelectedItem -and -not $execState.Job) { & $ShowScriptDetail -ScriptData $lv.SelectedItem.Tag } }.GetNewClosure()) $w.Add_Closing({ & $StopRunningExecution }.GetNewClosure()) & $UpdateTexts & $SetDetailPlaceholder $w.ShowDialog() | Out-Null } # ===================================================================== # FLUX PRINCIPAL # ===================================================================== Clear-Host Write-Host "" Write-Host " +==========================================+" -ForegroundColor Cyan Write-Host " | cadMasterCLI v$CADMASTER_VERSION |" -ForegroundColor Cyan Write-Host " | Launcher de scripts PowerShell |" -ForegroundColor Cyan Write-Host " +==========================================+" -ForegroundColor Cyan Write-Host "" $policy = Get-ExecutionPolicy -Scope CurrentUser if ($policy -eq "Restricted") { Write-Log "ExecutionPolicy trop restrictive detectee : $policy" "WARN" Write-Log "Executez cette commande pour corriger : Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" "WARN" Write-Host "" } if (-not (Test-Path $LOCAL_CACHE_DIR)) { New-Item -ItemType Directory -Path $LOCAL_CACHE_DIR -Force | Out-Null Write-Log "Dossier cache cree : $LOCAL_CACHE_DIR" "OK" } # === Etape 0 : Verifier une mise a jour de cadMasterCLI lui-meme === $remoteVersion = Test-CadMasterUpdate if ($remoteVersion) { $rep = Show-MessageBox -Message "$(T 'msg.selfUpdateAvailable') ($remoteVersion).`n$(T 'msg.selfUpdateConfirm')" ` -Title (T "msg.selfUpdateTitle") -Type "Question" if ($rep -eq "Yes") { if (Invoke-CadMasterSelfUpdate -RemoteVersion $remoteVersion) { exit 0 } } } # === Etape 1 : Recuperer la liste des scripts depuis Gitea === $rawList = Get-ScriptList $modeOffline = $false if ($null -eq $rawList) { Write-Log "Mode hors ligne : utilisation du cache local." "WARN" $modeOffline = $true $rawList = Get-ChildItem -Path $LOCAL_CACHE_DIR -Directory -ErrorAction SilentlyContinue | ForEach-Object { [PSCustomObject]@{ Nom = $_.Name; GiteaPath = $null } } if (-not $rawList -or @($rawList).Count -eq 0) { Write-Log "Aucun script disponible (cache vide et Gitea inaccessible)." "ERROR" Show-MessageBox -Message (T "msg.offline") -Title (T "msg.errorTitle") -Type "Error" exit 1 } } # === Etape 2 : Enrichir avec README, date et statut de mise a jour === Write-Log "Chargement des informations ($(@($rawList).Count) script(s))..." $scripts = @($rawList) | ForEach-Object { $name = $_.Nom $branch = if ($_.DefaultBranch) { $_.DefaultBranch } else { $GITEA_BRANCH } $readme = if (-not $modeOffline) { Get-ScriptReadme -ScriptName $name -Branch $branch } else { $null } $date = if (-not $modeOffline) { Get-ScriptLastCommitDate -ScriptName $name -Branch $branch } else { $null } $majDispo = Test-ScriptUpdate -ScriptName $name -RemoteDate $date if (-not $readme) { $localReadme = "$LOCAL_CACHE_DIR\$name\README.md" if (Test-Path $localReadme) { $readme = Get-Content $localReadme -Raw -Encoding UTF8 } } [PSCustomObject]@{ Nom = $name ReadmeFull = $readme DateGitea = $date MajDispo = $majDispo DefaultBranch = $branch UpdatePromptShown = $false } } # === Etape 3 : Fenetre unique (liste + parametres + logs) === Show-MainWindow -Scripts $scripts Write-Log "Fermeture de cadMasterCLI."