From 1422ec1826475668c80679bd4865bb0aa7a911ca Mon Sep 17 00:00:00 2001 From: Kawa Date: Tue, 10 Mar 2026 10:18:13 +0100 Subject: [PATCH] Initial push --- README.md | 124 ++ Sharepoint_Export_v6.0.ps1 | 3775 ++++++++++++++++++++++++++++++++++++ TODO.md | 5 + 3 files changed, 3904 insertions(+) create mode 100644 README.md create mode 100644 Sharepoint_Export_v6.0.ps1 create mode 100644 TODO.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..64c1243 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# SharePoint Exporter v6.0 + +Application PowerShell avec interface graphique (WinForms) pour administrer, auditer et exporter des données depuis des sites SharePoint Online. + +## Prérequis + +- **PowerShell 5.1** ou supérieur +- **Module PnP.PowerShell** (`Install-Module PnP.PowerShell`) +- **Azure AD App Registration** avec les permissions déléguées nécessaires (Client ID requis) +- Accès au tenant SharePoint cible + +## Lancement + +```powershell +.\Sharepoint_Export_v6.0.ps1 +``` + +--- + +## Fonctionnalités + +### Connexion et profils + +- Saisie du **Tenant URL**, **Client ID** et **Site URL** +- **Profils sauvegardés** : créez, renommez, supprimez et chargez des profils de connexion réutilisables +- **Sélecteur de sites** : parcourez et cochez plusieurs sites du tenant en une seule vue (chargement asynchrone) +- Dossier de sortie configurable pour tous les exports + +--- + +### Onglet — Permissions Report + +Audit complet des permissions d'un ou plusieurs sites. + +- Scan des **bibliothèques, listes et dossiers** (profondeur configurable ou illimitée) +- Option **Recursive** pour inclure les sous-sites +- Inclusion optionnelle des permissions héritées +- Export **CSV** (données brutes, compatibles Excel) ou **HTML** (rapport visuel avec tableau interactif, filtrage, tri par colonne, regroupement par utilisateur/groupe) + +--- + +### Onglet — Storage Metrics + +Analyse de l'occupation du stockage SharePoint. + +- Répartition **par bibliothèque** avec profondeur de dossiers configurable +- Option d'inclusion des **sous-sites** +- Métriques : taille totale, taille des versions, nombre d'éléments, dernière modification +- Export **CSV** ou **HTML** (rapport avec graphiques de répartition et arborescence dépliable) + +--- + +### Onglet — Templates + +Capture et réapplication de la structure d'un site SharePoint. + +- **Capture** : arborescence (bibliothèques et dossiers), permissions (groupes et rôles), paramètres du site (titre, langue), logo +- **Création** depuis un template : nouveau site Communication ou Teams à partir d'un template capturé, avec application sélective des éléments capturés +- Templates persistés localement dans `Sharepoint_Templates.json` + +--- + +### Onglet — Recherche de fichiers + +Recherche avancée de fichiers à travers les bibliothèques d'un site. + +| Filtre | Description | +|---|---| +| Extension(s) | Ex : `docx pdf xlsx` | +| Nom / Regex | Expression régulière appliquée sur le chemin du fichier | +| Créé après / avant | Plage de dates de création | +| Modifié après / avant | Plage de dates de modification | +| Créé par | Nom ou email de l'auteur | +| Modifié par | Nom ou email du dernier éditeur | +| Bibliothèque | Limite la recherche à un chemin relatif | +| Max résultats | Plafond configurable (10 – 50 000) | + +Utilise la **Search API SharePoint (KQL)** avec pagination automatique. Le filtre regex est appliqué côté client après récupération des résultats. + +Export **CSV** ou **HTML** (tableau trié par colonne, filtrage en temps réel, indicateurs de tri). + +--- + +### Onglet — Doublons + +Détection de fichiers ou dossiers en double au sein d'un site. + +**Type de scan :** +- Fichiers en double (via Search API) +- Dossiers en double (via énumération des bibliothèques) + +**Critères de comparaison (combinables) :** +- Nom — *toujours inclus comme critère principal* +- Taille identique +- Date de création identique +- Date de modification identique +- Nombre de sous-dossiers identique *(dossiers uniquement)* +- Nombre de fichiers identique *(dossiers uniquement)* + +Le rapport HTML présente les doublons regroupés en **cartes dépliables** avec mise en évidence visuelle des valeurs identiques (vert) et différentes (orange), ainsi qu'un badge "Identiques" / "Différences détectées" par groupe. + +Export **CSV** (avec colonne `DuplicateGroup`) ou **HTML**. + +--- + +## Fichiers générés + +| Fichier | Description | +|---|---| +| `Sharepoint_Export_profiles.json` | Profils de connexion sauvegardés | +| `Sharepoint_Templates.json` | Templates de sites capturés | +| `Permissions__.csv/html` | Rapports de permissions | +| `Storage__.csv/html` | Rapports de stockage | +| `FileSearch_.csv/html` | Résultats de recherche de fichiers | +| `Duplicates__.csv/html` | Résultats du scan de doublons | + +--- + +## Architecture technique + +- Interface **WinForms** (PowerShell natif, aucune dépendance UI externe) +- Toutes les opérations longues s'exécutent dans des **runspaces séparés** pour ne pas bloquer l'interface +- Communication runspace → UI via **hashtable synchronisée** + timer +- Module **PnP.PowerShell** pour toutes les interactions avec l'API SharePoint diff --git a/Sharepoint_Export_v6.0.ps1 b/Sharepoint_Export_v6.0.ps1 new file mode 100644 index 0000000..3eb7499 --- /dev/null +++ b/Sharepoint_Export_v6.0.ps1 @@ -0,0 +1,3775 @@ +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +#region ===== Shared Helpers ===== + +function Write-Log { + param([string]$Message, [string]$Color = "LightGreen") + if ($script:LogBox -and !$script:LogBox.IsDisposed) { + $script:LogBox.SelectionStart = $script:LogBox.TextLength + $script:LogBox.SelectionLength = 0 + $script:LogBox.SelectionColor = [System.Drawing.Color]::$Color + $script:LogBox.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Message`n") + $script:LogBox.ScrollToCaret() + [System.Windows.Forms.Application]::DoEvents() + } + Write-Host $Message +} + +function EscHtml([string]$s) { + return $s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' +} + +function Format-Bytes([long]$b) { + if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" } + if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" } + if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" } + return "$b B" +} + +function Validate-Inputs { + if ([string]::IsNullOrWhiteSpace($script:txtClientId.Text)) { + [System.Windows.Forms.MessageBox]::Show("Please enter a Client ID.", "Missing Field", "OK", "Warning") + return $false + } + $hasSites = ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) + if (-not $hasSites -and [string]::IsNullOrWhiteSpace($script:txtSiteURL.Text)) { + [System.Windows.Forms.MessageBox]::Show( + "Please enter a Site URL or select sites via 'Voir les sites'.", + "Missing Field", "OK", "Warning") + return $false + } + return $true +} + +#endregion + +#region ===== Profile Management ===== + +function Get-ProfilesFilePath { + $dir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } + return Join-Path $dir "Sharepoint_Export_profiles.json" +} + +function Load-Profiles { + $path = Get-ProfilesFilePath + if (Test-Path $path) { + try { + $data = Get-Content $path -Raw | ConvertFrom-Json + if ($data.profiles) { return @($data.profiles) } + } catch {} + } + return @() +} + +function Save-Profiles { + param([array]$Profiles) + $path = Get-ProfilesFilePath + @{ profiles = @($Profiles) } | ConvertTo-Json -Depth 5 | Set-Content $path -Encoding UTF8 +} + +function Show-InputDialog { + param( + [string]$Prompt, + [string]$Title, + [string]$Default = "", + [System.Windows.Forms.Form]$Owner = $null + ) + $dlg = New-Object System.Windows.Forms.Form + $dlg.Text = $Title + $dlg.Size = New-Object System.Drawing.Size(380, 140) + $dlg.StartPosition = "CenterParent" + $dlg.FormBorderStyle = "FixedDialog" + $dlg.MaximizeBox = $false + $dlg.MinimizeBox = $false + $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke + $lbl = New-Object System.Windows.Forms.Label + $lbl.Text = $Prompt; $lbl.Location = New-Object System.Drawing.Point(10,10) + $lbl.Size = New-Object System.Drawing.Size(340,20) + $txt = New-Object System.Windows.Forms.TextBox + $txt.Text = $Default; $txt.Location = New-Object System.Drawing.Point(10,36) + $txt.Size = New-Object System.Drawing.Size(340,22) + $btnOK = New-Object System.Windows.Forms.Button + $btnOK.Text = "OK"; $btnOK.Location = New-Object System.Drawing.Point(152,70) + $btnOK.Size = New-Object System.Drawing.Size(80,26); $btnOK.DialogResult = "OK" + $dlg.AcceptButton = $btnOK + $btnCancel = New-Object System.Windows.Forms.Button + $btnCancel.Text = "Annuler"; $btnCancel.Location = New-Object System.Drawing.Point(246,70) + $btnCancel.Size = New-Object System.Drawing.Size(104,26); $btnCancel.DialogResult = "Cancel" + $dlg.CancelButton = $btnCancel + $dlg.Controls.AddRange(@($lbl, $txt, $btnOK, $btnCancel)) + $result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() } + if ($result -eq "OK") { return $txt.Text.Trim() } + return $null +} + +function Refresh-ProfileList { + $script:Profiles = Load-Profiles + $script:cboProfile.Items.Clear() + foreach ($p in $script:Profiles) { [void]$script:cboProfile.Items.Add($p.name) } + if ($script:cboProfile.Items.Count -gt 0) { $script:cboProfile.SelectedIndex = 0 } +} + +function Apply-Profile { + param([int]$idx) + if ($idx -lt 0 -or $idx -ge $script:Profiles.Count) { return } + $p = $script:Profiles[$idx] + if ($p.clientId) { $script:txtClientId.Text = $p.clientId } + if ($p.tenantUrl) { $script:txtTenantUrl.Text = $p.tenantUrl } + # Reset site selection when switching profile + $script:SelectedSites = @() + $script:btnBrowseSites.Text = "Voir les sites" +} + +#endregion + +#region ===== Site Picker ===== + +# All state in $script:_pkl; accessible from any event handler (no closure tricks needed) + +function _Pkl-FormatMB([long]$mb) { + if ($mb -ge 1024) { return "$([math]::Round($mb / 1024, 1)) GB" } + if ($mb -gt 0) { return "$mb MB" } + return "-" +} + +function _Pkl-Sort { + $col = $script:_pkl.SortCol + $desc = -not $script:_pkl.SortAsc + $script:_pkl.AllSites = @(switch ($col) { + 0 { $script:_pkl.AllSites | Sort-Object -Property Title -Descending:$desc } + 1 { $script:_pkl.AllSites | Sort-Object -Property { [int][bool]$_.IsTeamsConnected } -Descending:$desc } + 2 { $script:_pkl.AllSites | Sort-Object -Property { [long]$_.StorageUsageCurrent } -Descending:$desc } + default { $script:_pkl.AllSites | Sort-Object -Property Title -Descending:$desc } + }) + $lv = $script:_pkl.Lv + $dir = if (-not $desc) { " ^" } else { " v" } + $script:_pkl.ColNames | ForEach-Object -Begin { $i = 0 } -Process { + $lv.Columns[$i].Text = $_ + $(if ($i -eq $col) { $dir } else { "" }) + $i++ + } +} + +function _Pkl-Repopulate { + $filter = $script:_pkl.TxtFilter.Text.ToLower() + $lv = $script:_pkl.Lv + $script:_pkl.SuppressCheck = $true + $lv.BeginUpdate() + $lv.Items.Clear() + $visible = if ($filter) { + $script:_pkl.AllSites | Where-Object { + $_.Title.ToLower().Contains($filter) -or $_.Url.ToLower().Contains($filter) + } + } else { $script:_pkl.AllSites } + foreach ($s in $visible) { + $teams = if ($s.IsTeamsConnected) { "Oui" } else { "Non" } + $stor = _Pkl-FormatMB ([long]$s.StorageUsageCurrent) + $item = New-Object System.Windows.Forms.ListViewItem($s.Title) + $item.Tag = $s.Url + [void]$item.SubItems.Add($teams) + [void]$item.SubItems.Add($stor) + [void]$item.SubItems.Add($s.Url) + [void]$lv.Items.Add($item) + $item.Checked = $script:_pkl.CheckedUrls.Contains($s.Url) + } + $lv.EndUpdate() + $script:_pkl.SuppressCheck = $false + $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " + + "$($lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)" + $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Gray +} + +function Show-SitePicker { + param( + [string]$TenantUrl, + [string]$ClientId, + [System.Windows.Forms.Form]$Owner = $null + ) + + $dlg = New-Object System.Windows.Forms.Form + $dlg.Text = "Sites SharePoint -- $TenantUrl" + $dlg.Size = New-Object System.Drawing.Size(900, 580) + $dlg.StartPosition = "CenterParent" + $dlg.FormBorderStyle = "Sizable" + $dlg.MinimumSize = New-Object System.Drawing.Size(600, 440) + $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke + + # -- Top bar -- + $lblFilter = New-Object System.Windows.Forms.Label + $lblFilter.Text = "Filtrer :" + $lblFilter.Location = New-Object System.Drawing.Point(10, 12) + $lblFilter.Size = New-Object System.Drawing.Size(52, 22) + $lblFilter.TextAlign = "MiddleLeft" + + $txtFilter = New-Object System.Windows.Forms.TextBox + $txtFilter.Location = New-Object System.Drawing.Point(66, 10) + $txtFilter.Size = New-Object System.Drawing.Size(570, 22) + $txtFilter.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $txtFilter.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right" + + $btnLoad = New-Object System.Windows.Forms.Button + $btnLoad.Text = "Charger les sites" + $btnLoad.Location = New-Object System.Drawing.Point(648, 8) + $btnLoad.Size = New-Object System.Drawing.Size(148, 26) + $btnLoad.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right" + $btnLoad.BackColor = [System.Drawing.Color]::SteelBlue + $btnLoad.ForeColor = [System.Drawing.Color]::White + $btnLoad.FlatStyle = "Flat" + $btnLoad.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + + # -- Site list (ListView with columns) -- + $lv = New-Object System.Windows.Forms.ListView + $lv.Location = New-Object System.Drawing.Point(10, 44) + $lv.Size = New-Object System.Drawing.Size(864, 400) + $lv.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right" + $lv.View = [System.Windows.Forms.View]::Details + $lv.CheckBoxes = $true + $lv.FullRowSelect = $true + $lv.GridLines = $true + $lv.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $lv.HeaderStyle = [System.Windows.Forms.ColumnHeaderStyle]::Clickable + + [void]$lv.Columns.Add("Nom", 380) + [void]$lv.Columns.Add("Equipe Teams", 90) + [void]$lv.Columns.Add("Stockage", 100) + [void]$lv.Columns.Add("URL", 280) + + # -- Status bar -- + $lblStatus = New-Object System.Windows.Forms.Label + $lblStatus.Text = "Cliquez sur 'Charger les sites' pour recuperer la liste du tenant." + $lblStatus.Location = New-Object System.Drawing.Point(10, 456) + $lblStatus.Size = New-Object System.Drawing.Size(860, 18) + $lblStatus.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left,Right" + $lblStatus.ForeColor = [System.Drawing.Color]::Gray + $lblStatus.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic) + + # -- Bottom buttons -- + $btnSelAll = New-Object System.Windows.Forms.Button + $btnSelAll.Text = "Tout selectionner" + $btnSelAll.Location = New-Object System.Drawing.Point(10, 484) + $btnSelAll.Size = New-Object System.Drawing.Size(130, 26) + $btnSelAll.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left" + + $btnSelNone = New-Object System.Windows.Forms.Button + $btnSelNone.Text = "Tout decocher" + $btnSelNone.Location = New-Object System.Drawing.Point(148, 484) + $btnSelNone.Size = New-Object System.Drawing.Size(110, 26) + $btnSelNone.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left" + + $btnOK = New-Object System.Windows.Forms.Button + $btnOK.Text = "OK" + $btnOK.Location = New-Object System.Drawing.Point(694, 484) + $btnOK.Size = New-Object System.Drawing.Size(90, 26) + $btnOK.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Right" + $btnOK.DialogResult = "OK" + $btnOK.BackColor = [System.Drawing.Color]::SteelBlue + $btnOK.ForeColor = [System.Drawing.Color]::White + $btnOK.FlatStyle = "Flat" + $btnOK.Enabled = $false + + $btnDlgCancel = New-Object System.Windows.Forms.Button + $btnDlgCancel.Text = "Annuler" + $btnDlgCancel.Location = New-Object System.Drawing.Point(794, 484) + $btnDlgCancel.Size = New-Object System.Drawing.Size(90, 26) + $btnDlgCancel.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Right" + $btnDlgCancel.DialogResult = "Cancel" + + $dlg.AcceptButton = $btnOK + $dlg.CancelButton = $btnDlgCancel + $dlg.Controls.AddRange(@($lblFilter, $txtFilter, $btnLoad, $lv, $lblStatus, + $btnSelAll, $btnSelNone, $btnOK, $btnDlgCancel)) + + # Init script-scope state (modal dialog — no concurrency issue) + $script:_pkl = @{ + AllSites = @() + CheckedUrls = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + Lv = $lv + TxtFilter = $txtFilter + LblStatus = $lblStatus + BtnOK = $btnOK + BtnLoad = $btnLoad + SuppressCheck = $false + SortCol = 0 + SortAsc = $true + ColNames = @("Nom", "Equipe Teams", "Stockage", "URL") + Sync = $null + Timer = $null + RS = $null + PS = $null + Hnd = $null + AdminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') { + "$($Matches[1])-admin$($Matches[2])" + } else { $null } + ClientId = $ClientId + } + + # ItemChecked fires AFTER the checked state changes (unlike ItemCheck) + $lv.Add_ItemChecked({ + param($s, $e) + if ($script:_pkl.SuppressCheck) { return } + $url = $e.Item.Tag + if ($e.Item.Checked) { [void]$script:_pkl.CheckedUrls.Add($url) } + else { [void]$script:_pkl.CheckedUrls.Remove($url) } + $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " + + "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)" + }) + + $lv.Add_ColumnClick({ + param($s, $e) + if ($script:_pkl.SortCol -eq $e.Column) { + $script:_pkl.SortAsc = -not $script:_pkl.SortAsc + } else { + $script:_pkl.SortCol = $e.Column + $script:_pkl.SortAsc = $true + } + _Pkl-Sort + _Pkl-Repopulate + }) + + $txtFilter.Add_TextChanged({ _Pkl-Repopulate }) + + $btnSelAll.Add_Click({ + $script:_pkl.CheckedUrls.Clear() + $script:_pkl.SuppressCheck = $true + foreach ($item in $script:_pkl.Lv.Items) { + $item.Checked = $true + [void]$script:_pkl.CheckedUrls.Add($item.Tag) + } + $script:_pkl.SuppressCheck = $false + $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " + + "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)" + }) + + $btnSelNone.Add_Click({ + $script:_pkl.CheckedUrls.Clear() + $script:_pkl.SuppressCheck = $true + foreach ($item in $script:_pkl.Lv.Items) { $item.Checked = $false } + $script:_pkl.SuppressCheck = $false + $script:_pkl.LblStatus.Text = "0 coche(s) -- " + + "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)" + }) + + $btnLoad.Add_Click({ + if (-not $script:_pkl.AdminUrl) { + [System.Windows.Forms.MessageBox]::Show( + "URL Tenant invalide (attendu: https://xxx.sharepoint.com).", + "Erreur", "OK", "Error") + return + } + $script:_pkl.BtnLoad.Enabled = $false + $script:_pkl.BtnOK.Enabled = $false + $script:_pkl.Lv.Items.Clear() + $script:_pkl.AllSites = @() + $script:_pkl.LblStatus.Text = "Connexion a $($script:_pkl.AdminUrl) ..." + $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::DarkOrange + + $sync = [hashtable]::Synchronized(@{ Done = $false; Error = $null; Sites = $null }) + $script:_pkl.Sync = $sync + + $bgFetch = { + param($AdminUrl, $ClientId, $Sync) + try { + Import-Module PnP.PowerShell -ErrorAction Stop + Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId + $Sync.Sites = @( + Get-PnPTenantSite | + Select-Object Title, Url, IsTeamsConnected, StorageUsageCurrent | + Sort-Object Title + ) + } catch { $Sync.Error = $_.Exception.Message } + finally { $Sync.Done = $true } + } + + $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open() + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript($bgFetch) + [void]$ps.AddArgument($script:_pkl.AdminUrl) + [void]$ps.AddArgument($script:_pkl.ClientId) + [void]$ps.AddArgument($sync) + $script:_pkl.RS = $rs + $script:_pkl.PS = $ps + $script:_pkl.Hnd = $ps.BeginInvoke() + + $tmr = New-Object System.Windows.Forms.Timer + $tmr.Interval = 300 + $script:_pkl.Timer = $tmr + + $tmr.Add_Tick({ + if ($script:_pkl.Sync.Done) { + $script:_pkl.Timer.Stop(); $script:_pkl.Timer.Dispose() + try { [void]$script:_pkl.PS.EndInvoke($script:_pkl.Hnd) } catch {} + try { $script:_pkl.RS.Close(); $script:_pkl.RS.Dispose() } catch {} + $script:_pkl.BtnLoad.Enabled = $true + if ($script:_pkl.Sync.Error) { + $script:_pkl.LblStatus.Text = "Erreur: $($script:_pkl.Sync.Error)" + $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Red + } else { + $script:_pkl.AllSites = @($script:_pkl.Sync.Sites) + _Pkl-Sort + _Pkl-Repopulate + $script:_pkl.BtnOK.Enabled = ($script:_pkl.Lv.Items.Count -gt 0) + } + } else { + $dot = "." * (([System.DateTime]::Now.Second % 4) + 1) + $script:_pkl.LblStatus.Text = "Chargement$dot" + } + }) + $tmr.Start() + }) + + $result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() } + + if ($result -eq "OK") { return @($script:_pkl.CheckedUrls) } + return @() +} + +#endregion + +#region ===== Template Management ===== + +function Get-TemplatesFilePath { + $dir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } + return Join-Path $dir "Sharepoint_Templates.json" +} + +function Load-Templates { + $path = Get-TemplatesFilePath + if (Test-Path $path) { + try { + $data = Get-Content $path -Raw | ConvertFrom-Json + if ($data.templates) { return @($data.templates) } + } catch {} + } + return @() +} + +function Save-Templates { + param([array]$Templates) + $path = Get-TemplatesFilePath + @{ templates = @($Templates) } | ConvertTo-Json -Depth 20 | Set-Content $path -Encoding UTF8 +} + +# Script-scope helpers (accessible from all event handlers — no closure tricks) +function _Tpl-Repopulate { + $lv = $script:_tpl.Lv + $lv.BeginUpdate() + $lv.Items.Clear() + foreach ($t in $script:_tpl.Templates) { + $opts = @() + if ($t.options.structure) { $opts += "Arborescence" } + if ($t.options.permissions) { $opts += "Permissions" } + if ($t.options.settings) { $opts += "Parametres" } + if ($t.options.style) { $opts += "Style" } + $item = New-Object System.Windows.Forms.ListViewItem($t.name) + $item.Tag = $t + $srcText = if ($t.sourceUrl) { $t.sourceUrl } else { "" } + $datText = if ($t.capturedAt) { $t.capturedAt } else { "" } + [void]$item.SubItems.Add($srcText) + [void]$item.SubItems.Add($datText) + [void]$item.SubItems.Add(($opts -join ", ")) + [void]$lv.Items.Add($item) + } + $lv.EndUpdate() + $script:_tpl.BtnDelete.Enabled = $false + $script:_tpl.BtnCreate.Enabled = $false +} + +function _Tpl-RefreshCombo { + $cbo = $script:_tpl.CboCrTpl + $cbo.Items.Clear() + foreach ($t in $script:_tpl.Templates) { [void]$cbo.Items.Add($t.name) } + if ($cbo.Items.Count -gt 0) { $cbo.SelectedIndex = 0 } +} + +function _Tpl-Log { + param([System.Windows.Forms.RichTextBox]$Box, [string]$Msg, [string]$Color = "LightGreen") + $Box.SelectionStart = $Box.TextLength + $Box.SelectionLength = 0 + $Box.SelectionColor = [System.Drawing.Color]::$Color + $Box.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Msg`n") + $Box.ScrollToCaret() +} + +function Show-TemplateManager { + param( + [string]$DefaultSiteUrl = "", + [string]$ClientId = "", + [string]$TenantUrl = "", + [System.Windows.Forms.Form]$Owner = $null + ) + + $dlg = New-Object System.Windows.Forms.Form + $dlg.Text = "Gestionnaire de Templates SharePoint" + $dlg.Size = New-Object System.Drawing.Size(980, 700) + $dlg.StartPosition = "CenterParent" + $dlg.FormBorderStyle = "Sizable" + $dlg.MinimumSize = New-Object System.Drawing.Size(700, 560) + $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke + + # ── Template list ────────────────────────────────────────────────────── + $grpList = New-Object System.Windows.Forms.GroupBox + $grpList.Text = "Templates enregistres" + $grpList.Location = New-Object System.Drawing.Point(10, 8) + $grpList.Size = New-Object System.Drawing.Size(950, 174) + $grpList.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right" + $grpList.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + + $lv = New-Object System.Windows.Forms.ListView + $lv.Location = New-Object System.Drawing.Point(8, 22) + $lv.Size = New-Object System.Drawing.Size(820, 138) + $lv.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right" + $lv.View = [System.Windows.Forms.View]::Details + $lv.FullRowSelect = $true + $lv.GridLines = $true + $lv.MultiSelect = $false + $lv.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Regular) + [void]$lv.Columns.Add("Nom", 200) + [void]$lv.Columns.Add("Site source", 280) + [void]$lv.Columns.Add("Date", 120) + [void]$lv.Columns.Add("Options capturees", 200) + + $btnDelete = New-Object System.Windows.Forms.Button + $btnDelete.Text = "Supprimer" + $btnDelete.Location = New-Object System.Drawing.Point(836, 22) + $btnDelete.Size = New-Object System.Drawing.Size(106, 26) + $btnDelete.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right" + $btnDelete.Enabled = $false + + $btnCreate = New-Object System.Windows.Forms.Button + $btnCreate.Text = "Creer depuis ce template >" + $btnCreate.Location = New-Object System.Drawing.Point(836, 56) + $btnCreate.Size = New-Object System.Drawing.Size(106, 42) + $btnCreate.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right" + $btnCreate.Enabled = $false + $btnCreate.BackColor = [System.Drawing.Color]::SteelBlue + $btnCreate.ForeColor = [System.Drawing.Color]::White + $btnCreate.FlatStyle = "Flat" + $btnCreate.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Bold) + + $grpList.Controls.AddRange(@($lv, $btnDelete, $btnCreate)) + + # ── Bottom tabs ──────────────────────────────────────────────────────── + $btabs = New-Object System.Windows.Forms.TabControl + $btabs.Location = New-Object System.Drawing.Point(10, 190) + $btabs.Size = New-Object System.Drawing.Size(950, 464) + $btabs.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right" + $btabs.Font = New-Object System.Drawing.Font("Segoe UI", 9) + + # ── Tab: Capturer ────────────────────────────────────────────────────── + $tabCap = New-Object System.Windows.Forms.TabPage + $tabCap.Text = " Capturer un template " + $tabCap.BackColor = [System.Drawing.Color]::WhiteSmoke + + $mkLbl = { + param($t,$x,$y,$w=110) + $l = New-Object System.Windows.Forms.Label + $l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y) + $l.Size = New-Object System.Drawing.Size($w,22); $l.TextAlign = "MiddleLeft"; $l + } + + $lblCapSrc = & $mkLbl "Site source :" 10 18 + $txtCapSrc = New-Object System.Windows.Forms.TextBox + $txtCapSrc.Location = New-Object System.Drawing.Point(128, 18) + $txtCapSrc.Size = New-Object System.Drawing.Size(560, 22) + $txtCapSrc.Text = $DefaultSiteUrl + $txtCapSrc.Font = New-Object System.Drawing.Font("Consolas", 9) + + $lblCapName = & $mkLbl "Nom du template :" 10 48 120 + $txtCapName = New-Object System.Windows.Forms.TextBox + $txtCapName.Location = New-Object System.Drawing.Point(134, 48) + $txtCapName.Size = New-Object System.Drawing.Size(300, 22) + $txtCapName.Font = New-Object System.Drawing.Font("Segoe UI", 9) + + $grpOpts = New-Object System.Windows.Forms.GroupBox + $grpOpts.Text = "Elements a capturer" + $grpOpts.Location = New-Object System.Drawing.Point(10, 80) + $grpOpts.Size = New-Object System.Drawing.Size(680, 68) + $grpOpts.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + + $mkChk = { + param($t,$x,$y,$w,$checked=$false) + $c = New-Object System.Windows.Forms.CheckBox + $c.Text = $t; $c.Location = New-Object System.Drawing.Point($x,$y) + $c.Size = New-Object System.Drawing.Size($w,20) + $c.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Regular) + $c.Checked = $checked; $c + } + + $chkCapStruct = & $mkChk "Arborescence (bibliotheques et dossiers)" 10 22 300 $true + $chkCapPerms = & $mkChk "Permissions (groupes et roles)" 320 22 260 + $chkCapSettings = & $mkChk "Parametres du site (titre, langue...)" 10 44 300 $true + $chkCapStyle = & $mkChk "Style (logo)" 320 44 200 + $grpOpts.Controls.AddRange(@($chkCapStruct, $chkCapPerms, $chkCapSettings, $chkCapStyle)) + + $btnCapture = New-Object System.Windows.Forms.Button + $btnCapture.Text = "Lancer la capture" + $btnCapture.Location = New-Object System.Drawing.Point(10, 162) + $btnCapture.Size = New-Object System.Drawing.Size(155, 34) + $btnCapture.BackColor = [System.Drawing.Color]::FromArgb(16,124,16) + $btnCapture.ForeColor = [System.Drawing.Color]::White + $btnCapture.FlatStyle = "Flat" + $btnCapture.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + + $txtCapLog = New-Object System.Windows.Forms.RichTextBox + $txtCapLog.Location = New-Object System.Drawing.Point(10, 206) + $txtCapLog.Size = New-Object System.Drawing.Size(918, 208) + $txtCapLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right" + $txtCapLog.ReadOnly = $true + $txtCapLog.BackColor = [System.Drawing.Color]::Black + $txtCapLog.ForeColor = [System.Drawing.Color]::LightGreen + $txtCapLog.Font = New-Object System.Drawing.Font("Consolas", 8) + $txtCapLog.ScrollBars = "Vertical" + + $tabCap.Controls.AddRange(@($lblCapSrc, $txtCapSrc, $lblCapName, $txtCapName, + $grpOpts, $btnCapture, $txtCapLog)) + + # ── Tab: Creer depuis template ───────────────────────────────────────── + $tabCr = New-Object System.Windows.Forms.TabPage + $tabCr.Text = " Creer depuis un template " + $tabCr.BackColor = [System.Drawing.Color]::WhiteSmoke + + $lblCrTpl = & $mkLbl "Template :" 10 18 + $cboCrTpl = New-Object System.Windows.Forms.ComboBox + $cboCrTpl.Location = New-Object System.Drawing.Point(128, 16) + $cboCrTpl.Size = New-Object System.Drawing.Size(400, 24) + $cboCrTpl.DropDownStyle = "DropDownList" + $cboCrTpl.Font = New-Object System.Drawing.Font("Segoe UI", 9) + + $lblCrTitle = & $mkLbl "Titre du site :" 10 48 + $txtCrTitle = New-Object System.Windows.Forms.TextBox + $txtCrTitle.Location = New-Object System.Drawing.Point(128, 46) + $txtCrTitle.Size = New-Object System.Drawing.Size(300, 22) + $txtCrTitle.Font = New-Object System.Drawing.Font("Segoe UI", 9) + + $lblCrAlias = & $mkLbl "Alias URL :" 10 78 + $txtCrAlias = New-Object System.Windows.Forms.TextBox + $txtCrAlias.Location = New-Object System.Drawing.Point(128, 76) + $txtCrAlias.Size = New-Object System.Drawing.Size(200, 22) + $txtCrAlias.Font = New-Object System.Drawing.Font("Consolas", 9) + + $lblCrAliasHint = New-Object System.Windows.Forms.Label + $lblCrAliasHint.Text = "(lettres, chiffres, tirets uniquement)" + $lblCrAliasHint.Location = New-Object System.Drawing.Point(336, 78) + $lblCrAliasHint.Size = New-Object System.Drawing.Size(250, 20) + $lblCrAliasHint.ForeColor = [System.Drawing.Color]::Gray + $lblCrAliasHint.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic) + + $lblCrType = & $mkLbl "Type :" 10 108 + $radCrTeam = New-Object System.Windows.Forms.RadioButton + $radCrTeam.Text = "Team Site (avec groupe M365)" + $radCrTeam.Location = New-Object System.Drawing.Point(128, 108) + $radCrTeam.Size = New-Object System.Drawing.Size(220, 22) + $radCrTeam.Checked = $true + + $radCrComm = New-Object System.Windows.Forms.RadioButton + $radCrComm.Text = "Communication Site" + $radCrComm.Location = New-Object System.Drawing.Point(358, 108) + $radCrComm.Size = New-Object System.Drawing.Size(200, 22) + + $lblCrOwners = & $mkLbl "Proprietaires :" 10 138 + $txtCrOwners = New-Object System.Windows.Forms.TextBox + $txtCrOwners.Location = New-Object System.Drawing.Point(128, 136) + $txtCrOwners.Size = New-Object System.Drawing.Size(550, 22) + $txtCrOwners.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $txtCrOwners.PlaceholderText = "user1@domain.com, user2@domain.com" + + $lblCrMembers = & $mkLbl "Membres :" 10 168 + $txtCrMembers = New-Object System.Windows.Forms.TextBox + $txtCrMembers.Location = New-Object System.Drawing.Point(128, 166) + $txtCrMembers.Size = New-Object System.Drawing.Size(410, 22) + $txtCrMembers.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $txtCrMembers.PlaceholderText = "user@domain.com, ..." + + $btnCrCsv = New-Object System.Windows.Forms.Button + $btnCrCsv.Text = "Importer CSV..." + $btnCrCsv.Location = New-Object System.Drawing.Point(546, 164) + $btnCrCsv.Size = New-Object System.Drawing.Size(120, 26) + + $grpApply = New-Object System.Windows.Forms.GroupBox + $grpApply.Text = "Appliquer depuis le template" + $grpApply.Location = New-Object System.Drawing.Point(10, 200) + $grpApply.Size = New-Object System.Drawing.Size(680, 60) + $grpApply.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + + $chkApplyStruct = & $mkChk "Arborescence" 10 22 140 $true + $chkApplyPerms = & $mkChk "Permissions" 158 22 120 + $chkApplySettings = & $mkChk "Parametres" 286 22 120 $true + $chkApplyStyle = & $mkChk "Style" 414 22 100 + $grpApply.Controls.AddRange(@($chkApplyStruct, $chkApplyPerms, $chkApplySettings, $chkApplyStyle)) + + $btnCreateSite = New-Object System.Windows.Forms.Button + $btnCreateSite.Text = "Creer le site" + $btnCreateSite.Location = New-Object System.Drawing.Point(10, 272) + $btnCreateSite.Size = New-Object System.Drawing.Size(155, 34) + $btnCreateSite.BackColor = [System.Drawing.Color]::SteelBlue + $btnCreateSite.ForeColor = [System.Drawing.Color]::White + $btnCreateSite.FlatStyle = "Flat" + $btnCreateSite.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + + $txtCreateLog = New-Object System.Windows.Forms.RichTextBox + $txtCreateLog.Location = New-Object System.Drawing.Point(10, 316) + $txtCreateLog.Size = New-Object System.Drawing.Size(918, 100) + $txtCreateLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right" + $txtCreateLog.ReadOnly = $true + $txtCreateLog.BackColor = [System.Drawing.Color]::Black + $txtCreateLog.ForeColor = [System.Drawing.Color]::LightGreen + $txtCreateLog.Font = New-Object System.Drawing.Font("Consolas", 8) + $txtCreateLog.ScrollBars = "Vertical" + + $tabCr.Controls.AddRange(@( + $lblCrTpl, $cboCrTpl, + $lblCrTitle, $txtCrTitle, + $lblCrAlias, $txtCrAlias, $lblCrAliasHint, + $lblCrType, $radCrTeam, $radCrComm, + $lblCrOwners, $txtCrOwners, + $lblCrMembers, $txtCrMembers, $btnCrCsv, + $grpApply, + $btnCreateSite, $txtCreateLog + )) + + $btabs.TabPages.AddRange(@($tabCap, $tabCr)) + $dlg.Controls.AddRange(@($grpList, $btabs)) + + # ── Init state ───────────────────────────────────────────────────────── + $script:_tpl = @{ + Lv = $lv + BtnDelete = $btnDelete + BtnCreate = $btnCreate + CboCrTpl = $cboCrTpl + TxtCapLog = $txtCapLog + TxtCreateLog = $txtCreateLog + Templates = @(Load-Templates) + ClientId = $ClientId + TenantUrl = $TenantUrl + CapSync = $null; CapTimer = $null; CapRS = $null; CapPS = $null; CapHnd = $null + CrSync = $null; CrTimer = $null; CrRS = $null; CrPS = $null; CrHnd = $null + CapTplName = ""; CapSrcUrl = ""; CapOpts = $null + } + _Tpl-Repopulate + _Tpl-RefreshCombo + + # ── Event handlers ───────────────────────────────────────────────────── + $lv.Add_SelectedIndexChanged({ + $sel = $script:_tpl.Lv.SelectedItems.Count -gt 0 + $script:_tpl.BtnDelete.Enabled = $sel + $script:_tpl.BtnCreate.Enabled = $sel + }) + + $btnDelete.Add_Click({ + $item = $script:_tpl.Lv.SelectedItems[0] + if (-not $item) { return } + $name = $item.Text + $res = [System.Windows.Forms.MessageBox]::Show( + "Supprimer le template '$name' ?", "Confirmer", "YesNo", "Warning") + if ($res -ne "Yes") { return } + $script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $name }) + Save-Templates -Templates $script:_tpl.Templates + _Tpl-Repopulate + _Tpl-RefreshCombo + }) + + $btnCreate.Add_Click({ + $item = $script:_tpl.Lv.SelectedItems[0] + if (-not $item) { return } + $t = $item.Tag + $idx = [array]::IndexOf(($script:_tpl.Templates | ForEach-Object { $_.name }), $t.name) + if ($idx -ge 0) { $script:_tpl.CboCrTpl.SelectedIndex = $idx } + $btabs.SelectedTab = $tabCr + }) + + # Auto-generate alias from title + $txtCrTitle.Add_TextChanged({ + $a = $txtCrTitle.Text -replace '[^a-zA-Z0-9 \-]','' -replace '\s+','-' -replace '\-+','-' + $a = $a.ToLower().Trim('-') + if ($a.Length -gt 64) { $a = $a.Substring(0,64) } + $txtCrAlias.Text = $a + }) + + # CSV import for members + $btnCrCsv.Add_Click({ + $ofd = New-Object System.Windows.Forms.OpenFileDialog + $ofd.Filter = "CSV (*.csv)|*.csv|Tous (*.*)|*.*" + $ofd.Title = "Importer des membres depuis CSV" + if ($ofd.ShowDialog() -ne "OK") { return } + try { + $rows = Import-Csv $ofd.FileName + $emails = $rows | ForEach-Object { + $r = $_ + $v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email } ` + elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn } ` + elseif ($r.UserPrincipalName) { $r.UserPrincipalName } ` + else { $r.userprincipalname } + $v + } | Where-Object { $_ } | Select-Object -Unique + $txtCrMembers.Text = ($emails -join ", ") + [System.Windows.Forms.MessageBox]::Show( + "$($emails.Count) membre(s) importe(s).", "Import CSV", "OK", "Information") + } catch { + [System.Windows.Forms.MessageBox]::Show( + "Erreur CSV: $($_.Exception.Message)", "Erreur", "OK", "Error") + } + }) + + # ── Capture button ───────────────────────────────────────────────────── + $btnCapture.Add_Click({ + $srcUrl = $txtCapSrc.Text.Trim() + $tplName = $txtCapName.Text.Trim() + if ([string]::IsNullOrWhiteSpace($srcUrl)) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir l'URL du site source.", "Champ manquant", "OK", "Warning") + return + } + if ([string]::IsNullOrWhiteSpace($tplName)) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir un nom pour le template.", "Champ manquant", "OK", "Warning") + return + } + if ([string]::IsNullOrWhiteSpace($script:_tpl.ClientId)) { + [System.Windows.Forms.MessageBox]::Show("Client ID manquant dans le formulaire principal.", "Erreur", "OK", "Warning") + return + } + + # Stash locals so timer tick can read them from script scope + $script:_tpl.CapTplName = $tplName + $script:_tpl.CapSrcUrl = $srcUrl + $script:_tpl.CapOpts = @{ + structure = $chkCapStruct.Checked + permissions = $chkCapPerms.Checked + settings = $chkCapSettings.Checked + style = $chkCapStyle.Checked + } + + $btnCapture.Enabled = $false + $script:_tpl.TxtCapLog.Clear() + + $bgCapture = { + param($SiteUrl, $ClientId, $Opts, $Sync) + function BgLog([string]$m, [string]$c="LightGreen") { + $Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c }) + } + function Collect-Folders([string]$SiteRelUrl) { + $out = [System.Collections.Generic.List[object]]::new() + try { + $items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue + foreach ($fi in $items) { + if ($fi.Name -match '^_|^Forms$') { continue } + $sub = Collect-Folders "$SiteRelUrl/$($fi.Name)" + $out.Add(@{ name=$fi.Name; subfolders=@($sub) }) + } + } catch {} + return @($out) + } + try { + Import-Module PnP.PowerShell -ErrorAction Stop + BgLog "Connexion a $SiteUrl..." "Yellow" + Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId + $web = Get-PnPWeb -Includes Title,Description,Language,SiteLogoUrl + $wSrl = $web.ServerRelativeUrl.TrimEnd('/') + $result = @{ settings=@{}; style=@{}; structure=@(); permissions=@(); folderPermissions=@() } + + if ($Opts.settings) { + BgLog "Capture des parametres..." "Yellow" + $result.settings = @{ + title = $web.Title + description = $web.Description + language = [int]$web.Language + } + BgLog " Titre: $($web.Title) | Langue: $($web.Language)" "Cyan" + } + + if ($Opts.style) { + BgLog "Capture du style..." "Yellow" + $result.style = @{ logoUrl = $web.SiteLogoUrl } + BgLog " Logo: $($web.SiteLogoUrl)" "Cyan" + } + + if ($Opts.structure) { + BgLog "Capture de l'arborescence..." "Yellow" + $lists = Get-PnPList | Where-Object { + !$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList") + } + $struct = [System.Collections.Generic.List[object]]::new() + foreach ($list in $lists) { + $rf = Get-PnPProperty -ClientObject $list -Property RootFolder + $srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/') + $fld = Collect-Folders $srl + $struct.Add(@{ + name = $list.Title + type = $list.BaseType.ToString() + template = [int]$list.BaseTemplate + rootSiteRel = $srl # site-relative URL of library root + folders = @($fld) + }) + BgLog " [$($list.BaseType)] $($list.Title) ($srl) — $($fld.Count) dossier(s)" "Cyan" + } + $result.structure = @($struct) + } + + if ($Opts.permissions) { + BgLog "Capture des groupes site..." "Yellow" + $groups = Get-PnPSiteGroup + $permArr = [System.Collections.Generic.List[object]]::new() + foreach ($g in $groups) { + try { + $members = @(Get-PnPGroupMember -Identity $g.LoginName -ErrorAction SilentlyContinue | + Where-Object { $_.Title -ne "System Account" } | + Select-Object -ExpandProperty LoginName) + $roles = @(Get-PnPGroupPermissions -Identity $g.LoginName -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name) + $permArr.Add(@{ + groupName = $g.Title + loginName = $g.LoginName + roles = @($roles) + members = @($members) + }) + BgLog " Groupe: $($g.Title) — $($members.Count) membre(s)" "Cyan" + } catch {} + } + $result.permissions = @($permArr) + + # Capture des permissions uniques sur les dossiers + BgLog "Scan des permissions sur les dossiers..." "Yellow" + $scanLists = Get-PnPList | Where-Object { + !$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList") + } + $folderPerms = [System.Collections.Generic.List[object]]::new() + $ctx = Get-PnPContext + foreach ($scanList in $scanLists) { + $rf = Get-PnPProperty -ClientObject $scanList -Property RootFolder + $listSrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/') + try { + $items = Get-PnPListItem -List $scanList -PageSize 2000 -ErrorAction SilentlyContinue | + Where-Object { $_.FileSystemObjectType -eq "Folder" } + foreach ($folder in $items) { + try { + $hasUnique = Get-PnPProperty -ClientObject $folder -Property HasUniqueRoleAssignments + if (-not $hasUnique) { continue } + $ra = Get-PnPProperty -ClientObject $folder -Property RoleAssignments + $folderRoleArr = [System.Collections.Generic.List[object]]::new() + foreach ($assignment in $ra) { + Get-PnPProperty -ClientObject $assignment.Member -Property Title,LoginName | Out-Null + Get-PnPProperty -ClientObject $assignment -Property RoleDefinitionBindings | Out-Null + $rNames = @($assignment.RoleDefinitionBindings | + Where-Object { $_.Name -ne "Limited Access" } | + Select-Object -ExpandProperty Name) + if ($rNames.Count -gt 0) { + $folderRoleArr.Add(@{ + principal = $assignment.Member.Title + loginName = $assignment.Member.LoginName + roles = @($rNames) + }) + } + } + $fileRef = $folder.FieldValues.FileRef + $relPath = $fileRef.Substring($wSrl.Length).TrimStart('/') + $folderPerms.Add(@{ + listSiteRel = $listSrl + path = $relPath + perms = @($folderRoleArr) + }) + BgLog " Perms uniques: $relPath ($($folderRoleArr.Count) entree(s))" "Cyan" + } catch {} + } + } catch { BgLog " Liste ignoree '$($scanList.Title)': $($_.Exception.Message)" "DarkGray" } + } + $result.folderPermissions = @($folderPerms) + BgLog " $($folderPerms.Count) dossier(s) avec permissions uniques captures" "LightGreen" + } + + $Sync.Result = $result + BgLog "=== Capture terminee ! ===" "White" + } catch { + $Sync.Error = $_.Exception.Message + BgLog "Erreur: $($_.Exception.Message)" "Red" + } finally { $Sync.Done = $true } + } + + $sync = [hashtable]::Synchronized(@{ + Queue = [System.Collections.Generic.Queue[object]]::new() + Done = $false; Error = $null; Result = $null + }) + $script:_tpl.CapSync = $sync + + $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open() + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript($bgCapture) + [void]$ps.AddArgument($srcUrl) + [void]$ps.AddArgument($script:_tpl.ClientId) + [void]$ps.AddArgument($script:_tpl.CapOpts) + [void]$ps.AddArgument($sync) + $script:_tpl.CapRS = $rs + $script:_tpl.CapPS = $ps + $script:_tpl.CapHnd = $ps.BeginInvoke() + + $tmr = New-Object System.Windows.Forms.Timer + $tmr.Interval = 200 + $script:_tpl.CapTimer = $tmr + $tmr.Add_Tick({ + while ($script:_tpl.CapSync.Queue.Count -gt 0) { + $m = $script:_tpl.CapSync.Queue.Dequeue() + _Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color + } + if ($script:_tpl.CapSync.Done) { + $script:_tpl.CapTimer.Stop(); $script:_tpl.CapTimer.Dispose() + while ($script:_tpl.CapSync.Queue.Count -gt 0) { + $m = $script:_tpl.CapSync.Queue.Dequeue() + _Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color + } + try { [void]$script:_tpl.CapPS.EndInvoke($script:_tpl.CapHnd) } catch {} + try { $script:_tpl.CapRS.Close(); $script:_tpl.CapRS.Dispose() } catch {} + $btnCapture.Enabled = $true + if (-not $script:_tpl.CapSync.Error -and $script:_tpl.CapSync.Result) { + $r = $script:_tpl.CapSync.Result + $newTpl = [PSCustomObject]@{ + name = $script:_tpl.CapTplName + sourceUrl = $script:_tpl.CapSrcUrl + capturedAt = (Get-Date -Format 'dd/MM/yyyy HH:mm') + options = $script:_tpl.CapOpts + settings = $r.settings + style = $r.style + structure = $r.structure + permissions = $r.permissions + folderPermissions = $r.folderPermissions + } + $n = $script:_tpl.CapTplName + $script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $n }) + $newTpl + Save-Templates -Templates $script:_tpl.Templates + _Tpl-Repopulate + _Tpl-RefreshCombo + [System.Windows.Forms.MessageBox]::Show( + "Template '$n' sauvegarde avec succes.", + "Capture reussie", "OK", "Information") + } + } + }) + $tmr.Start() + }) + + # ── Create site button ───────────────────────────────────────────────── + $btnCreateSite.Add_Click({ + $tplIdx = $cboCrTpl.SelectedIndex + if ($tplIdx -lt 0 -or $tplIdx -ge $script:_tpl.Templates.Count) { + [System.Windows.Forms.MessageBox]::Show("Veuillez selectionner un template.", "Aucun template", "OK", "Warning") + return + } + $tpl = $script:_tpl.Templates[$tplIdx] + $title = $txtCrTitle.Text.Trim() + $alias = $txtCrAlias.Text.Trim() + $owners = @($txtCrOwners.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $members = @($txtCrMembers.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + if ([string]::IsNullOrWhiteSpace($title)) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir le titre du site.", "Champ manquant", "OK", "Warning"); return + } + if ([string]::IsNullOrWhiteSpace($alias)) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir un alias URL.", "Champ manquant", "OK", "Warning"); return + } + if ([string]::IsNullOrWhiteSpace($script:_tpl.TenantUrl)) { + [System.Windows.Forms.MessageBox]::Show("Tenant URL manquant dans le formulaire principal.", "Erreur", "OK", "Warning"); return + } + if ($owners.Count -eq 0) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir au moins un proprietaire.", "Champ manquant", "OK", "Warning"); return + } + + $applyOpts = @{ + structure = $chkApplyStruct.Checked -and $tpl.options.structure + permissions = $chkApplyPerms.Checked -and $tpl.options.permissions + settings = $chkApplySettings.Checked -and $tpl.options.settings + style = $chkApplyStyle.Checked -and $tpl.options.style + } + $isTeam = $radCrTeam.Checked + + $btnCreateSite.Enabled = $false + $script:_tpl.TxtCreateLog.Clear() + + $bgCreate = { + param($TenantUrl, $ClientId, $Title, $Alias, $IsTeam, $Owners, $Members, $ApplyOpts, $Tpl, $Sync) + function BgLog([string]$m, [string]$c="LightGreen") { + $Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c }) + } + function Apply-FolderTree([object[]]$Folders, [string]$ParentSrl) { + foreach ($f in $Folders) { + try { + Add-PnPFolder -Name $f.name -Folder $ParentSrl -ErrorAction SilentlyContinue | Out-Null + BgLog " + $($f.name)" "Cyan" + } catch {} + if ($f.subfolders -and $f.subfolders.Count -gt 0) { + Apply-FolderTree $f.subfolders "$ParentSrl/$($f.name)" + } + } + } + try { + Import-Module PnP.PowerShell -ErrorAction Stop + $adminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') { + "$($Matches[1])-admin$($Matches[2])" + } else { $TenantUrl } + + BgLog "Connexion au tenant admin..." "Yellow" + Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $ClientId + + BgLog "Creation du site '$Title' (alias: $Alias)..." "Yellow" + $newUrl = if ($IsTeam) { + New-PnPSite -Type TeamSite -Title $Title -Alias $Alias -Owners $Owners -Wait + } else { + $base = if ($TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $TenantUrl } + New-PnPSite -Type CommunicationSite -Title $Title -Url "$base/sites/$Alias" -Wait + } + $Sync.NewSiteUrl = $newUrl + BgLog "Site cree : $newUrl" "LightGreen" + + BgLog "Connexion au nouveau site..." "Yellow" + Connect-PnPOnline -Url $newUrl -Interactive -ClientId $ClientId + $web = Get-PnPWeb + $wSrl = $web.ServerRelativeUrl.TrimEnd('/') + + if ($ApplyOpts.settings -and $Tpl.settings -and $Tpl.settings.description) { + BgLog "Application des parametres..." "Yellow" + Set-PnPWeb -Description $Tpl.settings.description + } + + if ($ApplyOpts.style -and $Tpl.style -and $Tpl.style.logoUrl) { + BgLog "Application du style (logo)..." "Yellow" + Set-PnPWeb -SiteLogoUrl $Tpl.style.logoUrl + } + + if ($ApplyOpts.structure -and $Tpl.structure -and $Tpl.structure.Count -gt 0) { + BgLog "Application de l'arborescence..." "Yellow" + foreach ($lib in $Tpl.structure) { + BgLog " Bibliotheque: $($lib.name)" "Yellow" + $existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue + if (-not $existing) { + try { + $tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") { + [Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary + } else { + [Microsoft.SharePoint.Client.ListTemplateType]::GenericList + } + New-PnPList -Title $lib.name -Template $tplType | Out-Null + BgLog " Creee." "Cyan" + } catch { BgLog " Ignoree: $($_.Exception.Message)" "DarkGray" } + } else { BgLog " Deja existante." "DarkGray" } + + if ($lib.folders -and $lib.folders.Count -gt 0) { + # Get actual root folder URL from target list (avoids display-name vs URL mismatch) + $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue + if ($targetList) { + $listRf = Get-PnPProperty -ClientObject $targetList -Property RootFolder + $libBase = $listRf.ServerRelativeUrl.TrimEnd('/') + BgLog " Base URL: $libBase" "DarkGray" + Apply-FolderTree $lib.folders $libBase + } else { + BgLog " Liste '$($lib.name)' non trouvee, dossiers ignores." "DarkGray" + } + } + } + } + + if ($Members -and $Members.Count -gt 0) { + BgLog "Ajout de $($Members.Count) membre(s)..." "Yellow" + $memberGroup = Get-PnPGroup | Where-Object { $_.Title -like "*Membres*" -or $_.Title -like "*Members*" } | Select-Object -First 1 + if ($memberGroup) { + foreach ($m in $Members) { + try { + Add-PnPGroupMember -LoginName $m -Group $memberGroup.Title -ErrorAction SilentlyContinue + BgLog " + $m" "Cyan" + } catch { BgLog " Ignore $m" "DarkGray" } + } + } else { BgLog " Groupe membres non trouve, ajout ignore." "DarkGray" } + } + + # Apply folder-level unique permissions + $fpList = $Tpl.folderPermissions + if ($ApplyOpts.permissions -and $fpList -and $fpList.Count -gt 0) { + BgLog "Application des permissions sur les dossiers ($($fpList.Count))..." "Yellow" + + # Build map: source library rootSiteRel -> target library server-relative URL + $libMap = @{} + foreach ($lib in $Tpl.structure) { + if (-not $lib.rootSiteRel) { continue } + $tgtLib = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue + if ($tgtLib) { + $tgtRf = Get-PnPProperty -ClientObject $tgtLib -Property RootFolder + $libMap[$lib.rootSiteRel] = $tgtRf.ServerRelativeUrl.TrimEnd('/') + } + } + + $ctx = Get-PnPContext + foreach ($fp in $fpList) { + # Compute folder SRL on target using the library map + $srcLibSrl = $fp.listSiteRel + $tgtLibBase = $libMap[$srcLibSrl] + if (-not $tgtLibBase) { + BgLog " Bibliotheque non mappee pour '$srcLibSrl', dossier ignore." "DarkGray" + continue + } + # Folder path relative to library root (strip the lib prefix from fp.path) + $folderRelToLib = $fp.path.Substring($srcLibSrl.Length).TrimStart('/') + $folderSrl = "$tgtLibBase/$folderRelToLib" + + try { + $folder = $ctx.Web.GetFolderByServerRelativeUrl($folderSrl) + $folderItem = $folder.ListItemAllFields + $ctx.Load($folderItem) + $ctx.ExecuteQuery() + + # Break inheritance and clear all inherited permissions + $folderItem.BreakRoleInheritance($false, $false) + $ctx.ExecuteQuery() + + # Apply each captured permission entry + foreach ($perm in $fp.perms) { + # Skip system accounts and source-specific SP groups (detected by failed EnsureUser) + try { + $principal = $ctx.Web.EnsureUser($perm.loginName) + $ctx.Load($principal) + $ctx.ExecuteQuery() + foreach ($roleName in $perm.roles) { + try { + $roleDef = $ctx.Web.RoleDefinitions.GetByName($roleName) + $bindings = New-Object Microsoft.SharePoint.Client.RoleDefinitionBindingCollection($ctx) + $bindings.Add($roleDef) + $folderItem.RoleAssignments.Add($principal, $bindings) | Out-Null + $ctx.ExecuteQuery() + BgLog " + $($perm.principal) [$roleName]" "Cyan" + } catch { BgLog " Role '$roleName' ignore pour '$($perm.principal)'" "DarkGray" } + } + } catch { + BgLog " Principal ignore (groupe source ou compte systeme) : '$($perm.principal)'" "DarkGray" + } + } + BgLog " OK: $folderRelToLib" "Cyan" + } catch { BgLog " Dossier ignore '$folderRelToLib': $($_.Exception.Message)" "DarkGray" } + } + } + + BgLog "=== Site cree avec succes ! ===" "White" + } catch { + $Sync.Error = $_.Exception.Message + BgLog "Erreur: $($_.Exception.Message)" "Red" + } finally { $Sync.Done = $true } + } + + $sync = [hashtable]::Synchronized(@{ + Queue = [System.Collections.Generic.Queue[object]]::new() + Done = $false; Error = $null; NewSiteUrl = $null + }) + $script:_tpl.CrSync = $sync + + $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open() + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript($bgCreate) + [void]$ps.AddArgument($script:_tpl.TenantUrl) + [void]$ps.AddArgument($script:_tpl.ClientId) + [void]$ps.AddArgument($title) + [void]$ps.AddArgument($alias) + [void]$ps.AddArgument($isTeam) + [void]$ps.AddArgument($owners) + [void]$ps.AddArgument($members) + [void]$ps.AddArgument($applyOpts) + [void]$ps.AddArgument($tpl) + [void]$ps.AddArgument($sync) + $script:_tpl.CrRS = $rs + $script:_tpl.CrPS = $ps + $script:_tpl.CrHnd = $ps.BeginInvoke() + + $tmr = New-Object System.Windows.Forms.Timer + $tmr.Interval = 200 + $script:_tpl.CrTimer = $tmr + $tmr.Add_Tick({ + while ($script:_tpl.CrSync.Queue.Count -gt 0) { + $m = $script:_tpl.CrSync.Queue.Dequeue() + _Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color + } + if ($script:_tpl.CrSync.Done) { + $script:_tpl.CrTimer.Stop(); $script:_tpl.CrTimer.Dispose() + while ($script:_tpl.CrSync.Queue.Count -gt 0) { + $m = $script:_tpl.CrSync.Queue.Dequeue() + _Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color + } + try { [void]$script:_tpl.CrPS.EndInvoke($script:_tpl.CrHnd) } catch {} + try { $script:_tpl.CrRS.Close(); $script:_tpl.CrRS.Dispose() } catch {} + $btnCreateSite.Enabled = $true + if ($script:_tpl.CrSync.NewSiteUrl -and -not $script:_tpl.CrSync.Error) { + $url = $script:_tpl.CrSync.NewSiteUrl + $res = [System.Windows.Forms.MessageBox]::Show( + "Site cree avec succes !`n`n$url`n`nOuvrir dans le navigateur ?", + "Succes", "YesNo", "Information") + if ($res -eq "Yes") { Start-Process $url } + } + } + }) + $tmr.Start() + }) + + if ($Owner) { [void]$dlg.ShowDialog($Owner) } else { [void]$dlg.ShowDialog() } + $dlg.Dispose() +} + +#endregion + +#region ===== HTML Export: Permissions ===== + +function Merge-PermissionRows([array]$Data) { + # Groups rows that share the same Users + Permissions + GrantedThrough into one merged row. + # Uses [ordered] to preserve insertion order without a separate list. + $map = [ordered]@{} + foreach ($row in $Data) { + $key = "$($row.Users)|$($row.Permissions)|$($row.GrantedThrough)" + if (-not $map.Contains($key)) { + $map[$key] = [PSCustomObject]@{ + Locations = @() + Permissions = $row.Permissions + GrantedThrough = $row.GrantedThrough + Type = $row.Type + Users = $row.Users + UserLogins = if ($row.UserLogins) { $row.UserLogins } else { "" } + } + } + $map[$key].Locations += [PSCustomObject]@{ + Object = [string]$row.Object + Title = [string]$row.Title + URL = if ($row.URL) { [string]$row.URL } else { "" } + HasUniquePermissions = $row.HasUniquePermissions + } + } + return @($map.Values) +} + +function Export-PermissionsToHTML { + param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath) + + $generated = Get-Date -Format 'dd/MM/yyyy HH:mm' + $count = $Data.Count + $uniqueCount = ($Data | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true } | Measure-Object).Count + $userCount = ($Data | ForEach-Object { $_.Users -split '; ' } | Where-Object { $_ } | Sort-Object -Unique | Measure-Object).Count + + # Build pills HTML for a list of names + emails + function Build-Pills([string[]]$Names, [string[]]$Emails) { + $html = "" + for ($i = 0; $i -lt $Names.Count; $i++) { + $n = EscHtml $Names[$i] + $e = if ($Emails -and $i -lt $Emails.Count) { EscHtml $Emails[$i] } else { "" } + $html += "$n" + } + return $html + } + + $mergedRows = Merge-PermissionRows $Data + $script:grpIdx = 0 + $rows = "" + foreach ($mrow in $mergedRows) { + $locs = @($mrow.Locations) + + # Dominant type badge (use first location's type — entries in a merged group are typically the same type) + $dominantType = $locs[0].Object + $badgeClass = switch -Regex ($dominantType) { + "Site Collection" { "bc"; break } + "^Site$" { "bs"; break } + "Folder" { "bf"; break } + Default { "bl" } + } + + # Name / locations cell + Unique Permissions cell + if ($locs.Count -eq 1) { + $loc = $locs[0] + $isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true) + $uqClass = if ($isUnique) { "uq" } else { "inh" } + $uqText = if ($isUnique) { "✓ Unique" } else { "Inherited" } + $nameCell = if ($loc.URL) { "$(EscHtml $loc.Title)" } else { EscHtml $loc.Title } + $uqCell = "$uqText" + } else { + $uqTotal = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count + $locHtml = "
" + foreach ($loc in $locs) { + $isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true) + $uqMark = if ($isUnique) { "" } else { "~" } + $locBc = switch -Regex ($loc.Object) { + "Site Collection" { "bc"; break } + "^Site$" { "bs"; break } + "Folder" { "bf"; break } + Default { "bl" } + } + $locLink = if ($loc.URL) { "$(EscHtml $loc.Title)" } else { EscHtml $loc.Title } + $locHtml += "
$(EscHtml $loc.Object) $locLink $uqMark
" + } + $locHtml += "
" + $nameCell = $locHtml + $uqCell = "$($locs.Count) emplacements
($uqTotal uniques)
" + } + + # Build Users / Members cell + $names = if ($mrow.Users) { @($mrow.Users -split '; ') } else { @() } + $emails = if ($mrow.UserLogins) { @($mrow.UserLogins -split '; ') } else { @() } + $usersCell = "" + if ($names.Count -gt 0) { + $pills = Build-Pills $names $emails + if ($mrow.GrantedThrough -match '^SharePoint Group: (.+)$') { + $grpName = EscHtml $Matches[1] + $gid = "g$($script:grpIdx)" + $script:grpIdx++ + $usersCell = "
$grpName
$pills
" + } else { + $usersCell = $pills + } + } + + $rows += "$(EscHtml $dominantType)" + $rows += "$nameCell" + $rows += "$usersCell" + $rows += "$(EscHtml $mrow.Permissions)" + $rows += "$(EscHtml $mrow.GrantedThrough)" + $rows += "$uqCell`n" + } + +$html = @" + + +Permissions - $(EscHtml $SiteTitle) + +
+

SharePoint Permissions Report

+
Site: $(EscHtml $SiteTitle) • Generated: $generated
+
+
+
$count
Total Entries
+
$uniqueCount
Unique Permission Sets
+
$userCount
Distinct Users / Groups
+
+
+ + +$rows +
TypeNameUsers / MembersPermission LevelGranted ThroughUnique Permissions
+
Generated by SharePoint Exporter v6.0
+ + +
+
📋 Copier l'adresse email
+
+
✉ Envoyer un email
+
+ + + +"@ + $html | Out-File -FilePath $OutputPath -Encoding UTF8 +} + +#endregion + +#region ===== HTML Export: Storage ===== + +function Export-StorageToHTML { + param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath) + + $generated = Get-Date -Format 'dd/MM/yyyy HH:mm' + $totalBytes = ($Data | Measure-Object -Property SizeBytes -Sum).Sum + $totalVersionBytes = ($Data | Where-Object { $_.VersionSizeBytes } | Measure-Object -Property VersionSizeBytes -Sum).Sum + if (-not $totalVersionBytes) { $totalVersionBytes = 0 } + $totalFiles = ($Data | Measure-Object -Property ItemCount -Sum).Sum + $libCount = $Data.Count + + # Shared toggle-ID counter — must be unique across all levels of nesting + $script:togIdx = 0 + + # Recursively builds folder rows; each folder with children gets its own toggle + function Build-FolderRows([array]$Folders) { + $html = "" + foreach ($sf in ($Folders | Sort-Object SizeBytes -Descending)) { + $myIdx = $script:togIdx++ + $hasSubs = $sf.SubFolders -and $sf.SubFolders.Count -gt 0 + $sfSzClass = if ($sf.SizeBytes -ge 10GB) { "sz-lg" } elseif ($sf.SizeBytes -ge 1GB) { "sz-md" } else { "sz-sm" } + $sfLink = if ($sf.URL) { "📁 $(EscHtml $sf.Name)" } else { "📁 $(EscHtml $sf.Name)" } + $togBtn = if ($hasSubs) { "" } else { "" } + + $sfVerBytes = if ($sf.VersionSizeBytes) { $sf.VersionSizeBytes } else { 0 } + $sfVerPct = if ($sf.SizeBytes -gt 0 -and $sfVerBytes -gt 0) { [math]::Round($sfVerBytes / $sf.SizeBytes * 100, 0) } else { 0 } + $sfVerClass = if ($sfVerPct -ge 40) { "sz-lg" } elseif ($sfVerPct -ge 15) { "sz-md" } else { "" } + $sfVerTxt = if ($sfVerBytes -gt 0) { "$(Format-Bytes $sfVerBytes) ($sfVerPct%)" } else { "-" } + $html += "
$togBtn $sfLink
" + $html += "$($sf.ItemCount)" + $html += "$(Format-Bytes $sf.SizeBytes)" + $html += "$sfVerTxt" + $html += "$(EscHtml $sf.LastModified)`n" + + if ($hasSubs) { + $html += "
" + $html += "" + $html += "$(Build-FolderRows $sf.SubFolders)
FolderFilesSizeVersionsLast Modified
`n" + } + } + return $html + } + + $rows = "" + foreach ($row in ($Data | Sort-Object SizeBytes -Descending)) { + $myLibIdx = $script:togIdx++ + $pct = if ($totalBytes -gt 0) { [math]::Round($row.SizeBytes / $totalBytes * 100, 1) } else { 0 } + $szClass = if ($row.SizeBytes -ge 10GB) { "sz-lg" } elseif ($row.SizeBytes -ge 1GB) { "sz-md" } else { "sz-sm" } + $hasFolders = $row.SubFolders -and $row.SubFolders.Count -gt 0 + $toggleBtn = if ($hasFolders) { "" } else { "" } + $nameCell = if ($row.LibraryURL) { "$(EscHtml $row.Library)" } else { EscHtml $row.Library } + + $verBytes = if ($row.VersionSizeBytes) { $row.VersionSizeBytes } else { 0 } + $verPct = if ($row.SizeBytes -gt 0 -and $verBytes -gt 0) { [math]::Round($verBytes / $row.SizeBytes * 100, 0) } else { 0 } + $verClass = if ($verPct -ge 40) { "sz-lg" } elseif ($verPct -ge 15) { "sz-md" } else { "" } + $verTxt = if ($verBytes -gt 0) { "$(Format-Bytes $verBytes) ($verPct%)" } else { "-" } + $rows += "
$toggleBtn $nameCell
" + $rows += "$(EscHtml $row.SiteTitle)" + $rows += "$($row.ItemCount)" + $rows += "$(Format-Bytes $row.SizeBytes)" + $rows += "$verTxt" + $rows += "
$pct%
" + $rows += "$(EscHtml $row.LastModified)`n" + + if ($hasFolders) { + $rows += "
" + $rows += "" + $rows += "$(Build-FolderRows $row.SubFolders)
FolderFilesSizeVersionsLast Modified
`n" + } + } + +$html = @" + + +Storage - $(EscHtml $SiteTitle) + + + +
+

SharePoint Storage Metrics

+
Site: $(EscHtml $SiteTitle) • Generated: $generated
+
+
+
$(Format-Bytes $totalBytes)
Total Storage Used
+
$(Format-Bytes $totalVersionBytes)
Version Storage
+
$totalFiles
Total Files
+
$libCount
Libraries / Sites Scanned
+
+
+ + +$rows +
LibrarySiteFilesSizeVersionsShare of TotalLast Modified
+
Generated by SharePoint Exporter v6.0
+ +"@ + $html | Out-File -FilePath $OutputPath -Encoding UTF8 +} + +#endregion + +#region ===== PnP: Permissions ===== + +Function Get-PnPPermissions([Microsoft.SharePoint.Client.SecurableObject]$Object) { + Switch ($Object.TypedObject.ToString()) { + "Microsoft.SharePoint.Client.Web" { + $ObjectType = "Site" + $ObjectURL = $Object.URL + $ObjectTitle = $Object.Title + } + "Microsoft.SharePoint.Client.ListItem" { + $ObjectType = "Folder" + $Folder = Get-PnPProperty -ClientObject $Object -Property Folder + $ObjectTitle = $Object.Folder.Name + $ObjectURL = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $Object.Folder.ServerRelativeUrl) + } + Default { + $ObjectType = $Object.BaseType + $ObjectTitle = $Object.Title + $RootFolder = Get-PnPProperty -ClientObject $Object -Property RootFolder + $ObjectURL = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $RootFolder.ServerRelativeUrl) + } + } + + Get-PnPProperty -ClientObject $Object -Property HasUniqueRoleAssignments, RoleAssignments + $HasUniquePermissions = $Object.HasUniqueRoleAssignments + + Foreach ($RoleAssignment in $Object.RoleAssignments) { + Get-PnPProperty -ClientObject $RoleAssignment -Property RoleDefinitionBindings, Member + $PermissionType = $RoleAssignment.Member.PrincipalType + $PermissionLevels = ($RoleAssignment.RoleDefinitionBindings | Select-Object -ExpandProperty Name | + Where-Object { $_ -ne "Limited Access" }) -join "; " + If ($PermissionLevels.Length -eq 0) { Continue } + + $entry = [PSCustomObject]@{ + Object = $ObjectType + Title = $ObjectTitle + URL = $ObjectURL + HasUniquePermissions = $HasUniquePermissions + Permissions = $PermissionLevels + GrantedThrough = "" + Type = $PermissionType + Users = "" + UserLogins = "" + } + + If ($PermissionType -eq "SharePointGroup") { + $grpLogin = $RoleAssignment.Member.LoginName + If ($grpLogin -match '^SharingLinks\.' -or $grpLogin -eq 'Limited Access System Group') { Continue } + $GroupMembers = Get-PnPGroupMember -Identity $grpLogin + If ($GroupMembers.count -eq 0) { Continue } + $filteredMembers = @($GroupMembers | Where-Object { $_.Title -ne "System Account" }) + $GroupUsers = ($filteredMembers | Select-Object -ExpandProperty Title) -join "; " + $GroupEmails = ($filteredMembers | Select-Object -ExpandProperty Email) -join "; " + If ($GroupUsers.Length -eq 0) { Continue } + $entry.Users = $GroupUsers + $entry.UserLogins = $GroupEmails + $entry.GrantedThrough = "SharePoint Group: $grpLogin" + } + Else { + $entry.Users = $RoleAssignment.Member.Title + $entry.UserLogins = $RoleAssignment.Member.Email + $entry.GrantedThrough = "Direct Permissions" + } + + $script:AllPermissions += $entry + } +} + +Function Generate-PnPSitePermissionRpt { + [cmdletbinding()] + Param ( + [String] $SiteURL, + [String] $ReportFile, + [switch] $Recursive, + [switch] $ScanFolders, + [switch] $IncludeInheritedPermissions + ) + Try { + Write-Log "Connecting to SharePoint... (browser window will open)" "Yellow" + Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $script:pnpCiD + $Web = Get-PnPWeb + + Write-Log "Getting Site Collection Administrators..." "Yellow" + $SiteAdmins = Get-PnPSiteCollectionAdmin + $script:AllPermissions += [PSCustomObject]@{ + Object = "Site Collection" + Title = $Web.Title + URL = $Web.URL + HasUniquePermissions = "TRUE" + Users = ($SiteAdmins | Select-Object -ExpandProperty Title) -join "; " + UserLogins = ($SiteAdmins | Select-Object -ExpandProperty Email) -join "; " + Type = "Site Collection Administrators" + Permissions = "Site Owner" + GrantedThrough = "Direct Permissions" + } + + Function Get-PnPFolderPermission([Microsoft.SharePoint.Client.List]$List) { + Write-Log "`t`tScanning folders in: $($List.Title)" "Yellow" + $ListItems = Get-PnPListItem -List $List -PageSize 2000 + $Folders = $ListItems | Where-Object { + ($_.FileSystemObjectType -eq "Folder") -and + ($_.FieldValues.FileLeafRef -ne "Forms") -and + (-Not($_.FieldValues.FileLeafRef.StartsWith("_"))) + } + # Apply folder depth filter (999 = maximum / no limit) + If ($script:PermFolderDepth -lt 999) { + $rf = Get-PnPProperty -ClientObject $List -Property RootFolder + $rootSrl = $rf.ServerRelativeUrl.TrimEnd('/') + $Folders = $Folders | Where-Object { + $relPath = $_.FieldValues.FileRef.Substring($rootSrl.Length).TrimStart('/') + ($relPath -split '/').Count -le $script:PermFolderDepth + } + } + $i = 0 + ForEach ($Folder in $Folders) { + If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $Folder } + Else { + If ((Get-PnPProperty -ClientObject $Folder -Property HasUniqueRoleAssignments) -eq $true) { + Get-PnPPermissions -Object $Folder + } + } + $i++ + Write-Progress -PercentComplete ($i / [Math]::Max($Folders.Count,1) * 100) ` + -Activity "Folders in '$($List.Title)'" ` + -Status "'$($Folder.FieldValues.FileLeafRef)' ($i of $($Folders.Count))" -Id 2 -ParentId 1 + } + } + + Function Get-PnPListPermission([Microsoft.SharePoint.Client.Web]$Web) { + $Lists = Get-PnPProperty -ClientObject $Web -Property Lists + $ExcludedLists = @( + "Access Requests","App Packages","appdata","appfiles","Apps in Testing","Cache Profiles", + "Composed Looks","Content and Structure Reports","Content type publishing error log", + "Converted Forms","Device Channels","Form Templates","fpdatasources", + "Get started with Apps for Office and SharePoint","List Template Gallery", + "Long Running Operation Status","Maintenance Log Library","Images","site collection images", + "Master Docs","Master Page Gallery","MicroFeed","NintexFormXml","Quick Deploy Items", + "Relationships List","Reusable Content","Reporting Metadata","Reporting Templates", + "Search Config List","Site Assets","Preservation Hold Library","Site Pages", + "Solution Gallery","Style Library","Suggested Content Browser Locations","Theme Gallery", + "TaxonomyHiddenList","User Information List","Web Part Gallery","wfpub","wfsvc", + "Workflow History","Workflow Tasks","Pages" + ) + $c = 0 + ForEach ($List in $Lists) { + If ($List.Hidden -eq $false -and $ExcludedLists -notcontains $List.Title) { + $c++ + Write-Log "`tList ($c/$($Lists.Count)): $($List.Title)" "Cyan" + Write-Progress -PercentComplete ($c / [Math]::Max($Lists.Count,1) * 100) ` + -Activity "Scanning lists in $($Web.URL)" ` + -Status "'$($List.Title)' ($c of $($Lists.Count))" -Id 1 + If ($ScanFolders) { Get-PnPFolderPermission -List $List } + If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $List } + Else { + If ((Get-PnPProperty -ClientObject $List -Property HasUniqueRoleAssignments) -eq $true) { + Get-PnPPermissions -Object $List + } + } + } + } + } + + Function Get-PnPWebPermission([Microsoft.SharePoint.Client.Web]$Web) { + Write-Log "Processing web: $($Web.URL)" "Yellow" + Get-PnPPermissions -Object $Web + Write-Log "`tScanning lists and libraries..." "Yellow" + Get-PnPListPermission($Web) + If ($Recursive) { + $Subwebs = Get-PnPProperty -ClientObject $Web -Property Webs + Foreach ($Subweb in $web.Webs) { + If ($IncludeInheritedPermissions) { Get-PnPWebPermission($Subweb) } + Else { + If ((Get-PnPProperty -ClientObject $SubWeb -Property HasUniqueRoleAssignments) -eq $true) { + Get-PnPWebPermission($Subweb) + } + } + } + } + } + + Get-PnPWebPermission $Web + + # Export based on chosen format + Write-Log "Writing output file..." "Yellow" + If ($script:PermFormat -eq "HTML") { + $outPath = [System.IO.Path]::ChangeExtension($ReportFile, ".html") + Export-PermissionsToHTML -Data $script:AllPermissions -SiteTitle $Web.Title -SiteURL $Web.URL -OutputPath $outPath + $script:PermOutputFile = $outPath + } + Else { + $mergedPerms = Merge-PermissionRows $script:AllPermissions + $mergedPerms | ForEach-Object { + $locs = @($_.Locations) + $uqN = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count + [PSCustomObject]@{ + Object = ($locs | Select-Object -ExpandProperty Object -Unique) -join ', ' + Title = ($locs | Select-Object -ExpandProperty Title) -join ' | ' + URL = ($locs | Select-Object -ExpandProperty URL) -join ' | ' + HasUniquePermissions = if ($locs.Count -eq 1) { $locs[0].HasUniquePermissions } else { "$uqN/$($locs.Count) uniques" } + Users = $_.Users + UserLogins = $_.UserLogins + Type = $_.Type + Permissions = $_.Permissions + GrantedThrough = $_.GrantedThrough + } + } | Export-Csv -Path $ReportFile -NoTypeInformation + $script:PermOutputFile = $ReportFile + } + + Write-Log "Report generated successfully!" "LightGreen" + } + Catch { + Write-Log "Error: $($_.Exception.Message)" "Red" + throw + } +} + +#endregion + +#region ===== PnP: Storage Metrics ===== + +function Get-SiteStorageMetrics { + param([string]$SiteURL, [switch]$IncludeSubsites, [switch]$PerLibrary) + + $script:storageResults = @() + + # Recursively collects subfolders up to $MaxDepth levels deep + function Collect-FolderStorage([string]$FolderSiteRelUrl, [string]$WebBaseUrl, [int]$CurrentDepth) { + if ($CurrentDepth -ge $script:FolderDepth) { return @() } + $result = @() + try { + $items = Get-PnPFolderItem -FolderSiteRelativeUrl $FolderSiteRelUrl -ItemType Folder + foreach ($fi in $items) { + $fiSrl = "$FolderSiteRelUrl/$($fi.Name)" + try { + $fiSm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $fiSrl + $children = Collect-FolderStorage -FolderSiteRelUrl $fiSrl -WebBaseUrl $WebBaseUrl -CurrentDepth ($CurrentDepth + 1) + $result += [PSCustomObject]@{ + Name = $fi.Name + URL = "$($WebBaseUrl.TrimEnd('/'))/$fiSrl" + ItemCount = $fiSm.TotalFileCount + SizeBytes = $fiSm.TotalSize + LastModified = if ($fiSm.LastModified) { $fiSm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" } + SubFolders = $children + } + } catch {} + } + } catch {} + return $result + } + + function Collect-WebStorage([string]$WebUrl) { + # Connect to this specific web (token is cached, no extra browser prompts) + Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD + $webObj = Get-PnPWeb + $wTitle = $webObj.Title + $wUrl = $webObj.Url + # ServerRelativeUrl of the web (e.g. "/sites/MySite") — used to compute site-relative paths + $wSrl = $webObj.ServerRelativeUrl.TrimEnd('/') + + if ($PerLibrary) { + $lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden } + foreach ($list in $lists) { + $rf = Get-PnPProperty -ClientObject $list -Property RootFolder + try { + # Convert server-relative (/sites/X/LibName) to site-relative (LibName) + $siteRelUrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/') + $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $siteRelUrl + $libUrl = "$($wUrl.TrimEnd('/'))/$siteRelUrl" + + # Recursively collect subfolders up to the configured depth + $subFolders = Collect-FolderStorage -FolderSiteRelUrl $siteRelUrl -WebBaseUrl $wUrl -CurrentDepth 0 + + $script:storageResults += [PSCustomObject]@{ + SiteTitle = $wTitle + SiteURL = $wUrl + Library = $list.Title + LibraryURL = $libUrl + ItemCount = $sm.TotalFileCount + SizeBytes = $sm.TotalSize + SizeMB = [math]::Round($sm.TotalSize / 1MB, 1) + SizeGB = [math]::Round($sm.TotalSize / 1GB, 3) + LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" } + SubFolders = $subFolders + } + Write-Log "`t $($list.Title): $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files, $($subFolders.Count) folders)" "Cyan" + } + catch { Write-Log "`t Skipped '$($list.Title)': $($_.Exception.Message)" "DarkGray" } + } + } + else { + try { + $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/" + $script:storageResults += [PSCustomObject]@{ + SiteTitle = $wTitle + SiteURL = $wUrl + Library = "(All Libraries)" + LibraryURL = $wUrl + ItemCount = $sm.TotalFileCount + SizeBytes = $sm.TotalSize + SizeMB = [math]::Round($sm.TotalSize / 1MB, 1) + SizeGB = [math]::Round($sm.TotalSize / 1GB, 3) + LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" } + } + Write-Log "`t${wTitle}: $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files)" "Cyan" + } + catch { Write-Log "`tSkipped '${wTitle}': $($_.Exception.Message)" "DarkGray" } + } + + if ($IncludeSubsites) { + $subWebs = Get-PnPSubWeb + foreach ($sub in $subWebs) { + Write-Log "`tProcessing subsite: $($sub.Title)" "Yellow" + Collect-WebStorage $sub.Url + # Reconnect to parent so subsequent sibling subsites resolve correctly + Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD + } + } + } + + Write-Log "Collecting storage metrics for: $SiteURL" "Yellow" + Collect-WebStorage $SiteURL + return $script:storageResults +} + +#endregion + +#region ===== File Search ===== + +function Export-SearchResultsToHTML { + param([array]$Results, [string]$KQL, [string]$SiteUrl) + $rows = "" + foreach ($r in $Results) { + $title = EscHtml ($r.Title -replace '^$', '(sans titre)') + $path = EscHtml ($r.Path -replace '^$', '') + $ext = EscHtml ($r.FileExtension -replace '^$', '') + $created = EscHtml ($r.Created -replace '^$', '') + $modif = EscHtml ($r.LastModifiedTime -replace '^$', '') + $author = EscHtml ($r.Author -replace '^$', '') + $modby = EscHtml ($r.ModifiedBy -replace '^$', '') + $sizeB = [long]($r.Size -replace '[^0-9]','0' -replace '^$','0') + $sizeStr = EscHtml (Format-Bytes $sizeB) + $href = EscHtml $r.Path + $rows += "$title" + $rows += "$ext" + $rows += "$created" + $rows += "$modif" + $rows += "$author$modby" + $rows += "$sizeStr`n" + } + $count = $Results.Count + $date = Get-Date -Format "dd/MM/yyyy HH:mm" + $kqlEsc = EscHtml $KQL + $siteEsc = EscHtml $SiteUrl + +$html = @" + + +Recherche de fichiers + + +
+

Recherche de fichiers SharePoint

+
Site : $siteEsc — Genere le $date
+
+
+
$count
Fichiers trouves
+
+
Requete KQL : $kqlEsc
+
+
+ + + + + + + + + + +$rows +
Nom du fichier Extension Cree le Modifie le Cree par Modifie par Taille
+ + + +"@ + return $html +} + +function Export-DuplicatesToHTML { + param([array]$Groups, [string]$Mode, [string]$SiteUrl) + + $totalItems = ($Groups | ForEach-Object { $_.Items.Count } | Measure-Object -Sum).Sum + $totalGroups = $Groups.Count + $date = Get-Date -Format "dd/MM/yyyy HH:mm" + $modeLabel = if ($Mode -eq "Folders") { "Dossiers" } else { "Fichiers" } + $siteEsc = EscHtml $SiteUrl + + # Build group cards + $cards = "" + $gIdx = 0 + foreach ($grp in $Groups | Sort-Object { $_.Items.Count } -Descending) { + $gIdx++ + $name = EscHtml $grp.Name + $count = $grp.Items.Count + $items = $grp.Items + + # Determine which columns to show + $hasSz = $items[0].PSObject.Properties.Name -contains "SizeBytes" + $hasCr = $items[0].PSObject.Properties.Name -contains "Created" + $hasMod = $items[0].PSObject.Properties.Name -contains "Modified" + $hasSub = $items[0].PSObject.Properties.Name -contains "FolderCount" + $hasFil = $items[0].PSObject.Properties.Name -contains "FileCount" + + # Helper: check if all values in column are identical → green, else orange + $allSame = { param($prop) + $vals = @($items | ForEach-Object { $_.$prop }) + ($vals | Select-Object -Unique).Count -le 1 + } + + $szSame = if ($hasSz) { & $allSame "SizeBytes" } else { $true } + $crSame = if ($hasCr) { & $allSame "CreatedDay" } else { $true } + $modSame = if ($hasMod) { & $allSame "ModifiedDay" } else { $true } + $subSame = if ($hasSub) { & $allSame "FolderCount" } else { $true } + $filSame = if ($hasFil) { & $allSame "FileCount" } else { $true } + + $thSz = if ($hasSz) { "Taille" } else {""} + $thCr = if ($hasCr) { "Cree le" } else {""} + $thMod = if ($hasMod) { "Modifie le" } else {""} + $thSub = if ($hasSub) { "Sous-dossiers" } else {""} + $thFil = if ($hasFil) { "Fichiers" } else {""} + + $rows = "" + foreach ($item in $items) { + $nm = EscHtml $item.Name + $path = EscHtml $item.Path + $href = EscHtml $item.Path + $lib = EscHtml $item.Library + + $tdSz = if ($hasSz) { + $cls = if ($szSame) { "ok" } else { "diff" } + "$(EscHtml (Format-Bytes ([long]$item.SizeBytes)))" + } else {""} + $tdCr = if ($hasCr) { + $cls = if ($crSame) { "ok" } else { "diff" } + "$(EscHtml $item.Created)" + } else {""} + $tdMod = if ($hasMod) { + $cls = if ($modSame) { "ok" } else { "diff" } + "$(EscHtml $item.Modified)" + } else {""} + $tdSub = if ($hasSub) { + $cls = if ($subSame) { "ok" } else { "diff" } + "$($item.FolderCount)" + } else {""} + $tdFil = if ($hasFil) { + $cls = if ($filSame) { "ok" } else { "diff" } + "$($item.FileCount)" + } else {""} + + $rows += "" + $rows += "$nm
$lib" + $rows += "$tdSz$tdCr$tdMod$tdSub$tdFil" + $rows += "`n" + } + + $diffBadge = if ($szSame -and $crSame -and $modSame -and $subSame -and $filSame) { + "Identiques" + } else { + "Differences detectees" + } + + $cards += @" +
+
+ $name + $count occurrences + $diffBadge + +
+
+ +$thSz$thCr$thMod$thSub$thFil +$rows
Chemin
+
+"@ + } + +$html = @" + + +Doublons $modeLabel - SharePoint + + +
+

Doublons de $modeLabel — SharePoint

+
Site : $siteEsc — Genere le $date
+
+
+
$totalGroups
Groupes de doublons
+
$totalItems
$modeLabel en double (total)
+
+
+$cards + + + +"@ + return $html +} + +#endregion + +#region ===== GUI ===== + +$form = New-Object System.Windows.Forms.Form +$form.Text = "SharePoint Exporter v6.0" +$form.Size = New-Object System.Drawing.Size(700, 810) +$form.StartPosition = "CenterScreen" +$form.FormBorderStyle = "FixedDialog" +$form.MaximizeBox = $false +$form.BackColor = [System.Drawing.Color]::WhiteSmoke + +# ── Shared: Client ID ────────────────────────────────────────────────────────── +$lbl = { param($t,$x,$y) + $l = New-Object System.Windows.Forms.Label + $l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y) + $l.Size = New-Object System.Drawing.Size(115,22); $l.TextAlign = "MiddleLeft"; $l +} + +# ── Profile selector ────────────────────────────────────────────────────────── +$lblProfile = (& $lbl "Profil :" 20 22) +$cboProfile = New-Object System.Windows.Forms.ComboBox +$cboProfile.Location = New-Object System.Drawing.Point(140, 20) +$cboProfile.Size = New-Object System.Drawing.Size(248, 24) +$cboProfile.DropDownStyle = "DropDownList" +$cboProfile.Font = New-Object System.Drawing.Font("Segoe UI", 9) + +$btnProfileNew = New-Object System.Windows.Forms.Button +$btnProfileNew.Text = "Creer" +$btnProfileNew.Location = New-Object System.Drawing.Point(396, 19) +$btnProfileNew.Size = New-Object System.Drawing.Size(60, 26) + +$btnProfileSave = New-Object System.Windows.Forms.Button +$btnProfileSave.Text = "Sauver" +$btnProfileSave.Location = New-Object System.Drawing.Point(460, 19) +$btnProfileSave.Size = New-Object System.Drawing.Size(60, 26) + +$btnProfileRename = New-Object System.Windows.Forms.Button +$btnProfileRename.Text = "Renommer" +$btnProfileRename.Location = New-Object System.Drawing.Point(524, 19) +$btnProfileRename.Size = New-Object System.Drawing.Size(72, 26) + +$btnProfileDelete = New-Object System.Windows.Forms.Button +$btnProfileDelete.Text = "Suppr." +$btnProfileDelete.Location = New-Object System.Drawing.Point(600, 19) +$btnProfileDelete.Size = New-Object System.Drawing.Size(62, 26) + +$lblTenantUrl = (& $lbl "Tenant URL :" 20 52) +$txtTenantUrl = New-Object System.Windows.Forms.TextBox +$txtTenantUrl.Location = New-Object System.Drawing.Point(140, 52) +$txtTenantUrl.Size = New-Object System.Drawing.Size(400, 22) +$txtTenantUrl.Font = New-Object System.Drawing.Font("Consolas", 9) + +$btnBrowseSites = New-Object System.Windows.Forms.Button +$btnBrowseSites.Text = "Voir les sites" +$btnBrowseSites.Location = New-Object System.Drawing.Point(548, 50) +$btnBrowseSites.Size = New-Object System.Drawing.Size(92, 26) + +$lblClientId = (& $lbl "Client ID :" 20 84) +$txtClientId = New-Object System.Windows.Forms.TextBox +$txtClientId.Location = New-Object System.Drawing.Point(140, 84) +$txtClientId.Size = New-Object System.Drawing.Size(500, 22) +$txtClientId.Font = New-Object System.Drawing.Font("Consolas", 9) + +$lblSiteURL = (& $lbl "Site URL :" 20 116) +$txtSiteURL = New-Object System.Windows.Forms.TextBox +$txtSiteURL.Location = New-Object System.Drawing.Point(140, 116) +$txtSiteURL.Size = New-Object System.Drawing.Size(500, 22) + +$lblOutput = (& $lbl "Output Folder :" 20 148) +$txtOutput = New-Object System.Windows.Forms.TextBox +$txtOutput.Location = New-Object System.Drawing.Point(140, 148) +$txtOutput.Size = New-Object System.Drawing.Size(408, 22) +$txtOutput.Text = $PWD.Path + +$btnBrowse = New-Object System.Windows.Forms.Button +$btnBrowse.Text = "Browse..." +$btnBrowse.Location = New-Object System.Drawing.Point(558, 146) +$btnBrowse.Size = New-Object System.Drawing.Size(82, 26) + +$sep = New-Object System.Windows.Forms.Panel +$sep.Location = New-Object System.Drawing.Point(20, 182) +$sep.Size = New-Object System.Drawing.Size(642, 1) +$sep.BackColor = [System.Drawing.Color]::LightGray + +# ── TabControl ───────────────────────────────────────────────────────────────── +$tabs = New-Object System.Windows.Forms.TabControl +$tabs.Location = New-Object System.Drawing.Point(10, 190) +$tabs.Size = New-Object System.Drawing.Size(662, 310) +$tabs.Font = New-Object System.Drawing.Font("Segoe UI", 9) + +# helper: GroupBox +function New-Group($text, $x, $y, $w, $h) { + $g = New-Object System.Windows.Forms.GroupBox + $g.Text = $text; $g.Location = New-Object System.Drawing.Point($x,$y) + $g.Size = New-Object System.Drawing.Size($w,$h); $g +} +# helper: CheckBox +function New-Check($text, $x, $y, $w, $checked=$false) { + $c = New-Object System.Windows.Forms.CheckBox + $c.Text = $text; $c.Location = New-Object System.Drawing.Point($x,$y) + $c.Size = New-Object System.Drawing.Size($w,22); $c.Checked = $checked; $c +} +# helper: RadioButton +function New-Radio($text, $x, $y, $w, $checked=$false) { + $r = New-Object System.Windows.Forms.RadioButton + $r.Text = $text; $r.Location = New-Object System.Drawing.Point($x,$y) + $r.Size = New-Object System.Drawing.Size($w,22); $r.Checked = $checked; $r +} +# helper: Action button +function New-ActionBtn($text, $x, $y, $color) { + $b = New-Object System.Windows.Forms.Button + $b.Text = $text; $b.Location = New-Object System.Drawing.Point($x,$y) + $b.Size = New-Object System.Drawing.Size(155,34); $b.BackColor = $color + $b.ForeColor = [System.Drawing.Color]::White; $b.FlatStyle = "Flat" + $b.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Bold); $b +} + +# ══ Tab 1: Permissions ════════════════════════════════════════════════════════ +$tabPerms = New-Object System.Windows.Forms.TabPage +$tabPerms.Text = " Permissions Report " +$tabPerms.BackColor = [System.Drawing.Color]::WhiteSmoke + +$grpPermOpts = New-Group "Scan Options" 10 10 615 96 +$chkScanFolders = New-Check "Scan Folders" 15 24 150 $true +$chkRecursive = New-Check "Recursive (subsites)" 175 24 185 + +# Folder depth controls (only active when Scan Folders is checked) +$lblPermDepth = New-Object System.Windows.Forms.Label +$lblPermDepth.Text = "Folder depth :" +$lblPermDepth.Location = New-Object System.Drawing.Point(15, 50) +$lblPermDepth.Size = New-Object System.Drawing.Size(100, 22) +$lblPermDepth.TextAlign = "MiddleLeft" + +$nudPermDepth = New-Object System.Windows.Forms.NumericUpDown +$nudPermDepth.Location = New-Object System.Drawing.Point(118, 50) +$nudPermDepth.Size = New-Object System.Drawing.Size(52, 22) +$nudPermDepth.Minimum = 1 +$nudPermDepth.Maximum = 20 +$nudPermDepth.Value = 1 + +$chkPermMaxDepth = New-Object System.Windows.Forms.CheckBox +$chkPermMaxDepth.Text = "Maximum (all levels)" +$chkPermMaxDepth.Location = New-Object System.Drawing.Point(182, 52) +$chkPermMaxDepth.Size = New-Object System.Drawing.Size(180, 20) + +$chkInheritedPerms = New-Check "Include Inherited Permissions" 15 74 230 +$grpPermOpts.Controls.AddRange(@($chkScanFolders, $chkRecursive, $lblPermDepth, $nudPermDepth, $chkPermMaxDepth, $chkInheritedPerms)) + +# Disable depth controls when Scan Folders is unchecked +$chkScanFolders.Add_CheckedChanged({ + $on = $chkScanFolders.Checked + $lblPermDepth.Enabled = $on + $nudPermDepth.Enabled = $on -and -not $chkPermMaxDepth.Checked + $chkPermMaxDepth.Enabled = $on +}) +# When Maximum is checked, grey out the spinner +$chkPermMaxDepth.Add_CheckedChanged({ + $nudPermDepth.Enabled = $chkScanFolders.Checked -and -not $chkPermMaxDepth.Checked +}) + +$grpPermFmt = New-Group "Export Format" 10 114 615 58 +$radPermCSV = New-Radio "CSV — raw data, Excel-friendly" 15 24 280 $true +$radPermHTML = New-Radio "HTML — visual report, client-friendly" 305 24 290 +$grpPermFmt.Controls.AddRange(@($radPermCSV, $radPermHTML)) + +$btnGenPerms = New-ActionBtn "Generate Report" 10 184 ([System.Drawing.Color]::SteelBlue) +$btnOpenPerms = New-Object System.Windows.Forms.Button +$btnOpenPerms.Text = "Open Report" +$btnOpenPerms.Location = New-Object System.Drawing.Point(175, 184) +$btnOpenPerms.Size = New-Object System.Drawing.Size(120, 34) +$btnOpenPerms.Enabled = $false + +$tabPerms.Controls.AddRange(@($grpPermOpts, $grpPermFmt, $btnGenPerms, $btnOpenPerms)) + +# ══ Tab 2: Storage Metrics ════════════════════════════════════════════════════ +$tabStorage = New-Object System.Windows.Forms.TabPage +$tabStorage.Text = " Storage Metrics " +$tabStorage.BackColor = [System.Drawing.Color]::WhiteSmoke + +$grpStorOpts = New-Group "Scan Options" 10 10 615 108 +$chkStorPerLib = New-Check "Per-Library Breakdown" 15 24 200 $true +$chkStorSubsites = New-Check "Include Subsites" 230 24 170 + +# Folder depth controls (only relevant in per-library mode) +$lblDepth = New-Object System.Windows.Forms.Label +$lblDepth.Text = "Folder depth :" +$lblDepth.Location = New-Object System.Drawing.Point(15, 52) +$lblDepth.Size = New-Object System.Drawing.Size(100, 22) +$lblDepth.TextAlign = "MiddleLeft" + +$nudDepth = New-Object System.Windows.Forms.NumericUpDown +$nudDepth.Location = New-Object System.Drawing.Point(118, 52) +$nudDepth.Size = New-Object System.Drawing.Size(52, 22) +$nudDepth.Minimum = 1 +$nudDepth.Maximum = 20 +$nudDepth.Value = 1 + +$chkMaxDepth = New-Object System.Windows.Forms.CheckBox +$chkMaxDepth.Text = "Maximum (all levels)" +$chkMaxDepth.Location = New-Object System.Drawing.Point(182, 54) +$chkMaxDepth.Size = New-Object System.Drawing.Size(180, 20) + +$lblStorNote = New-Object System.Windows.Forms.Label +$lblStorNote.Text = "Note: deeper folder scans on large sites may take several minutes." +$lblStorNote.Location = New-Object System.Drawing.Point(15, 80) +$lblStorNote.Size = New-Object System.Drawing.Size(580, 18) +$lblStorNote.ForeColor = [System.Drawing.Color]::Gray +$lblStorNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic) + +$grpStorOpts.Controls.AddRange(@($chkStorPerLib, $chkStorSubsites, $lblDepth, $nudDepth, $chkMaxDepth, $lblStorNote)) + +$grpStorFmt = New-Group "Export Format" 10 128 615 58 +$radStorCSV = New-Radio "CSV — raw data, Excel-friendly" 15 24 280 $true +$radStorHTML = New-Radio "HTML — visual report, client-friendly" 305 24 290 +$grpStorFmt.Controls.AddRange(@($radStorCSV, $radStorHTML)) + +$msGreen = [System.Drawing.Color]::FromArgb(16,124,16) +$btnGenStorage = New-ActionBtn "Generate Metrics" 10 200 $msGreen +$btnOpenStorage = New-Object System.Windows.Forms.Button +$btnOpenStorage.Text = "Open Report" +$btnOpenStorage.Location = New-Object System.Drawing.Point(175, 200) +$btnOpenStorage.Size = New-Object System.Drawing.Size(120, 34) +$btnOpenStorage.Enabled = $false + +# Disable depth controls when Per-Library is unchecked +$chkStorPerLib.Add_CheckedChanged({ + $on = $chkStorPerLib.Checked + $lblDepth.Enabled = $on + $nudDepth.Enabled = $on -and -not $chkMaxDepth.Checked + $chkMaxDepth.Enabled = $on +}) +# When Maximum is checked, grey out the spinner +$chkMaxDepth.Add_CheckedChanged({ + $nudDepth.Enabled = $chkStorPerLib.Checked -and -not $chkMaxDepth.Checked +}) + +$tabStorage.Controls.AddRange(@($grpStorOpts, $grpStorFmt, $btnGenStorage, $btnOpenStorage)) + +# ══ Tab 3: Templates ══════════════════════════════════════════════════════ +$tabTemplates = New-Object System.Windows.Forms.TabPage +$tabTemplates.Text = " Templates " +$tabTemplates.BackColor = [System.Drawing.Color]::WhiteSmoke + +$lblTplDesc = New-Object System.Windows.Forms.Label +$lblTplDesc.Text = "Creez des templates depuis un site existant et appliquez-les pour creer de nouveaux sites." +$lblTplDesc.Location = New-Object System.Drawing.Point(10, 18) +$lblTplDesc.Size = New-Object System.Drawing.Size(580, 20) +$lblTplDesc.ForeColor = [System.Drawing.Color]::DimGray + +$lblTplCount = New-Object System.Windows.Forms.Label +$lblTplCount.Name = "lblTplCount" +$lblTplCount.Location = New-Object System.Drawing.Point(10, 44) +$lblTplCount.Size = New-Object System.Drawing.Size(380, 20) +$lblTplCount.ForeColor = [System.Drawing.Color]::DimGray +$lblTplCount.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic) + +$btnOpenTplMgr = New-Object System.Windows.Forms.Button +$btnOpenTplMgr.Text = "Gerer les templates..." +$btnOpenTplMgr.Location = New-Object System.Drawing.Point(10, 72) +$btnOpenTplMgr.Size = New-Object System.Drawing.Size(185, 34) +$btnOpenTplMgr.BackColor = [System.Drawing.Color]::FromArgb(50, 50, 120) +$btnOpenTplMgr.ForeColor = [System.Drawing.Color]::White +$btnOpenTplMgr.FlatStyle = "Flat" +$btnOpenTplMgr.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + +$tabTemplates.Controls.AddRange(@($lblTplDesc, $lblTplCount, $btnOpenTplMgr)) + +# ══ Tab 4: Recherche de fichiers ══════════════════════════════════════════════ +$tabSearch = New-Object System.Windows.Forms.TabPage +$tabSearch.Text = " Recherche de fichiers " +$tabSearch.BackColor = [System.Drawing.Color]::WhiteSmoke + +# ── GroupBox Filtres ─────────────────────────────────────────────────────────── +$grpSearchFilters = New-Group "Filtres de recherche" 10 6 620 170 + +# Row 1 — Extension & Regex +$lblSrchExt = New-Object System.Windows.Forms.Label +$lblSrchExt.Text = "Extension(s) :" +$lblSrchExt.Location = New-Object System.Drawing.Point(10, 24) +$lblSrchExt.Size = New-Object System.Drawing.Size(88, 22) +$lblSrchExt.TextAlign = "MiddleLeft" +$txtSrchExt = New-Object System.Windows.Forms.TextBox +$txtSrchExt.Location = New-Object System.Drawing.Point(100, 24) +$txtSrchExt.Size = New-Object System.Drawing.Size(120, 22) +$txtSrchExt.Font = New-Object System.Drawing.Font("Consolas", 9) +$txtSrchExt.PlaceholderText = "docx pdf xlsx" + +$lblSrchRegex = New-Object System.Windows.Forms.Label +$lblSrchRegex.Text = "Nom / Regex :" +$lblSrchRegex.Location = New-Object System.Drawing.Point(232, 24) +$lblSrchRegex.Size = New-Object System.Drawing.Size(88, 22) +$lblSrchRegex.TextAlign = "MiddleLeft" +$txtSrchRegex = New-Object System.Windows.Forms.TextBox +$txtSrchRegex.Location = New-Object System.Drawing.Point(322, 24) +$txtSrchRegex.Size = New-Object System.Drawing.Size(286, 22) +$txtSrchRegex.Font = New-Object System.Drawing.Font("Consolas", 9) +$txtSrchRegex.PlaceholderText = "Ex: rapport.* ou \.bak$" + +# Row 2 — Created dates +$chkSrchCrA = New-Object System.Windows.Forms.CheckBox +$chkSrchCrA.Text = "Cree apres le :" +$chkSrchCrA.Location = New-Object System.Drawing.Point(10, 52) +$chkSrchCrA.Size = New-Object System.Drawing.Size(108, 22) +$dtpSrchCrA = New-Object System.Windows.Forms.DateTimePicker +$dtpSrchCrA.Location = New-Object System.Drawing.Point(120, 52) +$dtpSrchCrA.Size = New-Object System.Drawing.Size(130, 22) +$dtpSrchCrA.Format = [System.Windows.Forms.DateTimePickerFormat]::Short +$dtpSrchCrA.Enabled = $false + +$chkSrchCrB = New-Object System.Windows.Forms.CheckBox +$chkSrchCrB.Text = "Cree avant le :" +$chkSrchCrB.Location = New-Object System.Drawing.Point(262, 52) +$chkSrchCrB.Size = New-Object System.Drawing.Size(108, 22) +$dtpSrchCrB = New-Object System.Windows.Forms.DateTimePicker +$dtpSrchCrB.Location = New-Object System.Drawing.Point(372, 52) +$dtpSrchCrB.Size = New-Object System.Drawing.Size(130, 22) +$dtpSrchCrB.Format = [System.Windows.Forms.DateTimePickerFormat]::Short +$dtpSrchCrB.Enabled = $false + +$chkSrchCrA.Add_CheckedChanged({ $dtpSrchCrA.Enabled = $chkSrchCrA.Checked }) +$chkSrchCrB.Add_CheckedChanged({ $dtpSrchCrB.Enabled = $chkSrchCrB.Checked }) + +# Row 3 — Modified dates +$chkSrchModA = New-Object System.Windows.Forms.CheckBox +$chkSrchModA.Text = "Modifie apres :" +$chkSrchModA.Location = New-Object System.Drawing.Point(10, 80) +$chkSrchModA.Size = New-Object System.Drawing.Size(108, 22) +$dtpSrchModA = New-Object System.Windows.Forms.DateTimePicker +$dtpSrchModA.Location = New-Object System.Drawing.Point(120, 80) +$dtpSrchModA.Size = New-Object System.Drawing.Size(130, 22) +$dtpSrchModA.Format = [System.Windows.Forms.DateTimePickerFormat]::Short +$dtpSrchModA.Enabled = $false + +$chkSrchModB = New-Object System.Windows.Forms.CheckBox +$chkSrchModB.Text = "Modifie avant :" +$chkSrchModB.Location = New-Object System.Drawing.Point(262, 80) +$chkSrchModB.Size = New-Object System.Drawing.Size(108, 22) +$dtpSrchModB = New-Object System.Windows.Forms.DateTimePicker +$dtpSrchModB.Location = New-Object System.Drawing.Point(372, 80) +$dtpSrchModB.Size = New-Object System.Drawing.Size(130, 22) +$dtpSrchModB.Format = [System.Windows.Forms.DateTimePickerFormat]::Short +$dtpSrchModB.Enabled = $false + +$chkSrchModA.Add_CheckedChanged({ $dtpSrchModA.Enabled = $chkSrchModA.Checked }) +$chkSrchModB.Add_CheckedChanged({ $dtpSrchModB.Enabled = $chkSrchModB.Checked }) + +# Row 4 — Created by / Modified by +$lblSrchCrBy = New-Object System.Windows.Forms.Label +$lblSrchCrBy.Text = "Cree par :" +$lblSrchCrBy.Location = New-Object System.Drawing.Point(10, 108) +$lblSrchCrBy.Size = New-Object System.Drawing.Size(70, 22) +$lblSrchCrBy.TextAlign = "MiddleLeft" +$txtSrchCrBy = New-Object System.Windows.Forms.TextBox +$txtSrchCrBy.Location = New-Object System.Drawing.Point(82, 108) +$txtSrchCrBy.Size = New-Object System.Drawing.Size(168, 22) +$txtSrchCrBy.PlaceholderText = "Prenom Nom ou email" + +$lblSrchModBy = New-Object System.Windows.Forms.Label +$lblSrchModBy.Text = "Modifie par :" +$lblSrchModBy.Location = New-Object System.Drawing.Point(262, 108) +$lblSrchModBy.Size = New-Object System.Drawing.Size(82, 22) +$lblSrchModBy.TextAlign = "MiddleLeft" +$txtSrchModBy = New-Object System.Windows.Forms.TextBox +$txtSrchModBy.Location = New-Object System.Drawing.Point(346, 108) +$txtSrchModBy.Size = New-Object System.Drawing.Size(168, 22) +$txtSrchModBy.PlaceholderText = "Prenom Nom ou email" + +# Row 5 — Library filter +$lblSrchLib = New-Object System.Windows.Forms.Label +$lblSrchLib.Text = "Bibliotheque :" +$lblSrchLib.Location = New-Object System.Drawing.Point(10, 136) +$lblSrchLib.Size = New-Object System.Drawing.Size(88, 22) +$lblSrchLib.TextAlign = "MiddleLeft" +$txtSrchLib = New-Object System.Windows.Forms.TextBox +$txtSrchLib.Location = New-Object System.Drawing.Point(100, 136) +$txtSrchLib.Size = New-Object System.Drawing.Size(508, 22) +$txtSrchLib.PlaceholderText = "Chemin relatif optionnel ex: Documents partages" + +$grpSearchFilters.Controls.AddRange(@( + $lblSrchExt, $txtSrchExt, $lblSrchRegex, $txtSrchRegex, + $chkSrchCrA, $dtpSrchCrA, $chkSrchCrB, $dtpSrchCrB, + $chkSrchModA, $dtpSrchModA, $chkSrchModB, $dtpSrchModB, + $lblSrchCrBy, $txtSrchCrBy, $lblSrchModBy, $txtSrchModBy, + $lblSrchLib, $txtSrchLib +)) + +# ── GroupBox Format ──────────────────────────────────────────────────────────── +$grpSearchFmt = New-Group "Format d'export" 10 180 620 48 +$radSrchCSV = New-Radio "CSV (Excel)" 15 22 130 $true +$radSrchHTML = New-Radio "HTML (rapport visuel)" 160 22 180 +$lblSrchMax = New-Object System.Windows.Forms.Label +$lblSrchMax.Text = "Max resultats :" +$lblSrchMax.Location = New-Object System.Drawing.Point(360, 22) +$lblSrchMax.Size = New-Object System.Drawing.Size(96, 22) +$lblSrchMax.TextAlign = "MiddleLeft" +$nudSrchMax = New-Object System.Windows.Forms.NumericUpDown +$nudSrchMax.Location = New-Object System.Drawing.Point(458, 22) +$nudSrchMax.Size = New-Object System.Drawing.Size(70, 22) +$nudSrchMax.Minimum = 10 +$nudSrchMax.Maximum = 50000 +$nudSrchMax.Value = 500 +$nudSrchMax.Increment = 100 +$grpSearchFmt.Controls.AddRange(@($radSrchCSV, $radSrchHTML, $lblSrchMax, $nudSrchMax)) + +# ── Buttons ──────────────────────────────────────────────────────────────────── +$btnSearch = New-ActionBtn "Lancer la recherche" 10 232 ([System.Drawing.Color]::FromArgb(0, 120, 212)) +$btnOpenSearch = New-Object System.Windows.Forms.Button +$btnOpenSearch.Text = "Ouvrir resultats" +$btnOpenSearch.Location = New-Object System.Drawing.Point(175, 232) +$btnOpenSearch.Size = New-Object System.Drawing.Size(130, 34) +$btnOpenSearch.Enabled = $false + +$tabSearch.Controls.AddRange(@($grpSearchFilters, $grpSearchFmt, $btnSearch, $btnOpenSearch)) + +# ══ Tab 5: Doublons ═══════════════════════════════════════════════════════════ +$tabDupes = New-Object System.Windows.Forms.TabPage +$tabDupes.Text = " Doublons " +$tabDupes.BackColor = [System.Drawing.Color]::WhiteSmoke + +# ── GroupBox: Type de doublons (y=4, h=44 → bottom 48) ────────────────────── +$grpDupType = New-Group "Type de doublons" 10 4 638 44 +$radDupFiles = New-Radio "Fichiers en double" 10 16 190 $true +$radDupFolders = New-Radio "Dossiers en double" 210 16 190 +$grpDupType.Controls.AddRange(@($radDupFiles, $radDupFolders)) + +# ── GroupBox: Critères de comparaison (y=52, h=88 → bottom 140) ───────────── +$grpDupCrit = New-Group "Criteres de comparaison" 10 52 638 88 + +$lblDupNote = New-Object System.Windows.Forms.Label +$lblDupNote.Text = "Le nom est toujours le critere principal. Cochez les criteres supplementaires :" +$lblDupNote.Location = New-Object System.Drawing.Point(10, 15) +$lblDupNote.Size = New-Object System.Drawing.Size(610, 16) +$lblDupNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic) +$lblDupNote.ForeColor = [System.Drawing.Color]::DimGray + +# Row 1 — criteres communs +$chkDupSize = New-Check "Taille identique" 10 34 148 $true +$chkDupCreated = New-Check "Date de creation identique" 164 34 208 +$chkDupModified = New-Check "Date de modification identique" 378 34 226 + +# Row 2 — criteres dossiers uniquement +$chkDupSubCount = New-Check "Nb sous-dossiers identique" 10 60 210 +$chkDupFileCount = New-Check "Nb fichiers identique" 226 60 200 +$chkDupSubCount.Enabled = $false +$chkDupFileCount.Enabled = $false + +$grpDupCrit.Controls.AddRange(@($lblDupNote, + $chkDupSize, $chkDupCreated, $chkDupModified, + $chkDupSubCount, $chkDupFileCount)) + +# Toggle folder-only criteria based on radio selection +$radDupFiles.Add_CheckedChanged({ + $chkDupSubCount.Enabled = -not $radDupFiles.Checked + $chkDupFileCount.Enabled = -not $radDupFiles.Checked + if ($radDupFiles.Checked) { $chkDupSubCount.Checked = $false; $chkDupFileCount.Checked = $false } +}) +$radDupFolders.Add_CheckedChanged({ + $chkDupSubCount.Enabled = $radDupFolders.Checked + $chkDupFileCount.Enabled = $radDupFolders.Checked +}) + +# ── GroupBox: Options (y=144, h=44 → bottom 188) ───────────────────────────── +$grpDupOpts = New-Group "Options" 10 144 638 44 +$chkDupSubsites = New-Check "Inclure les sous-sites" 10 18 192 +$lblDupLib = New-Object System.Windows.Forms.Label +$lblDupLib.Text = "Bibliotheque :" +$lblDupLib.Location = New-Object System.Drawing.Point(210, 18) +$lblDupLib.Size = New-Object System.Drawing.Size(88, 22) +$lblDupLib.TextAlign = "MiddleLeft" +$txtDupLib = New-Object System.Windows.Forms.TextBox +$txtDupLib.Location = New-Object System.Drawing.Point(300, 18) +$txtDupLib.Size = New-Object System.Drawing.Size(326, 22) +$txtDupLib.PlaceholderText = "Toutes (laisser vide)" +$grpDupOpts.Controls.AddRange(@($chkDupSubsites, $lblDupLib, $txtDupLib)) + +# ── GroupBox: Format (y=192, h=40 → bottom 232) ────────────────────────────── +$grpDupFmt = New-Group "Format d'export" 10 192 638 40 +$radDupCSV = New-Radio "CSV (Excel)" 10 16 130 $true +$radDupHTML = New-Radio "HTML (rapport visuel)" 155 16 200 +$grpDupFmt.Controls.AddRange(@($radDupCSV, $radDupHTML)) + +# ── Buttons (y=236 → bottom 270, within 284px inner) ───────────────────────── +$btnScanDupes = New-ActionBtn "Lancer le scan" 10 236 ([System.Drawing.Color]::FromArgb(136, 0, 21)) +$btnOpenDupes = New-Object System.Windows.Forms.Button +$btnOpenDupes.Text = "Ouvrir resultats" +$btnOpenDupes.Location = New-Object System.Drawing.Point(175, 236) +$btnOpenDupes.Size = New-Object System.Drawing.Size(130, 34) +$btnOpenDupes.Enabled = $false + +$tabDupes.Controls.AddRange(@($grpDupType, $grpDupCrit, $grpDupOpts, $grpDupFmt, $btnScanDupes, $btnOpenDupes)) + +$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes)) + +# ── Progress bar ─────────────────────────────────────────────────────────────── +$progressBar = New-Object System.Windows.Forms.ProgressBar +$progressBar.Location = New-Object System.Drawing.Point(20, 510) +$progressBar.Size = New-Object System.Drawing.Size(642, 16) +$progressBar.Style = "Marquee" +$progressBar.MarqueeAnimationSpeed = 0 + +# ── Log ──────────────────────────────────────────────────────────────────────── +$lblLog = New-Object System.Windows.Forms.Label +$lblLog.Text = "Log :" +$lblLog.Location = New-Object System.Drawing.Point(20, 534) +$lblLog.Size = New-Object System.Drawing.Size(60, 20) + +$txtLog = New-Object System.Windows.Forms.RichTextBox +$txtLog.Location = New-Object System.Drawing.Point(20, 554) +$txtLog.Size = New-Object System.Drawing.Size(642, 208) +$txtLog.ReadOnly = $true +$txtLog.BackColor = [System.Drawing.Color]::Black +$txtLog.ForeColor = [System.Drawing.Color]::LightGreen +$txtLog.Font = New-Object System.Drawing.Font("Consolas", 9) +$txtLog.ScrollBars = "Vertical" + +$script:LogBox = $txtLog +$script:txtClientId = $txtClientId +$script:txtSiteURL = $txtSiteURL +$script:txtTenantUrl = $txtTenantUrl +$script:cboProfile = $cboProfile +$script:btnBrowseSites = $btnBrowseSites +$script:Profiles = @() +$script:SelectedSites = @() + +$form.Controls.AddRange(@( + $lblProfile, $cboProfile, + $btnProfileNew, $btnProfileSave, $btnProfileRename, $btnProfileDelete, + $lblTenantUrl, $txtTenantUrl, $btnBrowseSites, + $lblClientId, $txtClientId, + $lblSiteURL, $txtSiteURL, + $lblOutput, $txtOutput, $btnBrowse, + $sep, $tabs, + $progressBar, + $lblLog, $txtLog +)) + +#endregion + +#region ===== Event Handlers ===== + +# ── Profile Management ───────────────────────────────────────────────────────── +$cboProfile.Add_SelectedIndexChanged({ + $idx = $cboProfile.SelectedIndex + Apply-Profile -idx $idx +}) + +$btnProfileNew.Add_Click({ + $name = Show-InputDialog -Prompt "Nom du profil :" -Title "Nouveau profil" -Default "Nouveau profil" -Owner $form + if ([string]::IsNullOrWhiteSpace($name)) { return } + $newProfile = [PSCustomObject]@{ + name = $name + clientId = $txtClientId.Text.Trim() + tenantUrl = $txtTenantUrl.Text.Trim() + } + $list = @($script:Profiles) + $newProfile + Save-Profiles -Profiles $list + Refresh-ProfileList + $idx = $cboProfile.Items.IndexOf($name) + if ($idx -ge 0) { $cboProfile.SelectedIndex = $idx } +}) + +$btnProfileSave.Add_Click({ + $idx = $cboProfile.SelectedIndex + if ($idx -lt 0) { + [System.Windows.Forms.MessageBox]::Show("Selectionnez d'abord un profil ou creez-en un nouveau.", "Aucun profil selectionne", "OK", "Warning") + return + } + $script:Profiles[$idx].clientId = $txtClientId.Text.Trim() + if (-not $script:Profiles[$idx].PSObject.Properties['tenantUrl']) { + $script:Profiles[$idx] | Add-Member -NotePropertyName tenantUrl -NotePropertyValue "" + } + $script:Profiles[$idx].tenantUrl = $txtTenantUrl.Text.Trim() + Save-Profiles -Profiles $script:Profiles + [System.Windows.Forms.MessageBox]::Show("Profil '$($script:Profiles[$idx].name)' sauvegarde.", "Sauvegarde", "OK", "Information") +}) + +$btnProfileRename.Add_Click({ + $idx = $cboProfile.SelectedIndex + if ($idx -lt 0) { return } + $oldName = $script:Profiles[$idx].name + $newName = Show-InputDialog -Prompt "Nouveau nom du profil :" -Title "Renommer le profil" -Default $oldName -Owner $form + if ([string]::IsNullOrWhiteSpace($newName) -or $newName -eq $oldName) { return } + $script:Profiles[$idx].name = $newName + Save-Profiles -Profiles $script:Profiles + Refresh-ProfileList + $idx2 = $cboProfile.Items.IndexOf($newName) + if ($idx2 -ge 0) { $cboProfile.SelectedIndex = $idx2 } +}) + +$btnProfileDelete.Add_Click({ + $idx = $cboProfile.SelectedIndex + if ($idx -lt 0) { return } + $name = $script:Profiles[$idx].name + $res = [System.Windows.Forms.MessageBox]::Show("Supprimer le profil '$name' ?", "Confirmer la suppression", "YesNo", "Warning") + if ($res -ne "Yes") { return } + $list = @($script:Profiles | Where-Object { $_.name -ne $name }) + Save-Profiles -Profiles $list + Refresh-ProfileList +}) + +$btnBrowse.Add_Click({ + $dlg = New-Object System.Windows.Forms.FolderBrowserDialog + $dlg.SelectedPath = $txtOutput.Text + if ($dlg.ShowDialog() -eq "OK") { $txtOutput.Text = $dlg.SelectedPath } +}) + +$btnBrowseSites.Add_Click({ + $tenantUrl = $txtTenantUrl.Text.Trim() + $clientId = $txtClientId.Text.Trim() + if ([string]::IsNullOrWhiteSpace($tenantUrl)) { + [System.Windows.Forms.MessageBox]::Show( + "Veuillez renseigner le Tenant URL (ex: https://contoso.sharepoint.com).", + "Tenant URL manquant", "OK", "Warning") + return + } + if ([string]::IsNullOrWhiteSpace($clientId)) { + [System.Windows.Forms.MessageBox]::Show( + "Veuillez renseigner le Client ID.", "Client ID manquant", "OK", "Warning") + return + } + $selected = Show-SitePicker -TenantUrl $tenantUrl -ClientId $clientId -Owner $form + if ($selected -and $selected.Count -gt 0) { + $script:SelectedSites = @($selected) + # Populate Site URL with first selection for compatibility + $txtSiteURL.Text = $selected[0] + $n = $selected.Count + $btnBrowseSites.Text = if ($n -eq 1) { "Voir les sites" } else { "Sites ($n)" } + } +}) + +# ── Permissions ──────────────────────────────────────────────────────────────── +$btnGenPerms.Add_Click({ + if (-not (Validate-Inputs)) { return } + + # Build site list: picker selection takes priority, else txtSiteURL + $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) { + @($script:SelectedSites) + } else { + @($txtSiteURL.Text.Trim()) + } + $siteUrls = @($siteUrls | Where-Object { $_ }) + + $script:pnpCiD = $txtClientId.Text.Trim() + $script:PermFormat = if ($radPermHTML.Checked) { "HTML" } else { "CSV" } + + $script:PermFolderDepth = if (-not $chkScanFolders.Checked) { 0 } + elseif ($chkPermMaxDepth.Checked) { 999 } + else { [int]$nudPermDepth.Value } + + $btnGenPerms.Enabled = $false + $btnOpenPerms.Enabled = $false + $txtLog.Clear() + $progressBar.MarqueeAnimationSpeed = 30 + + $depthLabel = if ($script:PermFolderDepth -ge 999) { "Maximum" } elseif ($script:PermFolderDepth -eq 0) { "N/A (folder scan off)" } else { $script:PermFolderDepth } + Write-Log "=== PERMISSIONS REPORT ===" "White" + Write-Log "Sites : $($siteUrls.Count)" "Gray" + Write-Log "Format : $($script:PermFormat)" "Gray" + Write-Log "Folder depth : $depthLabel" "Gray" + Write-Log ("-" * 52) "DarkGray" + + $lastFile = $null + foreach ($SiteURL in $siteUrls) { + $SiteName = ($SiteURL -split '/')[-1]; if (!$SiteName) { $SiteName = "root" } + $csvPath = Join-Path $txtOutput.Text "$SiteName-Permissions.csv" + $script:PermOutputFile = $csvPath + $script:AllPermissions = @() + + Write-Log "" + Write-Log "--- Site: $SiteURL" "White" + + $params = @{ SiteURL = $SiteURL; ReportFile = $csvPath } + if ($chkScanFolders.Checked) { $params.ScanFolders = $true } + if ($chkRecursive.Checked) { $params.Recursive = $true } + if ($chkInheritedPerms.Checked) { $params.IncludeInheritedPermissions = $true } + + try { + Generate-PnPSitePermissionRpt @params + Write-Log ("-" * 52) "DarkGray" + Write-Log "Done! $($script:AllPermissions.Count) entries -- Saved: $csvPath" "Cyan" + $lastFile = $csvPath + $btnOpenPerms.Enabled = $true + } + catch { Write-Log "Failed ($SiteURL): $($_.Exception.Message)" "Red" } + } + + $script:PermOutputFile = $lastFile + $btnGenPerms.Enabled = $true + $progressBar.MarqueeAnimationSpeed = 0 +}) + +$btnOpenPerms.Add_Click({ + if ($script:PermOutputFile -and (Test-Path $script:PermOutputFile)) { + Start-Process $script:PermOutputFile + } +}) + +# ── Storage ──────────────────────────────────────────────────────────────────── + +# Background worker scriptblock (shared across all site scans) +$script:_StorBgWork = { + param($SiteURL, $ClientId, $InclSub, $PerLib, $FolderDepth, $Sync) + + $script:_bgSync = $Sync + $script:_bgDepth = $FolderDepth + + function BgLog([string]$msg, [string]$color = "LightGreen") { + $script:_bgSync.Queue.Enqueue([PSCustomObject]@{ Text = $msg; Color = $color }) + } + function Format-Bytes([long]$b) { + if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" } + if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" } + if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" } + return "$b B" + } + function Collect-FolderStorage([string]$SiteRelUrl, [string]$WebBaseUrl, [int]$Depth) { + if ($Depth -ge $script:_bgDepth) { return @() } + $out = [System.Collections.Generic.List[object]]::new() + try { + $items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue + foreach ($fi in $items) { + $childUrl = "$SiteRelUrl/$($fi.Name)" + try { + $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $childUrl -ErrorAction SilentlyContinue + $sub = Collect-FolderStorage -SiteRelUrl $childUrl -WebBaseUrl $WebBaseUrl -Depth ($Depth + 1) + $out.Add([PSCustomObject]@{ + Name = $fi.Name + URL = "$($WebBaseUrl.TrimEnd('/'))/$childUrl" + ItemCount = $sm.TotalFileCount + SizeBytes = $sm.TotalSize + VersionSizeBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize) + LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" } + SubFolders = $sub + }) + } catch {} + } + } catch {} + return @($out) + } + $script:_bgResults = [System.Collections.Generic.List[object]]::new() + function Collect-WebStorage([string]$WebUrl, [bool]$PerLib, [bool]$InclSub, [string]$ClientId) { + Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId + $web = Get-PnPWeb + $wUrl = $web.Url + $wSrl = $web.ServerRelativeUrl.TrimEnd('/') + BgLog "Site : $($web.Title)" "Yellow" + if ($PerLib) { + $lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden } + BgLog "`t$($lists.Count) bibliotheque(s) trouvee(s)" "Gray" + foreach ($list in $lists) { + $rf = Get-PnPProperty -ClientObject $list -Property RootFolder + try { + $srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/') + $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $srl + $libUrl = "$($wUrl.TrimEnd('/'))/$srl" + $subs = Collect-FolderStorage -SiteRelUrl $srl -WebBaseUrl $wUrl -Depth 0 + $verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize) + $script:_bgResults.Add([PSCustomObject]@{ + SiteTitle = $web.Title + SiteURL = $wUrl + Library = $list.Title + LibraryURL = $libUrl + ItemCount = $sm.TotalFileCount + SizeBytes = $sm.TotalSize + VersionSizeBytes = $verBytes + SizeMB = [math]::Round($sm.TotalSize / 1MB, 1) + VersionSizeMB = [math]::Round($verBytes / 1MB, 1) + SizeGB = [math]::Round($sm.TotalSize / 1GB, 3) + LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" } + SubFolders = $subs + }) + BgLog "`t $($list.Title): $(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] ($($sm.TotalFileCount) files)" "Cyan" + } catch { BgLog "`t '$($list.Title)' skipped: $($_.Exception.Message)" "DarkGray" } + } + } else { + try { + $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/" + $verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize) + $script:_bgResults.Add([PSCustomObject]@{ + SiteTitle = $web.Title + SiteURL = $wUrl + Library = "(All Libraries)" + LibraryURL = $wUrl + ItemCount = $sm.TotalFileCount + SizeBytes = $sm.TotalSize + VersionSizeBytes = $verBytes + SizeMB = [math]::Round($sm.TotalSize / 1MB, 1) + VersionSizeMB = [math]::Round($verBytes / 1MB, 1) + SizeGB = [math]::Round($sm.TotalSize / 1GB, 3) + LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" } + }) + BgLog "`t$(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] -- $($sm.TotalFileCount) files" "Cyan" + } catch { BgLog "`tIgnored: $($_.Exception.Message)" "DarkGray" } + } + if ($InclSub) { + $subwebs = Get-PnPSubWeb + foreach ($sub in $subwebs) { + BgLog "`tSubsite : $($sub.Title)" "Yellow" + Collect-WebStorage -WebUrl $sub.Url -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId + Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId + } + } + } + try { + Import-Module PnP.PowerShell -ErrorAction Stop + Collect-WebStorage -WebUrl $SiteURL -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId + $web = Get-PnPWeb + $Sync.WebTitle = $web.Title + $Sync.WebURL = $web.URL + $Sync.Data = @($script:_bgResults) + } catch { + $Sync.Error = $_.Exception.Message + BgLog "Error : $($_.Exception.Message)" "Red" + } finally { + $Sync.Done = $true + } +} + +# Launches scan for next site in queue; called from button click and timer Done block +function Start-NextStorageScan { + if ($script:_StorSiteQueue.Count -eq 0) { + Write-Log "=== All sites processed ===" "Cyan" + $btnGenStorage.Enabled = $true + $progressBar.MarqueeAnimationSpeed = 0 + return + } + + $nextUrl = $script:_StorSiteQueue.Dequeue() + $siteName = ($nextUrl -split '/')[-1]; if (!$siteName) { $siteName = "root" } + $ext = if ($script:_StorFmt -eq "HTML") { ".html" } else { ".csv" } + $outFile = Join-Path $script:_StorOutFolder "$siteName-Storage$ext" + $script:_StorOut = $outFile + + Write-Log "" + Write-Log "--- Site: $nextUrl" "White" + Write-Log " Output: $outFile" "Gray" + Write-Log ("-" * 52) "DarkGray" + + $script:_StorSyn = [hashtable]::Synchronized(@{ + Queue = [System.Collections.Generic.Queue[object]]::new() + Done = $false + Error = $null + Data = $null + WebTitle = "" + WebURL = "" + }) + + $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open() + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript($script:_StorBgWork) + [void]$ps.AddArgument($nextUrl) + [void]$ps.AddArgument($script:_StorClientId) + [void]$ps.AddArgument($script:_StorInclSub) + [void]$ps.AddArgument($script:_StorPerLib) + [void]$ps.AddArgument($script:_StorDepth) + [void]$ps.AddArgument($script:_StorSyn) + $script:_StorRS = $rs + $script:_StorPS = $ps + $script:_StorHnd = $ps.BeginInvoke() + + $script:_StorTimer = New-Object System.Windows.Forms.Timer + $script:_StorTimer.Interval = 200 + $script:_StorTimer.Add_Tick({ + while ($script:_StorSyn.Queue.Count -gt 0) { + $m = $script:_StorSyn.Queue.Dequeue() + Write-Log $m.Text $m.Color + } + if ($script:_StorSyn.Done) { + $script:_StorTimer.Stop(); $script:_StorTimer.Dispose() + while ($script:_StorSyn.Queue.Count -gt 0) { + $m = $script:_StorSyn.Queue.Dequeue(); Write-Log $m.Text $m.Color + } + try { [void]$script:_StorPS.EndInvoke($script:_StorHnd) } catch {} + try { $script:_StorRS.Close(); $script:_StorRS.Dispose() } catch {} + + if ($script:_StorSyn.Error) { + Write-Log "Failed: $($script:_StorSyn.Error)" "Red" + } elseif ($script:_StorSyn.Data -and $script:_StorSyn.Data.Count -gt 0) { + $data = $script:_StorSyn.Data + Write-Log "Writing output..." "Yellow" + if ($script:_StorFmt -eq "HTML") { + Export-StorageToHTML -Data $data -SiteTitle $script:_StorSyn.WebTitle ` + -SiteURL $script:_StorSyn.WebURL -OutputPath $script:_StorOut + } else { + $data | Select-Object SiteTitle,SiteURL,Library,ItemCount,SizeMB,VersionSizeMB,SizeGB,LastModified | + Export-Csv -Path $script:_StorOut -NoTypeInformation + } + Write-Log "Done! $($data.Count) libs -- $(Format-Bytes (($data | Measure-Object -Property SizeBytes -Sum).Sum))" "Cyan" + Write-Log "Saved: $script:_StorOut" "White" + $script:_StorLastOut = $script:_StorOut + $btnOpenStorage.Enabled = $true + } else { + Write-Log "No data -- check permissions or URL." "Orange" + } + # Process next site in queue (if any) + Start-NextStorageScan + } + }) + $script:_StorTimer.Start() +} + +$btnGenStorage.Add_Click({ + if (-not (Validate-Inputs)) { return } + + # Build site list: picker selection takes priority, else txtSiteURL + $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) { + @($script:SelectedSites) + } else { + @($txtSiteURL.Text.Trim()) + } + $siteUrls = @($siteUrls | Where-Object { $_ }) + + # Store common scan parameters as script vars (read by Start-NextStorageScan) + $script:_StorClientId = $txtClientId.Text.Trim() + $script:pnpCiD = $script:_StorClientId + $script:_StorInclSub = $chkStorSubsites.Checked + $script:_StorPerLib = $chkStorPerLib.Checked + $script:_StorDepth = if (-not $chkStorPerLib.Checked) { 0 } elseif ($chkMaxDepth.Checked) { 999 } else { [int]$nudDepth.Value } + $script:_StorFmt = if ($radStorHTML.Checked) { "HTML" } else { "CSV" } + $script:_StorOutFolder = $txtOutput.Text + $script:_StorLastOut = $null + + # Build queue + $script:_StorSiteQueue = [System.Collections.Generic.Queue[string]]::new() + foreach ($u in $siteUrls) { $script:_StorSiteQueue.Enqueue($u) } + + $btnGenStorage.Enabled = $false + $btnOpenStorage.Enabled = $false + $txtLog.Clear() + $progressBar.MarqueeAnimationSpeed = 30 + + $depthLabel = if ($script:_StorDepth -ge 999) { "Maximum" } elseif ($script:_StorDepth -eq 0) { "N/A" } else { $script:_StorDepth } + Write-Log "=== STORAGE METRICS ===" "White" + Write-Log "Sites : $($siteUrls.Count)" "Gray" + Write-Log "Format : $($script:_StorFmt)" "Gray" + Write-Log "Folder depth : $depthLabel" "Gray" + Write-Log ("-" * 52) "DarkGray" + + Start-NextStorageScan +}) + +$btnOpenStorage.Add_Click({ + $f = $script:_StorLastOut + if ($f -and (Test-Path $f)) { Start-Process $f } +}) + +# ── Templates ─────────────────────────────────────────────────────────────── +$btnOpenTplMgr.Add_Click({ + Show-TemplateManager ` + -DefaultSiteUrl $txtSiteURL.Text.Trim() ` + -ClientId $txtClientId.Text.Trim() ` + -TenantUrl $txtTenantUrl.Text.Trim() ` + -Owner $form + # Refresh count after dialog closes + $n = (Load-Templates).Count + $lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer" +}) + +# ── Recherche de fichiers ─────────────────────────────────────────────────── +$btnSearch.Add_Click({ + if (-not (Validate-Inputs)) { return } + + $siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) { + $script:SelectedSites[0] + } else { $txtSiteURL.Text.Trim() } + + if ([string]::IsNullOrWhiteSpace($siteUrl)) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning") + return + } + + # Validate regex before launching + $regexStr = $txtSrchRegex.Text.Trim() + if ($regexStr) { + try { [void][System.Text.RegularExpressions.Regex]::new($regexStr) } + catch { + [System.Windows.Forms.MessageBox]::Show( + "Expression reguliere invalide :`n$($_.Exception.Message)", + "Regex invalide", "OK", "Error") + return + } + } + + $filters = @{ + Extensions = @($txtSrchExt.Text.Trim() -split '[,\s]+' | Where-Object { $_ } | ForEach-Object { $_.TrimStart('.').ToLower() }) + Regex = $regexStr + CreatedAfter = if ($chkSrchCrA.Checked) { $dtpSrchCrA.Value.Date } else { $null } + CreatedBefore = if ($chkSrchCrB.Checked) { $dtpSrchCrB.Value.Date } else { $null } + ModifiedAfter = if ($chkSrchModA.Checked) { $dtpSrchModA.Value.Date } else { $null } + ModifiedBefore= if ($chkSrchModB.Checked) { $dtpSrchModB.Value.Date } else { $null } + CreatedBy = $txtSrchCrBy.Text.Trim() + ModifiedBy = $txtSrchModBy.Text.Trim() + Library = $txtSrchLib.Text.Trim() + MaxResults = [int]$nudSrchMax.Value + Format = if ($radSrchHTML.Checked) { "HTML" } else { "CSV" } + OutFolder = $txtOutput.Text.Trim() + SiteUrl = $siteUrl + ClientId = $txtClientId.Text.Trim() + } + + $btnSearch.Enabled = $false + $btnOpenSearch.Enabled = $false + $txtLog.Clear() + $progressBar.MarqueeAnimationSpeed = 30 + Write-Log "=== RECHERCHE DE FICHIERS ===" "White" + Write-Log "Site : $siteUrl" "Gray" + if ($filters.Extensions.Count -gt 0) { Write-Log "Extensions : $($filters.Extensions -join ', ')" "Gray" } + if ($filters.Regex) { Write-Log "Regex : $($filters.Regex)" "Gray" } + if ($filters.CreatedAfter) { Write-Log "Cree apres : $($filters.CreatedAfter.ToString('dd/MM/yyyy'))" "Gray" } + if ($filters.CreatedBefore) { Write-Log "Cree avant : $($filters.CreatedBefore.ToString('dd/MM/yyyy'))" "Gray" } + if ($filters.ModifiedAfter) { Write-Log "Modifie apres: $($filters.ModifiedAfter.ToString('dd/MM/yyyy'))" "Gray" } + if ($filters.ModifiedBefore){ Write-Log "Modifie avant: $($filters.ModifiedBefore.ToString('dd/MM/yyyy'))" "Gray" } + if ($filters.CreatedBy) { Write-Log "Cree par : $($filters.CreatedBy)" "Gray" } + if ($filters.ModifiedBy) { Write-Log "Modifie par : $($filters.ModifiedBy)" "Gray" } + if ($filters.Library) { Write-Log "Bibliotheque : $($filters.Library)" "Gray" } + Write-Log "Max resultats: $($filters.MaxResults)" "Gray" + Write-Log ("-" * 52) "DarkGray" + + $bgSearch = { + param($Filters, $Sync) + function BgLog([string]$m, [string]$c = "LightGreen") { + $Sync.Queue.Enqueue(@{ Text = $m; Color = $c }) + } + try { + Import-Module PnP.PowerShell -ErrorAction Stop + Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId + + # Build KQL query + $kqlParts = @("ContentType:Document") + if ($Filters.Extensions -and $Filters.Extensions.Count -gt 0) { + $extParts = $Filters.Extensions | ForEach-Object { "FileExtension:$_" } + $kqlParts += "($($extParts -join ' OR '))" + } + if ($Filters.CreatedAfter) { $kqlParts += "Created>=$($Filters.CreatedAfter.ToString('yyyy-MM-dd'))" } + if ($Filters.CreatedBefore) { $kqlParts += "Created<=$($Filters.CreatedBefore.ToString('yyyy-MM-dd'))" } + if ($Filters.ModifiedAfter) { $kqlParts += "Write>=$($Filters.ModifiedAfter.ToString('yyyy-MM-dd'))" } + if ($Filters.ModifiedBefore) { $kqlParts += "Write<=$($Filters.ModifiedBefore.ToString('yyyy-MM-dd'))" } + if ($Filters.CreatedBy) { $kqlParts += "Author:""$($Filters.CreatedBy)""" } + if ($Filters.ModifiedBy) { $kqlParts += "ModifiedBy:""$($Filters.ModifiedBy)""" } + if ($Filters.Library) { + $libPath = "$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))" + $kqlParts += "Path:""$libPath*""" + } + + $kql = $kqlParts -join " AND " + BgLog "Requete KQL : $kql" "Yellow" + $Sync.KQL = $kql + + $selectProps = @("Title","Path","Author","LastModifiedTime","FileExtension","Created","ModifiedBy","Size") + $allResults = [System.Collections.Generic.List[object]]::new() + $startRow = 0 + $batchSize = 500 + + do { + $batch = Submit-PnPSearchQuery -Query $kql ` + -StartRow $startRow -MaxResults $batchSize ` + -SelectProperties $selectProps -TrimDuplicates $false + $hits = @($batch.ResultRows) + foreach ($h in $hits) { $allResults.Add($h) } + BgLog " Lot $([math]::Floor($startRow/$batchSize)+1) : $($hits.Count) resultats (total: $($allResults.Count))" "Cyan" + $startRow += $batchSize + } while ($hits.Count -eq $batchSize -and $allResults.Count -lt $Filters.MaxResults) + + # Client-side regex filter on file path/name + if ($Filters.Regex) { + $rx = [System.Text.RegularExpressions.Regex]::new( + $Filters.Regex, + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + $allResults = [System.Collections.Generic.List[object]]@( + $allResults | Where-Object { $rx.IsMatch($_.Path) } + ) + BgLog " Apres filtre regex : $($allResults.Count) resultats" "Cyan" + } + + # Cap at MaxResults + if ($allResults.Count -gt $Filters.MaxResults) { + $allResults = [System.Collections.Generic.List[object]]@( + $allResults | Select-Object -First $Filters.MaxResults + ) + } + + BgLog "$($allResults.Count) fichier(s) trouves" "LightGreen" + $Sync.Results = @($allResults) + } catch { + $Sync.Error = $_.Exception.Message + BgLog "Erreur : $($_.Exception.Message)" "Red" + } finally { + $Sync.Done = $true + } + } + + $sync = [hashtable]::Synchronized(@{ + Queue = [System.Collections.Generic.Queue[object]]::new() + Done = $false + Error = $null + Results = $null + KQL = "" + }) + $script:_SrchSync = $sync + $script:_SrchFilters = $filters + + $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open() + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript($bgSearch) + [void]$ps.AddArgument($filters) + [void]$ps.AddArgument($sync) + $script:_SrchRS = $rs + $script:_SrchPS = $ps + $script:_SrchHnd = $ps.BeginInvoke() + + $tmr = New-Object System.Windows.Forms.Timer + $tmr.Interval = 250 + $script:_SrchTimer = $tmr + $tmr.Add_Tick({ + while ($script:_SrchSync.Queue.Count -gt 0) { + $m = $script:_SrchSync.Queue.Dequeue() + Write-Log $m.Text $m.Color + } + if ($script:_SrchSync.Done) { + $script:_SrchTimer.Stop(); $script:_SrchTimer.Dispose() + while ($script:_SrchSync.Queue.Count -gt 0) { + $m = $script:_SrchSync.Queue.Dequeue(); Write-Log $m.Text $m.Color + } + try { [void]$script:_SrchPS.EndInvoke($script:_SrchHnd) } catch {} + try { $script:_SrchRS.Close(); $script:_SrchRS.Dispose() } catch {} + $btnSearch.Enabled = $true + $progressBar.MarqueeAnimationSpeed = 0 + + if ($script:_SrchSync.Error) { + Write-Log "Echec : $($script:_SrchSync.Error)" "Red" + return + } + + $results = $script:_SrchSync.Results + $kql = $script:_SrchSync.KQL + $f = $script:_SrchFilters + + if (-not $results -or $results.Count -eq 0) { + Write-Log "Aucun fichier trouve avec ces criteres." "Orange" + return + } + + $stamp = Get-Date -Format "yyyyMMdd_HHmmss" + $outDir = $f.OutFolder + if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } + + if ($f.Format -eq "HTML") { + $outFile = Join-Path $outDir "FileSearch_$stamp.html" + $html = Export-SearchResultsToHTML -Results $results -KQL $kql -SiteUrl $f.SiteUrl + $html | Set-Content -Path $outFile -Encoding UTF8 + } else { + $outFile = Join-Path $outDir "FileSearch_$stamp.csv" + $results | ForEach-Object { + [PSCustomObject]@{ + Title = $_.Title + Path = $_.Path + FileExtension = $_.FileExtension + Created = $_.Created + LastModifiedTime= $_.LastModifiedTime + Author = $_.Author + ModifiedBy = $_.ModifiedBy + SizeBytes = $_.Size + } + } | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8 + } + + Write-Log "Sauvegarde : $outFile" "White" + $script:_SrchLastOut = $outFile + $btnOpenSearch.Enabled = $true + } + }) + $tmr.Start() +}) + +$btnOpenSearch.Add_Click({ + $f = $script:_SrchLastOut + if ($f -and (Test-Path $f)) { Start-Process $f } +}) + +# ── Scan de doublons ──────────────────────────────────────────────────────── +$btnScanDupes.Add_Click({ + if (-not (Validate-Inputs)) { return } + + $siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) { + $script:SelectedSites[0] + } else { $txtSiteURL.Text.Trim() } + if ([string]::IsNullOrWhiteSpace($siteUrl)) { + [System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning") + return + } + + $dupFilters = @{ + Mode = if ($radDupFolders.Checked) { "Folders" } else { "Files" } + MatchSize = $chkDupSize.Checked + MatchCreated = $chkDupCreated.Checked + MatchMod = $chkDupModified.Checked + MatchSubDir = $chkDupSubCount.Checked + MatchFiles = $chkDupFileCount.Checked + IncludeSubs = $chkDupSubsites.Checked + Library = $txtDupLib.Text.Trim() + Format = if ($radDupHTML.Checked) { "HTML" } else { "CSV" } + OutFolder = $txtOutput.Text.Trim() + SiteUrl = $siteUrl + ClientId = $txtClientId.Text.Trim() + } + + $btnScanDupes.Enabled = $false + $btnOpenDupes.Enabled = $false + $txtLog.Clear() + $progressBar.MarqueeAnimationSpeed = 30 + Write-Log "=== SCAN DE DOUBLONS ===" "White" + Write-Log "Mode : $($dupFilters.Mode)" "Gray" + Write-Log "Site : $siteUrl" "Gray" + Write-Log "Criteres : Nom (toujours)$(if($dupFilters.MatchSize){', Taille'})$(if($dupFilters.MatchCreated){', Cree le'})$(if($dupFilters.MatchMod){', Modifie le'})$(if($dupFilters.MatchSubDir){', Nb sous-doss.'})$(if($dupFilters.MatchFiles){', Nb fichiers'})" "Gray" + Write-Log ("-" * 52) "DarkGray" + + $bgDupScan = { + param($Filters, $Sync) + function BgLog([string]$m, [string]$c = "LightGreen") { + $Sync.Queue.Enqueue(@{ Text = $m; Color = $c }) + } + function MakeKey($name, $item, $f) { + $parts = @($name.ToLower()) + if ($f.MatchSize -and $null -ne $item.SizeBytes) { $parts += [string]$item.SizeBytes } + if ($f.MatchCreated -and $null -ne $item.CreatedDay) { $parts += $item.CreatedDay } + if ($f.MatchMod -and $null -ne $item.ModifiedDay){ $parts += $item.ModifiedDay } + if ($f.MatchSubDir -and $null -ne $item.FolderCount){ $parts += [string]$item.FolderCount } + if ($f.MatchFiles -and $null -ne $item.FileCount) { $parts += [string]$item.FileCount } + return $parts -join "|" + } + try { + Import-Module PnP.PowerShell -ErrorAction Stop + Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId + + $allItems = [System.Collections.Generic.List[object]]::new() + + if ($Filters.Mode -eq "Files") { + # ── Files: use Search API ────────────────────────────────── + $kql = "ContentType:Document" + if ($Filters.Library) { + $kql += " AND Path:""$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))*""" + } + BgLog "Requete KQL : $kql" "Yellow" + $startRow = 0; $batchSize = 500 + do { + $batch = Submit-PnPSearchQuery -Query $kql ` + -StartRow $startRow -MaxResults $batchSize ` + -SelectProperties @("Title","Path","Author","LastModifiedTime","FileExtension","Created","Size") ` + -TrimDuplicates $false + $hits = @($batch.ResultRows) + foreach ($h in $hits) { + $fname = [System.IO.Path]::GetFileName($h.Path) + try { $crDay = ([DateTime]$h.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" } + try { $modDay = ([DateTime]$h.LastModifiedTime).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" } + $sizeB = [long]($h.Size -replace '[^0-9]','0' -replace '^$','0') + $allItems.Add([PSCustomObject]@{ + Name = $fname + Path = $h.Path + Library = "" + SizeBytes = $sizeB + Created = $h.Created + Modified = $h.LastModifiedTime + CreatedDay = $crDay + ModifiedDay = $modDay + }) + } + BgLog " $($hits.Count) fichiers recuperes (total: $($allItems.Count))" "Cyan" + $startRow += $batchSize + } while ($hits.Count -eq $batchSize) + + } else { + # ── Folders: use Get-PnPListItem ─────────────────────────── + $webUrls = @($Filters.SiteUrl) + if ($Filters.IncludeSubs) { + $subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue + if ($subs) { $webUrls += @($subs | Select-Object -ExpandProperty Url) } + } + + foreach ($webUrl in $webUrls) { + BgLog "Scan web : $webUrl" "Yellow" + Connect-PnPOnline -Url $webUrl -Interactive -ClientId $Filters.ClientId + $lists = Get-PnPList | Where-Object { + !$_.Hidden -and $_.BaseType -eq "DocumentLibrary" -and + (-not $Filters.Library -or $_.Title -like "*$($Filters.Library)*") + } + foreach ($list in $lists) { + BgLog " Bibliotheque : $($list.Title)" "Cyan" + try { + $folderItems = Get-PnPListItem -List $list -PageSize 2000 -ErrorAction SilentlyContinue | + Where-Object { $_.FileSystemObjectType -eq "Folder" } + foreach ($fi in $folderItems) { + $fv = $fi.FieldValues + $fname = $fv.FileLeafRef + try { $crDay = ([DateTime]$fv.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" } + try { $modDay = ([DateTime]$fv.Modified).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" } + $subCount = [int]($fv.FolderChildCount) + $fileCount = [int]($fv.ItemChildCount) - $subCount + if ($fileCount -lt 0) { $fileCount = 0 } + $allItems.Add([PSCustomObject]@{ + Name = $fname + Path = "$($Filters.SiteUrl.TrimEnd('/'))/$($fv.FileRef.TrimStart('/'))" + Library = $list.Title + SizeBytes = $null + Created = $fv.Created + Modified = $fv.Modified + CreatedDay = $crDay + ModifiedDay = $modDay + FolderCount = $subCount + FileCount = $fileCount + }) + } + BgLog " $($folderItems.Count) dossier(s)" "Cyan" + } catch { BgLog " Ignore : $($_.Exception.Message)" "DarkGray" } + } + } + } + + BgLog "$($allItems.Count) element(s) collecte(s), recherche des doublons..." "Yellow" + + # Group by computed key and keep only groups with ≥ 2 + $grouped = $allItems | Group-Object { MakeKey $_.Name $_ $Filters } | + Where-Object { $_.Count -ge 2 } | + ForEach-Object { + [PSCustomObject]@{ + Key = $_.Name + Name = $_.Group[0].Name + Items = @($_.Group) + } + } + + BgLog "$($grouped.Count) groupe(s) de doublons trouve(s)" "LightGreen" + $Sync.Groups = @($grouped) + + } catch { + $Sync.Error = $_.Exception.Message + BgLog "Erreur : $($_.Exception.Message)" "Red" + } finally { $Sync.Done = $true } + } + + $dupSync = [hashtable]::Synchronized(@{ + Queue = [System.Collections.Generic.Queue[object]]::new() + Done = $false; Error = $null; Groups = $null + }) + $script:_DupSync = $dupSync + $script:_DupFilters = $dupFilters + + $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() + $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open() + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript($bgDupScan) + [void]$ps.AddArgument($dupFilters) + [void]$ps.AddArgument($dupSync) + $script:_DupRS = $rs + $script:_DupPS = $ps + $script:_DupHnd = $ps.BeginInvoke() + + $tmr = New-Object System.Windows.Forms.Timer + $tmr.Interval = 250 + $script:_DupTimer = $tmr + $tmr.Add_Tick({ + while ($script:_DupSync.Queue.Count -gt 0) { + $m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color + } + if ($script:_DupSync.Done) { + $script:_DupTimer.Stop(); $script:_DupTimer.Dispose() + while ($script:_DupSync.Queue.Count -gt 0) { + $m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color + } + try { [void]$script:_DupPS.EndInvoke($script:_DupHnd) } catch {} + try { $script:_DupRS.Close(); $script:_DupRS.Dispose() } catch {} + $btnScanDupes.Enabled = $true + $progressBar.MarqueeAnimationSpeed = 0 + + if ($script:_DupSync.Error) { + Write-Log "Echec : $($script:_DupSync.Error)" "Red" + return + } + + $groups = $script:_DupSync.Groups + if (-not $groups -or $groups.Count -eq 0) { + Write-Log "Aucun doublon detecte avec ces criteres." "Orange" + return + } + + $f = $script:_DupFilters + $stamp = Get-Date -Format "yyyyMMdd_HHmmss" + $outDir = $f.OutFolder + if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } + + if ($f.Format -eq "HTML") { + $outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.html" + $html = Export-DuplicatesToHTML -Groups $groups -Mode $f.Mode -SiteUrl $f.SiteUrl + $html | Set-Content -Path $outFile -Encoding UTF8 + } else { + $outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.csv" + $groups | ForEach-Object { + $grp = $_ + $grp.Items | ForEach-Object { + [PSCustomObject]@{ + DuplicateGroup = $grp.Name + Name = $_.Name + Path = $_.Path + Library = $_.Library + SizeBytes = $_.SizeBytes + Created = $_.Created + Modified = $_.Modified + FolderCount = $_.FolderCount + FileCount = $_.FileCount + } + } + } | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8 + } + + Write-Log "Sauvegarde : $outFile" "White" + $script:_DupLastOut = $outFile + $btnOpenDupes.Enabled = $true + } + }) + $tmr.Start() +}) + +$btnOpenDupes.Add_Click({ + $f = $script:_DupLastOut + if ($f -and (Test-Path $f)) { Start-Process $f } +}) + +#endregion + +Refresh-ProfileList +$n = (Load-Templates).Count +$lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer" + +[System.Windows.Forms.Application]::Run($form) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..800fd96 --- /dev/null +++ b/TODO.md @@ -0,0 +1,5 @@ +# Features à ajouter : +- Sauvegarde du contexte d'authentification en plus des profils +- Possibilité de demander la liste de site auquels un user precis a acces +- Possibilité de faire des recherches de fichiers avec pleins de parametres (exemple : Date d'ajout, de modification, extension de fichier, crée par $user, modifié par $user, regex) +- Copie de site à site \ No newline at end of file