Files
cadMasterCLI/cadMasterCLI.ps1
T
cadjou 87abd0c970 Version 2.0.2 : suppression de version.txt, vérification de version basée sur cadMasterCLI.ps1
Détail :
- Test-CadMasterUpdate lit désormais $CADMASTER_VERSION directement dans le cadMasterCLI.ps1 distant (regex) au lieu d'un fichier version.txt séparé
- Suppression de version.txt (plus utilisé) et de sa mention dans README.md
2026-07-06 04:03:01 +02:00

1819 lines
86 KiB
PowerShell

#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.2"
$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 <!-- FR --> / <!-- EN -->.
# 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)<!--\s*(FR|EN)\s*-->(.*?)(?=<!--\s*(?:FR|EN)\s*-->|$)'
$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/cadMasterCLI.ps1?ref=$GITEA_BRANCH"
$content = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 10 -ErrorAction Stop
if ($content -match '\$CADMASTER_VERSION\s*=\s*"([\d.]+)"') {
$remote = $Matches[1]
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 "<html><body style='font-family:Segoe UI,sans-serif;padding:12px'><p><em>$emptyMsg</em></p></body></html>"
}
$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]+)\)", "<a href='`$2'>`$1</a>"
$s = $s -replace "\*\*(.+?)\*\*", "<strong>`$1</strong>"
$s = $s -replace "\*(.+?)\*", "<em>`$1</em>"
$s = $s -replace "``(.+?)``", "<code>`$1</code>"
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 = "<table>"
$isHeader = $true
foreach ($row in $buf) {
if ($row -match "^\s*\|[\s\-\|:]+$") { continue }
$cells = $row -split "\|" | Where-Object { $_ -ne "" } | ForEach-Object { $_.Trim() }
if ($isHeader) {
$html += "<thead><tr>"
foreach ($c in $cells) { $html += "<th>$([System.Net.WebUtility]::HtmlEncode($c))</th>" }
$html += "</tr></thead><tbody>"
$isHeader = $false
} else {
$html += "<tr>"
foreach ($c in $cells) { $html += "<td>$([System.Net.WebUtility]::HtmlEncode($c))</td>" }
$html += "</tr>"
}
}
$html += "</tbody></table>"
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("<html><head><meta charset='utf-8'><style>$css</style></head><body>") | Out-Null
foreach ($line in ($Markdown -split "`n")) {
$raw = $line.TrimEnd()
# Toggle bloc de code
if ($raw -match "^``````") {
if ($listOpen) { $sb.Append("</ul>") | Out-Null; $listOpen = $false }
if ($bqOpen) { $sb.Append("</blockquote>") | Out-Null; $bqOpen = $false }
if ($inTable) { $sb.Append((& $flushTable $tableBuffer)) | Out-Null; $tableBuffer.Clear(); $inTable = $false }
if ($inCodeBlock) { $sb.Append("</code></pre>") | Out-Null; $inCodeBlock = $false }
else { $sb.Append("<pre><code>") | 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("</ul>") | Out-Null; $listOpen = $false }
if ($bqOpen) { $sb.Append("</blockquote>") | 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("</ul>") | Out-Null; $listOpen = $false }
if (-not $bqOpen) { $sb.Append("<blockquote>") | Out-Null; $bqOpen = $true }
$content = & $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1]))
$sb.Append("<p>$content</p>") | Out-Null
continue
}
if ($bqOpen) { $sb.Append("</blockquote>") | Out-Null; $bqOpen = $false }
# Fermer la liste si la ligne n'est pas un item
if ($listOpen -and $raw -notmatch "^- ") {
$sb.Append("</ul>") | Out-Null
$listOpen = $false
}
# Elements structurels (detection sur ligne brute avant encodage)
if ($raw -match "^# (.+)") { $sb.Append("<h1>$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))</h1>") | Out-Null }
elseif ($raw -match "^## (.+)") { $sb.Append("<h2>$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))</h2>") | Out-Null }
elseif ($raw -match "^### (.+)") { $sb.Append("<h3>$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))</h3>") | Out-Null }
elseif ($raw -match "^---$") { $sb.Append("<hr/>") | Out-Null }
elseif ($raw -match "^- (.+)") {
if (-not $listOpen) { $sb.Append("<ul>") | Out-Null; $listOpen = $true }
$sb.Append("<li>$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($Matches[1])))</li>") | Out-Null
}
elseif ($raw.Trim() -eq "") { $sb.Append("<br/>") | Out-Null }
else { $sb.Append("<p>$(& $applyInline ([System.Net.WebUtility]::HtmlEncode($raw)))</p>") | Out-Null }
}
# Flush des etats ouverts en fin de document
if ($inTable) { $sb.Append((& $flushTable $tableBuffer)) | Out-Null }
if ($listOpen) { $sb.Append("</ul>") | Out-Null }
if ($bqOpen) { $sb.Append("</blockquote>") | Out-Null }
if ($inCodeBlock){ $sb.Append("</code></pre>") | Out-Null }
$sb.Append("</body></html>") | 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."