diff --git a/filer-manager.ps1 b/filer-manager.ps1
new file mode 100644
index 0000000..ee546fa
--- /dev/null
+++ b/filer-manager.ps1
@@ -0,0 +1,1762 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ Filer Manager - analyse des dossiers Windows et rapporte les tailles des
+ dossiers, les chemins trop longs et les permissions NTFS, avec export HTML et CSV.
+
+.DESCRIPTION
+ S'exécute avec une interface graphique WinForms par défaut. Peut aussi
+ s'exécuter sans interface (mode headless) pour la planification :
+
+ .\filer-manager.ps1 -Path "D:\Shares\Public" -Output report.html -NoGui
+
+ Fonctionne sur Windows PowerShell 5.1 et PowerShell 7+.
+
+.PARAMETER Path
+ Un ou plusieurs dossiers racines à analyser. S'il est fourni avec -Output (ou
+ avec -NoGui), le script s'exécute sans interface et écrit un rapport HTML.
+
+.PARAMETER Output
+ Chemin du rapport HTML à écrire en mode headless.
+
+.PARAMETER CsvOutput
+ Chemin de base du/des rapport(s) CSV à écrire en mode headless. Un fichier CSV
+ distinct est produit par catégorie ayant des données (p. ex. base.csv ->
+ base-tree.csv, base-permissions.csv, ...). Peut être combiné avec -Output pour
+ écrire HTML et CSV en même temps, ou utilisé seul pour un export CSV uniquement.
+
+.PARAMETER MaxPathLength
+ Longueur de chemin (caractères) à partir de laquelle un élément est signalé
+ comme « trop long ». Par défaut 260 (Windows MAX_PATH).
+
+.PARAMETER PermissionDepth
+ Nombre de niveaux de dossiers sous chaque racine pour lesquels collecter les
+ permissions NTFS. 0 = racines uniquement, 1 = racines + enfants immédiats
+ (par défaut), etc.
+
+.PARAMETER IncludeFilesInTree
+ Inclure les fichiers individuels (pas seulement les dossiers) dans
+ l'arborescence des tailles.
+
+.PARAMETER HideInheritedChildPerms
+ Omettre des rapports les permissions héritées portées par les dossiers
+ enfants (les entrées héritées qui ne font que recopier celles du parent). Les
+ permissions héritées des dossiers racines sont conservées, car le parent d'une
+ racine est hors analyse et ces entrées sont la seule trace des droits en vigueur.
+
+.PARAMETER HideSystemPrincipals
+ Omettre des rapports les permissions portées par des comptes/groupes système
+ et intégrés bien connus (p. ex. NT AUTHORITY\SYSTEM, BUILTIN\Administrators,
+ CREATOR OWNER, NT SERVICE\*). La détection se fait par SID (indépendante de la
+ langue), avec un repli sur le nom. Utile pour ne garder que les identités
+ métier dans l'audit des permissions.
+
+.PARAMETER GrantAccess
+ Lorsqu'un dossier/fichier ne peut pas être lu (accès refusé), s'approprie
+ automatiquement la propriété pour Administrators et accorde le FullControl à
+ BUILTIN\Administrators, puis réessaye. Nécessite une exécution en mode élevé.
+ Chaque modification est consignée dans le rapport.
+
+.PARAMETER KeepGrants
+ Conserver l'accès accordé par -GrantAccess après l'analyse. Par défaut, l'outil
+ annule chaque modification une fois l'analyse terminée (en restaurant le
+ propriétaire et l'ACL d'origine là où ils ont pu être lus au préalable).
+
+.PARAMETER NoGui
+ Forcer le mode sans interface (nécessite -Path et -Output).
+
+.PARAMETER Category
+ Catégories de rapport à inclure dans le HTML. Par défaut 'All'. Passez toute
+ combinaison de : Tree (tailles des dossiers), LongPaths, Permissions, Grants
+ (journal des accès accordés), Errors. À utiliser pour garder de gros rapports
+ petits, p. ex. -Category Permissions ou -Category Tree,LongPaths.
+
+.EXAMPLE
+ .\filer-manager.ps1
+ Lance l'interface graphique.
+
+.EXAMPLE
+ .\filer-manager.ps1 -Path "\\FILER01\Data","D:\Profiles" -Output C:\Reports\filer.html -NoGui
+
+.EXAMPLE
+ .\filer-manager.ps1 -Path "D:\Shares" -Output filer.html -NoGui -GrantAccess
+ Analyse D:\Shares en accordant l'accès Administrators aux éléments refusés afin
+ que l'analyse puisse se terminer, puis annule ces modifications ensuite.
+#>
+[CmdletBinding()]
+param(
+ [string[]]$Path,
+ [string]$Output,
+ [string]$CsvOutput,
+ [int]$MaxPathLength = 260,
+ [int]$PermissionDepth = 1,
+ [switch]$IncludeFilesInTree,
+ [switch]$HideInheritedChildPerms,
+ [switch]$HideSystemPrincipals,
+ [switch]$GrantAccess,
+ [switch]$KeepGrants,
+ [switch]$NoGui,
+ [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
+ [string[]]$Category = @('All')
+)
+
+# ============================================================================
+# CORE (GUI-independent). Kept as a string so it can be dot-sourced both here
+# and inside a background runspace used by the GUI to stay responsive.
+# ============================================================================
+$CoreFunctions = @'
+function Format-Bytes {
+ param([long]$Bytes)
+ if ($Bytes -ge 1TB) { '{0:N2} TB' -f ($Bytes / 1TB) }
+ elseif ($Bytes -ge 1GB) { '{0:N2} GB' -f ($Bytes / 1GB) }
+ elseif ($Bytes -ge 1MB) { '{0:N2} MB' -f ($Bytes / 1MB) }
+ elseif ($Bytes -ge 1KB) { '{0:N2} KB' -f ($Bytes / 1KB) }
+ else { "$Bytes B" }
+}
+
+function Test-IsElevated {
+ # True when the current process is running with Administrator rights.
+ try {
+ $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
+ $pr = New-Object System.Security.Principal.WindowsPrincipal($id)
+ return $pr.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
+ } catch { return $false }
+}
+
+function Test-IsAccessDenied {
+ # Recognise an "access denied" failure across locales and PowerShell hosts.
+ param($ErrorRecord)
+ $ex = $ErrorRecord.Exception
+ if ($ex -is [System.UnauthorizedAccessException]) { return $true }
+ if ($ErrorRecord.CategoryInfo -and $ErrorRecord.CategoryInfo.Category -eq 'PermissionDenied') { return $true }
+ return $false
+}
+
+function Resolve-PrincipalSid {
+ # Best-effort translation of an ACE IdentityReference (an NTAccount or a
+ # SecurityIdentifier) to its SID string. Returns $null when unresolvable
+ # (e.g. an orphaned account from a deleted domain user).
+ param($IdentityReference)
+ try {
+ if ($IdentityReference -is [System.Security.Principal.SecurityIdentifier]) { return $IdentityReference.Value }
+ return $IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
+ } catch {
+ # The reference may already be a raw SID string (unresolved account).
+ try { return (New-Object System.Security.Principal.SecurityIdentifier ([string]$IdentityReference)).Value } catch { return $null }
+ }
+}
+
+function Test-IsSystemPrincipal {
+ # True for well-known built-in / system accounts and groups, so the audit can
+ # focus on real business identities. Detection is by SID prefix (locale-
+ # independent), with a name-based fallback for the few cases where the SID
+ # could not be resolved. Examples: NT AUTHORITY\SYSTEM (S-1-5-18),
+ # BUILTIN\Administrators (S-1-5-32-544), CREATOR OWNER (S-1-3-*),
+ # NT SERVICE\TrustedInstaller (S-1-5-80-*).
+ param([string]$Identity, [string]$Sid)
+
+ if ($Sid) {
+ switch -Regex ($Sid) {
+ '^S-1-5-(18|19|20)$' { return $true } # SYSTEM / LOCAL SERVICE / NETWORK SERVICE
+ '^S-1-5-(6|9|17)$' { return $true } # SERVICE / Enterprise DCs / IUSR
+ '^S-1-5-32-' { return $true } # BUILTIN\* (Administrators, Users, ...)
+ '^S-1-5-(80|83|90|96)-' { return $true } # NT SERVICE / VM / Window Manager / Font driver
+ '^S-1-3-' { return $true } # CREATOR OWNER / CREATOR GROUP
+ }
+ }
+
+ # Name fallback: only when the SID could not be resolved (offline domain,
+ # broken trust). When a SID is known it is authoritative, so groups like
+ # Authenticated Users (S-1-5-11) are intentionally not treated as system even
+ # though their name carries the NT AUTHORITY prefix. Covers common
+ # English/French/German forms of the built-in domains and standalone principals.
+ if (-not $Sid -and $Identity) {
+ $id = $Identity.Trim()
+ foreach ($p in @('NT AUTHORITY\', 'AUTORITE NT\', "AUTORIT$([char]0xC9) NT\", 'NT-AUTORIT', 'BUILTIN\', 'NT SERVICE\', 'AUTORITE DE SECURITE')) {
+ if ($id.StartsWith($p, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
+ }
+ foreach ($e in @('SYSTEM', 'CREATOR OWNER', 'CREATOR GROUP', 'TrustedInstaller')) {
+ if ($id.Equals($e, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
+ }
+ }
+ return $false
+}
+
+function Grant-AdminAccess {
+ <# Seizes ownership for Administrators and grants BUILTIN\Administrators
+ FullControl on a single item (no recursion), so a denied path can be
+ scanned. Captures the original owner/ACL first (when readable) so the
+ change can be reverted later. Records every attempt in $Grants. Returns
+ $true when something was changed (so the caller can retry). #>
+ param(
+ [string]$Path,
+ [string]$Reason,
+ [System.Collections.ArrayList]$Grants,
+ [System.Collections.ArrayList]$ScanErrors
+ )
+
+ # BUILTIN\Administrators well-known SID - locale-independent (the group is
+ # named differently on non-English systems).
+ $adminSid = 'S-1-5-32-544'
+
+ # Don't act twice on the same path.
+ foreach ($g in $Grants) { if ($g.Path -eq $Path) { return $g.Success } }
+
+ # Capture the original state for a possible revert (may be unreadable).
+ $origSddl = $null; $origOwner = $null
+ try {
+ $a = Get-Acl -LiteralPath $Path -ErrorAction Stop
+ $origSddl = $a.Sddl; $origOwner = [string]$a.Owner
+ } catch { }
+
+ $isDir = $true
+ try { $isDir = [bool]((Get-Item -LiteralPath $Path -Force -ErrorAction Stop).PSIsContainer) } catch { }
+
+ $changes = New-Object System.Collections.ArrayList
+ $errText = $null
+
+ # 1) Seize ownership for Administrators (icacls enables the needed privilege
+ # itself and, unlike takeown, has no locale-specific confirmation prompt).
+ $null = & icacls.exe $Path /setowner "*$adminSid" /C /Q 2>&1
+ if ($LASTEXITCODE -eq 0) { [void]$changes.Add('Propriétaire défini -> Administrators') }
+ else { $errText = "setowner a échoué (code $LASTEXITCODE)" }
+
+ # 2) Grant Administrators FullControl (inheritable on directories).
+ $perm = if ($isDir) { "*${adminSid}:(OI)(CI)F" } else { "*${adminSid}:F" }
+ $null = & icacls.exe $Path /grant $perm /C /Q 2>&1
+ if ($LASTEXITCODE -eq 0) { [void]$changes.Add('Administrators -> FullControl accordé') }
+ else { if ($errText) { $errText += '; ' }; $errText += "grant a échoué (code $LASTEXITCODE)" }
+
+ $success = ($changes.Count -gt 0)
+
+ [void]$Grants.Add([pscustomobject]@{
+ Path = $Path
+ IsDir = $isDir
+ Reason = $Reason
+ Changes = ($changes -join '; ')
+ OriginalOwner = $origOwner
+ OriginalSddl = $origSddl
+ Success = $success
+ Error = $errText
+ Reverted = $false
+ RevertError = $null
+ })
+
+ if (-not $success -and $errText) {
+ [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = "Octroi automatique échoué : $errText" })
+ }
+ return $success
+}
+
+function Restore-Grants {
+ <# Reverts the changes made by Grant-AdminAccess, children before parents.
+ Restores the full original security descriptor (owner + ACL) when it was
+ captured; otherwise removes only the Administrators ACE we added. #>
+ param(
+ [System.Collections.ArrayList]$Grants,
+ [System.Collections.ArrayList]$ScanErrors,
+ [hashtable]$Progress
+ )
+ $adminSid = 'S-1-5-32-544'
+ for ($i = $Grants.Count - 1; $i -ge 0; $i--) {
+ $g = $Grants[$i]
+ if (-not $g.Success) { continue }
+ if ($Progress) { $Progress.Status = "Annulation de l'accès : $($g.Path)" }
+
+ $done = $false; $err = $null
+ if ($g.OriginalSddl) {
+ try {
+ if ($g.IsDir) { $sec = New-Object System.Security.AccessControl.DirectorySecurity }
+ else { $sec = New-Object System.Security.AccessControl.FileSecurity }
+ $sec.SetSecurityDescriptorSddlForm($g.OriginalSddl)
+ Set-Acl -LiteralPath $g.Path -AclObject $sec -ErrorAction Stop
+ $done = $true
+ } catch { $err = "Échec de la restauration Set-Acl : $($_.Exception.Message)" }
+ }
+ if (-not $done) {
+ # Best-effort fallback: drop the ACE we added and put the owner back.
+ try {
+ $null = & icacls.exe $g.Path /remove:g "*$adminSid" /C /Q 2>&1
+ if ($g.OriginalOwner) { $null = & icacls.exe $g.Path /setowner $g.OriginalOwner /C /Q 2>&1 }
+ if (-not $g.OriginalSddl) {
+ $done = $true
+ $err = "l'ACL d'origine était illisible ; seul l'ACE Administrators ajouté a été supprimé"
+ }
+ } catch { if ($err) { $err += '; ' }; $err += "Repli icacls échoué : $($_.Exception.Message)" }
+ }
+
+ $g.Reverted = $done
+ $g.RevertError = $err
+ if ($err) { [void]$ScanErrors.Add([pscustomobject]@{ Path = $g.Path; Error = "Annulation : $err" }) }
+ }
+}
+
+function Get-FolderNode {
+ <# Recursively builds a size tree. Accumulates long paths and errors via
+ synchronized collections passed by the caller. #>
+ param(
+ [string]$Path,
+ [int]$MaxLen,
+ [System.Collections.ArrayList]$LongPaths,
+ [System.Collections.ArrayList]$ScanErrors,
+ [hashtable]$Progress,
+ [int]$Depth = 0
+ )
+
+ $name = Split-Path -Path $Path -Leaf
+ if ([string]::IsNullOrEmpty($name)) { $name = $Path } # e.g. a drive root
+
+ $node = [ordered]@{
+ Name = $name
+ FullPath = $Path
+ Size = [long]0
+ FileCount = 0
+ FolderCount = 0
+ Depth = $Depth
+ Children = New-Object System.Collections.ArrayList
+ }
+
+ if ($Progress) { $Progress.Status = "Analyse : $Path" }
+
+ if ($Path.Length -ge $MaxLen) {
+ [void]$LongPaths.Add([pscustomobject]@{ Type = 'Dossier'; Length = $Path.Length; Path = $Path })
+ }
+
+ $entries = $null
+ $granted = $false
+ while ($true) {
+ try {
+ $entries = Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop
+ break
+ }
+ catch {
+ if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) {
+ $granted = $true
+ if ($Progress) { $Progress.Status = "Octroi de l'accès : $Path" }
+ if (Grant-AdminAccess -Path $Path -Reason 'énumérer le dossier' -Grants $GrantList -ScanErrors $ScanErrors) {
+ continue # retry once, now that access has been granted
+ }
+ }
+ [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = $_.Exception.Message })
+ return [pscustomobject]$node
+ }
+ }
+
+ foreach ($entry in $entries) {
+ if ($entry.PSIsContainer) {
+ $child = Get-FolderNode -Path $entry.FullName -MaxLen $MaxLen `
+ -LongPaths $LongPaths -ScanErrors $ScanErrors `
+ -Progress $Progress -Depth ($Depth + 1)
+ $node.Size += $child.Size
+ $node.FileCount += $child.FileCount
+ $node.FolderCount += 1 + $child.FolderCount
+ [void]$node.Children.Add($child)
+ }
+ else {
+ $len = 0
+ try { $len = [long]$entry.Length } catch { $len = 0 }
+ $node.Size += $len
+ $node.FileCount += 1
+ if ($entry.FullName.Length -ge $MaxLen) {
+ [void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullName.Length; Path = $entry.FullName })
+ }
+ if ($IncludeFilesInTreeFlag) {
+ [void]$node.Children.Add([pscustomobject]@{
+ Name = $entry.Name; FullPath = $entry.FullName; Size = $len
+ FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsFile = $true
+ Children = (New-Object System.Collections.ArrayList)
+ })
+ }
+ }
+ }
+
+ return [pscustomobject]$node
+}
+
+function Get-FolderPermissions {
+ param(
+ [string]$Path,
+ [int]$Depth,
+ [System.Collections.ArrayList]$ScanErrors,
+ [int]$Current = 0
+ )
+ $results = New-Object System.Collections.ArrayList
+ $granted = $false
+ while ($true) {
+ try {
+ $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
+ foreach ($ace in $acl.Access) {
+ [void]$results.Add([pscustomobject]@{
+ Folder = $Path
+ Owner = $acl.Owner
+ Identity = [string]$ace.IdentityReference
+ Sid = Resolve-PrincipalSid $ace.IdentityReference
+ Rights = [string]$ace.FileSystemRights
+ Type = [string]$ace.AccessControlType
+ Inherited = $ace.IsInherited
+ })
+ }
+ break
+ }
+ catch {
+ if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) {
+ $granted = $true
+ if (Grant-AdminAccess -Path $Path -Reason 'lire les permissions' -Grants $GrantList -ScanErrors $ScanErrors) {
+ continue # retry once, now that the ACL is readable
+ }
+ }
+ [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = "ACL : $($_.Exception.Message)" })
+ break
+ }
+ }
+
+ if ($Current -lt $Depth) {
+ $subDirs = $null
+ try { $subDirs = Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction Stop } catch { $subDirs = @() }
+ foreach ($d in $subDirs) {
+ $child = Get-FolderPermissions -Path $d.FullName -Depth $Depth -ScanErrors $ScanErrors -Current ($Current + 1)
+ foreach ($r in $child) { [void]$results.Add($r) }
+ }
+ }
+ return $results
+}
+
+function Invoke-FilerScan {
+ param(
+ [string[]]$Paths,
+ [int]$MaxPathLength = 260,
+ [int]$PermissionDepth = 1,
+ [bool]$IncludeFilesInTree = $false,
+ [bool]$GrantAccess = $false,
+ [bool]$RevertGrants = $true,
+ [hashtable]$Progress
+ )
+
+ $script:IncludeFilesInTreeFlag = $IncludeFilesInTree
+ $script:GrantAccessFlag = $GrantAccess
+ $script:GrantList = New-Object System.Collections.ArrayList
+
+ $longPaths = New-Object System.Collections.ArrayList
+ $scanErrors = New-Object System.Collections.ArrayList
+ $permissions = New-Object System.Collections.ArrayList
+ $roots = New-Object System.Collections.ArrayList
+
+ foreach ($p in $Paths) {
+ if (-not (Test-Path -LiteralPath $p)) {
+ [void]$scanErrors.Add([pscustomobject]@{ Path = $p; Error = 'Chemin introuvable' })
+ continue
+ }
+ $fullPath = (Resolve-Path -LiteralPath $p).Path
+ $node = Get-FolderNode -Path $fullPath -MaxLen $MaxPathLength `
+ -LongPaths $longPaths -ScanErrors $scanErrors -Progress $Progress
+ [void]$roots.Add($node)
+
+ if ($Progress) { $Progress.Status = "Lecture des permissions : $fullPath" }
+ $perms = Get-FolderPermissions -Path $fullPath -Depth $PermissionDepth -ScanErrors $scanErrors
+ foreach ($r in $perms) { [void]$permissions.Add($r) }
+ }
+
+ # Revert the access we granted, once the scan (and ACL reads) are done.
+ if ($GrantAccess -and $RevertGrants -and $script:GrantList.Count -gt 0) {
+ if ($Progress) { $Progress.Status = 'Annulation des accès accordés...' }
+ Restore-Grants -Grants $script:GrantList -ScanErrors $scanErrors -Progress $Progress
+ }
+
+ $totalSize = ($roots | Measure-Object -Property Size -Sum).Sum
+ if (-not $totalSize) { $totalSize = 0 }
+
+ $grantsMade = @($script:GrantList | Where-Object { $_.Success })
+
+ return [pscustomobject]@{
+ Roots = $roots
+ LongPaths = ($longPaths | Sort-Object Length -Descending)
+ Permissions = $permissions
+ Grants = $script:GrantList
+ Errors = $scanErrors
+ Settings = [pscustomobject]@{
+ MaxPathLength = $MaxPathLength
+ PermissionDepth = $PermissionDepth
+ IncludeFilesInTree = $IncludeFilesInTree
+ GrantAccess = $GrantAccess
+ RevertGrants = $RevertGrants
+ }
+ Stats = [pscustomobject]@{
+ TotalSize = [long]$totalSize
+ TotalFiles = ($roots | Measure-Object -Property FileCount -Sum).Sum
+ TotalFolders = ($roots | Measure-Object -Property FolderCount -Sum).Sum
+ LongPaths = $longPaths.Count
+ Errors = $scanErrors.Count
+ Grants = $grantsMade.Count
+ }
+ Computer = $env:COMPUTERNAME
+ GeneratedAt = (Get-Date)
+ }
+}
+
+function Get-FilerRootPathSet {
+ # Case-insensitive set of the scan-root full paths. Used to decide which
+ # permission rows sit on a scan root (vs. a child folder).
+ param($Scan)
+ $set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($r in $Scan.Roots) { [void]$set.Add([string]$r.FullPath) }
+ return $set
+}
+
+function Select-FilerPerms {
+ # Returns the scan's permission ACEs, optionally dropping inherited ACEs that
+ # sit on a child folder and/or ACEs held by well-known system principals.
+ # Inherited ACEs on the scan roots are always kept: a root's parent is outside
+ # the scan, so those entries are the only record of the permissions in effect there.
+ param($Scan, [bool]$HideInheritedChildPerms, [bool]$HideSystemPrincipals)
+ $rows = @($Scan.Permissions)
+ if ($HideInheritedChildPerms) {
+ $roots = Get-FilerRootPathSet -Scan $Scan
+ $rows = @($rows | Where-Object { (-not $_.Inherited) -or $roots.Contains([string]$_.Folder) })
+ }
+ if ($HideSystemPrincipals) {
+ $rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) })
+ }
+ return $rows
+}
+
+function ConvertTo-FilerHtmlReport {
+ param(
+ [Parameter(Mandatory)] $Scan,
+ [Parameter(Mandatory)] [string]$Path,
+ # Which report categories to include. 'All' (default) emits everything;
+ # otherwise pass any combination of Tree, LongPaths, Permissions, Grants, Errors.
+ [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
+ [string[]]$Categories = @('All'),
+ # When set, inherited permissions on child folders are omitted (roots keep theirs).
+ [bool]$HideInheritedChildPerms = $false,
+ # When set, ACEs held by well-known system/built-in principals are omitted.
+ [bool]$HideSystemPrincipals = $false
+ )
+
+ function _enc([string]$s) { [System.Net.WebUtility]::HtmlEncode($s) }
+
+ # Resolve the requested categories into per-section switches.
+ $all = ($Categories -contains 'All') -or ($Categories.Count -eq 0)
+ $wantTree = $all -or ($Categories -contains 'Tree')
+ $wantLong = $all -or ($Categories -contains 'LongPaths')
+ $wantPerm = $all -or ($Categories -contains 'Permissions')
+ $wantGrants = $all -or ($Categories -contains 'Grants')
+ $wantErrors = $all -or ($Categories -contains 'Errors')
+
+ $sb = New-Object System.Text.StringBuilder
+
+ # ---- recursive tree renderer (folders sorted largest-first) ----
+ function _renderNode {
+ param($Node, [long]$ParentSize, [System.Text.StringBuilder]$Out)
+ $pct = if ($ParentSize -gt 0) { [math]::Round(($Node.Size / $ParentSize) * 100, 1) } else { 100 }
+ $sizeStr = Format-Bytes $Node.Size
+ $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
+ $icon = if ($isFile) { '📄' } else { '📁' }
+ $meta = if ($isFile) { '' } else { " $($Node.FolderCount) dossiers, $($Node.FileCount) fichiers " }
+ $kids = @($Node.Children | Where-Object { $_ } )
+
+ $summary = "$icon $(_enc $Node.Name) $sizeStr " +
+ "$pct% $meta"
+
+ if ($kids.Count -gt 0) {
+ $open = if ($Node.Depth -eq 0) { ' open' } else { '' }
+ [void]$Out.Append("$summary ")
+ foreach ($c in ($kids | Sort-Object @{E={$_.Size}} -Descending)) {
+ _renderNode -Node $c -ParentSize $Node.Size -Out $Out
+ }
+ [void]$Out.Append("
")
+ }
+ else {
+ [void]$Out.Append("
$summary
")
+ }
+ }
+
+ $treeSb = New-Object System.Text.StringBuilder
+ if ($wantTree) {
+ foreach ($root in $Scan.Roots) { _renderNode -Node $root -ParentSize $root.Size -Out $treeSb }
+ }
+
+ # ---- long paths ----
+ $longRows = New-Object System.Text.StringBuilder
+ if ($wantLong) {
+ foreach ($lp in $Scan.LongPaths) {
+ [void]$longRows.Append("$($lp.Length) $(_enc $lp.Type) $(_enc $lp.Path) ")
+ }
+ if ($Scan.LongPaths.Count -eq 0) { [void]$longRows.Append("Aucun chemin égal ou supérieur à $($Scan.Settings.MaxPathLength) caractères. ✅ ") }
+ }
+
+ # ---- permissions (nested folder tree; every level is collapsible) ----
+ $permSb = New-Object System.Text.StringBuilder
+ if ($wantPerm) {
+ # Canonicalise so 8.3 / trailing-slash / casing differences between a folder
+ # and its parent (from Get-ChildItem) still line up when building the tree.
+ function _canon([string]$p) { try { ([System.IO.Path]::GetFullPath($p)).TrimEnd('\') } catch { $p.TrimEnd('\') } }
+ $byFolder = (Select-FilerPerms -Scan $Scan -HideInheritedChildPerms $HideInheritedChildPerms -HideSystemPrincipals $HideSystemPrincipals) | Group-Object Folder
+
+ # One node per folder, keyed by canonical path.
+ $nodes = @{}
+ foreach ($grp in $byFolder) {
+ $canon = _canon ([string]$grp.Name)
+ $nodes[$canon] = [pscustomobject]@{
+ Canon = $canon
+ Display = [string]$grp.Name
+ Owner = ($grp.Group | Select-Object -First 1).Owner
+ Aces = $grp.Group
+ Children = New-Object System.Collections.ArrayList
+ }
+ }
+ # Attach each folder to its nearest ancestor that also has permissions; the
+ # ones with no such ancestor are the tree's top-level (scan-root) nodes.
+ $tops = New-Object System.Collections.ArrayList
+ foreach ($node in $nodes.Values) {
+ $parent = [System.IO.Path]::GetDirectoryName($node.Canon)
+ $attached = $false
+ while ($parent) {
+ if ($nodes.ContainsKey($parent)) { [void]$nodes[$parent].Children.Add($node); $attached = $true; break }
+ $up = [System.IO.Path]::GetDirectoryName($parent)
+ if ($up -eq $parent) { break }
+ $parent = $up
+ }
+ if (-not $attached) { [void]$tops.Add($node) }
+ }
+
+ function _renderPermNode {
+ param($Node, [bool]$IsTop, [System.Text.StringBuilder]$Out)
+ # Top-level nodes show their full path; nested ones just the leaf name.
+ $label = if ($IsTop) { $Node.Display } else { Split-Path -Leaf $Node.Display }
+ $topCls = if ($IsTop) { ' top' } else { '' }
+ [void]$Out.Append("$(_enc $label) Propriétaire : $(_enc $Node.Owner) ")
+ [void]$Out.Append("Identité Droits Type Hérité ")
+ foreach ($ace in $Node.Aces) {
+ $cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' }
+ $sys = Test-IsSystemPrincipal -Identity $ace.Identity -Sid $ace.Sid
+ [void]$Out.Append("$(_enc $ace.Identity) $(_enc $ace.Rights) $(_enc $ace.Type) $($ace.Inherited) ")
+ }
+ [void]$Out.Append("
")
+ if ($Node.Children.Count -gt 0) {
+ [void]$Out.Append("")
+ foreach ($c in ($Node.Children | Sort-Object Display)) { _renderPermNode -Node $c -IsTop $false -Out $Out }
+ [void]$Out.Append("
")
+ }
+ [void]$Out.Append(" ")
+ }
+
+ foreach ($t in ($tops | Sort-Object Display)) { _renderPermNode -Node $t -IsTop $true -Out $permSb }
+ if ($byFolder.Count -eq 0) { [void]$permSb.Append("Aucune permission collectée.
") }
+ }
+
+ # ---- granted access (auto-remediation log) ----
+ $grantSb = New-Object System.Text.StringBuilder
+ $grantRows = @($Scan.Grants | Where-Object { $_.Success })
+ if ($wantGrants) {
+ foreach ($g in $grantRows) {
+ if ($g.Reverted) { $state = "annulé " }
+ elseif ($Scan.Settings.RevertGrants) { $state = "NON annulé$(if($g.RevertError){' - ' + (_enc $g.RevertError)}) " }
+ else { $state = "conservé " }
+ [void]$grantSb.Append("$(_enc $g.Path) $(_enc $g.Reason) $(_enc $g.Changes) $(_enc $g.OriginalOwner) $state ")
+ }
+ }
+
+ # ---- errors ----
+ $errSb = New-Object System.Text.StringBuilder
+ if ($wantErrors) {
+ foreach ($e in $Scan.Errors) {
+ [void]$errSb.Append("$(_enc $e.Path) $(_enc $e.Error) ")
+ }
+ }
+
+ $rootsList = ($Scan.Roots | ForEach-Object { _enc $_.FullPath }) -join ' '
+ $gen = $Scan.GeneratedAt.ToString('yyyy-MM-dd HH:mm:ss')
+
+ # When only some categories are exported, name them in the header.
+ $catLabels = [ordered]@{
+ Tree = 'Tailles des dossiers'; LongPaths = 'Chemins trop longs'; Permissions = 'Permissions'
+ Grants = 'Accès accordé'; Errors = 'Erreurs'
+ }
+ $included = @()
+ if ($wantTree) { $included += $catLabels.Tree }
+ if ($wantLong) { $included += $catLabels.LongPaths }
+ if ($wantPerm) { $included += $catLabels.Permissions }
+ if ($wantGrants -and $Scan.Settings.GrantAccess) { $included += $catLabels.Grants }
+ if ($wantErrors -and $Scan.Errors.Count -gt 0) { $included += $catLabels.Errors }
+ $catNote = if ($all) { '' } else { "Catégories : $(_enc ($included -join ', '))
" }
+
+ $html = @"
+
+
+
+Rapport Filer Manager - $gen
+
+
+
+
+
+
$(Format-Bytes $Scan.Stats.TotalSize)
Taille totale
+
$('{0:N0}' -f $Scan.Stats.TotalFolders)
Dossiers
+
$('{0:N0}' -f $Scan.Stats.TotalFiles)
Fichiers
+
$($Scan.Stats.LongPaths)
Chemins trop longs
+
$($Scan.Stats.Errors)
Erreurs / refusés
+ $(if ($Scan.Settings.GrantAccess) { "
$($Scan.Stats.Grants)
Accès accordé
" })
+
+
+ $(if ($wantTree) { @"
+
+ 📊 Tailles des dossiers
+ $($treeSb.ToString())
+
+"@ })
+
+ $(if ($wantLong) { @"
+
+"@ })
+
+ $(if ($wantPerm) { @"
+
+"@ })
+
+ $(if ($wantGrants -and $Scan.Settings.GrantAccess) { @"
+
+ 🔨 Accès accordé pour terminer l'analyse
+ Éléments qui étaient refusés, sur lesquels la propriété/le FullControl Administrators a été appliqué afin de pouvoir les analyser.
+ Mode : $(if ($Scan.Settings.RevertGrants) { "annuler après l'analyse" } else { 'conserver les modifications' }).
+ $(if ($grantRows.Count -gt 0) { @"
+
+"@ } else { "Aucune modification d'accès n'était nécessaire. ✅
" })
+
+"@ })
+
+ $(if ($wantErrors -and $Scan.Errors.Count -gt 0) { @"
+
+ ❌ Erreurs et accès refusé
+
+
+"@ })
+
+Filer Manager · rapport autonome · $gen
+
+
+"@
+
+ Set-Content -LiteralPath $Path -Value $html -Encoding UTF8
+ return $Path
+}
+
+function ConvertTo-FilerCsvReport {
+ <# Writes the scan results to CSV. Because the report categories have very
+ different shapes, each selected category with data is written to its own
+ file, derived from the base $Path (e.g. report.csv -> report-tree.csv,
+ report-permissions.csv, ...). Returns the list of files written. The list
+ separator follows the current culture so the files open cleanly in the
+ local Excel (';' on French systems, ',' elsewhere). #>
+ param(
+ [Parameter(Mandatory)] $Scan,
+ [Parameter(Mandatory)] [string]$Path,
+ [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
+ [string[]]$Categories = @('All'),
+ # When set, inherited permissions on child folders are omitted (roots keep theirs).
+ [bool]$HideInheritedChildPerms = $false,
+ # When set, ACEs held by well-known system/built-in principals are omitted.
+ [bool]$HideSystemPrincipals = $false
+ )
+
+ $all = ($Categories -contains 'All') -or ($Categories.Count -eq 0)
+ $wantTree = $all -or ($Categories -contains 'Tree')
+ $wantLong = $all -or ($Categories -contains 'LongPaths')
+ $wantPerm = $all -or ($Categories -contains 'Permissions')
+ $wantGrants = $all -or ($Categories -contains 'Grants')
+ $wantErrors = $all -or ($Categories -contains 'Errors')
+
+ $delim = (Get-Culture).TextInfo.ListSeparator
+ if ([string]::IsNullOrEmpty($delim)) { $delim = ',' }
+
+ # Derive a per-category file path from the base path.
+ $dir = Split-Path -Path $Path -Parent
+ $base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
+ if ([string]::IsNullOrEmpty($base)) { $base = 'filer-report' }
+
+ $written = New-Object System.Collections.ArrayList
+ function _writeCsv {
+ param($Rows, [string]$Suffix)
+ $rows = @($Rows | Where-Object { $null -ne $_ })
+ if ($rows.Count -eq 0) { return } # don't emit empty files
+ $name = "$base-$Suffix.csv"
+ $p = if ([string]::IsNullOrEmpty($dir)) { $name } else { Join-Path $dir $name }
+ $rows | Export-Csv -LiteralPath $p -NoTypeInformation -Encoding UTF8 -Delimiter $delim
+ [void]$written.Add($p)
+ }
+
+ # ---- tree (flattened depth-first, folders largest-first like the HTML) ----
+ if ($wantTree) {
+ $treeRows = New-Object System.Collections.ArrayList
+ function _flattenNode {
+ param($Node, [string]$Root, [System.Collections.ArrayList]$Acc)
+ $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
+ [void]$Acc.Add([pscustomobject]@{
+ Racine = $Root
+ Chemin = $Node.FullPath
+ Nom = $Node.Name
+ Type = if ($isFile) { 'Fichier' } else { 'Dossier' }
+ Profondeur = $Node.Depth
+ TailleOctets = [long]$Node.Size
+ Taille = Format-Bytes $Node.Size
+ Fichiers = $Node.FileCount
+ Dossiers = $Node.FolderCount
+ })
+ foreach ($c in @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)) {
+ _flattenNode -Node $c -Root $Root -Acc $Acc
+ }
+ }
+ foreach ($root in $Scan.Roots) { _flattenNode -Node $root -Root $root.FullPath -Acc $treeRows }
+ _writeCsv -Rows $treeRows -Suffix 'tree'
+ }
+
+ # ---- long paths ----
+ if ($wantLong) {
+ $longRows = foreach ($lp in $Scan.LongPaths) {
+ [pscustomobject]@{ Longueur = $lp.Length; Type = $lp.Type; Chemin = $lp.Path }
+ }
+ _writeCsv -Rows $longRows -Suffix 'longpaths'
+ }
+
+ # ---- permissions ----
+ if ($wantPerm) {
+ $permRows = foreach ($ace in (Select-FilerPerms -Scan $Scan -HideInheritedChildPerms $HideInheritedChildPerms -HideSystemPrincipals $HideSystemPrincipals)) {
+ [pscustomobject]@{
+ Dossier = $ace.Folder
+ Proprietaire = $ace.Owner
+ Identite = $ace.Identity
+ Droits = $ace.Rights
+ Type = $ace.Type
+ Herite = $ace.Inherited
+ }
+ }
+ _writeCsv -Rows $permRows -Suffix 'permissions'
+ }
+
+ # ---- granted access (successful remediations only, like the HTML) ----
+ if ($wantGrants -and $Scan.Settings.GrantAccess) {
+ $grantRows = foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) {
+ if ($g.Reverted) { $state = 'annulé' }
+ elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé' }
+ else { $state = 'conservé' }
+ [pscustomobject]@{
+ Chemin = $g.Path
+ Raison = $g.Reason
+ Modification = $g.Changes
+ ProprietaireOrigine = $g.OriginalOwner
+ Etat = $state
+ ErreurAnnulation = $g.RevertError
+ }
+ }
+ _writeCsv -Rows $grantRows -Suffix 'grants'
+ }
+
+ # ---- errors ----
+ if ($wantErrors) {
+ $errRows = foreach ($e in $Scan.Errors) {
+ [pscustomobject]@{ Chemin = $e.Path; Erreur = $e.Error }
+ }
+ _writeCsv -Rows $errRows -Suffix 'errors'
+ }
+
+ return $written.ToArray()
+}
+'@
+
+# Dot-source core functions into the current scope (for headless + GUI export).
+. ([scriptblock]::Create($CoreFunctions))
+
+# ============================================================================
+# HEADLESS MODE
+# ============================================================================
+$runHeadless = $NoGui -or ($Path -and ($Output -or $CsvOutput))
+if ($runHeadless) {
+ if (-not $Path) { throw "Le mode sans interface requiert -Path." }
+ if (-not $Output -and -not $CsvOutput) { throw "Le mode sans interface requiert -Output et/ou -CsvOutput." }
+
+ if ($GrantAccess -and -not (Test-IsElevated)) {
+ Write-Warning "-GrantAccess nécessite des droits Administrateur ; ce processus n'est pas élevé. Les éléments refusés peuvent ne pas être corrigés."
+ }
+
+ Write-Host "Analyse de $($Path -join ', ') en cours ..." -ForegroundColor Cyan
+ $progress = [hashtable]::Synchronized(@{ Status = '' })
+ $scan = Invoke-FilerScan -Paths $Path -MaxPathLength $MaxPathLength `
+ -PermissionDepth $PermissionDepth `
+ -IncludeFilesInTree:$IncludeFilesInTree `
+ -GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress
+
+ if ($Output) {
+ $out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category -HideInheritedChildPerms:$HideInheritedChildPerms -HideSystemPrincipals:$HideSystemPrincipals
+ Write-Host "Rapport HTML généré : $out" -ForegroundColor Green
+ }
+ if ($CsvOutput) {
+ $csvFiles = @(ConvertTo-FilerCsvReport -Scan $scan -Path $CsvOutput -Categories $Category -HideInheritedChildPerms:$HideInheritedChildPerms -HideSystemPrincipals:$HideSystemPrincipals)
+ if ($csvFiles.Count -gt 0) {
+ Write-Host "Rapport(s) CSV généré(s) :" -ForegroundColor Green
+ foreach ($f in $csvFiles) { Write-Host " $f" -ForegroundColor Green }
+ } else {
+ Write-Host "Aucun fichier CSV généré (catégories sans données)." -ForegroundColor Yellow
+ }
+ }
+ Write-Host (" {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs" -f `
+ (Format-Bytes $scan.Stats.TotalSize), $scan.Stats.TotalFolders, $scan.Stats.TotalFiles,
+ $scan.Stats.LongPaths, $scan.Stats.Errors)
+ if ($GrantAccess) {
+ Write-Host (" {0} élément(s) ont reçu un accès ({1})" -f `
+ $scan.Stats.Grants, $(if ($KeepGrants) { 'conservé' } else { 'annulé' })) -ForegroundColor Yellow
+ }
+ return
+}
+
+# ============================================================================
+# GUI MODE
+# ============================================================================
+Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
+[System.Windows.Forms.Application]::EnableVisualStyles()
+
+$form = New-Object System.Windows.Forms.Form
+$form.Text = 'Filer Manager'
+$form.Size = New-Object System.Drawing.Size(960, 720)
+$form.StartPosition = 'CenterScreen'
+$form.MinimumSize = New-Object System.Drawing.Size(760, 560)
+
+# ---- top: folder list + add/remove ----
+$lblFolders = New-Object System.Windows.Forms.Label
+$lblFolders.Text = 'Dossiers à analyser :'
+$lblFolders.Location = '12,12'; $lblFolders.AutoSize = $true
+$form.Controls.Add($lblFolders)
+
+$lstFolders = New-Object System.Windows.Forms.ListBox
+$lstFolders.Location = '12,32'; $lstFolders.Size = '700,84'
+$lstFolders.Anchor = 'Top,Left,Right'
+$lstFolders.HorizontalScrollbar = $true
+$form.Controls.Add($lstFolders)
+
+$btnAdd = New-Object System.Windows.Forms.Button
+$btnAdd.Text = 'Ajouter...'; $btnAdd.Location = '724,32'; $btnAdd.Size = '110,28'
+$btnAdd.Anchor = 'Top,Right'
+$form.Controls.Add($btnAdd)
+
+$btnRemove = New-Object System.Windows.Forms.Button
+$btnRemove.Text = 'Supprimer'; $btnRemove.Location = '724,66'; $btnRemove.Size = '110,28'
+$btnRemove.Anchor = 'Top,Right'
+$form.Controls.Add($btnRemove)
+
+# ---- settings row ----
+$lblMax = New-Object System.Windows.Forms.Label
+$lblMax.Text = 'Longueur max :'; $lblMax.Location = '12,128'; $lblMax.AutoSize = $true
+$form.Controls.Add($lblMax)
+
+$numMax = New-Object System.Windows.Forms.NumericUpDown
+$numMax.Location = '118,126'; $numMax.Size = '70,24'
+$numMax.Minimum = 1; $numMax.Maximum = 32767; $numMax.Value = $MaxPathLength
+$form.Controls.Add($numMax)
+
+$lblDepth = New-Object System.Windows.Forms.Label
+$lblDepth.Text = 'Profondeur :'; $lblDepth.Location = '202,128'; $lblDepth.AutoSize = $true
+$form.Controls.Add($lblDepth)
+
+$numDepth = New-Object System.Windows.Forms.NumericUpDown
+$numDepth.Location = '288,126'; $numDepth.Size = '60,24'
+$numDepth.Minimum = 0; $numDepth.Maximum = 20; $numDepth.Value = $PermissionDepth
+$form.Controls.Add($numDepth)
+
+$chkFiles = New-Object System.Windows.Forms.CheckBox
+$chkFiles.Text = 'Inclure les fichiers'; $chkFiles.Location = '362,127'; $chkFiles.AutoSize = $true
+$chkFiles.Checked = [bool]$IncludeFilesInTree
+$form.Controls.Add($chkFiles)
+
+# ---- auto-grant row ----
+$chkGrant = New-Object System.Windows.Forms.CheckBox
+$chkGrant.Text = "Accorder l'accès Administrators aux éléments refusés"
+$chkGrant.Location = '12,156'; $chkGrant.AutoSize = $true
+$chkGrant.Checked = [bool]$GrantAccess
+$form.Controls.Add($chkGrant)
+
+$chkRevert = New-Object System.Windows.Forms.CheckBox
+$chkRevert.Text = "Annuler les modifications après l'analyse"
+$chkRevert.Location = '380,156'; $chkRevert.AutoSize = $true
+$chkRevert.Checked = (-not $KeepGrants)
+$chkRevert.Enabled = $chkGrant.Checked
+$form.Controls.Add($chkRevert)
+
+$chkGrant.Add_CheckedChanged({ $chkRevert.Enabled = $chkGrant.Checked })
+
+$btnScan = New-Object System.Windows.Forms.Button
+$btnScan.Text = 'Analyser'; $btnScan.Location = '600,124'; $btnScan.Size = '110,30'
+$btnScan.Anchor = 'Top,Right'
+$btnScan.BackColor = [System.Drawing.Color]::FromArgb(79,140,255)
+$btnScan.ForeColor = [System.Drawing.Color]::White
+$btnScan.FlatStyle = 'Flat'
+$form.Controls.Add($btnScan)
+
+$btnExport = New-Object System.Windows.Forms.Button
+$btnExport.Text = 'Exporter...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30'
+$btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false
+$form.Controls.Add($btnExport)
+
+# ---- tabs ----
+$tabs = New-Object System.Windows.Forms.TabControl
+$tabs.Location = '12,188'; $tabs.Size = '922,456'
+$tabs.Anchor = 'Top,Bottom,Left,Right'
+$form.Controls.Add($tabs)
+
+$tabTree = New-Object System.Windows.Forms.TabPage; $tabTree.Text = 'Tailles des dossiers'
+$tabLong = New-Object System.Windows.Forms.TabPage; $tabLong.Text = 'Chemins trop longs'
+$tabPerm = New-Object System.Windows.Forms.TabPage; $tabPerm.Text = 'Permissions'
+$tabGrant = New-Object System.Windows.Forms.TabPage; $tabGrant.Text = 'Accès accordé'
+$tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant))
+
+$tree = New-Object System.Windows.Forms.TreeView
+$tree.Dock = 'Fill'; $tree.HideSelection = $false
+# Owner-draw the labels so we can paint a proportional "space taken" bar after
+# each node, mirroring the bars in the HTML report. The control still draws the
+# +/- expanders and connecting lines itself.
+$tree.DrawMode = 'OwnerDrawText'
+$tabTree.Controls.Add($tree)
+# Fill a node's real children the first time it is expanded (lazy loading keeps
+# the GUI responsive no matter how large the scanned tree is).
+$tree.Add_BeforeExpand({ param($s, $e) Expand-FilerTreeNode -TreeNode $e.Node })
+
+# Render each tree node: the label, then a bar showing the folder's share of its
+# parent's size (roots = 100%), then the percentage - matching the HTML report.
+$tree.Add_DrawNode({
+ param($s, $e)
+ $nodeData = $e.Node.Tag
+ # Placeholder ("Chargement...") and not-yet-measured nodes: draw normally.
+ if ($null -eq $nodeData -or $e.Bounds.Width -le 0 -or
+ -not ($nodeData.PSObject.Properties.Name -contains 'Size')) {
+ $e.DrawDefault = $true; return
+ }
+
+ $selected = ($e.State -band [System.Windows.Forms.TreeNodeStates]::Selected) -ne 0
+ $foreColor = if ($selected) { [System.Drawing.SystemColors]::HighlightText } else { $tree.ForeColor }
+ if ($selected) {
+ $hl = New-Object System.Drawing.SolidBrush ([System.Drawing.SystemColors]::Highlight)
+ $e.Graphics.FillRectangle($hl, $e.Bounds); $hl.Dispose()
+ }
+
+ $flags = [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor `
+ [System.Windows.Forms.TextFormatFlags]::Left -bor `
+ [System.Windows.Forms.TextFormatFlags]::NoPrefix
+ [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.Node.Text, $tree.Font, $e.Bounds, $foreColor, $flags)
+
+ # Share of the parent folder's size (top-level roots have no parent = 100%).
+ $pct = 100.0
+ $parent = $e.Node.Parent
+ if ($parent -and $parent.Tag -and
+ ($parent.Tag.PSObject.Properties.Name -contains 'Size') -and $parent.Tag.Size -gt 0) {
+ $pct = [math]::Round(($nodeData.Size / $parent.Tag.Size) * 100, 1)
+ }
+ if ($pct -lt 0) { $pct = 0 } elseif ($pct -gt 100) { $pct = 100 }
+
+ # Bar just to the right of the label.
+ $barW = 120; $barH = 9
+ $barX = $e.Bounds.Right + 10
+ $barY = $e.Bounds.Top + [int](($e.Bounds.Height - $barH) / 2)
+
+ $track = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(228, 230, 235))
+ $e.Graphics.FillRectangle($track, $barX, $barY, $barW, $barH); $track.Dispose()
+
+ $fillW = [int][math]::Round($barW * $pct / 100)
+ if ($fillW -gt 0) {
+ $fill = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(59, 111, 212))
+ $e.Graphics.FillRectangle($fill, $barX, $barY, $fillW, $barH); $fill.Dispose()
+ }
+ $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 175, 185))
+ $e.Graphics.DrawRectangle($pen, $barX, $barY, $barW, $barH); $pen.Dispose()
+
+ $pctRect = New-Object System.Drawing.Rectangle (($barX + $barW + 6), $e.Bounds.Top, 52, $e.Bounds.Height)
+ [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, ('{0:N1}%' -f $pct), $tree.Font, $pctRect, $tree.ForeColor, $flags)
+})
+
+$lvLong = New-Object System.Windows.Forms.ListView
+$lvLong.Dock = 'Fill'; $lvLong.View = 'Details'; $lvLong.FullRowSelect = $true; $lvLong.GridLines = $true
+[void]$lvLong.Columns.Add('Longueur', 70); [void]$lvLong.Columns.Add('Type', 70); [void]$lvLong.Columns.Add('Chemin', 760)
+$tabLong.Controls.Add($lvLong)
+
+$lvPerm = New-Object System.Windows.Forms.ListView
+$lvPerm.Dock = 'Fill'; $lvPerm.View = 'Details'; $lvPerm.FullRowSelect = $true; $lvPerm.GridLines = $true
+[void]$lvPerm.Columns.Add('Dossier', 320); [void]$lvPerm.Columns.Add('Identité', 200)
+[void]$lvPerm.Columns.Add('Droits', 200); [void]$lvPerm.Columns.Add('Type', 60); [void]$lvPerm.Columns.Add('Hérité', 70)
+$tabPerm.Controls.Add($lvPerm)
+
+$lvGrant = New-Object System.Windows.Forms.ListView
+$lvGrant.Dock = 'Fill'; $lvGrant.View = 'Details'; $lvGrant.FullRowSelect = $true; $lvGrant.GridLines = $true
+[void]$lvGrant.Columns.Add('Chemin', 360); [void]$lvGrant.Columns.Add('Raison', 110)
+[void]$lvGrant.Columns.Add('Modification', 240); [void]$lvGrant.Columns.Add("Propriétaire d'origine", 150)
+[void]$lvGrant.Columns.Add('État', 90)
+$tabGrant.Controls.Add($lvGrant)
+
+# Filter toolbar for the permissions tab (docked above the list).
+$permBar = New-Object System.Windows.Forms.Panel
+$permBar.Dock = 'Top'; $permBar.Height = 30
+$lblPermHint = New-Object System.Windows.Forms.Label
+$lblPermHint.Text = 'Cliquez sur un en-tête de colonne pour filtrer ou trier.'
+$lblPermHint.AutoSize = $true; $lblPermHint.Location = '6,7'
+$lblPermHint.ForeColor = [System.Drawing.Color]::DimGray
+$btnPermClear = New-Object System.Windows.Forms.Button
+$btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24'
+$btnPermClear.Dock = 'Right'; $btnPermClear.Enabled = $false
+$chkPermHideInh = New-Object System.Windows.Forms.CheckBox
+$chkPermHideInh.Text = 'Masquer les permissions héritées (enfants)'
+$chkPermHideInh.AutoSize = $true; $chkPermHideInh.Dock = 'Right'
+$chkPermHideInh.Padding = '0,4,8,0'
+$chkPermHideSys = New-Object System.Windows.Forms.CheckBox
+$chkPermHideSys.Text = 'Masquer les comptes/groupes système'
+$chkPermHideSys.AutoSize = $true; $chkPermHideSys.Dock = 'Right'
+$chkPermHideSys.Padding = '0,4,8,0'
+$permBar.Controls.Add($lblPermHint)
+$permBar.Controls.Add($chkPermHideInh)
+$permBar.Controls.Add($chkPermHideSys)
+$permBar.Controls.Add($btnPermClear)
+$tabPerm.Controls.Add($permBar)
+
+$lvPerm.Add_ColumnClick({ param($s, $e) Show-PermColumnMenu -ColumnIndex $e.Column })
+$btnPermClear.Add_Click({ $script:PermFilters = @{}; Update-PermView })
+$chkPermHideInh.Add_CheckedChanged({ $script:PermHideInherited = $chkPermHideInh.Checked; Update-PermView })
+$chkPermHideSys.Add_CheckedChanged({ $script:PermHideSystem = $chkPermHideSys.Checked; Update-PermView })
+
+# ---- status bar ----
+$status = New-Object System.Windows.Forms.StatusStrip
+$statusLbl = New-Object System.Windows.Forms.ToolStripStatusLabel
+$statusLbl.Text = 'Prêt. Ajoutez un ou plusieurs dossiers, puis Analyser.'
+$progressBar = New-Object System.Windows.Forms.ToolStripProgressBar
+$progressBar.Style = 'Marquee'; $progressBar.Visible = $false; $progressBar.Width = 140
+[void]$status.Items.Add($statusLbl)
+[void]$status.Items.Add($progressBar)
+$form.Controls.Add($status)
+
+# ---- state ----
+$script:LastScan = $null
+$script:Runspace = $null
+$script:PowerShell = $null
+$script:Handle = $null
+$script:Shared = $null
+
+# Background export state (report generation runs off the UI thread too).
+$script:ExportRunspace = $null
+$script:ExportPowerShell = $null
+$script:ExportHandle = $null
+$script:ExportShared = $null
+$script:ExportHtmlPath = $null
+
+# Permissions view state: source rows, active per-column filters, and sort.
+$script:AllPerms = @()
+$script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # column index -> property
+# Display labels for the permission columns (property name -> French header).
+$script:PermColLabels = @{ Folder = 'Dossier'; Identity = 'Identité'; Rights = 'Droits'; Type = 'Type'; Inherited = 'Hérité' }
+$script:PermFilters = @{} # property -> System.Collections.Generic.HashSet[string] of allowed values
+$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
+# When $true, inherited ACEs on child folders are hidden (scan roots keep theirs).
+$script:PermHideInherited = $false
+# When $true, ACEs held by well-known system/built-in principals are hidden.
+$script:PermHideSystem = $false
+$script:PermRootPaths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
+
+# ---- helpers ----
+function New-FilerTreeNode {
+ # Creates a TreeNode for a single scan node WITHOUT recursing into its
+ # children. A placeholder child is added so the [+] expander shows; the real
+ # children are filled on demand by Expand-FilerTreeNode (see the tree's
+ # BeforeExpand handler). This makes even very large trees appear instantly.
+ param($Node)
+ $sizeStr = Format-Bytes $Node.Size
+ $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
+ if ($isFile) {
+ $text = "$($Node.Name) - $sizeStr"
+ } else {
+ $text = "$($Node.Name) - $sizeStr ($($Node.FolderCount) dossiers, $($Node.FileCount) fichiers)"
+ }
+ $tn = New-Object System.Windows.Forms.TreeNode($text)
+ $tn.Tag = $Node # keep the scan node so its children can be filled lazily
+ $kids = @($Node.Children | Where-Object { $_ })
+ if ($kids.Count -gt 0) {
+ # Placeholder (Tag stays $null, which marks the node as "not yet loaded").
+ [void]$tn.Nodes.Add((New-Object System.Windows.Forms.TreeNode('Chargement...')))
+ }
+ return $tn
+}
+
+function Expand-FilerTreeNode {
+ # Replaces the placeholder with the node's real children the first time it is
+ # expanded. A node is "not yet loaded" while its single child has a $null Tag.
+ param($TreeNode)
+ if ($TreeNode.Nodes.Count -ne 1 -or $null -ne $TreeNode.Nodes[0].Tag) { return }
+ $node = $TreeNode.Tag
+ $TreeNode.TreeView.BeginUpdate()
+ $TreeNode.Nodes.Clear()
+ $kids = @($node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)
+ foreach ($c in $kids) { [void]$TreeNode.Nodes.Add((New-FilerTreeNode -Node $c)) }
+ $TreeNode.TreeView.EndUpdate()
+}
+
+function Show-Results {
+ param($Scan)
+ $script:LastScan = $Scan
+
+ $tree.BeginUpdate(); $tree.Nodes.Clear()
+ foreach ($root in $Scan.Roots) { [void]$tree.Nodes.Add((New-FilerTreeNode -Node $root)) }
+ if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() } # loads first level lazily
+ $tree.EndUpdate()
+
+ $lvLong.BeginUpdate(); $lvLong.Items.Clear()
+ foreach ($lp in $Scan.LongPaths) {
+ $it = New-Object System.Windows.Forms.ListViewItem([string]$lp.Length)
+ [void]$it.SubItems.Add($lp.Type); [void]$it.SubItems.Add($lp.Path)
+ [void]$lvLong.Items.Add($it)
+ }
+ $lvLong.EndUpdate()
+
+ $script:AllPerms = @($Scan.Permissions)
+ $script:PermRootPaths = Get-FilerRootPathSet -Scan $Scan
+ $script:PermFilters = @{}
+ $script:PermSort = @{ Prop = 'Folder'; Asc = $true }
+ Update-PermView
+
+ $lvGrant.BeginUpdate(); $lvGrant.Items.Clear()
+ foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) {
+ $it = New-Object System.Windows.Forms.ListViewItem([string]$g.Path)
+ [void]$it.SubItems.Add([string]$g.Reason)
+ [void]$it.SubItems.Add([string]$g.Changes)
+ [void]$it.SubItems.Add([string]$g.OriginalOwner)
+ if ($g.Reverted) { $state = 'annulé' }
+ elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé'; $it.ForeColor = [System.Drawing.Color]::Firebrick }
+ else { $state = 'conservé'; $it.ForeColor = [System.Drawing.Color]::DarkGoldenrod }
+ [void]$it.SubItems.Add($state)
+ [void]$lvGrant.Items.Add($it)
+ }
+ $lvGrant.EndUpdate()
+ $tabGrant.Text = if ($lvGrant.Items.Count -gt 0) { "Accès accordé ($($lvGrant.Items.Count))" } else { 'Accès accordé' }
+
+ $statusLbl.Text = ("Terminé. {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs." -f `
+ (Format-Bytes $Scan.Stats.TotalSize), $Scan.Stats.TotalFolders, $Scan.Stats.TotalFiles,
+ $Scan.Stats.LongPaths, $Scan.Stats.Errors)
+ $btnExport.Enabled = $true
+}
+
+function Get-PermCellValue {
+ param($Ace, [string]$Prop)
+ if ($Prop -eq 'Inherited') { return [string]$Ace.Inherited }
+ return [string]$Ace.$Prop
+}
+
+function Update-PermView {
+ # Re-renders the permissions ListView from $script:AllPerms applying the
+ # active per-column filters and the current sort.
+ $rows = $script:AllPerms
+ if ($script:PermHideInherited) {
+ $rows = @($rows | Where-Object { (-not $_.Inherited) -or $script:PermRootPaths.Contains([string]$_.Folder) })
+ }
+ if ($script:PermHideSystem) {
+ $rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) })
+ }
+ foreach ($prop in $script:PermFilters.Keys) {
+ $allowed = $script:PermFilters[$prop]
+ $rows = @($rows | Where-Object { $allowed.Contains((Get-PermCellValue $_ $prop)) })
+ }
+
+ $sortProp = $script:PermSort.Prop
+ $rows = @($rows | Sort-Object @{ E = { Get-PermCellValue $_ $sortProp } })
+ if (-not $script:PermSort.Asc) { [array]::Reverse($rows) }
+
+ $lvPerm.BeginUpdate(); $lvPerm.Items.Clear()
+ foreach ($ace in $rows) {
+ $it = New-Object System.Windows.Forms.ListViewItem($ace.Folder)
+ [void]$it.SubItems.Add($ace.Identity); [void]$it.SubItems.Add($ace.Rights)
+ [void]$it.SubItems.Add($ace.Type); [void]$it.SubItems.Add([string]$ace.Inherited)
+ if ($ace.Type -eq 'Deny') { $it.ForeColor = [System.Drawing.Color]::Firebrick }
+ [void]$lvPerm.Items.Add($it)
+ }
+ $lvPerm.EndUpdate()
+
+ # Update column headers to show sort arrow and filter funnel.
+ for ($i = 0; $i -lt $script:PermCols.Count; $i++) {
+ $prop = $script:PermCols[$i]
+ $label = $script:PermColLabels[$prop]
+ if ($script:PermFilters.ContainsKey($prop)) { $label += ' (v)' }
+ if ($prop -eq $sortProp) { $label += $(if ($script:PermSort.Asc) { ' ^' } else { ' v' }) }
+ $lvPerm.Columns[$i].Text = $label
+ }
+
+ $shown = $lvPerm.Items.Count; $total = $script:AllPerms.Count
+ $nFilt = $script:PermFilters.Count
+ $btnPermClear.Enabled = ($nFilt -gt 0)
+ if ($script:AllPerms.Count -gt 0) {
+ $msg = "Permissions : $shown sur $total affichées"
+ if ($nFilt -gt 0) { $msg += " ($nFilt filtre$(if($nFilt -gt 1){'s'}) de colonne) - cliquez sur un en-tête pour filtrer/trier" }
+ else { $msg += " - cliquez sur un en-tête de colonne pour filtrer ou trier" }
+ $statusLbl.Text = $msg
+ }
+}
+
+function Show-PermColumnMenu {
+ param([int]$ColumnIndex)
+ if ($script:AllPerms.Count -eq 0) { return }
+ $prop = $script:PermCols[$ColumnIndex]
+
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
+ $menu.ShowImageMargin = $false
+
+ $asc = New-Object System.Windows.Forms.ToolStripMenuItem("Trier A -> Z")
+ $asc.Add_Click({ $script:PermSort = @{ Prop = $prop; Asc = $true }; Update-PermView }.GetNewClosure())
+ $desc = New-Object System.Windows.Forms.ToolStripMenuItem("Trier Z -> A")
+ $desc.Add_Click({ $script:PermSort = @{ Prop = $prop; Asc = $false }; Update-PermView }.GetNewClosure())
+ [void]$menu.Items.Add($asc); [void]$menu.Items.Add($desc)
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
+
+ # Select all / Clear toggles for the value checklist.
+ $selAll = New-Object System.Windows.Forms.ToolStripMenuItem("(Tout sélectionner)")
+ $clrAll = New-Object System.Windows.Forms.ToolStripMenuItem("(Tout effacer)")
+ [void]$menu.Items.Add($selAll); [void]$menu.Items.Add($clrAll)
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
+
+ # Distinct values for this column (from the unfiltered source).
+ $distinct = @($script:AllPerms | ForEach-Object { Get-PermCellValue $_ $prop } | Sort-Object -Unique)
+ $current = $script:PermFilters[$prop] # $null => everything allowed
+ $valueItems = New-Object System.Collections.ArrayList
+ foreach ($v in $distinct) {
+ $label = if ([string]::IsNullOrEmpty($v)) { '(vide)' } else { $v }
+ $mi = New-Object System.Windows.Forms.ToolStripMenuItem($label)
+ $mi.CheckOnClick = $true
+ $mi.Checked = ($null -eq $current) -or $current.Contains($v)
+ $mi.Tag = $v
+ [void]$menu.Items.Add($mi)
+ [void]$valueItems.Add($mi)
+ }
+
+ $selAll.Add_Click({ foreach ($m in $valueItems) { $m.Checked = $true } }.GetNewClosure())
+ $clrAll.Add_Click({ foreach ($m in $valueItems) { $m.Checked = $false } }.GetNewClosure())
+
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
+ $apply = New-Object System.Windows.Forms.ToolStripMenuItem("Appliquer le filtre")
+ $apply.Add_Click({
+ $checked = New-Object 'System.Collections.Generic.HashSet[string]'
+ foreach ($m in $valueItems) { if ($m.Checked) { [void]$checked.Add([string]$m.Tag) } }
+ if ($checked.Count -eq $valueItems.Count) { $script:PermFilters.Remove($prop) } # all selected = no filter
+ else { $script:PermFilters[$prop] = $checked }
+ Update-PermView
+ }.GetNewClosure())
+ $clear = New-Object System.Windows.Forms.ToolStripMenuItem("Supprimer le filtre de cette colonne")
+ $clear.Add_Click({ [void]$script:PermFilters.Remove($prop); Update-PermView }.GetNewClosure())
+ [void]$menu.Items.Add($apply); [void]$menu.Items.Add($clear)
+
+ # Keep the menu open while ticking value checkboxes or using Select/Clear all;
+ # close only on Sort / Apply / Remove filter.
+ $menu.Add_Closing({
+ param($s, $e)
+ if ($e.CloseReason -eq [System.Windows.Forms.ToolStripDropDownCloseReason]::ItemClicked) {
+ $clicked = $s.GetItemAt($s.PointToClient([System.Windows.Forms.Cursor]::Position))
+ if ($clicked -and (($valueItems -contains $clicked) -or ($clicked -eq $selAll) -or ($clicked -eq $clrAll))) {
+ $e.Cancel = $true
+ }
+ }
+ }.GetNewClosure())
+
+ # Show just under the clicked column header.
+ $x = 0
+ for ($i = 0; $i -lt $ColumnIndex; $i++) { $x += $lvPerm.Columns[$i].Width }
+ $menu.Show($lvPerm, $x, 4)
+}
+
+function Show-ExportDialog {
+ <# Modal picker for the export: which report categories and which formats
+ (HTML and/or CSV). Returns $null if the user cancelled, otherwise an object
+ with .Categories (array of Tree/LongPaths/Permissions/Grants/Errors or
+ @('All')), .Html and .Csv booleans. Categories with no data are disabled. #>
+ param($Scan)
+
+ $hasGrants = @($Scan.Grants | Where-Object { $_.Success }).Count -gt 0
+ $hasErrors = $Scan.Errors.Count -gt 0
+
+ # label, canonical name, enabled?
+ $cats = @(
+ @{ Text = 'Tailles des dossiers'; Name = 'Tree'; Enabled = $true },
+ @{ Text = 'Chemins trop longs'; Name = 'LongPaths'; Enabled = $true },
+ @{ Text = 'Permissions'; Name = 'Permissions'; Enabled = $true },
+ @{ Text = 'Accès accordé'; Name = 'Grants'; Enabled = $hasGrants },
+ @{ Text = 'Erreurs / accès refusé'; Name = 'Errors'; Enabled = $hasErrors }
+ )
+
+ $dlg = New-Object System.Windows.Forms.Form
+ $dlg.Text = 'Exporter - choisir les catégories et formats'
+ $dlg.FormBorderStyle = 'FixedDialog'
+ $dlg.StartPosition = 'CenterParent'
+ $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
+ $dlg.ClientSize = New-Object System.Drawing.Size(320, 330)
+
+ $lbl = New-Object System.Windows.Forms.Label
+ $lbl.Text = 'Inclure ces catégories dans le rapport :'
+ $lbl.Location = '14,12'; $lbl.AutoSize = $true
+ $dlg.Controls.Add($lbl)
+
+ $boxes = @()
+ $y = 40
+ foreach ($c in $cats) {
+ $cb = New-Object System.Windows.Forms.CheckBox
+ $cb.Text = $c.Text
+ $cb.Tag = $c.Name
+ $cb.Location = "20,$y"; $cb.AutoSize = $true
+ $cb.Checked = $c.Enabled
+ $cb.Enabled = $c.Enabled
+ if (-not $c.Enabled) { $cb.Text += ' (aucune donnée)' }
+ $dlg.Controls.Add($cb)
+ $boxes += $cb
+ $y += 26
+ }
+
+ # ---- format selection (HTML and/or CSV) ----
+ $y += 8
+ $lblFmt = New-Object System.Windows.Forms.Label
+ $lblFmt.Text = 'Formats à générer :'
+ $lblFmt.Location = "14,$y"; $lblFmt.AutoSize = $true
+ $dlg.Controls.Add($lblFmt)
+ $y += 26
+
+ $chkHtml = New-Object System.Windows.Forms.CheckBox
+ $chkHtml.Text = 'HTML (rapport unique)'; $chkHtml.Tag = 'Html'
+ $chkHtml.Location = "20,$y"; $chkHtml.AutoSize = $true; $chkHtml.Checked = $true
+ $dlg.Controls.Add($chkHtml)
+ $y += 26
+
+ $chkCsv = New-Object System.Windows.Forms.CheckBox
+ $chkCsv.Text = 'CSV (un fichier par catégorie)'; $chkCsv.Tag = 'Csv'
+ $chkCsv.Location = "20,$y"; $chkCsv.AutoSize = $true; $chkCsv.Checked = $false
+ $dlg.Controls.Add($chkCsv)
+ $y += 36
+
+ $btnOk = New-Object System.Windows.Forms.Button
+ $btnOk.Text = 'Exporter...'; $btnOk.Size = '90,28'; $btnOk.Location = "124,$y"
+ $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
+ $dlg.Controls.Add($btnOk); $dlg.AcceptButton = $btnOk
+
+ $btnCancel = New-Object System.Windows.Forms.Button
+ $btnCancel.Text = 'Annuler'; $btnCancel.Size = '90,28'; $btnCancel.Location = "220,$y"
+ $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
+ $dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel
+
+ # Enable Export only when at least one category AND at least one format is ticked.
+ $sync = {
+ $anyCat = @($boxes | Where-Object { $_.Checked }).Count -gt 0
+ $anyFmt = $chkHtml.Checked -or $chkCsv.Checked
+ $btnOk.Enabled = $anyCat -and $anyFmt
+ }.GetNewClosure()
+ foreach ($cb in $boxes) { $cb.Add_CheckedChanged($sync) }
+ $chkHtml.Add_CheckedChanged($sync)
+ $chkCsv.Add_CheckedChanged($sync)
+
+ $result = $dlg.ShowDialog($form)
+ $picked = @($boxes | Where-Object { $_.Checked } | ForEach-Object { [string]$_.Tag })
+ $wantHtml = $chkHtml.Checked
+ $wantCsv = $chkCsv.Checked
+ $dlg.Dispose()
+
+ if ($result -ne [System.Windows.Forms.DialogResult]::OK -or $picked.Count -eq 0) { return $null }
+ if (-not ($wantHtml -or $wantCsv)) { return $null }
+ # All categories ticked -> 'All' so the header shows the full report.
+ $categories = if ($picked.Count -eq $boxes.Count) { @('All') } else { $picked }
+ return [pscustomobject]@{ Categories = $categories; Html = $wantHtml; Csv = $wantCsv }
+}
+
+# ---- poll timer (reads background runspace) ----
+$timer = New-Object System.Windows.Forms.Timer
+$timer.Interval = 200
+$timer.Add_Tick({
+ if ($script:Shared) {
+ if ($script:Shared.Status) { $statusLbl.Text = $script:Shared.Status }
+ if ($script:Handle -and $script:Handle.IsCompleted) {
+ $timer.Stop()
+ try {
+ $result = $script:PowerShell.EndInvoke($script:Handle)
+ $scan = $script:Shared.Result
+ if ($scan) { Show-Results -Scan $scan }
+ else { $statusLbl.Text = 'Analyse terminée mais aucune donnée renvoyée.' }
+ }
+ catch {
+ [System.Windows.Forms.MessageBox]::Show("Échec de l'analyse :`n$($_.Exception.Message)", 'Filer Manager',
+ 'OK', 'Error') | Out-Null
+ $statusLbl.Text = "Échec de l'analyse."
+ }
+ finally {
+ if ($script:PowerShell) { $script:PowerShell.Dispose() }
+ if ($script:Runspace) { $script:Runspace.Close(); $script:Runspace.Dispose() }
+ $script:PowerShell = $null; $script:Runspace = $null; $script:Handle = $null
+ $progressBar.Visible = $false
+ $btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true
+ }
+ }
+ }
+})
+
+# ---- poll timer (reads background export runspace) ----
+$exportTimer = New-Object System.Windows.Forms.Timer
+$exportTimer.Interval = 200
+$exportTimer.Add_Tick({
+ if (-not $script:ExportHandle) { $exportTimer.Stop(); return }
+ if (-not $script:ExportHandle.IsCompleted) { return }
+ $exportTimer.Stop()
+
+ $written = $null; $err = $null
+ try {
+ $script:ExportPowerShell.EndInvoke($script:ExportHandle) | Out-Null
+ if ($script:ExportShared) { $written = $script:ExportShared.Written; $err = $script:ExportShared.Error }
+ }
+ catch { $err = $_.Exception.Message }
+ finally {
+ if ($script:ExportPowerShell) { $script:ExportPowerShell.Dispose() }
+ if ($script:ExportRunspace) { $script:ExportRunspace.Close(); $script:ExportRunspace.Dispose() }
+ $script:ExportPowerShell = $null; $script:ExportRunspace = $null; $script:ExportHandle = $null
+ $progressBar.Visible = $false
+ $btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true
+ $btnExport.Enabled = ($null -ne $script:LastScan)
+ }
+
+ $htmlPath = $script:ExportHtmlPath
+ if ($err) {
+ $statusLbl.Text = "Échec de l'export."
+ [System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$err", 'Filer Manager', 'OK', 'Error') | Out-Null
+ return
+ }
+
+ $written = @($written)
+ if ($written.Count -eq 0) {
+ $statusLbl.Text = 'Aucun fichier généré.'
+ [System.Windows.Forms.MessageBox]::Show('Aucun fichier généré (catégories CSV sans données).',
+ 'Filer Manager', 'OK', 'Information') | Out-Null
+ return
+ }
+
+ $statusLbl.Text = "Export terminé : $($written.Count) fichier(s)."
+ $list = ($written -join "`n")
+ $prompt = if ($htmlPath) { "`n`nOuvrir le rapport HTML maintenant ?" } else { "`n`nOuvrir le dossier maintenant ?" }
+ if ([System.Windows.Forms.MessageBox]::Show("Fichier(s) enregistré(s) :`n$list$prompt", 'Filer Manager',
+ 'YesNo', 'Question') -eq 'Yes') {
+ if ($htmlPath) { Start-Process $htmlPath }
+ else { Start-Process (Split-Path -Path $written[0] -Parent) }
+ }
+})
+
+# ---- events ----
+$btnAdd.Add_Click({
+ $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
+ $dlg.Description = 'Sélectionnez un dossier à analyser'
+ if ($dlg.ShowDialog() -eq 'OK') {
+ if (-not $lstFolders.Items.Contains($dlg.SelectedPath)) { [void]$lstFolders.Items.Add($dlg.SelectedPath) }
+ }
+})
+
+$btnRemove.Add_Click({
+ if ($lstFolders.SelectedIndex -ge 0) { $lstFolders.Items.RemoveAt($lstFolders.SelectedIndex) }
+})
+
+$btnScan.Add_Click({
+ if ($lstFolders.Items.Count -eq 0) {
+ [System.Windows.Forms.MessageBox]::Show('Ajoutez au moins un dossier à analyser.', 'Filer Manager', 'OK', 'Information') | Out-Null
+ return
+ }
+ $paths = @($lstFolders.Items)
+ $maxLen = [int]$numMax.Value
+ $depth = [int]$numDepth.Value
+ $incFiles = [bool]$chkFiles.Checked
+ $grant = [bool]$chkGrant.Checked
+ $revert = [bool]$chkRevert.Checked
+
+ if ($grant -and -not (Test-IsElevated)) {
+ $msg = "L'octroi automatique nécessite des droits Administrateur, mais Filer Manager n'est pas exécuté en mode élevé.`n`n" +
+ "Les éléments refusés peuvent ne pas être corrigés. Continuer quand même ?"
+ if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -ne 'Yes') { return }
+ }
+
+ $btnScan.Enabled = $false; $btnAdd.Enabled = $false; $btnRemove.Enabled = $false; $btnExport.Enabled = $false
+ $progressBar.Visible = $true
+ $statusLbl.Text = 'Analyse en cours...'
+
+ $script:Shared = [hashtable]::Synchronized(@{ Status = 'Démarrage...'; Result = $null })
+ $script:Runspace = [runspacefactory]::CreateRunspace()
+ $script:Runspace.ApartmentState = 'STA'
+ $script:Runspace.Open()
+ $script:Runspace.SessionStateProxy.SetVariable('Shared', $script:Shared)
+
+ $script:PowerShell = [powershell]::Create()
+ $script:PowerShell.Runspace = $script:Runspace
+ [void]$script:PowerShell.AddScript($CoreFunctions)
+ [void]$script:PowerShell.AddScript(@'
+param($Paths, $MaxLen, $Depth, $IncFiles, $Grant, $Revert, $Shared)
+$Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared
+'@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).AddArgument($grant).AddArgument($revert).AddArgument($script:Shared)
+
+ $script:Handle = $script:PowerShell.BeginInvoke()
+ $timer.Start()
+})
+
+$btnExport.Add_Click({
+ if (-not $script:LastScan) { return }
+
+ $choice = Show-ExportDialog -Scan $script:LastScan
+ if (-not $choice) { return } # cancelled or nothing selected
+ $categories = $choice.Categories
+
+ # Build a filename hint reflecting the chosen scope.
+ $scope = if ($categories -contains 'All') { 'all' } else { ($categories -join '-').ToLower() }
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
+ $baseName = "filer-report-$scope-$stamp"
+
+ $dlg = New-Object System.Windows.Forms.SaveFileDialog
+ if ($choice.Html -and $choice.Csv) {
+ $dlg.Title = 'Enregistrer les rapports (nom de base) - HTML + CSV'
+ $dlg.Filter = 'Rapports HTML + CSV|*.html'
+ $dlg.FileName = "$baseName.html"
+ }
+ elseif ($choice.Html) {
+ $dlg.Title = 'Enregistrer le rapport HTML'
+ $dlg.Filter = 'Rapport HTML (*.html)|*.html'
+ $dlg.FileName = "$baseName.html"
+ }
+ else {
+ $dlg.Title = 'Enregistrer le(s) rapport(s) CSV (nom de base)'
+ $dlg.Filter = 'Rapport CSV (*.csv)|*.csv'
+ $dlg.FileName = "$baseName.csv"
+ }
+
+ if ($dlg.ShowDialog() -ne 'OK') { return }
+
+ # Generate the report(s) on a background runspace so the GUI stays responsive;
+ # rendering a large scan to HTML/CSV can take several seconds.
+ $htmlPath = if ($choice.Html) { [System.IO.Path]::ChangeExtension($dlg.FileName, '.html') } else { $null }
+ $csvBase = $dlg.FileName
+ $script:ExportHtmlPath = $htmlPath
+
+ $btnScan.Enabled = $false; $btnAdd.Enabled = $false; $btnRemove.Enabled = $false; $btnExport.Enabled = $false
+ $progressBar.Visible = $true
+ $statusLbl.Text = 'Export en cours...'
+
+ $script:ExportShared = [hashtable]::Synchronized(@{ Written = $null; Error = $null })
+ $script:ExportRunspace = [runspacefactory]::CreateRunspace()
+ $script:ExportRunspace.ApartmentState = 'STA'
+ $script:ExportRunspace.Open()
+
+ $script:ExportPowerShell = [powershell]::Create()
+ $script:ExportPowerShell.Runspace = $script:ExportRunspace
+ [void]$script:ExportPowerShell.AddScript($CoreFunctions)
+ [void]$script:ExportPowerShell.AddScript(@'
+param($Scan, $HtmlPath, $WantHtml, $WantCsv, $CsvBase, $Categories, $HidePerm, $HideSys, $Shared)
+try {
+ $written = New-Object System.Collections.ArrayList
+ if ($WantHtml) {
+ $out = ConvertTo-FilerHtmlReport -Scan $Scan -Path $HtmlPath -Categories $Categories -HideInheritedChildPerms $HidePerm -HideSystemPrincipals $HideSys
+ [void]$written.Add($out)
+ }
+ if ($WantCsv) {
+ $csvFiles = @(ConvertTo-FilerCsvReport -Scan $Scan -Path $CsvBase -Categories $Categories -HideInheritedChildPerms $HidePerm -HideSystemPrincipals $HideSys)
+ foreach ($f in $csvFiles) { [void]$written.Add($f) }
+ }
+ $Shared.Written = $written.ToArray()
+}
+catch { $Shared.Error = $_.Exception.Message }
+'@).AddArgument($script:LastScan).AddArgument($htmlPath).AddArgument([bool]$choice.Html).AddArgument([bool]$choice.Csv).AddArgument($csvBase).AddArgument($categories).AddArgument([bool]$script:PermHideInherited).AddArgument([bool]$script:PermHideSystem).AddArgument($script:ExportShared)
+
+ $script:ExportHandle = $script:ExportPowerShell.BeginInvoke()
+ $exportTimer.Start()
+})
+
+# Pre-fill folders if -Path was supplied without -Output
+if ($Path) { foreach ($p in $Path) { [void]$lstFolders.Items.Add($p) } }
+
+[void]$form.ShowDialog()
+$form.Dispose()
\ No newline at end of file