#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.
Accepte un chemin local, un chemin UNC (\\serveur\partage\dossier) ou un
lecteur réseau mappé (Z:\dossier). Un lecteur mappé est automatiquement
réécrit vers sa cible UNC lorsque la lettre n'est pas montée dans le jeton
courant : les mappages appartiennent à la session qui les a créés, donc ceux
de l'utilisateur interactif sont absents d'une exécution en mode élevé (et
inversement). En tâche planifiée sous un autre compte, indiquez l'UNC.
.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 ExcludeFolder
Noms de dossiers à exclure de l'analyse (tailles, chemins trop longs et
permissions). Les jokers sont acceptés et la comparaison est insensible à la
casse, p. ex. '#recycle', '@eaDir', 'Thumbs*'. Par défaut, les corbeilles et
dossiers système courants sont exclus : #recycle et @eaDir (NAS Synology) et
$RECYCLE.BIN (corbeille Windows). Passez une liste vide (-ExcludeFolder @())
pour ne rien exclure par nom.
.PARAMETER ExcludeHidden
Exclure de l'analyse les dossiers portant l'attribut « masqué ».
.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.
.EXAMPLE
.\filer-manager.ps1 -Path "\\NAS\Public" -Output filer.html -NoGui -ExcludeFolder '#recycle','@eaDir','Thumbs*' -ExcludeHidden
Exclut les dossiers système Synology (#recycle, @eaDir), tout dossier « Thumbs… »
et les dossiers masqués de l'analyse.
#>
[CmdletBinding()]
param(
[string[]]$Path,
[string]$Output,
[string]$CsvOutput,
[int]$MaxPathLength = 260,
[int]$PermissionDepth = 1,
[switch]$IncludeFilesInTree,
[string[]]$ExcludeFolder = @('#recycle', '@eaDir', '$RECYCLE.BIN'),
[switch]$ExcludeHidden,
[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 Test-IsExcludedFolder {
# True when a directory entry should be skipped from the analysis. A folder is
# excluded when its name matches one of the (wildcard, case-insensitive)
# patterns - e.g. '#recycle', '@eaDir', 'Thumbs*' - or when -ExcludeHidden is
# set and the directory carries the Hidden attribute. Exclusions apply to
# descendants only; a scan root is always analysed even if it would match.
param($Entry, [string[]]$Patterns, [bool]$ExcludeHidden)
if ($ExcludeHidden) {
try { if (($Entry.Attributes -band [System.IO.FileAttributes]::Hidden) -ne 0) { return $true } } catch { }
}
if ($Patterns) {
$name = [string]$Entry.Name
foreach ($pat in $Patterns) {
if ([string]::IsNullOrWhiteSpace($pat)) { continue }
if ($name -like $pat) { return $true }
}
}
return $false
}
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) {
if (Test-IsExcludedFolder -Entry $entry -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue }
$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) {
if (Test-IsExcludedFolder -Entry $d -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue }
$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,
[string[]]$ExcludeFolder = @(),
[bool]$ExcludeHidden = $false,
[bool]$GrantAccess = $false,
[bool]$RevertGrants = $true,
[hashtable]$Progress
)
$script:IncludeFilesInTreeFlag = $IncludeFilesInTree
$script:ExcludeFolderFlag = $ExcludeFolder
$script:ExcludeHiddenFlag = $ExcludeHidden
$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
# Drive inventory is only needed when a root is given as a drive letter; it is
# resolved once and shared, since a network path can be slow to interrogate.
$inventory = $null
if (@($Paths | Where-Object { $_ -match '^[A-Za-z]:' }).Count -gt 0) {
$inventory = Get-FilerDriveInventory
}
foreach ($rawPath in $Paths) {
# A mapped drive letter is rewritten to its UNC target when the mapping is
# missing from this process token (typical for an elevated run: UAC gives
# each token its own set of network drives).
$resolved = Resolve-FilerScanPath -Path $rawPath -Inventory $inventory
$p = $resolved.Path
if (-not $p) {
[void]$scanErrors.Add([pscustomobject]@{ Path = $rawPath; Error = 'Chemin vide' })
continue
}
if ($Progress -and $resolved.Rewritten) { $Progress.Status = "Chemin réseau : $rawPath -> $p" }
$reach = Test-FilerPathReachable -Path $p -TimeoutSeconds 20
if (-not $reach.Exists) {
$msg = 'Chemin introuvable'
if ($reach.TimedOut) {
$msg = 'Serveur injoignable (délai dépassé)'
}
elseif ($resolved.Letter -and -not $resolved.Mounted) {
$msg = if ($resolved.Unc) {
"Lecteur $($resolved.Letter) non monté dans cette session ; cible $($resolved.Unc) inaccessible"
} else {
"Lecteur $($resolved.Letter) non monté dans cette session et cible UNC inconnue (mappez-le ou saisissez le chemin \\serveur\partage)"
}
}
elseif ($resolved.IsNetwork) {
$msg = 'Chemin réseau inaccessible (hors ligne ou identifiants manquants)'
}
if ($reach.Error) { $msg += " : $($reach.Error)" }
[void]$scanErrors.Add([pscustomobject]@{ Path = $rawPath; Error = $msg })
continue
}
$fullPath = $p
try { $fullPath = (Resolve-Path -LiteralPath $p).Path } catch { }
$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
ExcludeFolder = @($ExcludeFolder | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
ExcludeHidden = $ExcludeHidden
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')
# Note which folders were excluded from the scan, when any exclusion is active.
$exclParts = @()
if ($Scan.Settings.ExcludeFolder -and $Scan.Settings.ExcludeFolder.Count -gt 0) {
$exclParts += ($Scan.Settings.ExcludeFolder | ForEach-Object { _enc $_ }) -join ', '
}
if ($Scan.Settings.ExcludeHidden) { $exclParts += 'dossiers masqués' }
$exclNote = if ($exclParts.Count -gt 0) { "Dossiers exclus : $($exclParts -join ' · ')
" } else { '' }
# 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()
}
# ----------------------------------------------------------------------------
# NETWORK / DRIVE SUPPORT
#
# Scanning a "mounted network drive" cannot rely on the drive letter alone:
# a mapping belongs to the logon token that created it, so the letters mapped
# by the interactive user do not exist inside an elevated process (and the
# reverse is also true). Filer Manager needs elevation for -GrantAccess, so
# every network path is resolved to its UNC target, which is token-agnostic.
# Mappings made with New-PSDrive are worse still: they exist only inside the
# PowerShell session and are invisible to Win32/Explorer.
# ----------------------------------------------------------------------------
function Initialize-FilerNetApi {
# Loads the P/Invoke shims used to enumerate shares, read the UNC target of a
# drive letter and open an authenticated connection. Idempotent: the type is
# added to the AppDomain once and stays visible to background runspaces.
if ('FilerManager.NetApi' -as [type]) { return }
Add-Type -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace FilerManager {
public class ShareEntry {
public string Name;
public string Remark;
public bool IsSpecial;
public bool IsDisk;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct ShareInfo1 {
[MarshalAs(UnmanagedType.LPWStr)] public string NetName;
public uint ShareType;
[MarshalAs(UnmanagedType.LPWStr)] public string Remark;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal class NetResource {
public int Scope = 2; // RESOURCE_GLOBALNET
public int ResourceType = 1; // RESOURCETYPE_DISK
public int DisplayType = 3; // RESOURCEDISPLAYTYPE_SHARE
public int Usage = 1; // RESOURCEUSAGE_CONNECTABLE
public string LocalName;
public string RemoteName;
public string Comment;
public string Provider;
}
public static class NetApi {
[DllImport("netapi32.dll", CharSet = CharSet.Unicode)]
private static extern int NetShareEnum(string serverName, int level, out IntPtr bufPtr,
int prefMaxLen, ref int entriesRead, ref int totalEntries, ref int resumeHandle);
[DllImport("netapi32.dll")]
private static extern int NetApiBufferFree(IntPtr buffer);
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
private static extern int WNetGetConnection(string localName, StringBuilder remoteName, ref int length);
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
private static extern int WNetAddConnection2(NetResource netResource, string password, string username, int flags);
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
private static extern int WNetCancelConnection2(string name, int flags, bool force);
// Shares on a server, RAP level 1: readable by any authenticated user
// (level 2 and above would require Administrator on the target).
public static ShareEntry[] EnumShares(string server) {
IntPtr buf = IntPtr.Zero;
int read = 0, total = 0, resume = 0;
List list = new List();
int rc = NetShareEnum(server, 1, out buf, -1, ref read, ref total, ref resume);
// 0 = success, 234 = ERROR_MORE_DATA (we still keep what came back).
if (rc != 0 && rc != 234) throw new System.ComponentModel.Win32Exception(rc);
try {
int size = Marshal.SizeOf(typeof(ShareInfo1));
for (int i = 0; i < read; i++) {
IntPtr p = new IntPtr(buf.ToInt64() + (i * size));
ShareInfo1 si = (ShareInfo1)Marshal.PtrToStructure(p, typeof(ShareInfo1));
ShareEntry e = new ShareEntry();
e.Name = si.NetName;
e.Remark = si.Remark;
e.IsSpecial = (si.ShareType & 0x80000000u) != 0; // STYPE_SPECIAL (ADMIN$, C$, IPC$)
e.IsDisk = (si.ShareType & 0xFFu) == 0; // STYPE_DISKTREE
list.Add(e);
}
}
finally { if (buf != IntPtr.Zero) NetApiBufferFree(buf); }
return list.ToArray();
}
// UNC target behind a mapped drive letter ("Z:"), or null when the letter
// is not a network mapping in this token.
public static string GetUncForDrive(string drive) {
StringBuilder sb = new StringBuilder(2048);
int len = sb.Capacity;
int rc = WNetGetConnection(drive, sb, ref len);
if (rc == 0) return sb.ToString();
return null;
}
// Opens a connection to a share, with or without a drive letter. Passing
// null for localName authenticates the UNC path without consuming a letter.
public static int Connect(string remoteName, string localName, string user, string password, bool persist) {
NetResource nr = new NetResource();
nr.RemoteName = remoteName;
nr.LocalName = string.IsNullOrEmpty(localName) ? null : localName;
int flags = persist ? 1 : 0; // CONNECT_UPDATE_PROFILE
return WNetAddConnection2(nr, password, user, flags);
}
public static int Disconnect(string name, bool force) {
return WNetCancelConnection2(name, 0, force);
}
}
}
"@
}
function Invoke-FilerTimed {
<# Runs a scriptblock on a throw-away runspace and gives up after
-TimeoutSeconds. Every network probe goes through this: an offline share
makes Test-Path / GetDirectories block for tens of seconds, which would
freeze the WinForms UI thread. The scriptblock must declare its own
param() block to receive -Arguments. #>
param(
[scriptblock]$Script,
[object[]]$Arguments = @(),
[int]$TimeoutSeconds = 8
)
$out = [pscustomobject]@{ Result = $null; Error = $null; TimedOut = $false }
$rs = $null; $ps = $null
try {
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'MTA'
$rs.Open()
$ps = [powershell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($Script.ToString())
foreach ($a in $Arguments) { [void]$ps.AddArgument($a) }
$handle = $ps.BeginInvoke()
if (-not $handle.AsyncWaitHandle.WaitOne([timespan]::FromSeconds($TimeoutSeconds))) {
# The call is stuck in a blocking Win32 wait; ask for a stop and walk
# away rather than blocking the caller on Dispose().
$out.TimedOut = $true
try { [void]$ps.BeginStop($null, $null) } catch { }
return $out
}
$out.Result = $ps.EndInvoke($handle)
if ($ps.Streams.Error.Count -gt 0) {
# GetBaseException() unwraps the "Exception calling GetDirectories..."
# noise and keeps the useful part (access denied, path not found, ...).
$out.Error = (($ps.Streams.Error | ForEach-Object { $_.Exception.GetBaseException().Message }) -join ' ; ')
}
}
catch { $out.Error = $_.Exception.Message }
finally {
if (-not $out.TimedOut) {
if ($ps) { try { $ps.Dispose() } catch { } }
if ($rs) { try { $rs.Dispose() } catch { } }
}
}
return $out
}
function Get-FilerDriveInventory {
<# Every drive the tool can reach, local and network, merged from three
sources:
- [IO.DriveInfo] : what is mounted in THIS process token,
- HKCU\Network : the user's persistent mappings, still listed when the
letter is absent from an elevated token (the hive is
shared by both tokens of the same user),
- Get-PSDrive : PowerShell-only mappings, invisible to Win32.
Network entries are never probed for readiness or free space: an offline
share would block the call for tens of seconds. #>
Initialize-FilerNetApi
$byKey = [ordered]@{}
# 1) Drives actually mounted in this process.
foreach ($d in [System.IO.DriveInfo]::GetDrives()) {
$name = $d.Name.Substring(0, 2)
$key = $name.ToUpperInvariant()
$type = switch ([string]$d.DriveType) {
'Fixed' { 'Local' }
'Network' { 'Network' }
'Removable' { 'Removable' }
'CDRom' { 'CDRom' }
'Ram' { 'RAM' }
default { 'Unknown' }
}
$entry = [ordered]@{
Name = $name; Type = $type; Unc = $null; Label = ''
Mounted = $true; Ready = $null; Persistent = $false
Source = 'Session'; FreeBytes = $null; TotalBytes = $null
}
if ($type -eq 'Network') {
try { $entry['Unc'] = [FilerManager.NetApi]::GetUncForDrive($name) } catch { }
}
else {
# Local media only: probing a network drive here can hang.
try {
$entry['Ready'] = [bool]$d.IsReady
if ($d.IsReady) {
$entry['Label'] = [string]$d.VolumeLabel
$entry['FreeBytes'] = [long]$d.AvailableFreeSpace
$entry['TotalBytes'] = [long]$d.TotalSize
}
} catch { }
}
$byKey[$key] = $entry
}
# 2) The user's persistent mappings (HKCU is shared between the elevated and
# non-elevated token of the same user, the mounted letters are not).
try {
$regRoot = 'Registry::HKEY_CURRENT_USER\Network'
if (Test-Path -LiteralPath $regRoot) {
foreach ($k in (Get-ChildItem -LiteralPath $regRoot -ErrorAction Stop)) {
$letter = ([string]$k.PSChildName).ToUpperInvariant()
if ($letter -notmatch '^[A-Z]$') { continue }
$remote = $null
try { $remote = (Get-ItemProperty -LiteralPath $k.PSPath -Name 'RemotePath' -ErrorAction Stop).RemotePath } catch { }
if ([string]::IsNullOrWhiteSpace($remote)) { continue }
$key = "${letter}:"
if ($byKey.Contains($key)) {
$e = $byKey[$key]
if (-not $e['Unc']) { $e['Unc'] = $remote }
$e['Persistent'] = $true
}
else {
$byKey[$key] = [ordered]@{
Name = $key; Type = 'Network'; Unc = $remote; Label = ''
Mounted = $false; Ready = $null; Persistent = $true
Source = 'Profil'; FreeBytes = $null; TotalBytes = $null
}
}
}
}
} catch { }
# 3) PowerShell-only drives rooted on a UNC path.
try {
foreach ($pd in (Get-PSDrive -PSProvider FileSystem -ErrorAction Stop)) {
$root = [string]$pd.Root
if (-not $root.StartsWith('\\')) { continue }
$key = ([string]$pd.Name + ':').ToUpperInvariant()
if ($byKey.Contains($key)) { continue }
$byKey[$key] = [ordered]@{
Name = ([string]$pd.Name + ':'); Type = 'Network'; Unc = $root.TrimEnd('\'); Label = ''
Mounted = $false; Ready = $null; Persistent = $false
Source = 'PSDrive'; FreeBytes = $null; TotalBytes = $null
}
}
} catch { }
$out = New-Object System.Collections.ArrayList
foreach ($key in $byKey.Keys) {
$e = $byKey[$key]
[void]$out.Add([pscustomobject]@{
Name = [string]$e['Name']
Root = ([string]$e['Name'] + '\')
Type = [string]$e['Type']
Unc = [string]$e['Unc']
Label = [string]$e['Label']
Mounted = [bool]$e['Mounted']
Ready = $e['Ready']
Persistent = [bool]$e['Persistent']
Source = [string]$e['Source']
FreeBytes = $e['FreeBytes']
TotalBytes = $e['TotalBytes']
IsNetwork = ([string]$e['Type'] -eq 'Network')
})
}
# Local media first, then network, each alphabetically.
return @($out | Sort-Object @{E = { [int][bool]$_.IsNetwork } }, Name)
}
function Get-FilerUncRoot {
# '\\srv\share\a\b' -> '\\srv\share' ; '\\srv' -> '\\srv' ; $null otherwise.
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$p = $Path.Trim()
if (-not $p.StartsWith('\\')) { return $null }
$parts = @($p.Substring(2) -split '\\' | Where-Object { $_ })
if ($parts.Count -eq 0) { return $null }
if ($parts.Count -eq 1) { return ('\\' + $parts[0]) }
return ('\\' + $parts[0] + '\' + $parts[1])
}
function Resolve-FilerScanPath {
<# Normalises a user-supplied path and works out the form that is actually
scannable from this process.
A drive letter is rewritten to its UNC target when the mapping is not
mounted in the current token (the usual case for an elevated run of a
drive mapped by the interactive user), or when -PreferUnc is set. Pass
-Inventory to reuse a Get-FilerDriveInventory result across many paths. #>
param(
[string]$Path,
[switch]$PreferUnc,
$Inventory
)
$res = [pscustomobject]@{
Input = $Path; Path = $null; Unc = $null; Letter = $null
IsNetwork = $false; Mounted = $true; Rewritten = $false; Note = $null
}
if ([string]::IsNullOrWhiteSpace($Path)) { return $res }
# Tidy up: quotes, environment variables, forward slashes, duplicate separators.
$p = $Path.Trim().Trim('"').Trim("'").Trim()
try { $p = [System.Environment]::ExpandEnvironmentVariables($p) } catch { }
if ($p.StartsWith('//')) { $p = '\\' + $p.Substring(2) }
$p = $p.Replace('/', '\')
$prefix = ''
if ($p.StartsWith('\\')) { $prefix = '\\'; $p = $p.Substring(2) }
while ($p.Contains('\\')) { $p = $p.Replace('\\', '\') }
$p = $prefix + $p
# Trailing separator is dropped, except on a drive root where it is required.
if ($p.Length -gt 3 -and $p.EndsWith('\')) { $p = $p.TrimEnd('\') }
# Extended-length syntax is passed through untouched.
if ($p.StartsWith('\\?\')) {
$res.Path = $p
$res.IsNetwork = $p.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase)
return $res
}
if ($p -match '^([A-Za-z]):($|\\)') {
$letter = $Matches[1].ToUpperInvariant() + ':'
$rest = $p.Substring(2).TrimEnd('\')
$res.Letter = $letter
if (-not $Inventory) { $Inventory = Get-FilerDriveInventory }
$drv = @($Inventory | Where-Object { $_.Name -eq $letter }) | Select-Object -First 1
if ($drv) {
$res.IsNetwork = [bool]$drv.IsNetwork
$res.Mounted = [bool]$drv.Mounted
if ($drv.Unc) { $res.Unc = ($drv.Unc.TrimEnd('\') + $rest) }
}
else {
$res.Mounted = $false
}
if ($res.Unc -and ($PreferUnc -or -not $res.Mounted)) {
$res.Path = $res.Unc
$res.Rewritten = $true
if (-not $res.Mounted) {
$res.Note = "$letter n'est pas monté dans cette session (élévation ?) ; chemin UNC utilisé : $($res.Unc)"
}
}
else {
$res.Path = $p
if ($res.IsNetwork -and -not $res.Mounted) {
$res.Note = "$letter n'est pas monté dans cette session et aucune cible UNC n'est connue."
}
}
return $res
}
if ($p.StartsWith('\\')) {
$res.IsNetwork = $true
$res.Unc = $p
$res.Path = $p
return $res
}
$res.Path = $p
return $res
}
function Test-FilerPathReachable {
<# Timeout-guarded Test-Path. Returns Exists / TimedOut / Error so the caller
can tell "not there" from "the server never answered". #>
param([string]$Path, [int]$TimeoutSeconds = 8)
$r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($Path) -Script {
param($p)
[bool](Test-Path -LiteralPath $p -PathType Container)
}
$exists = $false
if (-not $r.TimedOut -and -not $r.Error) { $exists = [bool](@($r.Result) | Select-Object -First 1) }
return [pscustomobject]@{ Exists = $exists; TimedOut = [bool]$r.TimedOut; Error = $r.Error }
}
function Get-FilerShare {
<# Disk shares published by a server, via NetShareEnum (locale-independent,
no Administrator rights needed). Falls back to WMI when the RAP call is
refused, which happens on some hardened hosts. #>
param(
[string]$Server,
[switch]$IncludeSpecial,
[int]$TimeoutSeconds = 10
)
Initialize-FilerNetApi
$srv = ([string]$Server).Trim().TrimStart('\')
$srv = @($srv -split '\\' | Where-Object { $_ })[0]
if (-not $srv) { throw 'Nom de serveur vide.' }
$r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($srv) -Script {
param($s)
[FilerManager.NetApi]::EnumShares('\\' + $s)
}
if ($r.TimedOut) { throw "Délai dépassé en interrogeant \\$srv." }
$entries = @()
if ($r.Error) {
# WMI fallback (needs rights on the target, but works where RAP is blocked).
$w = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($srv) -Script {
param($s)
Get-CimInstance -ClassName Win32_Share -ComputerName $s -ErrorAction Stop |
ForEach-Object { [pscustomobject]@{ Name = $_.Name; Remark = $_.Description; IsSpecial = ([string]$_.Name).EndsWith('$'); IsDisk = ($_.Type -eq 0) } }
}
if ($w.TimedOut -or $w.Error) { throw ("Énumération des partages impossible sur \\{0} : {1}" -f $srv, $r.Error) }
$entries = @($w.Result)
}
else {
$entries = @($r.Result)
}
$shares = @($entries | Where-Object { $_ -and $_.Name -and $_.IsDisk })
if (-not $IncludeSpecial) { $shares = @($shares | Where-Object { -not $_.IsSpecial }) }
return @($shares | ForEach-Object {
[pscustomobject]@{
Name = [string]$_.Name
Remark = [string]$_.Remark
IsSpecial = [bool]$_.IsSpecial
Path = ('\\' + $srv + '\' + $_.Name)
}
} | Sort-Object Name)
}
function Get-FilerChildFolder {
<# Timeout-guarded subfolder listing, usable on local and UNC paths alike.
Returns Folders / TimedOut / Error. #>
param([string]$Path, [int]$TimeoutSeconds = 15)
$r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($Path) -Script {
param($p)
[System.IO.Directory]::GetDirectories($p)
}
$folders = @()
if (-not $r.TimedOut -and -not $r.Error) {
$folders = @($r.Result | Where-Object { $_ } | Sort-Object)
}
return [pscustomobject]@{ Folders = $folders; TimedOut = [bool]$r.TimedOut; Error = $r.Error }
}
function Connect-FilerShare {
<# Opens an authenticated connection to a UNC path, optionally on a drive
letter. Without a letter the credentials are simply registered for the
session, which is what a scan needs and costs no letter.
Note: WNetAddConnection2 takes the password as plain text, so it is
materialised from the PSCredential for the duration of the call only. #>
param(
[Parameter(Mandatory)] [string]$RemotePath,
[string]$DriveLetter,
[System.Management.Automation.PSCredential]$Credential,
[switch]$Persistent
)
Initialize-FilerNetApi
$remote = ([string]$RemotePath).Trim().TrimEnd('\')
if (-not $remote.StartsWith('\\')) { throw "Chemin réseau attendu (\\serveur\partage) : $RemotePath" }
$user = $null; $pass = $null
if ($Credential) {
$user = $Credential.UserName
$pass = $Credential.GetNetworkCredential().Password
}
$local = if ($DriveLetter) { ([string]$DriveLetter).Trim().TrimEnd('\') } else { $null }
$rc = [FilerManager.NetApi]::Connect($remote, $local, $user, $pass, [bool]$Persistent)
$pass = $null
if ($rc -eq 0) { return }
$msg = switch ($rc) {
5 { "Accès refusé." }
53 { "Serveur ou partage introuvable." }
67 { "Nom de partage introuvable." }
86 { "Mot de passe incorrect." }
1219 { "Une autre connexion à ce serveur existe déjà avec d'autres identifiants (fermez-la d'abord : net use /delete)." }
1326 { "Nom d'utilisateur ou mot de passe incorrect." }
default { (New-Object System.ComponentModel.Win32Exception($rc)).Message }
}
throw ("Connexion à {0} échouée : {1} (code {2})" -f $remote, $msg, $rc)
}
'@
# 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 `
-ExcludeFolder $ExcludeFolder -ExcludeHidden:$ExcludeHidden `
-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)
# ---- exclusions row ----
$lblExclude = New-Object System.Windows.Forms.Label
$lblExclude.Text = 'Exclure dossiers :'; $lblExclude.Location = '12,160'; $lblExclude.AutoSize = $true
$form.Controls.Add($lblExclude)
$txtExclude = New-Object System.Windows.Forms.TextBox
$txtExclude.Location = '118,157'; $txtExclude.Size = '230,24'
# Patterns are separated by ';' or ','. Wildcards (* ?) and casing are honoured.
$txtExclude.Text = ($ExcludeFolder -join '; ')
$tip = New-Object System.Windows.Forms.ToolTip
$tip.SetToolTip($txtExclude, "Noms de dossiers à ignorer, séparés par ';'. Jokers acceptés (ex. #recycle; @eaDir; Thumbs*).")
$form.Controls.Add($txtExclude)
$chkHidden = New-Object System.Windows.Forms.CheckBox
$chkHidden.Text = 'Dossiers masqués'; $chkHidden.Location = '362,159'; $chkHidden.AutoSize = $true
$chkHidden.Checked = [bool]$ExcludeHidden
$tip.SetToolTip($chkHidden, "Exclure aussi les dossiers portant l'attribut « masqué ».")
$form.Controls.Add($chkHidden)
# ---- 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,188'; $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,188'; $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,220'; $tabs.Size = '922,424'
$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é'
$tabRename = New-Object System.Windows.Forms.TabPage; $tabRename.Text = 'Renommer'
$tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant, $tabRename))
$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 })
# ---- Renommer tab: batch rename with find/replace, regex, case, affixes, numbering ----
$rnW = 914 # baseline width used to anchor the right-aligned controls
# Preview grid (added first so the docked panels below claim their edges and the
# grid fills the remaining space - same ordering trick used by the perm filter bar).
$lvRename = New-Object System.Windows.Forms.ListView
$lvRename.Dock = 'Fill'; $lvRename.View = 'Details'; $lvRename.FullRowSelect = $true
$lvRename.GridLines = $true; $lvRename.CheckBoxes = $true
[void]$lvRename.Columns.Add('Type', 60); [void]$lvRename.Columns.Add('Ancien nom', 250)
[void]$lvRename.Columns.Add('Nouveau nom', 250); [void]$lvRename.Columns.Add('État', 130)
[void]$lvRename.Columns.Add('Dossier', 320)
$tabRename.Controls.Add($lvRename)
# Top options panel.
$rnTop = New-Object System.Windows.Forms.Panel
$rnTop.Dock = 'Top'; $rnTop.Height = 200; $rnTop.Width = $rnW
# Row 1: source folder + browse + list.
$lblRnPath = New-Object System.Windows.Forms.Label
$lblRnPath.Text = 'Dossier :'; $lblRnPath.Location = '6,13'; $lblRnPath.AutoSize = $true
$rnPath = New-Object System.Windows.Forms.TextBox
$rnPath.Location = '70,10'; $rnPath.Width = ($rnW - 70 - 188); $rnPath.Anchor = 'Top,Left,Right'
$rnBrowse = New-Object System.Windows.Forms.Button
$rnBrowse.Text = 'Parcourir...'; $rnBrowse.Size = '88,24'; $rnBrowse.Location = "$($rnW - 184),9"; $rnBrowse.Anchor = 'Top,Right'
$rnList = New-Object System.Windows.Forms.Button
$rnList.Text = 'Lister'; $rnList.Size = '88,24'; $rnList.Location = "$($rnW - 92),9"; $rnList.Anchor = 'Top,Right'
$rnTop.Controls.AddRange(@($lblRnPath, $rnPath, $rnBrowse, $rnList))
# Row 2: scope options.
$chkRnRecurse = New-Object System.Windows.Forms.CheckBox
$chkRnRecurse.Text = 'Inclure les sous-dossiers'; $chkRnRecurse.Location = '70,40'; $chkRnRecurse.AutoSize = $true
$chkRnFiles = New-Object System.Windows.Forms.CheckBox
$chkRnFiles.Text = 'Fichiers'; $chkRnFiles.Location = '250,40'; $chkRnFiles.AutoSize = $true; $chkRnFiles.Checked = $true
$chkRnFolders = New-Object System.Windows.Forms.CheckBox
$chkRnFolders.Text = 'Dossiers'; $chkRnFolders.Location = '345,40'; $chkRnFolders.AutoSize = $true; $chkRnFolders.Checked = $true
$chkRnHidden = New-Object System.Windows.Forms.CheckBox
$chkRnHidden.Text = 'Exclure les dossiers masqués'; $chkRnHidden.Location = '450,40'; $chkRnHidden.AutoSize = $true; $chkRnHidden.Checked = $true
$rnTop.Controls.AddRange(@($chkRnRecurse, $chkRnFiles, $chkRnFolders, $chkRnHidden))
# Row 3: find.
$lblRnFind = New-Object System.Windows.Forms.Label
$lblRnFind.Text = 'Rechercher :'; $lblRnFind.Location = '6,75'; $lblRnFind.AutoSize = $true
$rnFind = New-Object System.Windows.Forms.TextBox
$rnFind.Location = '90,72'; $rnFind.Width = ($rnW - 90 - 270); $rnFind.Anchor = 'Top,Left,Right'
$chkRnRegex = New-Object System.Windows.Forms.CheckBox
$chkRnRegex.Text = 'Regex'; $chkRnRegex.Size = '70,22'; $chkRnRegex.Location = "$($rnW - 172),74"; $chkRnRegex.Anchor = 'Top,Right'
$chkRnIgnoreCase = New-Object System.Windows.Forms.CheckBox
$chkRnIgnoreCase.Text = 'Ignorer la casse'; $chkRnIgnoreCase.Size = '130,22'; $chkRnIgnoreCase.Location = "$($rnW - 100),74"; $chkRnIgnoreCase.Anchor = 'Top,Right'
$rnTop.Controls.AddRange(@($lblRnFind, $rnFind, $chkRnRegex, $chkRnIgnoreCase))
# Row 4: replace.
$lblRnReplace = New-Object System.Windows.Forms.Label
$lblRnReplace.Text = 'Remplacer :'; $lblRnReplace.Location = '6,105'; $lblRnReplace.AutoSize = $true
$rnReplace = New-Object System.Windows.Forms.TextBox
$rnReplace.Location = '90,102'; $rnReplace.Width = ($rnW - 90 - 270); $rnReplace.Anchor = 'Top,Left,Right'
$chkRnIgnoreExt = New-Object System.Windows.Forms.CheckBox
$chkRnIgnoreExt.Text = "Ignorer l'extension"; $chkRnIgnoreExt.Size = '160,22'; $chkRnIgnoreExt.Location = "$($rnW - 172),104"; $chkRnIgnoreExt.Anchor = 'Top,Right'; $chkRnIgnoreExt.Checked = $true
$rnTop.Controls.AddRange(@($lblRnReplace, $rnReplace, $chkRnIgnoreExt))
# Row 5: case + affixes.
$lblRnCase = New-Object System.Windows.Forms.Label
$lblRnCase.Text = 'Casse :'; $lblRnCase.Location = '6,137'; $lblRnCase.AutoSize = $true
$rnCase = New-Object System.Windows.Forms.ComboBox
$rnCase.DropDownStyle = 'DropDownList'; $rnCase.Location = '62,134'; $rnCase.Width = 150
[void]$rnCase.Items.AddRange(@('Conserver', 'MAJUSCULES', 'minuscules', 'Première Lettre Des Mots', 'Première lettre'))
$rnCase.SelectedIndex = 0
$lblRnPrefix = New-Object System.Windows.Forms.Label
$lblRnPrefix.Text = 'Préfixe :'; $lblRnPrefix.Location = '228,137'; $lblRnPrefix.AutoSize = $true
$rnPrefix = New-Object System.Windows.Forms.TextBox
$rnPrefix.Location = '282,134'; $rnPrefix.Width = 150
$lblRnSuffix = New-Object System.Windows.Forms.Label
$lblRnSuffix.Text = 'Suffixe :'; $lblRnSuffix.Location = '444,137'; $lblRnSuffix.AutoSize = $true
$rnSuffix = New-Object System.Windows.Forms.TextBox
$rnSuffix.Location = '498,134'; $rnSuffix.Width = 150
$rnTop.Controls.AddRange(@($lblRnCase, $rnCase, $lblRnPrefix, $rnPrefix, $lblRnSuffix, $rnSuffix))
# Row 6: sequential numbering + preview.
$chkRnNumber = New-Object System.Windows.Forms.CheckBox
$chkRnNumber.Text = 'Numéroter ({n})'; $chkRnNumber.Location = '6,170'; $chkRnNumber.AutoSize = $true
$lblRnStart = New-Object System.Windows.Forms.Label
$lblRnStart.Text = 'Début'; $lblRnStart.Location = '150,172'; $lblRnStart.AutoSize = $true
$numRnStart = New-Object System.Windows.Forms.NumericUpDown
$numRnStart.Location = '196,168'; $numRnStart.Width = 60; $numRnStart.Minimum = 0; $numRnStart.Maximum = 1000000; $numRnStart.Value = 1
$lblRnStep = New-Object System.Windows.Forms.Label
$lblRnStep.Text = 'Pas'; $lblRnStep.Location = '266,172'; $lblRnStep.AutoSize = $true
$numRnStep = New-Object System.Windows.Forms.NumericUpDown
$numRnStep.Location = '298,168'; $numRnStep.Width = 55; $numRnStep.Minimum = 1; $numRnStep.Maximum = 100000; $numRnStep.Value = 1
$lblRnPad = New-Object System.Windows.Forms.Label
$lblRnPad.Text = 'Chiffres'; $lblRnPad.Location = '364,172'; $lblRnPad.AutoSize = $true
$numRnPad = New-Object System.Windows.Forms.NumericUpDown
$numRnPad.Location = '418,168'; $numRnPad.Width = 50; $numRnPad.Minimum = 1; $numRnPad.Maximum = 10; $numRnPad.Value = 2
$rnPreview = New-Object System.Windows.Forms.Button
$rnPreview.Text = "Rafraîchir l'aperçu"; $rnPreview.Size = '160,26'; $rnPreview.Location = "$($rnW - 166),167"; $rnPreview.Anchor = 'Top,Right'
$rnTop.Controls.AddRange(@($chkRnNumber, $lblRnStart, $numRnStart, $lblRnStep, $numRnStep, $lblRnPad, $numRnPad, $rnPreview))
$tabRename.Controls.Add($rnTop)
# Bottom action panel.
$rnBottom = New-Object System.Windows.Forms.Panel
$rnBottom.Dock = 'Bottom'; $rnBottom.Height = 44
$rnApply = New-Object System.Windows.Forms.Button
$rnApply.Text = 'Renommer la sélection'; $rnApply.Size = '180,30'; $rnApply.Location = '6,7'; $rnApply.Enabled = $false
$rnCheckAll = New-Object System.Windows.Forms.Button
$rnCheckAll.Text = 'Tout cocher'; $rnCheckAll.Size = '110,30'; $rnCheckAll.Location = '194,7'
$rnCheckNone = New-Object System.Windows.Forms.Button
$rnCheckNone.Text = 'Tout décocher'; $rnCheckNone.Size = '110,30'; $rnCheckNone.Location = '310,7'
$lblRnStatus = New-Object System.Windows.Forms.Label
$lblRnStatus.Text = 'Choisissez un dossier puis cliquez sur Lister.'; $lblRnStatus.AutoSize = $true; $lblRnStatus.Location = '432,14'
$lblRnStatus.ForeColor = [System.Drawing.Color]::DimGray
$rnBottom.Controls.AddRange(@($rnApply, $rnCheckAll, $rnCheckNone, $lblRnStatus))
$tabRename.Controls.Add($rnBottom)
# Wire the Renommer controls (handlers reference functions defined further below).
$rnBrowse.Add_Click({
$picked = @(Show-FilerFolderPicker -Owner $form -SingleSelection `
-Description 'Sélectionnez le dossier contenant les éléments à renommer.' `
-InitialPath $rnPath.Text)
if ($picked.Count -gt 0) { $rnPath.Text = [string]$picked[0] }
})
$rnList.Add_Click({ Invoke-RenameListing })
$rnPreview.Add_Click({ Update-RenamePreview })
# Recompute the preview live as the rename options change.
$rnOptionRecalc = { Update-RenamePreview }
$rnFind.Add_TextChanged($rnOptionRecalc); $rnReplace.Add_TextChanged($rnOptionRecalc)
$rnPrefix.Add_TextChanged($rnOptionRecalc); $rnSuffix.Add_TextChanged($rnOptionRecalc)
$chkRnRegex.Add_CheckedChanged($rnOptionRecalc); $chkRnIgnoreCase.Add_CheckedChanged($rnOptionRecalc)
$chkRnIgnoreExt.Add_CheckedChanged($rnOptionRecalc); $chkRnNumber.Add_CheckedChanged($rnOptionRecalc)
$rnCase.Add_SelectedIndexChanged($rnOptionRecalc)
$numRnStart.Add_ValueChanged($rnOptionRecalc); $numRnStep.Add_ValueChanged($rnOptionRecalc); $numRnPad.Add_ValueChanged($rnOptionRecalc)
$rnCheckAll.Add_Click({ foreach ($it in $lvRename.Items) { if ($it.Tag -and $it.Tag.Changed) { $it.Checked = $true } } })
$rnCheckNone.Add_Click({ foreach ($it in $lvRename.Items) { $it.Checked = $false } })
# Only renameable rows (changed + no conflict/error) may be ticked.
$lvRename.Add_ItemCheck({
param($s, $e)
if ($e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) {
$it = $lvRename.Items[$e.Index]
if (-not ($it.Tag -and $it.Tag.Changed)) { $e.NewValue = [System.Windows.Forms.CheckState]::Unchecked }
}
})
$rnApply.Add_Click({ Invoke-RenameApply })
# ---- 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)
# Rename view state: items listed from the chosen folder and the computed plan.
$script:RenameItems = @() # source items: Name, FullPath, IsFile, Dir, Depth
$script:RenameRows = @() # computed rows: Item, New, Status, Changed
$script:RnCaseModes = @('none', 'upper', 'lower', 'title', 'first') # combobox index -> mode
# ---- 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 }
}
function Get-FilerNewName {
<# Computes the new leaf name for one item by applying, in order:
find/replace (literal or regex), a case transform, prefix/suffix and an
optional sequence number ({n}). Returns the candidate name plus a status.
The file extension is preserved untouched when $O.IgnoreExt is set. #>
param([string]$Name, [bool]$IsFile, [int]$Counter, [hashtable]$O)
$ext = ''
$base = $Name
if ($IsFile -and $O.IgnoreExt) {
$ext = [System.IO.Path]::GetExtension($Name)
$base = [System.IO.Path]::GetFileNameWithoutExtension($Name)
}
$work = $base
if ($O.Find -ne '') {
try {
if ($O.Regex) {
$opt = if ($O.IgnoreCase) { [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } else { [System.Text.RegularExpressions.RegexOptions]::None }
$work = [regex]::Replace($work, $O.Find, $O.Replace, $opt)
}
elseif ($O.IgnoreCase) {
# Literal, case-insensitive: escape the pattern, and escape any '$'
# in the replacement so it stays literal for the regex engine.
$rep = $O.Replace -replace '\$', '$$$$'
$work = [regex]::Replace($work, [regex]::Escape($O.Find), $rep, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}
else {
$work = $work.Replace($O.Find, $O.Replace)
}
}
catch {
return [pscustomobject]@{ NewName = $Name; Status = 'erreur regex'; Changed = $false }
}
}
switch ($O.Case) {
'upper' { $work = $work.ToUpper() }
'lower' { $work = $work.ToLower() }
'title' { $work = (Get-Culture).TextInfo.ToTitleCase($work.ToLower()) }
'first' { if ($work.Length -gt 0) { $work = $work.Substring(0, 1).ToUpper() + $work.Substring(1) } }
}
$combined = $O.Prefix + $work + $O.Suffix
if ($O.Number) {
$num = ([string]$Counter).PadLeft($O.Pad, '0')
if ($combined -match '\{n\}') { $combined = $combined -replace '\{n\}', $num }
else { $combined += $num }
}
$newName = $combined + $ext
if ([string]::IsNullOrWhiteSpace($newName)) {
return [pscustomobject]@{ NewName = $newName; Status = 'nom vide'; Changed = $false }
}
if ($newName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
return [pscustomobject]@{ NewName = $newName; Status = 'caractères invalides'; Changed = $false }
}
$changed = -not $newName.Equals($Name, [System.StringComparison]::Ordinal)
return [pscustomobject]@{ NewName = $newName; Status = $(if ($changed) { 'modifié' } else { 'inchangé' }); Changed = $changed }
}
function Invoke-RenameListing {
# Walks the chosen folder (optionally recursively, honouring the same folder
# exclusions as the scan) and loads its files/folders into $script:RenameItems.
$root = $rnPath.Text.Trim()
if (-not $root) {
[System.Windows.Forms.MessageBox]::Show('Indiquez un dossier à parcourir.', 'Filer Manager', 'OK', 'Information') | Out-Null
return
}
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
[System.Windows.Forms.MessageBox]::Show("Dossier introuvable :`n$root", 'Filer Manager', 'OK', 'Warning') | Out-Null
return
}
$recurse = $chkRnRecurse.Checked
$wantFile = $chkRnFiles.Checked
$wantDir = $chkRnFolders.Checked
$exclHide = $chkRnHidden.Checked
$patterns = @($txtExclude.Text -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$items = New-Object System.Collections.ArrayList
$form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
try {
$stack = New-Object System.Collections.Stack
$stack.Push([pscustomobject]@{ Path = $root; Depth = 0 })
while ($stack.Count -gt 0) {
$cur = $stack.Pop()
$children = @()
try { $children = Get-ChildItem -LiteralPath $cur.Path -Force -ErrorAction Stop } catch { continue }
foreach ($c in $children) {
if ($c.PSIsContainer) {
if (Test-IsExcludedFolder -Entry $c -Patterns $patterns -ExcludeHidden $exclHide) { continue }
if ($wantDir) {
[void]$items.Add([pscustomobject]@{
Name = $c.Name; FullPath = $c.FullName; IsFile = $false
Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1)
})
}
if ($recurse) { $stack.Push([pscustomobject]@{ Path = $c.FullName; Depth = ($cur.Depth + 1) }) }
}
elseif ($wantFile) {
[void]$items.Add([pscustomobject]@{
Name = $c.Name; FullPath = $c.FullName; IsFile = $true
Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1)
})
}
}
}
}
finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default }
$script:RenameItems = @($items | Sort-Object @{ E = { $_.FullPath } })
Update-RenamePreview
}
function Update-RenamePreview {
# Recomputes every item's target name from the current options, flags
# duplicate / pre-existing collisions, and refreshes the preview grid.
if ($null -eq $script:RenameItems -or $script:RenameItems.Count -eq 0) {
$lvRename.Items.Clear(); $script:RenameRows = @(); $rnApply.Enabled = $false
$lblRnStatus.Text = 'Aucun élément listé. Choisissez un dossier puis cliquez sur Lister.'
return
}
$o = @{
Find = $rnFind.Text
Replace = $rnReplace.Text
Regex = $chkRnRegex.Checked
IgnoreCase = $chkRnIgnoreCase.Checked
IgnoreExt = $chkRnIgnoreExt.Checked
Case = $script:RnCaseModes[$rnCase.SelectedIndex]
Prefix = $rnPrefix.Text
Suffix = $rnSuffix.Text
Number = $chkRnNumber.Checked
Pad = [int]$numRnPad.Value
}
$start = [int]$numRnStart.Value
$step = [int]$numRnStep.Value
$rows = New-Object System.Collections.ArrayList
$i = 0
foreach ($item in $script:RenameItems) {
$r = Get-FilerNewName -Name $item.Name -IsFile $item.IsFile -Counter ($start + $i * $step) -O $o
[void]$rows.Add([pscustomobject]@{ Item = $item; New = $r.NewName; Status = $r.Status; Changed = $r.Changed })
$i++
}
# Per-directory collision checks (only matter for items that actually change).
foreach ($grp in ($rows | Group-Object { $_.Item.Dir })) {
$seen = @{}
foreach ($row in $grp.Group) {
if (-not $row.Changed) { continue }
$key = $row.New.ToLowerInvariant()
if ($seen.ContainsKey($key)) {
$row.Status = 'conflit (doublon)'; $row.Changed = $false
$seen[$key].Status = 'conflit (doublon)'; $seen[$key].Changed = $false
}
else { $seen[$key] = $row }
}
foreach ($row in $grp.Group) {
if (-not $row.Changed) { continue }
$target = Join-Path $row.Item.Dir $row.New
# Skip a case-only rename of the item onto itself (-ne is case-insensitive).
if ($target -ne $row.Item.FullPath -and (Test-Path -LiteralPath $target)) {
# OK only if the current occupant is another listed item that will move away.
$occupantMoves = $grp.Group | Where-Object {
$_.Changed -and $_.Item.Name.ToLowerInvariant() -eq $row.New.ToLowerInvariant()
}
if (-not $occupantMoves) { $row.Status = 'conflit (existe déjà)'; $row.Changed = $false }
}
}
}
$script:RenameRows = @($rows)
$lvRename.BeginUpdate(); $lvRename.Items.Clear()
foreach ($row in $rows) {
$it = New-Object System.Windows.Forms.ListViewItem($(if ($row.Item.IsFile) { 'Fichier' } else { 'Dossier' }))
[void]$it.SubItems.Add($row.Item.Name)
[void]$it.SubItems.Add($row.New)
[void]$it.SubItems.Add($row.Status)
[void]$it.SubItems.Add($row.Item.Dir)
$it.Tag = $row
if ($row.Changed) { $it.Checked = $true }
elseif ($row.Status -like 'conflit*' -or $row.Status -like 'erreur*' -or $row.Status -eq 'caractères invalides' -or $row.Status -eq 'nom vide') {
$it.ForeColor = [System.Drawing.Color]::Firebrick
}
else { $it.ForeColor = [System.Drawing.Color]::Gray } # inchangé
[void]$lvRename.Items.Add($it)
}
$lvRename.EndUpdate()
$nChange = @($rows | Where-Object { $_.Changed }).Count
$nConf = @($rows | Where-Object { $_.Status -like 'conflit*' }).Count
$rnApply.Enabled = ($nChange -gt 0)
$msg = "$($rows.Count) élément(s), $nChange à renommer"
if ($nConf -gt 0) { $msg += ", $nConf conflit(s) ignoré(s)" }
$lblRnStatus.Text = $msg
}
function Invoke-RenameApply {
# Renames every ticked, changed row. Deepest paths first so a folder is only
# renamed after its contents, keeping the captured child paths valid.
$todo = @()
foreach ($it in $lvRename.Items) {
if ($it.Checked -and $it.Tag -and $it.Tag.Changed) { $todo += $it.Tag }
}
if ($todo.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show('Aucun élément coché à renommer.', 'Filer Manager', 'OK', 'Information') | Out-Null
return
}
$confirm = [System.Windows.Forms.MessageBox]::Show(
"Renommer $($todo.Count) élément(s) ? Cette action modifie les fichiers/dossiers sur le disque.",
'Filer Manager', 'YesNo', 'Warning')
if ($confirm -ne 'Yes') { return }
$todo = @($todo | Sort-Object @{ E = { $_.Item.Depth }; Descending = $true })
$ok = 0; $failures = New-Object System.Collections.ArrayList
$form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
try {
foreach ($row in $todo) {
try {
Rename-Item -LiteralPath $row.Item.FullPath -NewName $row.New -ErrorAction Stop
$ok++
}
catch {
[void]$failures.Add("$($row.Item.Name) -> $($row.New) : $($_.Exception.Message)")
}
}
}
finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default }
if ($failures.Count -gt 0) {
$detail = (@($failures) | Select-Object -First 15) -join "`n"
if ($failures.Count -gt 15) { $detail += "`n... (+$($failures.Count - 15))" }
[System.Windows.Forms.MessageBox]::Show("$ok renommé(s), $($failures.Count) échec(s) :`n`n$detail",
'Filer Manager', 'OK', 'Warning') | Out-Null
}
else {
[System.Windows.Forms.MessageBox]::Show("$ok élément(s) renommé(s).", 'Filer Manager', 'OK', 'Information') | Out-Null
}
# Re-list from disk so the grid reflects the new names.
Invoke-RenameListing
}
# ---- 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) }
}
})
# ============================================================================
# FOLDER PICKER (local drives, mapped network drives, UNC paths, shares)
#
# FolderBrowserDialog is not usable here: on Windows PowerShell 5.1 it is the
# legacy shell dialog (no path box, no way to type a UNC path) and it only ever
# shows the drive letters mounted in the current process token - so an elevated
# Filer Manager sees none of the drives the user mapped. This picker works from
# Get-FilerDriveInventory instead, browses servers share by share, accepts a
# pasted UNC path and can open an authenticated connection.
# ============================================================================
# Servers added by hand during this session, kept between two openings of the picker.
$script:FilerPickerServers = New-Object System.Collections.ArrayList
function Show-FilerInputBox {
# Minimal one-line prompt (avoids a dependency on Microsoft.VisualBasic).
param(
[System.Windows.Forms.Form]$Owner,
[string]$Title = 'Filer Manager',
[string]$Prompt = '',
[string]$Default = ''
)
$dlg = New-Object System.Windows.Forms.Form
$dlg.Text = $Title
$dlg.FormBorderStyle = 'FixedDialog'
$dlg.StartPosition = 'CenterParent'
$dlg.MinimizeBox = $false; $dlg.MaximizeBox = $false
$dlg.ClientSize = New-Object System.Drawing.Size(452, 122)
$lbl = New-Object System.Windows.Forms.Label
$lbl.Text = $Prompt; $lbl.Location = '12,12'; $lbl.Size = '428,34'
$txt = New-Object System.Windows.Forms.TextBox
$txt.Location = '12,52'; $txt.Size = '428,24'; $txt.Text = $Default
$ok = New-Object System.Windows.Forms.Button
$ok.Text = 'OK'; $ok.Size = '96,28'; $ok.Location = '246,84'
$ok.DialogResult = [System.Windows.Forms.DialogResult]::OK
$cancel = New-Object System.Windows.Forms.Button
$cancel.Text = 'Annuler'; $cancel.Size = '96,28'; $cancel.Location = '346,84'
$cancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
$dlg.Controls.AddRange(@($lbl, $txt, $ok, $cancel))
$dlg.AcceptButton = $ok; $dlg.CancelButton = $cancel
$r = $dlg.ShowDialog($Owner)
$val = [string]$txt.Text
$dlg.Dispose()
if ($r -ne [System.Windows.Forms.DialogResult]::OK) { return $null }
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
return $val.Trim()
}
function Show-FilerConnectDialog {
<# Opens a connection to a share, with optional credentials and an optional
drive letter. Returns the UNC path on success, $null when cancelled.
Connecting without a letter is enough for a scan and is the default: it
registers the credentials for the session without consuming a letter. #>
param(
[System.Windows.Forms.Form]$Owner,
[string]$InitialPath = '\\'
)
$dlg = New-Object System.Windows.Forms.Form
$dlg.Text = 'Connecter un partage réseau'
$dlg.FormBorderStyle = 'FixedDialog'
$dlg.StartPosition = 'CenterParent'
$dlg.MinimizeBox = $false; $dlg.MaximizeBox = $false
$dlg.ClientSize = New-Object System.Drawing.Size(470, 250)
$lblPath = New-Object System.Windows.Forms.Label
$lblPath.Text = 'Chemin réseau (\\serveur\partage) :'; $lblPath.Location = '12,12'; $lblPath.AutoSize = $true
$txtPath = New-Object System.Windows.Forms.TextBox
$txtPath.Location = '12,32'; $txtPath.Size = '446,24'; $txtPath.Text = $InitialPath
$lblUser = New-Object System.Windows.Forms.Label
$lblUser.Text = 'Utilisateur (vide = session en cours) :'; $lblUser.Location = '12,64'; $lblUser.AutoSize = $true
$txtUser = New-Object System.Windows.Forms.TextBox
$txtUser.Location = '12,84'; $txtUser.Size = '216,24'
$lblPass = New-Object System.Windows.Forms.Label
$lblPass.Text = 'Mot de passe :'; $lblPass.Location = '242,64'; $lblPass.AutoSize = $true
$txtPass = New-Object System.Windows.Forms.TextBox
$txtPass.Location = '242,84'; $txtPass.Size = '216,24'; $txtPass.UseSystemPasswordChar = $true
$lblLetter = New-Object System.Windows.Forms.Label
$lblLetter.Text = 'Lettre de lecteur :'; $lblLetter.Location = '12,118'; $lblLetter.AutoSize = $true
$cmbLetter = New-Object System.Windows.Forms.ComboBox
$cmbLetter.Location = '12,138'; $cmbLetter.Size = '216,24'; $cmbLetter.DropDownStyle = 'DropDownList'
[void]$cmbLetter.Items.Add('(aucune - authentifier seulement)')
$used = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $_.Name.Substring(0, 1).ToUpperInvariant() })
foreach ($code in 68..90) { # D..Z
$c = [string][char]$code
if ($used -notcontains $c) { [void]$cmbLetter.Items.Add("${c}:") }
}
$cmbLetter.SelectedIndex = 0
$chkPersist = New-Object System.Windows.Forms.CheckBox
$chkPersist.Text = 'Reconnecter à l''ouverture de session'; $chkPersist.Location = '242,138'; $chkPersist.Size = '216,24'
$lblStatus = New-Object System.Windows.Forms.Label
$lblStatus.Location = '12,172'; $lblStatus.Size = '446,36'
$lblStatus.ForeColor = [System.Drawing.Color]::Firebrick
$btnOk = New-Object System.Windows.Forms.Button
$btnOk.Text = 'Connecter'; $btnOk.Size = '110,28'; $btnOk.Location = '236,212'
$btnCancel = New-Object System.Windows.Forms.Button
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '110,28'; $btnCancel.Location = '350,212'
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
$dlg.Controls.AddRange(@($lblPath, $txtPath, $lblUser, $txtUser, $lblPass, $txtPass,
$lblLetter, $cmbLetter, $chkPersist, $lblStatus, $btnOk, $btnCancel))
$dlg.AcceptButton = $btnOk; $dlg.CancelButton = $btnCancel
$script:FilerConnectResult = $null
$btnOk.Add_Click({
$remote = ([string]$txtPath.Text).Trim().TrimEnd('\')
if (-not $remote.StartsWith('\\') -or $remote.Length -lt 5) {
$lblStatus.Text = 'Chemin attendu sous la forme \\serveur\partage.'
return
}
$letter = $null
if ($cmbLetter.SelectedIndex -gt 0) { $letter = [string]$cmbLetter.SelectedItem }
$cred = $null
if (-not [string]::IsNullOrWhiteSpace($txtUser.Text)) {
$sec = New-Object System.Security.SecureString
foreach ($ch in ([string]$txtPass.Text).ToCharArray()) { $sec.AppendChar($ch) }
$sec.MakeReadOnly()
$cred = New-Object System.Management.Automation.PSCredential(([string]$txtUser.Text).Trim(), $sec)
}
$lblStatus.Text = 'Connexion...'; $lblStatus.Refresh()
try {
Connect-FilerShare -RemotePath $remote -DriveLetter $letter -Credential $cred -Persistent:([bool]$chkPersist.Checked)
$script:FilerConnectResult = $remote
$dlg.DialogResult = [System.Windows.Forms.DialogResult]::OK
$dlg.Close()
}
catch {
$lblStatus.Text = $_.Exception.Message
}
}.GetNewClosure())
[void]$dlg.ShowDialog($Owner)
$dlg.Dispose()
return $script:FilerConnectResult
}
function New-FilerPickerNode {
<# Tree node carrying its own metadata: Kind (group / drive / server / share /
folder / typed), the path to scan, and whether its children were loaded.
A dummy child makes the node expandable before anything is enumerated. #>
param(
[string]$Text,
[string]$Kind,
[string]$Path,
[switch]$Expandable
)
$n = New-Object System.Windows.Forms.TreeNode($Text)
$n.Tag = [pscustomobject]@{ Kind = $Kind; Path = $Path; Loaded = $false }
if ($Expandable) { [void]$n.Nodes.Add((New-Object System.Windows.Forms.TreeNode('...'))) }
return $n
}
function Expand-FilerPickerNode {
<# Fills a node on demand: shares for a server node, subfolders otherwise.
Every enumeration is timeout-guarded, so an offline share reports itself
instead of freezing the dialog. #>
param(
[System.Windows.Forms.TreeNode]$Node,
[System.Windows.Forms.Label]$Status
)
$tag = $Node.Tag
if (-not $tag) { return }
if ($tag.Kind -eq 'group') { return }
if ($tag.Loaded) { return }
if (-not $tag.Path) { return }
$prev = [System.Windows.Forms.Cursor]::Current
[System.Windows.Forms.Cursor]::Current = [System.Windows.Forms.Cursors]::WaitCursor
if ($Status) { $Status.Text = "Lecture de $($tag.Path)..."; $Status.Refresh() }
try {
$children = @()
$err = $null
if ($tag.Kind -eq 'server') {
try {
$children = @(Get-FilerShare -Server $tag.Path | ForEach-Object {
[pscustomobject]@{ Text = $_.Name; Path = $_.Path; Kind = 'share' }
})
}
catch { $err = $_.Exception.Message }
}
else {
$r = Get-FilerChildFolder -Path $tag.Path
if ($r.TimedOut) { $err = 'délai dépassé (serveur injoignable)' }
elseif ($r.Error) { $err = $r.Error }
else {
$children = @($r.Folders | ForEach-Object {
[pscustomobject]@{ Text = (Split-Path -Path $_ -Leaf); Path = $_; Kind = 'folder' }
})
}
}
$Node.Nodes.Clear()
foreach ($c in $children) {
[void]$Node.Nodes.Add((New-FilerPickerNode -Text $c.Text -Kind $c.Kind -Path $c.Path -Expandable))
}
# Keep the node reloadable after a failure (the share may come back, or the
# user may connect with credentials and try again).
$tag.Loaded = (-not $err)
if ($err) {
$bad = New-Object System.Windows.Forms.TreeNode("(inaccessible : $err)")
$bad.ForeColor = [System.Drawing.Color]::Firebrick
[void]$Node.Nodes.Add($bad)
if ($Status) { $Status.Text = "$($tag.Path) : $err" }
}
elseif ($Status) {
$label = if ($tag.Kind -eq 'server') { 'partage(s)' } else { 'sous-dossier(s)' }
$Status.Text = "$($tag.Path) : $($children.Count) $label."
}
}
finally { [System.Windows.Forms.Cursor]::Current = $prev }
}
function Get-FilerPickerCheckedPath {
# Paths of every ticked node, depth-first. Group nodes and the placeholder /
# error nodes (no Tag) are ignored.
param($Nodes)
$out = New-Object System.Collections.ArrayList
foreach ($n in $Nodes) {
$t = $n.Tag
if ($n.Checked -and $t -and $t.Kind -ne 'group' -and $t.Path) { [void]$out.Add([string]$t.Path) }
if ($n.Nodes.Count -gt 0) {
foreach ($c in (Get-FilerPickerCheckedPath -Nodes $n.Nodes)) { [void]$out.Add($c) }
}
}
return $out.ToArray()
}
function Get-FilerServerName {
# '\\nas\share\sub' -> '\\nas'
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$p = $Path.Trim()
if (-not $p.StartsWith('\\')) { return $null }
$first = @($p.TrimStart('\') -split '\\' | Where-Object { $_ }) | Select-Object -First 1
if (-not $first) { return $null }
return ('\\' + $first)
}
function Update-FilerPickerTree {
<# (Re)builds the two fixed branches: 'Ce PC' from the drive inventory, and
'Réseau' from the servers behind the known mappings plus those added by
hand. Network drives are listed even when the letter is missing from this
token; their UNC target is then used for browsing. #>
param(
[System.Windows.Forms.TreeView]$Tree,
[System.Windows.Forms.TreeNode]$PcNode,
[System.Windows.Forms.TreeNode]$NetNode,
[System.Windows.Forms.Label]$Status
)
$typeLabel = @{ Local = 'Local'; Network = 'Réseau'; Removable = 'Amovible'; CDRom = 'CD/DVD'; RAM = 'RAM'; Unknown = 'Inconnu' }
$Tree.BeginUpdate()
try {
$PcNode.Nodes.Clear()
$NetNode.Nodes.Clear()
$inv = @(Get-FilerDriveInventory)
$servers = New-Object System.Collections.ArrayList
foreach ($d in $inv) {
if ($d.IsNetwork) {
$path = if ($d.Mounted) { $d.Root } else { $d.Unc }
if (-not $path) { continue } # unmounted letter with no known target
$target = if ($d.Unc) { $d.Unc } else { 'cible inconnue' }
$state = if ($d.Mounted) { 'Réseau' } else { 'Réseau, non monté dans cette session' }
$text = "$($d.Name) -> $target [$state]"
[void]$PcNode.Nodes.Add((New-FilerPickerNode -Text $text -Kind 'drive' -Path $path -Expandable))
$srv = Get-FilerServerName -Path $d.Unc
if ($srv -and ($servers -notcontains $srv)) { [void]$servers.Add($srv) }
}
else {
# Skip empty optical / removable slots: they can only fail.
if (($d.Type -eq 'CDRom' -or $d.Type -eq 'Removable') -and $d.Ready -eq $false) { continue }
$bits = New-Object System.Collections.ArrayList
[void]$bits.Add([string]$typeLabel[$d.Type])
if ($d.Label) { [void]$bits.Add([string]$d.Label) }
if ($null -ne $d.FreeBytes) { [void]$bits.Add(((Format-Bytes $d.FreeBytes) + ' libres')) }
$text = "$($d.Name) [" + (($bits | Where-Object { $_ }) -join ', ') + ']'
[void]$PcNode.Nodes.Add((New-FilerPickerNode -Text $text -Kind 'drive' -Path $d.Root -Expandable))
}
}
foreach ($s in @($script:FilerPickerServers)) {
if ($s -and ($servers -notcontains $s)) { [void]$servers.Add($s) }
}
foreach ($s in ($servers | Sort-Object)) {
[void]$NetNode.Nodes.Add((New-FilerPickerNode -Text $s -Kind 'server' -Path $s -Expandable))
}
if ($NetNode.Nodes.Count -eq 0) {
$hint = New-Object System.Windows.Forms.TreeNode('(aucun serveur connu - utilisez « Ajouter un serveur... »)')
$hint.ForeColor = [System.Drawing.Color]::DimGray
[void]$NetNode.Nodes.Add($hint)
}
$PcNode.Expand()
$NetNode.Expand()
}
finally { $Tree.EndUpdate() }
if ($Status) { $Status.Text = "$($PcNode.Nodes.Count) lecteur(s), $($NetNode.Nodes.Count) serveur(s)." }
}
function Show-FilerFolderPicker {
<# Folder picker able to reach network storage. Returns the selected paths as
a string[] (empty when cancelled). With -SingleSelection the tree has no
check boxes and the highlighted node is returned. #>
param(
[System.Windows.Forms.Form]$Owner,
[string]$Description = 'Cochez le ou les dossiers à analyser, ou collez un chemin \\serveur\partage.',
[string]$InitialPath,
[switch]$SingleSelection
)
$dlg = New-Object System.Windows.Forms.Form
$dlg.Text = if ($SingleSelection) { 'Sélectionner un dossier' } else { 'Sélectionner des dossiers' }
$dlg.StartPosition = 'CenterParent'
$dlg.ClientSize = New-Object System.Drawing.Size(760, 592)
$dlg.MinimumSize = New-Object System.Drawing.Size(640, 500)
$dlg.ShowInTaskbar = $false
$lblDesc = New-Object System.Windows.Forms.Label
$lblDesc.Text = $Description; $lblDesc.Location = '12,10'; $lblDesc.AutoSize = $true
$txtPath = New-Object System.Windows.Forms.TextBox
$txtPath.Location = '12,34'; $txtPath.Size = '618,24'; $txtPath.Anchor = 'Top,Left,Right'
if ($InitialPath) { $txtPath.Text = $InitialPath }
$btnGo = New-Object System.Windows.Forms.Button
$btnGo.Text = 'Ouvrir'; $btnGo.Location = '638,33'; $btnGo.Size = '110,26'; $btnGo.Anchor = 'Top,Right'
$tree = New-Object System.Windows.Forms.TreeView
$tree.Location = '12,68'; $tree.Size = '736,436'
$tree.Anchor = 'Top,Bottom,Left,Right'
$tree.CheckBoxes = (-not $SingleSelection)
$tree.HideSelection = $false
$tree.ShowLines = $true
$btnRefresh = New-Object System.Windows.Forms.Button
$btnRefresh.Text = 'Actualiser'; $btnRefresh.Location = '12,512'; $btnRefresh.Size = '110,28'; $btnRefresh.Anchor = 'Bottom,Left'
$btnServer = New-Object System.Windows.Forms.Button
$btnServer.Text = 'Ajouter un serveur...'; $btnServer.Location = '130,512'; $btnServer.Size = '160,28'; $btnServer.Anchor = 'Bottom,Left'
$btnConnect = New-Object System.Windows.Forms.Button
$btnConnect.Text = 'Connecter un partage...'; $btnConnect.Location = '298,512'; $btnConnect.Size = '170,28'; $btnConnect.Anchor = 'Bottom,Left'
$chkUnc = New-Object System.Windows.Forms.CheckBox
$chkUnc.Text = 'Utiliser les chemins UNC pour les lecteurs réseau (recommandé en mode Administrateur)'
$chkUnc.Location = '12,544'; $chkUnc.Size = '560,22'; $chkUnc.Checked = $true; $chkUnc.Anchor = 'Bottom,Left'
$lblStatus = New-Object System.Windows.Forms.Label
$lblStatus.Location = '12,568'; $lblStatus.Size = '520,18'; $lblStatus.Anchor = 'Bottom,Left'
$lblStatus.ForeColor = [System.Drawing.Color]::DimGray
$btnOk = New-Object System.Windows.Forms.Button
$btnOk.Text = 'Valider'; $btnOk.Size = '104,30'; $btnOk.Location = '542,552'; $btnOk.Anchor = 'Bottom,Right'
$btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
$btnCancel = New-Object System.Windows.Forms.Button
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '104,30'; $btnCancel.Location = '654,552'; $btnCancel.Anchor = 'Bottom,Right'
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
$dlg.Controls.AddRange(@($lblDesc, $txtPath, $btnGo, $tree, $btnRefresh, $btnServer,
$btnConnect, $chkUnc, $lblStatus, $btnOk, $btnCancel))
$dlg.CancelButton = $btnCancel # no AcceptButton: Entrée in the path box means "Ouvrir"
# ---- fixed branches ----
$nodePc = New-FilerPickerNode -Text 'Ce PC' -Kind 'group' -Path ''
$nodeNet = New-FilerPickerNode -Text 'Réseau' -Kind 'group' -Path ''
$nodeTyped = New-FilerPickerNode -Text 'Chemins saisis' -Kind 'group' -Path ''
[void]$tree.Nodes.Add($nodePc)
[void]$tree.Nodes.Add($nodeNet)
[void]$tree.Nodes.Add($nodeTyped)
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
# ---- handlers ----
$tree.Add_BeforeExpand({
param($sender, $e)
Expand-FilerPickerNode -Node $e.Node -Status $lblStatus
}.GetNewClosure())
$tree.Add_AfterSelect({
param($sender, $e)
$t = $e.Node.Tag
if ($t -and $t.Path) { $txtPath.Text = [string]$t.Path }
}.GetNewClosure())
if (-not $SingleSelection) {
$tree.Add_AfterCheck({
param($sender, $e)
$n = @(Get-FilerPickerCheckedPath -Nodes $tree.Nodes).Count
$lblStatus.Text = "$n dossier(s) coché(s)."
}.GetNewClosure())
}
# "Ouvrir": resolve what was typed/pasted, then show it in the tree. A bare
# \\server is treated as a server to browse; anything else must be an existing
# folder before it is added.
$goAction = {
$raw = [string]$txtPath.Text
if ([string]::IsNullOrWhiteSpace($raw)) { return }
$r = Resolve-FilerScanPath -Path $raw
$p = [string]$r.Path
if (-not $p) { $lblStatus.Text = 'Chemin invalide.'; return }
# \\server (no share): browse it instead of testing it as a folder.
if ($p -match '^\\\\[^\\]+\\?$') {
$srv = Get-FilerServerName -Path $p
if ($script:FilerPickerServers -notcontains $srv) { [void]$script:FilerPickerServers.Add($srv) }
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
foreach ($n in $nodeNet.Nodes) {
if ($n.Tag -and [string]$n.Tag.Path -eq $srv) { $tree.SelectedNode = $n; $n.Expand(); break }
}
return
}
$lblStatus.Text = "Vérification de $p..."; $lblStatus.Refresh()
$reach = Test-FilerPathReachable -Path $p -TimeoutSeconds 10
if (-not $reach.Exists) {
$why = if ($reach.TimedOut) { 'serveur injoignable (délai dépassé)' }
elseif ($reach.Error) { $reach.Error }
elseif ($r.Letter -and -not $r.Mounted) { "lecteur $($r.Letter) non monté dans cette session" }
else { 'introuvable' }
$lblStatus.Text = "$p : $why"
return
}
$existing = $null
foreach ($n in $nodeTyped.Nodes) {
if ($n.Tag -and ([string]$n.Tag.Path).Equals($p, [System.StringComparison]::OrdinalIgnoreCase)) { $existing = $n; break }
}
if (-not $existing) {
$existing = New-FilerPickerNode -Text $p -Kind 'typed' -Path $p -Expandable
[void]$nodeTyped.Nodes.Add($existing)
}
$nodeTyped.Expand()
$tree.SelectedNode = $existing
if ($tree.CheckBoxes) { $existing.Checked = $true }
$existing.Expand()
if ($r.Note) { $lblStatus.Text = $r.Note }
}.GetNewClosure()
$btnGo.Add_Click($goAction)
$txtPath.Add_KeyDown({
param($sender, $e)
if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) {
$e.SuppressKeyPress = $true
& $goAction
}
}.GetNewClosure())
$btnRefresh.Add_Click({
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
}.GetNewClosure())
$btnServer.Add_Click({
$srvRaw = Show-FilerInputBox -Owner $dlg -Title 'Ajouter un serveur' `
-Prompt 'Nom ou adresse du serveur de fichiers (nas1, 10.0.0.5, \\nas1) :'
if (-not $srvRaw) { return }
$srv = Get-FilerServerName -Path $srvRaw
if (-not $srv) { $srv = '\\' + $srvRaw.Trim().TrimStart('\') }
if ($script:FilerPickerServers -notcontains $srv) { [void]$script:FilerPickerServers.Add($srv) }
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
foreach ($n in $nodeNet.Nodes) {
if ($n.Tag -and [string]$n.Tag.Path -eq $srv) { $tree.SelectedNode = $n; $n.Expand(); break }
}
}.GetNewClosure())
$btnConnect.Add_Click({
$seed = [string]$txtPath.Text
if (-not $seed.StartsWith('\\')) { $seed = '\\' }
$connected = Show-FilerConnectDialog -Owner $dlg -InitialPath $seed
if (-not $connected) { return }
$srv = Get-FilerServerName -Path $connected
if ($srv -and ($script:FilerPickerServers -notcontains $srv)) { [void]$script:FilerPickerServers.Add($srv) }
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
$txtPath.Text = $connected
$lblStatus.Text = "Connecté à $connected."
}.GetNewClosure())
# ---- result ----
$result = $dlg.ShowDialog($Owner)
$picked = @()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
if ($SingleSelection) {
$sel = $tree.SelectedNode
if ($sel -and $sel.Tag -and $sel.Tag.Path) { $picked = @([string]$sel.Tag.Path) }
}
else {
$picked = @(Get-FilerPickerCheckedPath -Nodes $tree.Nodes)
}
}
$preferUnc = [bool]$chkUnc.Checked
$dlg.Dispose()
if ($picked.Count -eq 0) { return @() }
# Normalise, optionally forcing the UNC form, and drop duplicates.
$inv = Get-FilerDriveInventory
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$out = New-Object System.Collections.ArrayList
foreach ($p in $picked) {
$r = Resolve-FilerScanPath -Path $p -PreferUnc:$preferUnc -Inventory $inv
if ($r.Path -and $seen.Add($r.Path)) { [void]$out.Add([string]$r.Path) }
}
return $out.ToArray()
}
# ---- events ----
$btnAdd.Add_Click({
# Custom picker: handles local drives, mapped network drives (even when the
# letter is missing from an elevated token), pasted UNC paths and shares.
$paths = @(Show-FilerFolderPicker -Owner $form)
$added = 0
foreach ($p in $paths) {
if (-not $p) { continue }
$dupe = $false
foreach ($existing in $lstFolders.Items) {
if (([string]$existing).Equals([string]$p, [System.StringComparison]::OrdinalIgnoreCase)) { $dupe = $true; break }
}
if (-not $dupe) { [void]$lstFolders.Items.Add([string]$p); $added++ }
}
if ($added -gt 0) { $statusLbl.Text = "$added dossier(s) ajouté(s)." }
})
$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
$exclude = @($txtExclude.Text -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$exclHidden = [bool]$chkHidden.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, $Exclude, $ExclHidden, $Grant, $Revert, $Shared)
$Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -ExcludeFolder $Exclude -ExcludeHidden $ExclHidden -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared
'@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).AddArgument($exclude).AddArgument($exclHidden).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()