diff --git a/README.md b/README.md
index a525187..2df3306 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,3 @@
-
# 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()