From 3793a3ec8835ef4b96e7e52a1b569de2a953de96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= <2+kawa@not.obvious>
Date: Fri, 5 Jun 2026 11:36:54 +0200
Subject: [PATCH] Upload files to "/"
---
README.md | 83 +++
Run-FilerManager.cmd | 12 +
filer-manager.ps1 | 1220 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 1315 insertions(+)
create mode 100644 README.md
create mode 100644 Run-FilerManager.cmd
create mode 100644 filer-manager.ps1
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e2f44f6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,83 @@
+# Filer Manager
+
+Un outil PowerShell mono-fichier pour les serveurs de fichiers Windows. Il analyse des dossiers et rapporte :
+
+- **Tailles des dossiers** — arborescence visuelle et repliable avec barres de taille (arbre GUI + HTML).
+- **Chemins trop longs** — fichiers/dossiers dont le chemin complet atteint ou dépasse un seuil (260 par défaut, le `MAX_PATH` de Windows).
+- **Permissions NTFS** — propriétaire + ACL (identité, droits, allow/deny, hérité) par dossier, jusqu'à une profondeur configurable.
+ Dans l'interface, **cliquez sur n'importe quel en-tête de colonne** (Dossier / Identité / Droits / Type / Hérité) pour une
+ fenêtre de type Excel : tri A→Z / Z→A et sélection des valeurs à conserver. Les filtres se combinent entre les colonnes ;
+ **Effacer les filtres** réinitialise le tout.
+- **Octroi automatique en cas de refus** *(optionnel)* — lorsqu'un élément ne peut pas être lu, l'outil s'approprie la
+ propriété pour Administrators et accorde le `FullControl` à `BUILTIN\Administrators` afin que l'analyse puisse se terminer.
+ Chaque modification est consignée dans l'onglet/la section de rapport **Accès accordé**, et par défaut chacune est
+ **annulée** après l'analyse (propriétaire + ACL d'origine restaurés là où ils ont pu être lus au préalable). Nécessite une
+ exécution en mode élevé.
+
+Tout est exportable vers un **rapport HTML autonome** (aucune ressource externe — fonctionne hors ligne, facile à envoyer par
+e-mail ou à archiver). Les rapports peuvent devenir volumineux, vous pouvez donc **n'exporter que les catégories nécessaires** —
+une, plusieurs ou toutes — aussi bien dans l'interface (sélecteur à cases sur **Exporter HTML…**) qu'en mode sans interface (`-Category`).
+
+Aucune installation ni dépendance. Fonctionne sur **Windows PowerShell 5.1** et **PowerShell 7+**.
+
+## Lancer l'interface graphique
+
+Double-cliquez sur **`Run-FilerManager.cmd`** (il se lance avec `-ExecutionPolicy Bypass` pour cette exécution uniquement),
+ou depuis une console :
+
+```powershell
+pwsh -STA -File .\filer-manager.ps1
+```
+
+Dans la fenêtre : **Ajouter…** un ou plusieurs dossiers, ajustez *Longueur max* / *Profondeur des permissions* /
+*Inclure les fichiers dans l'arborescence*, cochez éventuellement **Accorder l'accès Administrators aux éléments refusés**
+(et **Annuler les modifications après l'analyse**), cliquez sur **Analyser**, puis **Exporter HTML…** — la boîte de dialogue
+d'export vous laisse **cocher les catégories** (Tailles des dossiers, Chemins trop longs, Permissions, Accès accordé, Erreurs)
+à inclure dans le fichier ; toutes sont sélectionnées par défaut.
+
+> L'octroi automatique n'agit que sur les éléments refusés, un élément à la fois. Exécutez Filer Manager
+> **en tant qu'administrateur** pour qu'il fonctionne — il vous avertit si vous n'êtes pas en mode élevé.
+
+> L'analyse s'exécute sur un thread d'arrière-plan, la fenêtre reste donc réactive sur les grosses arborescences.
+
+## Lancer sans interface (pour le Planificateur de tâches / cron)
+
+```powershell
+.\filer-manager.ps1 -Path "\\FILER01\Data","D:\Profiles" -Output C:\Reports\filer.html -NoGui
+```
+
+### Paramètres
+
+| Paramètre | Défaut | Description |
+|-----------------------|---------|----------------------------------------------------------------------|
+| `-Path` | — | Un ou plusieurs dossiers racines à analyser. |
+| `-Output` | — | Chemin du rapport HTML (mode sans interface). |
+| `-MaxPathLength` | `260` | Signale les éléments dont la longueur de chemin complet est ≥ ce nombre de caractères. |
+| `-PermissionDepth` | `1` | Niveaux de dossiers sous chaque racine pour lesquels collecter les ACL (0 = racines uniquement). |
+| `-IncludeFilesInTree` | off | Liste les fichiers individuels dans l'arborescence des tailles, pas seulement les dossiers. |
+| `-GrantAccess` | off | S'approprie la propriété + accorde le FullControl à Administrators sur les éléments refusés, puis réessaye. Nécessite le mode élevé. |
+| `-KeepGrants` | off | Conserve l'accès accordé par `-GrantAccess` (par défaut : annulé après l'analyse). |
+| `-NoGui` | off | Force le mode sans interface (nécessite `-Path` et `-Output`). |
+| `-Category` | `All` | Catégories à inclure dans le rapport — toute combinaison de `Tree`, `LongPaths`, `Permissions`, `Grants`, `Errors` (pour garder les gros rapports petits). |
+
+N'exporter que les permissions, ou seulement les tailles + chemins trop longs :
+
+```powershell
+.\filer-manager.ps1 -Path "D:\Shares" -Output perms.html -NoGui -Category Permissions
+.\filer-manager.ps1 -Path "D:\Shares" -Output sizes.html -NoGui -Category Tree,LongPaths
+```
+
+### Planifier un rapport hebdomadaire
+
+```powershell
+$action = New-ScheduledTaskAction -Execute "pwsh.exe" `
+ -Argument '-NoProfile -File "C:\Tools\filer-manager\filer-manager.ps1" -Path "D:\Shares" -Output "C:\Reports\filer.html" -NoGui'
+$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6am
+Register-ScheduledTask -TaskName "Filer Report" -Action $action -Trigger $trigger -RunLevel Highest
+```
+
+## Notes
+
+- Exécutez l'outil avec un compte disposant d'un accès en lecture aux dossiers (et `SeBackupPrivilege` / Administrateur aide à atteindre les ACL et à contourner les refus par dossier). Les éléments qui ne peuvent pas être lus sont listés dans la section **Erreurs / accès refusé** au lieu de faire échouer l'analyse — ou, avec `-GrantAccess` (en mode élevé), l'outil s'accorde l'accès et liste les modifications dans **Accès accordé**.
+- Taille d'un dossier = somme des longueurs des fichiers (taille logique), pas la taille sur le disque.
+- Le rapport est un seul fichier `.html` avec CSS/JS inline — sans risque à envoyer par e-mail ou à conserver comme instantané d'audit.
diff --git a/Run-FilerManager.cmd b/Run-FilerManager.cmd
new file mode 100644
index 0000000..b60e49c
--- /dev/null
+++ b/Run-FilerManager.cmd
@@ -0,0 +1,12 @@
+@echo off
+REM Lanceur double-clic pour Filer Manager (contourne la stratégie d'exécution pour cette exécution uniquement).
+setlocal
+set "SCRIPT=%~dp0filer-manager.ps1"
+
+where pwsh >nul 2>&1
+if %errorlevel%==0 (
+ pwsh -NoProfile -ExecutionPolicy Bypass -STA -File "%SCRIPT%"
+) else (
+ powershell -NoProfile -ExecutionPolicy Bypass -STA -File "%SCRIPT%"
+)
+endlocal
diff --git a/filer-manager.ps1 b/filer-manager.ps1
new file mode 100644
index 0000000..e8b8b68
--- /dev/null
+++ b/filer-manager.ps1
@@ -0,0 +1,1220 @@
+#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 un export HTML.
+
+.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 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 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,
+ [int]$MaxPathLength = 260,
+ [int]$PermissionDepth = 1,
+ [switch]$IncludeFilesInTree,
+ [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 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
+ 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 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')
+ )
+
+ 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 (grouped by folder) ----
+ $permSb = New-Object System.Text.StringBuilder
+ if ($wantPerm) {
+ $byFolder = $Scan.Permissions | Group-Object Folder
+ foreach ($grp in $byFolder) {
+ $owner = ($grp.Group | Select-Object -First 1).Owner
+ [void]$permSb.Append("$(_enc $grp.Name) Propriétaire : $(_enc $owner) ")
+ [void]$permSb.Append("Identité Droits Type Hérité ")
+ foreach ($ace in $grp.Group) {
+ $cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' }
+ [void]$permSb.Append("$(_enc $ace.Identity) $(_enc $ace.Rights) $(_enc $ace.Type) $($ace.Inherited) ")
+ }
+ [void]$permSb.Append("
")
+ }
+ 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) { @"
+
+ 🔐 Permissions
+ $($permSb.ToString())
+
+"@ })
+
+ $(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
+}
+'@
+
+# Dot-source core functions into the current scope (for headless + GUI export).
+. ([scriptblock]::Create($CoreFunctions))
+
+# ============================================================================
+# HEADLESS MODE
+# ============================================================================
+$runHeadless = $NoGui -or ($Path -and $Output)
+if ($runHeadless) {
+ if (-not $Path) { throw "Le mode sans interface requiert -Path." }
+ if (-not $Output) { throw "Le mode sans interface requiert -Output." }
+
+ 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
+
+ $out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category
+ Write-Host "Rapport généré : $out" -ForegroundColor Green
+ 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 HTML...'; $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
+$tabTree.Controls.Add($tree)
+
+$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
+$permBar.Controls.Add($lblPermHint)
+$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 })
+
+# ---- 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
+
+# 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 }
+
+# ---- helpers ----
+function Add-TreeNodes {
+ param($Parent, $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.FullPath
+ [void]$Parent.Add($tn)
+ $kids = @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)
+ foreach ($c in $kids) { Add-TreeNodes -Parent $tn.Nodes -Node $c }
+}
+
+function Show-Results {
+ param($Scan)
+ $script:LastScan = $Scan
+
+ $tree.BeginUpdate(); $tree.Nodes.Clear()
+ foreach ($root in $Scan.Roots) { Add-TreeNodes -Parent $tree.Nodes -Node $root }
+ if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() }
+ $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: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
+ 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 which report categories to export. Returns an array of
+ canonical category names (Tree/LongPaths/Permissions/Grants/Errors) or
+ $null if the user cancelled. 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 HTML - choisir les catégories'
+ $dlg.FormBorderStyle = 'FixedDialog'
+ $dlg.StartPosition = 'CenterParent'
+ $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
+ $dlg.ClientSize = New-Object System.Drawing.Size(300, 250)
+
+ $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
+ }
+
+ $btnOk = New-Object System.Windows.Forms.Button
+ $btnOk.Text = 'Exporter...'; $btnOk.Size = '90,28'; $btnOk.Location = '104,210'
+ $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 = '200,210'
+ $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
+ $dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel
+
+ # Disable Export when nothing is ticked.
+ $sync = {
+ $btnOk.Enabled = @($boxes | Where-Object { $_.Checked }).Count -gt 0
+ }.GetNewClosure()
+ foreach ($cb in $boxes) { $cb.Add_CheckedChanged($sync) }
+
+ $result = $dlg.ShowDialog($form)
+ $picked = @($boxes | Where-Object { $_.Checked } | ForEach-Object { [string]$_.Tag })
+ $dlg.Dispose()
+
+ if ($result -ne [System.Windows.Forms.DialogResult]::OK -or $picked.Count -eq 0) { return $null }
+ # All categories ticked -> 'All' so the header shows the full report.
+ if ($picked.Count -eq $boxes.Count) { return @('All') }
+ return $picked
+}
+
+# ---- 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
+ }
+ }
+ }
+})
+
+# ---- 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 }
+
+ $categories = Show-ExportDialog -Scan $script:LastScan
+ if (-not $categories) { return } # cancelled or nothing selected
+
+ # Build a filename hint reflecting the chosen scope.
+ $scope = if ($categories -contains 'All') { 'all' } else { ($categories -join '-').ToLower() }
+
+ $dlg = New-Object System.Windows.Forms.SaveFileDialog
+ $dlg.Filter = 'Rapport HTML (*.html)|*.html'
+ $dlg.FileName = "filer-report-$scope-$(Get-Date -Format 'yyyyMMdd-HHmmss').html"
+ if ($dlg.ShowDialog() -eq 'OK') {
+ try {
+ $out = ConvertTo-FilerHtmlReport -Scan $script:LastScan -Path $dlg.FileName -Categories $categories
+ $statusLbl.Text = "Rapport enregistré : $out"
+ if ([System.Windows.Forms.MessageBox]::Show("Rapport enregistré dans :`n$out`n`nL'ouvrir maintenant ?", 'Filer Manager',
+ 'YesNo', 'Question') -eq 'Yes') {
+ Start-Process $out
+ }
+ }
+ catch {
+ [System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$($_.Exception.Message)", 'Filer Manager', 'OK', 'Error') | Out-Null
+ }
+ }
+})
+
+# 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()