@@ -2854,6 +2854,32 @@ $script:LangDefault = @{
" bulk.status.creating " = " Creating... "
" bulk.status.ok " = " OK "
" bulk.status.error " = " Error "
" tab.structure " = " Structure "
" grp.struct.csv " = " CSV Import "
" lbl.struct.desc " = " Import a CSV to create a folder tree. Each column represents a depth level. "
" btn.struct.csv " = " Load CSV... "
" grp.struct.preview " = " Preview "
" grp.struct.target " = " Target "
" lbl.struct.library " = " Target library: "
" ph.struct.library " = " Shared Documents "
" btn.struct.create " = " Create Structure "
" btn.struct.clear " = " Clear "
" struct.col.path " = " Full Path "
" struct.col.depth " = " Depth "
" tab.versions " = " Versions "
" grp.ver.keep " = " Versions to Keep "
" lbl.ver.count " = " Number of versions to keep: "
" chk.ver.date " = " Also filter by date "
" rad.ver.before " = " Keep versions before: "
" rad.ver.after " = " Keep versions after: "
" grp.ver.scope " = " Scope "
" lbl.ver.library " = " Library / Folder: "
" ph.ver.library " = " Shared Documents "
" chk.ver.recursive " = " Include subfolders (recursive) "
" chk.ver.subsites " = " Include subsites "
" chk.ver.dryrun " = " Dry run (preview only, no deletion) "
" btn.ver.run " = " Clean Versions "
" btn.ver.open " = " Open Report "
}
$script:Lang = $null # null = use LangDefault
@@ -3606,7 +3632,137 @@ $btnBulkCreate.Size = New-Object System.Drawing.Size(200, 34)
$tabBulk . Controls . AddRange ( @ ( $grpBulkList , $btnBulkCreate ) )
$tabs . TabPages . AddRange ( @ ( $tabPerms , $tabStorage , $tabTemplates , $tabSearch , $tabDupes , $tabTransfer , $tabBulk ) )
# ══════════════════════════════════════════════════════════════════════════════
# Tab 8 – Structure (folder tree from CSV)
# ══════════════════════════════════════════════════════════════════════════════
$tabStruct = New-Object System . Windows . Forms . TabPage
$tabStruct . Text = T " tab.structure "
# ── CSV import + target (single row) ───────────────────────────────────────
$grpStructCsv = New-Group ( T " grp.struct.csv " ) 10 4 620 52
$lblStructDesc = New-Object System . Windows . Forms . Label
$lblStructDesc . Text = T " lbl.struct.desc "
$lblStructDesc . Location = New-Object System . Drawing . Point ( 10 , 20 )
$lblStructDesc . Size = New-Object System . Drawing . Size ( 460 , 20 )
$btnStructCsv = New-Object System . Windows . Forms . Button
$btnStructCsv . Text = T " btn.struct.csv "
$btnStructCsv . Location = New-Object System . Drawing . Point ( 490 , 18 )
$btnStructCsv . Size = New-Object System . Drawing . Size ( 118 , 26 )
$grpStructCsv . Controls . AddRange ( @ ( $lblStructDesc , $btnStructCsv ) )
# ── Preview ────────────────────────────────────────────────────────────────
$grpStructPreview = New-Group ( T " grp.struct.preview " ) 10 58 620 148
$tvStruct = New-Object System . Windows . Forms . TreeView
$tvStruct . Location = New-Object System . Drawing . Point ( 10 , 18 )
$tvStruct . Size = New-Object System . Drawing . Size ( 598 , 120 )
$tvStruct . Font = New-Object System . Drawing . Font ( " Segoe UI " , 9 )
$tvStruct . ShowLines = $true
$tvStruct . ShowPlusMinus = $true
$grpStructPreview . Controls . Add ( $tvStruct )
# ── Target + Buttons (single row) ─────────────────────────────────────────
$lblStructLib = New-Object System . Windows . Forms . Label
$lblStructLib . Text = T " lbl.struct.library "
$lblStructLib . Location = New-Object System . Drawing . Point ( 12 , 214 )
$lblStructLib . Size = New-Object System . Drawing . Size ( 110 , 20 )
$txtStructLib = New-Object System . Windows . Forms . TextBox
$txtStructLib . Location = New-Object System . Drawing . Point ( 124 , 212 )
$txtStructLib . Size = New-Object System . Drawing . Size ( 200 , 22 )
$txtStructLib . PlaceholderText = T " ph.struct.library "
$btnStructCreate = New-ActionBtn ( T " btn.struct.create " ) 340 208 ( [ System.Drawing.Color ] :: FromArgb ( 0 , 120 , 212 ) )
$btnStructCreate . Size = New-Object System . Drawing . Size ( 180 , 30 )
$btnStructClear = New-Object System . Windows . Forms . Button
$btnStructClear . Text = T " btn.struct.clear "
$btnStructClear . Location = New-Object System . Drawing . Point ( 528 , 208 )
$btnStructClear . Size = New-Object System . Drawing . Size ( 90 , 30 )
$tabStruct . Controls . AddRange ( @ ( $grpStructCsv , $grpStructPreview , $lblStructLib , $txtStructLib , $btnStructCreate , $btnStructClear ) )
# ══════════════════════════════════════════════════════════════════════════════
# Tab 9 – Version Cleanup
# ══════════════════════════════════════════════════════════════════════════════
$tabVersions = New-Object System . Windows . Forms . TabPage
$tabVersions . Text = T " tab.versions "
$tabVersions . BackColor = [ System.Drawing.Color ] :: WhiteSmoke
# ── Versions to keep ─────────────────────────────────────────────────────────
$grpVerKeep = New-Group ( T " grp.ver.keep " ) 10 4 620 110
$lblVerCount = New-Object System . Windows . Forms . Label
$lblVerCount . Text = T " lbl.ver.count "
$lblVerCount . Location = New-Object System . Drawing . Point ( 10 , 22 )
$lblVerCount . Size = New-Object System . Drawing . Size ( 220 , 20 )
$nudVerCount = New-Object System . Windows . Forms . NumericUpDown
$nudVerCount . Location = New-Object System . Drawing . Point ( 235 , 20 )
$nudVerCount . Size = New-Object System . Drawing . Size ( 70 , 22 )
$nudVerCount . Minimum = 0
$nudVerCount . Maximum = 500
$nudVerCount . Value = 5
$chkVerDate = New-Check ( T " chk.ver.date " ) 10 50 250 $false
$radVerBefore = New-Radio ( T " rad.ver.before " ) 30 74 200 $true
$radVerBefore . Enabled = $false
$radVerAfter = New-Radio ( T " rad.ver.after " ) 30 96 200 $false
$radVerAfter . Enabled = $false
$dtpVer = New-Object System . Windows . Forms . DateTimePicker
$dtpVer . Location = New-Object System . Drawing . Point ( 235 , 74 )
$dtpVer . Size = New-Object System . Drawing . Size ( 150 , 22 )
$dtpVer . Format = [ System.Windows.Forms.DateTimePickerFormat ] :: Short
$dtpVer . Enabled = $false
$chkVerDate . Add_CheckedChanged ( {
$on = $chkVerDate . Checked
$radVerBefore . Enabled = $on
$radVerAfter . Enabled = $on
$dtpVer . Enabled = $on
} )
$grpVerKeep . Controls . AddRange ( @ ( $lblVerCount , $nudVerCount , $chkVerDate , $radVerBefore , $radVerAfter , $dtpVer ) )
# ── Scope ─────────────────────────────────────────────────────────────────────
$grpVerScope = New-Group ( T " grp.ver.scope " ) 10 118 620 76
$lblVerLib = New-Object System . Windows . Forms . Label
$lblVerLib . Text = T " lbl.ver.library "
$lblVerLib . Location = New-Object System . Drawing . Point ( 10 , 22 )
$lblVerLib . Size = New-Object System . Drawing . Size ( 150 , 20 )
$txtVerLib = New-Object System . Windows . Forms . TextBox
$txtVerLib . Location = New-Object System . Drawing . Point ( 164 , 20 )
$txtVerLib . Size = New-Object System . Drawing . Size ( 230 , 22 )
$txtVerLib . PlaceholderText = T " ph.ver.library "
$chkVerRecursive = New-Check ( T " chk.ver.recursive " ) 10 48 260 $true
$chkVerSubsites = New-Check ( T " chk.ver.subsites " ) 280 48 200 $false
$grpVerScope . Controls . AddRange ( @ ( $lblVerLib , $txtVerLib , $chkVerRecursive , $chkVerSubsites ) )
# ── Options + Buttons ─────────────────────────────────────────────────────────
$chkVerDryRun = New-Check ( T " chk.ver.dryrun " ) 12 200 350 $true
$btnVerRun = New-ActionBtn ( T " btn.ver.run " ) 10 228 ( [ System.Drawing.Color ] :: FromArgb ( 180 , 60 , 20 ) )
$btnVerRun . Size = New-Object System . Drawing . Size ( 180 , 30 )
$btnVerOpen = New-Object System . Windows . Forms . Button
$btnVerOpen . Text = T " btn.ver.open "
$btnVerOpen . Location = New-Object System . Drawing . Point ( 200 , 228 )
$btnVerOpen . Size = New-Object System . Drawing . Size ( 130 , 30 )
$btnVerOpen . Enabled = $false
$tabVersions . Controls . AddRange ( @ ( $grpVerKeep , $grpVerScope , $chkVerDryRun , $btnVerRun , $btnVerOpen ) )
$tabs . TabPages . AddRange ( @ ( $tabPerms , $tabStorage , $tabTemplates , $tabSearch , $tabDupes , $tabTransfer , $tabBulk , $tabStruct , $tabVersions ) )
# ── Progress bar ───────────────────────────────────────────────────────────────
$progressBar = New-Object System . Windows . Forms . ProgressBar
@@ -3793,6 +3949,24 @@ $_reg = {
& $_reg $script:i18nMap $btnBulkRemove " btn.bulk.remove "
& $_reg $script:i18nMap $btnBulkClear " btn.bulk.clear "
& $_reg $script:i18nMap $btnBulkCreate " btn.bulk.create "
& $_reg $script:i18nMap $lblStructDesc " lbl.struct.desc "
& $_reg $script:i18nMap $btnStructCsv " btn.struct.csv "
& $_reg $script:i18nMap $lblStructLib " lbl.struct.library "
& $_reg $script:i18nMap $btnStructCreate " btn.struct.create "
& $_reg $script:i18nMap $btnStructClear " btn.struct.clear "
# Version Cleanup tab
& $_reg $script:i18nMap $lblVerCount " lbl.ver.count "
& $_reg $script:i18nMap $chkVerDate " chk.ver.date "
& $_reg $script:i18nMap $radVerBefore " rad.ver.before "
& $_reg $script:i18nMap $radVerAfter " rad.ver.after "
& $_reg $script:i18nMap $lblVerLib " lbl.ver.library "
& $_reg $script:i18nMap $chkVerRecursive " chk.ver.recursive "
& $_reg $script:i18nMap $chkVerSubsites " chk.ver.subsites "
& $_reg $script:i18nMap $chkVerDryRun " chk.ver.dryrun "
& $_reg $script:i18nMap $btnVerRun " btn.ver.run "
& $_reg $script:i18nMap $btnVerOpen " btn.ver.open "
& $_reg $script:i18nMap $grpVerKeep " grp.ver.keep "
& $_reg $script:i18nMap $grpVerScope " grp.ver.scope "
# Tab pages
& $_reg $script:i18nTabs $tabPerms " tab.perms "
@@ -3802,6 +3976,8 @@ $_reg = {
& $_reg $script:i18nTabs $tabDupes " tab.dupes "
& $_reg $script:i18nTabs $tabTransfer " tab.transfer "
& $_reg $script:i18nTabs $tabBulk " tab.bulk "
& $_reg $script:i18nTabs $tabStruct " tab.structure "
& $_reg $script:i18nTabs $tabVersions " tab.versions "
# Menu items
& $_reg $script:i18nMenus $menuSettings " menu.settings "
@@ -3816,10 +3992,12 @@ $script:i18nPlaceholders = [System.Collections.Generic.Dictionary[string,object]
& $_reg $script:i18nPlaceholders $txtSrchModBy " ph.modified.by "
& $_reg $script:i18nPlaceholders $txtSrchLib " ph.library "
& $_reg $script:i18nPlaceholders $txtDupLib " ph.dup.lib "
& $_reg $script:i18nPlaceholders $txtStructLib " ph.struct.library "
& $_reg $script:i18nPlaceholders $txtXferSrcSite " ph.xfer.site "
& $_reg $script:i18nPlaceholders $txtXferSrcLib " ph.xfer.library "
& $_reg $script:i18nPlaceholders $txtXferDstSite " ph.xfer.site "
& $_reg $script:i18nPlaceholders $txtXferDstLib " ph.xfer.library "
& $_reg $script:i18nPlaceholders $txtVerLib " ph.ver.library "
#endregion
@@ -5578,6 +5756,318 @@ $btnBulkCreate.Add_Click({
#endregion
#region ===== Structure (folder tree from CSV) =====
# Store the parsed folder paths
$script:_StructPaths = @ ( )
function Build-StructTree([string]$csvPath ) {
# Auto-detect delimiter
$raw = Get-Content $csvPath -Raw
$delim = if ( $raw -match ';' ) { ';' } else { ',' }
$rows = Import-Csv $csvPath -Delimiter $delim
$paths = [ System.Collections.Generic.List[string] ] :: new ( )
foreach ( $r in $rows ) {
$cols = @ ( $r . PSObject . Properties | ForEach-Object { " $( $_ . Value ) " . Trim ( ) } )
# Build path from non-empty columns
$parts = @ ( $cols | Where-Object { $_ -ne '' } )
if ( $parts . Count -gt 0 ) {
# Add all intermediate paths to ensure parents exist
for ( $i = 1 ; $i -le $parts . Count ; $i + + ) {
$p = ( $parts [ 0 . . ( $i - 1 ) ] -join '/' )
if ( -not $paths . Contains ( $p ) ) { $paths . Add ( $p ) }
}
}
}
$script:_StructPaths = @ ( $paths | Sort-Object )
return $script:_StructPaths
}
function Populate-StructTreeView([string[]]$paths ) {
$tvStruct . Nodes . Clear ( )
$nodeMap = @ { }
foreach ( $p in $paths ) {
$parts = $p -split '/'
$parentKey = if ( $parts . Count -gt 1 ) { ( $parts [ 0 . . ( $parts . Count - 2 ) ] -join '/' ) } else { '' }
$name = $parts [ -1 ]
$node = New-Object System . Windows . Forms . TreeNode ( $name )
$node . Tag = $p
if ( $parentKey -and $nodeMap . ContainsKey ( $parentKey ) ) {
$nodeMap [ $parentKey ] . Nodes . Add ( $node ) | Out-Null
} else {
$tvStruct . Nodes . Add ( $node ) | Out-Null
}
$nodeMap [ $p ] = $node
}
$tvStruct . ExpandAll ( )
}
$btnStructCsv . Add_Click ( {
$ofd = New-Object System . Windows . Forms . OpenFileDialog
$ofd . Filter = " CSV (*.csv)|*.csv|All (*.*)|*.* "
if ( $ofd . ShowDialog ( $form ) -ne " OK " ) { return }
try {
$paths = Build-StructTree $ofd . FileName
Populate-StructTreeView $paths
Write-Log " $( $paths . Count ) folder(s) loaded from CSV. " " LightGreen "
} catch {
Write-Log " CSV error: $( $_ . Exception . Message ) " " Red "
}
} )
$btnStructClear . Add_Click ( {
$tvStruct . Nodes . Clear ( )
$script:_StructPaths = @ ( )
Write-Log " Structure cleared. " " Gray "
} )
$btnStructCreate . Add_Click ( {
$siteUrl = $txtSiteUrl . Text . Trim ( )
$clientId = $txtClientId . Text . Trim ( )
$library = $txtStructLib . Text . Trim ( )
if ( -not $siteUrl ) { Write-Log " Site URL required. " " Red " ; return }
if ( -not $clientId ) { Write-Log " Client ID required. " " Red " ; return }
if ( -not $library ) { Write-Log " Target library required. " " Red " ; return }
if ( $script:_StructPaths . Count -eq 0 ) { Write-Log " No structure loaded. Load a CSV first. " " Red " ; return }
$btnStructCreate . Enabled = $false
$btnStructCsv . Enabled = $false
Start-ProgressAnim
Write-Log " === CREATING FOLDER STRUCTURE === " " White "
Write-Log " Target: $siteUrl / $library " " Gray "
Write-Log " Folders to create: $( $script:_StructPaths . Count ) " " Gray "
Write-Log ( " - " * 52 ) " DarkGray "
try {
Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
# Get the library root
$list = Get-PnPList -Identity $library -ErrorAction Stop
$rf = Get-PnPProperty -ClientObject $list -Property RootFolder
$base = $rf . ServerRelativeUrl . TrimEnd ( '/' )
$ok = 0
$err = 0
$total = $script:_StructPaths . Count
foreach ( $p in $script:_StructPaths ) {
$folderPath = " $base / $p "
try {
# Resolve-PnPFolder creates the full path recursively
Resolve-PnPFolder -SiteRelativePath " $library / $p " -ErrorAction Stop | Out-Null
$ok + +
Write-Log " OK: $p " " LightGreen "
} catch {
$err + +
Write-Log " FAIL: $p — $( $_ . Exception . Message ) " " Red "
}
}
Write-Log " === STRUCTURE COMPLETE: $ok OK, $err error(s) === " " White "
} catch {
Write-Log " Error: $( $_ . Exception . Message ) " " Red "
} finally {
$btnStructCreate . Enabled = $true
$btnStructCsv . Enabled = $true
Stop-ProgressAnim
}
} )
# ── Version Cleanup handlers ─────────────────────────────────────────────────
$script:_VerReport = $null
$btnVerOpen . Add_Click ( {
if ( $script:_VerReport -and ( Test-Path $script:_VerReport ) ) {
Start-Process $script:_VerReport
}
} )
$btnVerRun . Add_Click ( {
# --- Gather all selected site URLs ---
$siteUrls = @ ( )
if ( $script:_CachedSites -and $script:_CachedSites . Count -gt 0 ) {
foreach ( $s in $script:_CachedSites ) {
if ( $s . Checked ) { $siteUrls + = $s . Url }
}
}
if ( $siteUrls . Count -eq 0 ) {
$single = $txtSiteUrl . Text . Trim ( )
if ( $single ) { $siteUrls = @ ( $single ) }
}
if ( $siteUrls . Count -eq 0 ) { Write-Log " Site URL required. " " Red " ; return }
$clientId = $txtClientId . Text . Trim ( )
if ( -not $clientId ) { Write-Log " Client ID required. " " Red " ; return }
$keepCount = [ int ] $nudVerCount . Value
$useDate = $chkVerDate . Checked
$dateBefore = $radVerBefore . Checked # true = keep before, false = keep after
$cutoffDate = $dtpVer . Value
$library = $txtVerLib . Text . Trim ( )
$recursive = $chkVerRecursive . Checked
$subsites = $chkVerSubsites . Checked
$dryRun = $chkVerDryRun . Checked
$btnVerRun . Enabled = $false
Start-ProgressAnim
$modeLabel = if ( $dryRun ) { " DRY RUN " } else { " LIVE " }
Write-Log " === VERSION CLEANUP ( $modeLabel ) === " " White "
Write-Log " Keep: $keepCount version(s) " " Gray "
if ( $useDate ) {
$dir = if ( $dateBefore ) { " before " } else { " after " }
Write-Log " Date filter: keep versions $dir $( $cutoffDate . ToString ( 'yyyy-MM-dd' ) ) " " Gray "
}
Write-Log ( " - " * 52 ) " DarkGray "
$report = [ System.Collections.Generic.List[object] ] :: new ( )
$totalDeleted = 0
$totalKept = 0
$totalErrors = 0
try {
foreach ( $siteUrl in $siteUrls ) {
Write-Log " Connecting to $siteUrl ... " " Gray "
Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
# Collect site URLs to process (main + subsites)
$sitesToProcess = @ ( $siteUrl )
if ( $subsites ) {
try {
$subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
foreach ( $sw in $subs ) { $sitesToProcess + = $sw . Url }
} catch { }
}
foreach ( $currentSite in $sitesToProcess ) {
if ( $currentSite -ne $siteUrl ) {
try { Connect-PnPOnline -Url $currentSite -Interactive -ClientId $clientId } catch {
Write-Log " Cannot connect to subsite $currentSite — skipped " " DarkOrange "
continue
}
}
Write-Log " Processing site: $currentSite " " White "
# Get target lists
$lists = @ ( )
if ( $library ) {
try { $lists = @ ( Get-PnPList -Identity $library -ErrorAction Stop ) } catch {
Write-Log " Library ' $library ' not found — skipped " " DarkOrange "
continue
}
} else {
$lists = Get-PnPList | Where-Object { $_ . BaseTemplate -eq 101 -and $_ . Hidden -eq $false }
}
foreach ( $list in $lists ) {
Write-Log " Library: $( $list . Title ) " " Gray "
try {
$camlQuery = " <View Scope='RecursiveAll'><Query></Query><RowLimit>5000</RowLimit></View> "
if ( -not $recursive ) {
$camlQuery = " <View><Query></Query><RowLimit>5000</RowLimit></View> "
}
$items = Get-PnPListItem -List $list . Title -Query $camlQuery -ErrorAction Stop |
Where-Object { $_ . FileSystemObjectType -eq " File " }
} catch {
Write-Log " Error listing files: $( $_ . Exception . Message ) " " Red "
$totalErrors + +
continue
}
foreach ( $item in $items ) {
try {
$file = $item . FieldValues [ " FileRef " ]
$versions = Get-PnPFileVersion -Url $file -ErrorAction Stop
if ( $versions . Count -le $keepCount ) { continue }
# Sort versions oldest first (by VersionLabel numeric)
$sorted = $versions | Sort-Object { [ double ] $_ . VersionLabel }
# Determine which versions to delete
$toDelete = @ ( )
foreach ( $v in $sorted ) {
# Always keep the last $keepCount versions
$idx = [ array ] :: IndexOf ( $sorted , $v )
$remaining = $sorted . Count - $idx
if ( $remaining -le $keepCount ) { break }
# Apply date filter if enabled
if ( $useDate ) {
$vDate = [ datetime ] $v . Created
if ( $dateBefore ) {
# Keep versions before cutoff → delete versions ON or AFTER cutoff
if ( $vDate -lt $cutoffDate ) { continue }
} else {
# Keep versions after cutoff → delete versions BEFORE cutoff
if ( $vDate -ge $cutoffDate ) { continue }
}
}
$toDelete + = $v
}
if ( $toDelete . Count -eq 0 ) { continue }
$fileName = Split-Path $file -Leaf
foreach ( $v in $toDelete ) {
if ( $dryRun ) {
Write-Log " [DRY] Would delete v $( $v . VersionLabel ) of $fileName ( $( $v . Created ) ) " " DarkOrange "
} else {
try {
Remove-PnPFileVersion -Url $file -Identity $v . Id -Force -ErrorAction Stop
Write-Log " Deleted v $( $v . VersionLabel ) of $fileName " " LightGreen "
} catch {
Write-Log " Error deleting v $( $v . VersionLabel ) of $fileName — $( $_ . Exception . Message ) " " Red "
$totalErrors + +
}
}
$totalDeleted + +
}
$kept = $sorted . Count - $toDelete . Count
$totalKept + = $kept
$report . Add ( [ PSCustomObject ] @ {
Site = $currentSite
Library = $list . Title
File = $file
TotalVer = $sorted . Count
Deleted = $toDelete . Count
Kept = $kept
} )
} catch {
$totalErrors + +
}
}
}
}
}
# Export CSV report
if ( $report . Count -gt 0 ) {
$outDir = $txtOutput . Text . Trim ( )
if ( -not $outDir ) { $outDir = if ( $PSScriptRoot ) { $PSScriptRoot } else { $PWD . Path } }
if ( -not ( Test-Path $outDir ) ) { New-Item -ItemType Directory -Path $outDir | Out-Null }
$stamp = Get-Date -Format " yyyyMMdd_HHmmss "
$prefix = if ( $dryRun ) { " VersionCleanup_DryRun " } else { " VersionCleanup " }
$csvFile = Join-Path $outDir " ${prefix} _ $stamp .csv "
$report | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
$script:_VerReport = $csvFile
$btnVerOpen . Enabled = $true
Write-Log " Report: $csvFile " " White "
}
Write-Log " === VERSION CLEANUP COMPLETE: $totalDeleted deleted, $totalKept kept, $totalErrors error(s) === " " White "
} catch {
Write-Log " Error: $( $_ . Exception . Message ) " " Red "
} finally {
$btnVerRun . Enabled = $true
Stop-ProgressAnim
}
} )
#endregion
# ── Initialisation : chargement des settings ───────────────────────────────
$_settings = Load-Settings
$script:DataFolder = if ( $_settings . dataFolder -and ( Test-Path $_settings . dataFolder ) ) {