From 29e79a2529ab8713cfccda7c6f6e5e1f8c901be4 Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 21 Aug 2026 11:20:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20s=C3=A9lection=20de=20dossiers=20r?= =?UTF-8?q?=C3=A9seau=20(lecteurs=20mapp=C3=A9s,=20UNC,=20partages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace FolderBrowserDialog par un sélecteur maison, dans l'onglet d'analyse comme dans l'onglet Renommer : - inventaire des lecteurs fusionnant IO.DriveInfo, HKCU\Network et Get-PSDrive, donc un lecteur mappé reste visible même absent du jeton élevé (les mappages appartiennent à la session qui les a créés) ; - réécriture automatique d'une lettre réseau vers sa cible UNC, appliquée aussi aux -Path du mode sans interface ; - navigation partage par partage via NetShareEnum (repli WMI) et connexion authentifiée via WNetAddConnection2, lettre facultative ; - chemin \serveur\partage collable, sélection multiple par cases ; - toute énumération réseau bornée par un délai sur un runspace jeté après usage, pour qu'un partage hors ligne ne fige plus l'interface ; - erreurs de racine distinctes : introuvable, lecteur non monté, serveur injoignable. Ce commit embarque aussi les travaux locaux antérieurs qui n'avaient jamais été poussés (onglet Renommer, export CSV par catégorie, filtres de permissions) : le dépôt distant était resté sur une version plus ancienne que la copie de travail. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 67 +- filer-manager.ps1 | 1577 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1614 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a525187..2df3306 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -![logo](https://f003.backblazeb2.com/file/KawaPublicCDN/logos/filermanager-smol.png) # Filer Manager Un outil PowerShell mono-fichier pour les serveurs de fichiers Windows. Il analyse des dossiers et rapporte : @@ -8,16 +7,44 @@ Un outil PowerShell mono-fichier pour les serveurs de fichiers Windows. Il analy - **Permissions NTFS** — propriétaire + ACL (identité, droits, allow/deny, hérité) par dossier, jusqu'à une profondeur configurable. Dans l'interface, **cliquez sur n'importe quel en-tête de colonne** (Dossier / Identité / Droits / Type / Hérité) pour une fenêtre de type Excel : tri A→Z / Z→A et sélection des valeurs à conserver. Les filtres se combinent entre les colonnes ; - **Effacer les filtres** réinitialise le tout. + **Effacer les filtres** réinitialise le tout. Deux cases à cocher permettent en outre de **masquer les permissions héritées** + et de **masquer les comptes/groupes système** (`NT AUTHORITY\SYSTEM`, `BUILTIN\Administrators`, `CREATOR OWNER`, + `NT SERVICE\*`…) afin de ne garder que les identités métier. La détection des comptes système se fait par **SID** + (indépendante de la langue), avec un repli sur le nom. Les mêmes filtres sont disponibles dans le rapport HTML (cases à + cocher) et en mode sans interface (`-HideSystemPrincipals`). +- **Exclusion de dossiers** — ignore certains dossiers de toute l'analyse (tailles, chemins trop longs et permissions). Par + défaut, les corbeilles et dossiers système courants sont exclus : **`#recycle`** et **`@eaDir`** (NAS Synology) et + **`$RECYCLE.BIN`** (corbeille Windows). La liste est modifiable + (jokers `*`/`?` acceptés, insensible à la casse) et il est possible d'exclure en plus les **dossiers masqués**. Disponible + dans l'interface (champ *Exclure dossiers* + case *Dossiers masqués*) et en mode sans interface (`-ExcludeFolder`, + `-ExcludeHidden`). +- **Sélection de dossiers réseau** — le sélecteur **Ajouter…** liste les disques locaux et **tous les lecteurs réseau connus** + avec leur cible UNC, parcourt un serveur **partage par partage** (**Ajouter un serveur…**), accepte un chemin + **`\\serveur\partage`** collé directement et sait **ouvrir une connexion authentifiée** (identifiants, lettre de lecteur + facultative). Plusieurs dossiers se cochent en une seule fois. Les chemins réseau sont convertis en **UNC** (case cochée par + défaut) : un lecteur mappé appartient au jeton de session qui l'a créé, il est donc **invisible depuis une exécution en mode + Administrateur**, alors que l'UNC reste valable partout — la même conversion est appliquée en mode sans interface, y compris + pour un `-Path` du type `Z:\dossier`. Toute énumération réseau est bornée par un délai : un partage hors ligne ne fige + plus la fenêtre et se signale dans la barre d'état. - **Octroi automatique en cas de refus** *(optionnel)* — lorsqu'un élément ne peut pas être lu, l'outil s'approprie la propriété pour Administrators et accorde le `FullControl` à `BUILTIN\Administrators` afin que l'analyse puisse se terminer. Chaque modification est consignée dans l'onglet/la section de rapport **Accès accordé**, et par défaut chacune est **annulée** après l'analyse (propriétaire + ACL d'origine restaurés là où ils ont pu être lus au préalable). Nécessite une exécution en mode élevé. +- **Renommage par lot** *(onglet Renommer)* — renomme en série les fichiers et dossiers d'un dossier choisi avec des règles + avancées : **rechercher/remplacer** littéral (avec option *Ignorer la casse*), **expression régulière** (.NET) avec + groupes de capture (`$1`, `${nom}`), transformation de **casse** (MAJUSCULES / minuscules / Première Lettre Des Mots / + première lettre), ajout de **préfixe/suffixe** et **numérotation séquentielle** (jeton `{n}`, début / pas / nombre de + chiffres réglables). Une **grille d'aperçu** affiche en direct *ancien nom → nouveau nom* avec un état par ligne et + détecte les **conflits** (doublons ou nom déjà existant), qui sont ignorés. Options *Inclure les sous-dossiers*, + *Fichiers* / *Dossiers* et exclusion des dossiers masqués ; l'**extension** des fichiers peut être préservée. Cochez les + lignes à appliquer puis **Renommer la sélection** (les dossiers sont renommés après leur contenu pour rester cohérents). Tout est exportable vers un **rapport HTML autonome** (aucune ressource externe — fonctionne hors ligne, facile à envoyer par -e-mail ou à archiver). Les rapports peuvent devenir volumineux, vous pouvez donc **n'exporter que les catégories nécessaires** — -une, plusieurs ou toutes — aussi bien dans l'interface (sélecteur à cases sur **Exporter HTML…**) qu'en mode sans interface (`-Category`). +e-mail ou à archiver) **et/ou vers des fichiers CSV** (un fichier par catégorie, prêt pour Excel / un tableur). Les deux formats +peuvent être générés **en même temps** : l'interface comme le mode sans interface produisent le HTML et les CSV en une seule +opération. Les rapports peuvent devenir volumineux, vous pouvez donc **n'exporter que les catégories nécessaires** — +une, plusieurs ou toutes — aussi bien dans l'interface (sélecteur à cases sur **Exporter…**) qu'en mode sans interface (`-Category`). Aucune installation ni dépendance. Fonctionne sur **Windows PowerShell 5.1** et **PowerShell 7+**. @@ -30,11 +57,18 @@ ou depuis une console : pwsh -STA -File .\filer-manager.ps1 ``` -Dans la fenêtre : **Ajouter…** un ou plusieurs dossiers, ajustez *Longueur max* / *Profondeur des permissions* / -*Inclure les fichiers dans l'arborescence*, cochez éventuellement **Accorder l'accès Administrators aux éléments refusés** -(et **Annuler les modifications après l'analyse**), cliquez sur **Analyser**, puis **Exporter HTML…** — la boîte de dialogue +Dans la fenêtre : **Ajouter…** un ou plusieurs dossiers — locaux, sur un lecteur réseau mappé ou sur un chemin UNC — ajustez *Longueur max* / *Profondeur des permissions* / +*Inclure les fichiers dans l'arborescence*, précisez les dossiers à ignorer dans *Exclure dossiers* (par défaut `#recycle; @eaDir; $RECYCLE.BIN`, +séparés par `;`) et cochez au besoin *Dossiers masqués*, cochez éventuellement **Accorder l'accès Administrators aux éléments refusés** +(et **Annuler les modifications après l'analyse**), cliquez sur **Analyser**, puis **Exporter…** — la boîte de dialogue d'export vous laisse **cocher les catégories** (Tailles des dossiers, Chemins trop longs, Permissions, Accès accordé, Erreurs) -à inclure dans le fichier ; toutes sont sélectionnées par défaut. +à inclure ainsi que les **formats** à générer : **HTML** (rapport unique) et/ou **CSV** (un fichier par catégorie). Si les deux +formats sont cochés, choisissez un **nom de base** et l'outil écrit `.html` plus `-tree.csv`, `-permissions.csv`, +etc. en une seule fois ; toutes les catégories sont sélectionnées par défaut, HTML coché et CSV décoché. + +Pour **renommer** des fichiers ou des dossiers, ouvrez l'onglet **Renommer** : choisissez un dossier (*Parcourir…*), cliquez sur +**Lister**, réglez les règles (rechercher/remplacer, regex, casse, préfixe/suffixe, numérotation), vérifiez l'aperçu, puis +**Renommer la sélection**. > L'octroi automatique n'agit que sur les éléments refusés, un élément à la fois. Exécutez Filer Manager > **en tant qu'administrateur** pour qu'il fonctionne — il vous avertit si vous n'êtes pas en mode élevé. @@ -45,20 +79,28 @@ d'export vous laisse **cocher les catégories** (Tailles des dossiers, Chemins t ```powershell .\filer-manager.ps1 -Path "\\FILER01\Data","D:\Profiles" -Output C:\Reports\filer.html -NoGui + +# HTML + CSV en même temps (les CSV sont dérivés du nom de base : filer-tree.csv, filer-permissions.csv, ...) +.\filer-manager.ps1 -Path "D:\Shares" -Output C:\Reports\filer.html -CsvOutput C:\Reports\filer.csv -NoGui ``` ### Paramètres | Paramètre | Défaut | Description | |-----------------------|---------|----------------------------------------------------------------------| -| `-Path` | — | Un ou plusieurs dossiers racines à analyser. | +| `-Path` | — | Un ou plusieurs dossiers racines à analyser. Chemin local, UNC (`\\serveur\partage`) ou lecteur réseau mappé (réécrit en UNC si la lettre n'est pas montée dans la session). | | `-Output` | — | Chemin du rapport HTML (mode sans interface). | +| `-CsvOutput` | — | Chemin de base des rapports CSV (un fichier par catégorie : `base-tree.csv`, `base-permissions.csv`, …). Combinable avec `-Output` ou utilisable seul. | | `-MaxPathLength` | `260` | Signale les éléments dont la longueur de chemin complet est ≥ ce nombre de caractères. | | `-PermissionDepth` | `1` | Niveaux de dossiers sous chaque racine pour lesquels collecter les ACL (0 = racines uniquement). | | `-IncludeFilesInTree` | off | Liste les fichiers individuels dans l'arborescence des tailles, pas seulement les dossiers. | +| `-ExcludeFolder` | `#recycle`, `@eaDir`, `$RECYCLE.BIN` | Noms de dossiers à exclure de l'analyse (jokers acceptés, insensible à la casse). `-ExcludeFolder @()` pour ne rien exclure par nom. | +| `-ExcludeHidden` | off | Exclut aussi les dossiers portant l'attribut « masqué ». | +| `-HideInheritedChildPerms` | off | Omet les permissions héritées portées par les dossiers enfants (les racines conservent les leurs). | +| `-HideSystemPrincipals` | off | Masque les ACL des comptes/groupes système bien connus (`NT AUTHORITY\SYSTEM`, `BUILTIN\*`, `CREATOR OWNER`, `NT SERVICE\*`…). Détection par SID, indépendante de la langue. | | `-GrantAccess` | off | S'approprie la propriété + accorde le FullControl à Administrators sur les éléments refusés, puis réessaye. Nécessite le mode élevé. | | `-KeepGrants` | off | Conserve l'accès accordé par `-GrantAccess` (par défaut : annulé après l'analyse). | -| `-NoGui` | off | Force le mode sans interface (nécessite `-Path` et `-Output`). | +| `-NoGui` | off | Force le mode sans interface (nécessite `-Path` et au moins un de `-Output` / `-CsvOutput`). | | `-Category` | `All` | Catégories à inclure dans le rapport — toute combinaison de `Tree`, `LongPaths`, `Permissions`, `Grants`, `Errors` (pour garder les gros rapports petits). | N'exporter que les permissions, ou seulement les tailles + chemins trop longs : @@ -80,5 +122,10 @@ Register-ScheduledTask -TaskName "Filer Report" -Action $action -Trigger $trigge ## Notes - Exécutez l'outil avec un compte disposant d'un accès en lecture aux dossiers (et `SeBackupPrivilege` / Administrateur aide à atteindre les ACL et à contourner les refus par dossier). Les éléments qui ne peuvent pas être lus sont listés dans la section **Erreurs / accès refusé** au lieu de faire échouer l'analyse — ou, avec `-GrantAccess` (en mode élevé), l'outil s'accorde l'accès et liste les modifications dans **Accès accordé**. +- Les chemins réseau sont analysés sous leur forme **UNC**. Un lecteur mappé (`Z:`) est réécrit automatiquement vers sa cible + (`\\serveur\partage`) quand la lettre n'existe pas dans le jeton courant — le cas classique d'une exécution en mode + Administrateur. Un lecteur créé avec `New-PSDrive` n'existe que dans la session PowerShell : indiquez son chemin UNC. En + tâche planifiée sous un autre compte (ou `SYSTEM`), les mappages de l'utilisateur n'existent pas du tout : passez toujours + un `-Path` en UNC. - Taille d'un dossier = somme des longueurs des fichiers (taille logique), pas la taille sur le disque. - Le rapport est un seul fichier `.html` avec CSS/JS inline — sans risque à envoyer par e-mail ou à conserver comme instantané d'audit. diff --git a/filer-manager.ps1 b/filer-manager.ps1 index ee546fa..cb94741 100644 --- a/filer-manager.ps1 +++ b/filer-manager.ps1 @@ -16,6 +16,13 @@ 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. @@ -38,6 +45,17 @@ 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 @@ -82,6 +100,11 @@ .\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( @@ -91,6 +114,8 @@ param( [int]$MaxPathLength = 260, [int]$PermissionDepth = 1, [switch]$IncludeFilesInTree, + [string[]]$ExcludeFolder = @('#recycle', '@eaDir', '$RECYCLE.BIN'), + [switch]$ExcludeHidden, [switch]$HideInheritedChildPerms, [switch]$HideSystemPrincipals, [switch]$GrantAccess, @@ -291,6 +316,27 @@ function Restore-Grants { } } +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. #> @@ -344,6 +390,7 @@ function Get-FolderNode { 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) @@ -414,6 +461,7 @@ function Get-FolderPermissions { $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) } } @@ -427,12 +475,16 @@ function Invoke-FilerScan { [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 @@ -441,12 +493,47 @@ function Invoke-FilerScan { $permissions = New-Object System.Collections.ArrayList $roots = New-Object System.Collections.ArrayList - foreach ($p in $Paths) { - if (-not (Test-Path -LiteralPath $p)) { - [void]$scanErrors.Add([pscustomobject]@{ Path = $p; Error = 'Chemin introuvable' }) + # 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 } - $fullPath = (Resolve-Path -LiteralPath $p).Path + 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) @@ -477,6 +564,8 @@ function Invoke-FilerScan { MaxPathLength = $MaxPathLength PermissionDepth = $PermissionDepth IncludeFilesInTree = $IncludeFilesInTree + ExcludeFolder = @($ExcludeFolder | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + ExcludeHidden = $ExcludeHidden GrantAccess = $GrantAccess RevertGrants = $RevertGrants } @@ -668,6 +757,14 @@ function ConvertTo-FilerHtmlReport { $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' @@ -738,6 +835,7 @@ function ConvertTo-FilerHtmlReport {
Généré le $gen · Hôte $(_enc $Scan.Computer) · Seuil chemin trop long $($Scan.Settings.MaxPathLength) · Profondeur des permissions $($Scan.Settings.PermissionDepth)
Analysé : $rootsList
+ $exclNote $catNote
@@ -956,6 +1054,500 @@ function ConvertTo-FilerCsvReport { 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). @@ -978,6 +1570,7 @@ if ($runHeadless) { $scan = Invoke-FilerScan -Paths $Path -MaxPathLength $MaxPathLength ` -PermissionDepth $PermissionDepth ` -IncludeFilesInTree:$IncludeFilesInTree ` + -ExcludeFolder $ExcludeFolder -ExcludeHidden:$ExcludeHidden ` -GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress if ($Output) { @@ -1062,16 +1655,35 @@ $chkFiles.Text = 'Inclure les fichiers'; $chkFiles.Location = '362,127'; $chkFil $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,156'; $chkGrant.AutoSize = $true +$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,156'; $chkRevert.AutoSize = $true +$chkRevert.Location = '380,188'; $chkRevert.AutoSize = $true $chkRevert.Checked = (-not $KeepGrants) $chkRevert.Enabled = $chkGrant.Checked $form.Controls.Add($chkRevert) @@ -1093,15 +1705,16 @@ $form.Controls.Add($btnExport) # ---- tabs ---- $tabs = New-Object System.Windows.Forms.TabControl -$tabs.Location = '12,188'; $tabs.Size = '922,456' +$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é' -$tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant)) +$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 @@ -1213,6 +1826,147 @@ $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 @@ -1250,6 +2004,11 @@ $script:PermHideInherited = $false $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 @@ -1554,6 +2313,255 @@ function Show-ExportDialog { 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 @@ -1632,13 +2640,540 @@ $exportTimer.Add_Tick({ } }) +# ============================================================================ +# 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({ - $dlg = New-Object System.Windows.Forms.FolderBrowserDialog - $dlg.Description = 'Sélectionnez un dossier à analyser' - if ($dlg.ShowDialog() -eq 'OK') { - if (-not $lstFolders.Items.Contains($dlg.SelectedPath)) { [void]$lstFolders.Items.Add($dlg.SelectedPath) } + # 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({ @@ -1654,6 +3189,8 @@ $btnScan.Add_Click({ $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 @@ -1677,9 +3214,9 @@ $btnScan.Add_Click({ $script:PowerShell.Runspace = $script:Runspace [void]$script:PowerShell.AddScript($CoreFunctions) [void]$script:PowerShell.AddScript(@' -param($Paths, $MaxLen, $Depth, $IncFiles, $Grant, $Revert, $Shared) -$Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared -'@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).AddArgument($grant).AddArgument($revert).AddArgument($script:Shared) +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() @@ -1759,4 +3296,4 @@ catch { $Shared.Error = $_.Exception.Message } if ($Path) { foreach ($p in $Path) { [void]$lstFolders.Items.Add($p) } } [void]$form.ShowDialog() -$form.Dispose() \ No newline at end of file +$form.Dispose()