diff --git a/Sharepoint_ToolBox.ps1 b/Sharepoint_ToolBox.ps1 deleted file mode 100644 index 08003d8..0000000 --- a/Sharepoint_ToolBox.ps1 +++ /dev/null @@ -1,6408 +0,0 @@ -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)) { - $msg = T "validate.missing.clientid.hint" - [System.Windows.Forms.MessageBox]::Show($msg, (T "validate.missing.title"), "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 ($script:DataFolder) { $script:DataFolder } - elseif ($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 ===== Settings ===== - -function Get-SettingsFilePath { - $dir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } - return Join-Path $dir "Sharepoint_Settings.json" -} - -function Load-Settings { - $path = Get-SettingsFilePath - if (Test-Path $path) { - try { - $data = Get-Content $path -Raw | ConvertFrom-Json - return $data - } catch {} - } - return [PSCustomObject]@{ dataFolder = ""; lang = "en" } -} - -function Save-Settings { - param([string]$DataFolder, [string]$Lang = "en") - $path = Get-SettingsFilePath - [PSCustomObject]@{ dataFolder = $DataFolder; lang = $Lang } | - ConvertTo-Json | Set-Content $path -Encoding UTF8 -} - -#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, - [object[]]$InitialSites = @(), - [string[]]$PreSelected = @() - ) - - $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 - } - - # Pre-populate from cache + restore previous selection - if ($InitialSites -and $InitialSites.Count -gt 0) { - $script:_pkl.AllSites = @($InitialSites) - foreach ($url in $PreSelected) { [void]$script:_pkl.CheckedUrls.Add($url) } - $btnLoad.Text = "Recharger" - _Pkl-Sort - _Pkl-Repopulate - $script:_pkl.BtnOK.Enabled = $true - } - - # 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) - $script:_SiteCache = @($script:_pkl.Sync.Sites) - _Pkl-Sort - _Pkl-Repopulate - $script:_pkl.BtnLoad.Text = "Recharger" - $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 $null -} - -#endregion - -#region ===== Template Management ===== - -function Get-TemplatesFilePath { - $dir = if ($script:DataFolder) { $script:DataFolder } - elseif ($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 Toolbox
- - -
-
📋 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 Toolbox
- - -"@ - $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" - [System.Windows.Forms.Application]::DoEvents() - Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $script:pnpCiD - $Web = Get-PnPWeb - [System.Windows.Forms.Application]::DoEvents() - - 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++ - [System.Windows.Forms.Application]::DoEvents() - } - } - - 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" - [System.Windows.Forms.Application]::DoEvents() - 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" - [System.Windows.Forms.Application]::DoEvents() - 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 ===== Transfer ===== - -function Export-TransferVerifyToHTML { - param([array]$Results, [string]$SrcSite, [string]$SrcLib, [string]$DstSite, [string]$DstLib) - - $okCount = @($Results | Where-Object { $_.Status -eq "OK" }).Count - $missingCount = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count - $mismatchCount = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count - $extraCount = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count - $totalCount = $Results.Count - $date = Get-Date -Format "dd/MM/yyyy HH:mm" - - $rows = "" - foreach ($r in $Results) { - $statusClass = switch ($r.Status) { - "OK" { "status-ok" } - "MISSING" { "status-missing" } - "SIZE_MISMATCH" { "status-mismatch" } - "EXTRA" { "status-extra" } - default { "" } - } - $statusLabel = switch ($r.Status) { - "OK" { "OK" } - "MISSING" { "MISSING" } - "SIZE_MISMATCH" { "SIZE MISMATCH" } - "EXTRA" { "EXTRA" } - default { $r.Status } - } - $name = EscHtml $r.Name - $relPath = EscHtml $r.RelativePath - $srcSize = if ($null -ne $r.SourceSize) { EscHtml (Format-Bytes $r.SourceSize) } else { "-" } - $dstSize = if ($null -ne $r.DestSize) { EscHtml (Format-Bytes $r.DestSize) } else { "-" } - $srcRaw = if ($null -ne $r.SourceSize) { $r.SourceSize } else { 0 } - $dstRaw = if ($null -ne $r.DestSize) { $r.DestSize } else { 0 } - $rows += "" - $rows += "$statusLabel" - $rows += "$name" - $rows += "$relPath" - $rows += "$srcSize" - $rows += "$dstSize`n" - } - - $srcEsc = EscHtml "$SrcSite/$SrcLib" - $dstEsc = EscHtml "$DstSite/$DstLib" - -$html = @" - - -Transfer Verification Report - - -
-

Transfer Verification Report

-
Source: $srcEsc → Destination: $dstEsc — $date
-
-
-
$totalCount
Total files
-
$okCount
Matched
-
$missingCount
Missing
-
$mismatchCount
Size mismatch
-
$extraCount
Extra at dest
-
-
-
- - - - - - - - -$rows -
Status File Name Relative Path Source Size Dest Size
- - - -"@ - return $html -} - -#endregion - -#region ===== Bulk Site Creation ===== - -function Show-BulkSiteDialog { - param( - [System.Windows.Forms.Form]$Owner = $null, - [hashtable]$Existing = $null # pass to edit an existing entry - ) - - $templates = @(Load-Templates) - - $dlg = New-Object System.Windows.Forms.Form - $dlg.Text = if ($Existing) { T "bulk.dlg.title.edit" } else { T "bulk.dlg.title" } - $dlg.Size = New-Object System.Drawing.Size(520, 400) - $dlg.StartPosition = "CenterParent" - $dlg.FormBorderStyle = "FixedDialog" - $dlg.MaximizeBox = $false - $dlg.MinimizeBox = $false - if ($Owner) { $dlg.Owner = $Owner } - - $y = 14 - - # Site name - $lblName = New-Object System.Windows.Forms.Label - $lblName.Text = T "bulk.lbl.name" - $lblName.Location = New-Object System.Drawing.Point(14, $y) - $lblName.Size = New-Object System.Drawing.Size(480, 18) - $y += 20 - $txtName = New-Object System.Windows.Forms.TextBox - $txtName.Location = New-Object System.Drawing.Point(14, $y) - $txtName.Size = New-Object System.Drawing.Size(476, 22) - $y += 30 - - # URL alias - $lblAlias = New-Object System.Windows.Forms.Label - $lblAlias.Text = T "bulk.lbl.alias" - $lblAlias.Location = New-Object System.Drawing.Point(14, $y) - $lblAlias.Size = New-Object System.Drawing.Size(480, 18) - $y += 20 - $txtAlias = New-Object System.Windows.Forms.TextBox - $txtAlias.Location = New-Object System.Drawing.Point(14, $y) - $txtAlias.Size = New-Object System.Drawing.Size(476, 22) - $y += 30 - - # Auto-generate alias from name - $txtName.Add_TextChanged({ - if (-not $txtAlias.Tag) { - $raw = $txtName.Text.Trim().ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', '' - $txtAlias.Text = $raw - } - }) - $txtAlias.Add_TextChanged({ if ($txtAlias.Focused) { $txtAlias.Tag = "manual" } }) - - # Site type - $lblType = New-Object System.Windows.Forms.Label - $lblType.Text = T "bulk.lbl.type" - $lblType.Location = New-Object System.Drawing.Point(14, $y) - $lblType.Size = New-Object System.Drawing.Size(150, 18) - $radTeam = New-Object System.Windows.Forms.RadioButton - $radTeam.Text = T "bulk.rad.team" - $radTeam.Location = New-Object System.Drawing.Point(170, ($y - 2)) - $radTeam.Size = New-Object System.Drawing.Size(140, 22) - $radTeam.Checked = $true - $radComm = New-Object System.Windows.Forms.RadioButton - $radComm.Text = T "bulk.rad.comm" - $radComm.Location = New-Object System.Drawing.Point(320, ($y - 2)) - $radComm.Size = New-Object System.Drawing.Size(170, 22) - $y += 28 - - # Template - $lblTpl = New-Object System.Windows.Forms.Label - $lblTpl.Text = T "bulk.lbl.template" - $lblTpl.Location = New-Object System.Drawing.Point(14, $y) - $lblTpl.Size = New-Object System.Drawing.Size(150, 18) - $cboTpl = New-Object System.Windows.Forms.ComboBox - $cboTpl.DropDownStyle = "DropDownList" - $cboTpl.Location = New-Object System.Drawing.Point(170, ($y - 2)) - $cboTpl.Size = New-Object System.Drawing.Size(320, 22) - $cboTpl.Items.Add((T "bulk.none")) | Out-Null - foreach ($t in $templates) { $cboTpl.Items.Add($t.name) | Out-Null } - $cboTpl.SelectedIndex = 0 - $y += 30 - - # Owners - $lblOwners = New-Object System.Windows.Forms.Label - $lblOwners.Text = T "bulk.lbl.owners" - $lblOwners.Location = New-Object System.Drawing.Point(14, $y) - $lblOwners.Size = New-Object System.Drawing.Size(480, 18) - $y += 20 - $txtOwners = New-Object System.Windows.Forms.TextBox - $txtOwners.Location = New-Object System.Drawing.Point(14, $y) - $txtOwners.Size = New-Object System.Drawing.Size(476, 22) - $txtOwners.PlaceholderText = T "bulk.ph.owners" - $y += 30 - - # Members - $lblMembers = New-Object System.Windows.Forms.Label - $lblMembers.Text = T "bulk.lbl.members" - $lblMembers.Location = New-Object System.Drawing.Point(14, $y) - $lblMembers.Size = New-Object System.Drawing.Size(380, 18) - - $btnCsvMembers = New-Object System.Windows.Forms.Button - $btnCsvMembers.Text = T "bulk.btn.csv.members" - $btnCsvMembers.Location = New-Object System.Drawing.Point(394, ($y - 4)) - $btnCsvMembers.Size = New-Object System.Drawing.Size(96, 24) - $btnCsvMembers.Add_Click({ - $ofd = New-Object System.Windows.Forms.OpenFileDialog - $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*" - if ($ofd.ShowDialog($dlg) -eq "OK") { - $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 - if ($emails.Count -gt 0) { - $existing = $txtMembers.Text.Trim() - if ($existing) { $txtMembers.Text = "$existing, $($emails -join ', ')" } - else { $txtMembers.Text = $emails -join ", " } - } - } - }) - - $y += 20 - $txtMembers = New-Object System.Windows.Forms.TextBox - $txtMembers.Location = New-Object System.Drawing.Point(14, $y) - $txtMembers.Size = New-Object System.Drawing.Size(476, 22) - $txtMembers.PlaceholderText = T "bulk.ph.members" - $y += 36 - - # OK / Cancel - $btnOk = New-Object System.Windows.Forms.Button - $btnOk.Text = "OK" - $btnOk.Location = New-Object System.Drawing.Point(310, $y) - $btnOk.Size = New-Object System.Drawing.Size(85, 30) - $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK - $dlg.AcceptButton = $btnOk - - $btnCancel = New-Object System.Windows.Forms.Button - $btnCancel.Text = "Cancel" - $btnCancel.Location = New-Object System.Drawing.Point(405, $y) - $btnCancel.Size = New-Object System.Drawing.Size(85, 30) - $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel - $dlg.CancelButton = $btnCancel - - # Pre-fill if editing - if ($Existing) { - $txtName.Text = $Existing.Name - $txtAlias.Tag = "manual" - $txtAlias.Text = $Existing.Alias - if ($Existing.Type -eq "Communication") { $radComm.Checked = $true } - if ($Existing.Template) { - $idx = $cboTpl.Items.IndexOf($Existing.Template) - if ($idx -ge 0) { $cboTpl.SelectedIndex = $idx } - } - $txtOwners.Text = $Existing.Owners - $txtMembers.Text = $Existing.Members - } - - $dlg.Controls.AddRange(@($lblName, $txtName, $lblAlias, $txtAlias, - $lblType, $radTeam, $radComm, $lblTpl, $cboTpl, - $lblOwners, $txtOwners, $lblMembers, $btnCsvMembers, $txtMembers, - $btnOk, $btnCancel)) - - $result = $dlg.ShowDialog($Owner) - if ($result -eq [System.Windows.Forms.DialogResult]::OK) { - $name = $txtName.Text.Trim() - $alias = $txtAlias.Text.Trim() - if (-not $name -or -not $alias) { return $null } - return @{ - Name = $name - Alias = $alias - Type = if ($radTeam.Checked) { "Team" } else { "Communication" } - Template = if ($cboTpl.SelectedIndex -gt 0) { $cboTpl.SelectedItem } else { "" } - Owners = $txtOwners.Text.Trim() - Members = $txtMembers.Text.Trim() - } - } - return $null -} - -#endregion - -#region ===== Internationalization ===== - -$script:LangDefault = @{ - "profile" = "Profile:" - "tenant.url" = "Tenant URL:" - "client.id" = "Client ID:" - "site.url" = "Site URL:" - "output.folder" = "Output Folder:" - "btn.new" = "New" - "btn.save" = "Save" - "btn.rename" = "Rename" - "btn.delete" = "Del." - "btn.view.sites" = "View Sites" - "btn.browse" = "Browse..." - "tab.perms" = " Permissions " - "tab.storage" = " Storage " - "tab.templates" = " Templates " - "tab.search" = " File Search " - "tab.dupes" = " Duplicates " - "grp.scan.opts" = "Scan Options" - "chk.scan.folders" = "Scan Folders" - "chk.recursive" = "Recursive (subsites)" - "lbl.folder.depth" = "Folder depth:" - "chk.max.depth" = "Maximum (all levels)" - "chk.inherited.perms" = "Include Inherited Permissions" - "grp.export.fmt" = "Export Format" - "rad.csv.perms" = "CSV" - "rad.html.perms" = "HTML" - "btn.gen.perms" = "Generate Report" - "btn.open.perms" = "Open Report" - "chk.per.lib" = "Per-Library Breakdown" - "chk.subsites" = "Include Subsites" - "stor.note" = "Note: deeper folder scans on large sites may take several minutes." - "btn.gen.storage" = "Generate Metrics" - "btn.open.storage" = "Open Report" - "tpl.desc" = "Create templates from an existing site and apply them to create new sites." - "btn.manage.tpl" = "Manage templates..." - "tpl.count" = "template(s) saved - click to manage" - "grp.search.filters" = "Search Filters" - "lbl.extensions" = "Extension(s):" - "lbl.regex" = "Name / Regex:" - "chk.created.after" = "Created after:" - "chk.created.before" = "Created before:" - "chk.modified.after" = "Modified after:" - "chk.modified.before" = "Modified before:" - "lbl.created.by" = "Created by:" - "lbl.modified.by" = "Modified by:" - "lbl.library" = "Library:" - "grp.search.fmt" = "Export Format" - "lbl.max.results" = "Max results:" - "btn.run.search" = "Run Search" - "btn.open.search" = "Open Results" - "grp.dup.type" = "Duplicate Type" - "rad.dup.files" = "Duplicate files" - "rad.dup.folders" = "Duplicate folders" - "grp.dup.criteria" = "Comparison Criteria" - "lbl.dup.note" = "Name is always the primary criterion. Check additional criteria:" - "chk.dup.size" = "Same size" - "chk.dup.created" = "Same creation date" - "chk.dup.modified" = "Same modification date" - "chk.dup.subfolders" = "Same subfolder count" - "chk.dup.filecount" = "Same file count" - "grp.options" = "Options" - "chk.include.subsites" = "Include subsites" - "btn.run.scan" = "Run Scan" - "btn.open.results" = "Open Results" - "lbl.log" = "Log:" - "menu.settings" = "Settings" - "menu.json.folder" = "JSON Data Folder..." - "menu.language" = "Language" - "dlg.json.folder.desc" = "Select the storage folder for JSON files (profiles, templates)" - "dlg.folder.not.found" = "The folder '{0}' does not exist. Do you want to create it?" - "dlg.folder.not.found.title"= "Folder not found" - "msg.lang.applied" = "Language applied: {0}" - "msg.lang.applied.title" = "Language" - "ph.extensions" = "docx pdf xlsx" - "ph.regex" = "Ex: report.* or \.bak$" - "ph.created.by" = "First Last or email" - "ph.modified.by" = "First Last or email" - "ph.library" = "Optional relative path e.g. Shared Documents" - "ph.dup.lib" = "All (leave empty)" - "tab.transfer" = " Transfer " - "grp.xfer.source" = "Source" - "grp.xfer.dest" = "Destination" - "lbl.xfer.site" = "Site URL:" - "lbl.xfer.library" = "Library / Folder:" - "grp.xfer.options" = "Options" - "chk.xfer.recursive" = "Include subfolders (recursive)" - "chk.xfer.overwrite" = "Overwrite existing files" - "btn.xfer.start" = "Start Transfer" - "btn.xfer.verify" = "Verify" - "btn.xfer.open" = "Open Report" - "ph.xfer.site" = "https://tenant.sharepoint.com/sites/xxx" - "ph.xfer.library" = "Shared Documents/subfolder" - "xfer.note" = "Only the current version of each file is transferred (no version history)." - "chk.xfer.create.folders" = "Create missing folders" - "btn.xfer.csv" = "Import CSV..." - "btn.xfer.csv.clear" = "Clear" - "lbl.xfer.csv.info" = "{0} transfer(s) loaded" - "lbl.xfer.report.fmt" = "Report:" - "tab.bulk" = " Bulk Create " - "grp.bulk.list" = "Sites to create" - "btn.bulk.add" = "Add Site..." - "btn.bulk.csv" = "Import CSV..." - "btn.bulk.remove" = "Remove" - "btn.bulk.clear" = "Clear All" - "btn.bulk.create" = "Create All Sites" - "bulk.col.name" = "Site Name" - "bulk.col.alias" = "URL Alias" - "bulk.col.type" = "Type" - "bulk.col.template" = "Template" - "bulk.col.owners" = "Owners" - "bulk.col.members" = "Members" - "bulk.dlg.title" = "Add Site" - "bulk.dlg.title.edit" = "Edit Site" - "bulk.lbl.name" = "Site name:" - "bulk.lbl.alias" = "URL alias (after /sites/):" - "bulk.lbl.type" = "Site type:" - "bulk.rad.team" = "Team Site" - "bulk.rad.comm" = "Communication Site" - "bulk.lbl.template" = "Template:" - "bulk.lbl.owners" = "Owners (comma-separated):" - "bulk.lbl.members" = "Members (comma-separated):" - "bulk.btn.csv.members" = "Import CSV..." - "bulk.none" = "(None)" - "bulk.ph.owners" = "admin@domain.com, user2@domain.com" - "bulk.ph.members" = "user@domain.com, ..." - "bulk.status.pending" = "Pending" - "bulk.status.creating" = "Creating..." - "bulk.status.ok" = "OK" - "bulk.status.error" = "Error" - "tab.structure" = " Structure " - "grp.struct.csv" = "CSV Import" - "lbl.struct.desc" = "Import a CSV to create a folder tree. Each column represents a depth level." - "btn.struct.csv" = "Load CSV..." - "grp.struct.preview" = "Preview" - "grp.struct.target" = "Target" - "lbl.struct.library" = "Target library:" - "ph.struct.library" = "Shared Documents" - "btn.struct.create" = "Create Structure" - "btn.struct.clear" = "Clear" - "struct.col.path" = "Full Path" - "struct.col.depth" = "Depth" - "tab.versions" = " Versions " - "grp.ver.keep" = "Versions to Keep" - "lbl.ver.count" = "Number of versions to keep:" - "chk.ver.date" = "Also filter by date" - "rad.ver.before" = "Keep versions before:" - "rad.ver.after" = "Keep versions after:" - "grp.ver.scope" = "Scope" - "lbl.ver.library" = "Library / Folder:" - "ph.ver.library" = "Shared Documents" - "chk.ver.recursive" = "Include subfolders (recursive)" - "chk.ver.subsites" = "Include subsites" - "chk.ver.dryrun" = "Dry run (preview only, no deletion)" - "btn.ver.run" = "Clean Versions" - "btn.ver.open" = "Open Report" - "btn.register.app" = "Register" - "reg.title" = "App Registration" - "reg.offer" = "No Client ID provided. Register a new app on this tenant?" - "reg.confirm" = "Register 'SharePoint Toolbox' app on tenant {0}?" - "reg.in.progress" = "Registering..." - "reg.success" = "App registered successfully!`nClient ID: {0}`nYou can save this profile to reuse it." - "reg.err.tenant" = "Cannot determine tenant from the provided URL." - "reg.err.nocmd" = "PnP.PowerShell module does not support app registration. Please register the app manually in Entra ID." - "reg.err.no.id" = "Registration completed but no Client ID was returned." - "reg.err.failed" = "Registration failed:`n{0}" - "reg.err.no.tenant" = "Please enter a Tenant URL first." - "reg.err.nopwsh" = "PowerShell 7+ (pwsh) is required for app registration but was not found. Install it from https://aka.ms/powershell" - "validate.missing.clientid" = "Please enter a Client ID." - "validate.missing.clientid.hint" = "Please enter a Client ID or use the 'Register' button to create one." - "validate.missing.title" = "Missing Field" -} - -$script:Lang = $null # null = use LangDefault - -function T([string]$key) { - if ($script:Lang -and $script:Lang.$key) { return $script:Lang.$key } - if ($script:LangDefault.ContainsKey($key)) { return $script:LangDefault[$key] } - return $key -} - -function Get-LangDir { - $base = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } - return Join-Path $base "lang" -} - -function Get-LangFiles { - $dir = Get-LangDir - if (-not (Test-Path $dir)) { return @() } - return @(Get-ChildItem -Path $dir -Filter "*.json" | ForEach-Object { - $code = $_.BaseName - $name = $code - try { - $data = Get-Content $_.FullName -Raw | ConvertFrom-Json - if ($data.'_name') { $name = $data.'_name' } - } catch {} - [PSCustomObject]@{ Code = $code; Name = $name; Path = $_.FullName } - }) -} - -function Load-Language([string]$LangCode) { - if ([string]::IsNullOrWhiteSpace($LangCode) -or $LangCode -eq "en") { - $script:Lang = $null - $script:CurrentLang = "en" - return - } - $dir = Get-LangDir - $path = Join-Path $dir "$LangCode.json" - if (-not (Test-Path $path)) { return } - try { - $data = Get-Content $path -Raw | ConvertFrom-Json - $ht = @{} - $data.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value } - $script:Lang = $ht - $script:CurrentLang = $LangCode - } catch {} -} - -function Update-UILanguage { - # Main labels - if ($script:i18nMap) { - foreach ($kv in $script:i18nMap.GetEnumerator()) { - $ctrl = $kv.Value.Control - $key = $kv.Value.Key - if ($ctrl -and !$ctrl.IsDisposed) { $ctrl.Text = T $key } - } - } - # Tab pages (Text property) - if ($script:i18nTabs) { - foreach ($kv in $script:i18nTabs.GetEnumerator()) { - $tab = $kv.Value.Control - $key = $kv.Value.Key - if ($tab -and !$tab.IsDisposed) { $tab.Text = T $key } - } - } - # Menu items - if ($script:i18nMenus) { - foreach ($kv in $script:i18nMenus.GetEnumerator()) { - $mi = $kv.Value.Control - $key = $kv.Value.Key - if ($mi) { $mi.Text = T $key } - } - } - # Placeholder texts - if ($script:i18nPlaceholders) { - foreach ($kv in $script:i18nPlaceholders.GetEnumerator()) { - $ctrl = $kv.Value.Control - $key = $kv.Value.Key - if ($ctrl -and !$ctrl.IsDisposed) { $ctrl.PlaceholderText = T $key } - } - } -} - -$script:CurrentLang = "en" - -#endregion - -#region ===== GUI ===== - -$form = New-Object System.Windows.Forms.Form -$form.Text = "SharePoint Toolbox" -$form.Size = New-Object System.Drawing.Size(700, 840) -$form.StartPosition = "CenterScreen" -$form.FormBorderStyle = "FixedDialog" -$form.MaximizeBox = $false -$form.BackColor = [System.Drawing.Color]::WhiteSmoke - -# ── MenuStrip ───────────────────────────────────────────────────────────────── -$menuStrip = New-Object System.Windows.Forms.MenuStrip -$menuStrip.BackColor = [System.Drawing.Color]::WhiteSmoke -$menuStrip.RenderMode = [System.Windows.Forms.ToolStripRenderMode]::System - -$menuSettings = New-Object System.Windows.Forms.ToolStripMenuItem -$menuSettings.Text = T "menu.settings" -$menuJsonFolder = New-Object System.Windows.Forms.ToolStripMenuItem -$menuJsonFolder.Text = T "menu.json.folder" -[void]$menuSettings.DropDownItems.Add($menuJsonFolder) - -$menuLang = New-Object System.Windows.Forms.ToolStripMenuItem -$menuLang.Text = T "menu.language" -$menuLangEn = New-Object System.Windows.Forms.ToolStripMenuItem -$menuLangEn.Text = "English (US)" -$menuLangEn.Tag = "en" -$menuLangEn.Checked = ($script:CurrentLang -eq "en") -[void]$menuLang.DropDownItems.Add($menuLangEn) -[void]$menuLang.DropDownItems.Add([System.Windows.Forms.ToolStripSeparator]::new()) -foreach ($lf in (Get-LangFiles)) { - $mi = New-Object System.Windows.Forms.ToolStripMenuItem - $mi.Text = $lf.Name - $mi.Tag = $lf.Code - $mi.Checked = ($script:CurrentLang -eq $lf.Code) - [void]$menuLang.DropDownItems.Add($mi) -} -[void]$menuStrip.Items.Add($menuSettings) -[void]$menuStrip.Items.Add($menuLang) -$form.MainMenuStrip = $menuStrip - -# ── Label helper (positions offset +24 to account for MenuStrip) ────────────── -$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 (T "profile") 20 46) -$cboProfile = New-Object System.Windows.Forms.ComboBox -$cboProfile.Location = New-Object System.Drawing.Point(140, 44) -$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 = T "btn.new" -$btnProfileNew.Location = New-Object System.Drawing.Point(396, 43) -$btnProfileNew.Size = New-Object System.Drawing.Size(60, 26) - -$btnProfileSave = New-Object System.Windows.Forms.Button -$btnProfileSave.Text = T "btn.save" -$btnProfileSave.Location = New-Object System.Drawing.Point(460, 43) -$btnProfileSave.Size = New-Object System.Drawing.Size(60, 26) - -$btnProfileRename = New-Object System.Windows.Forms.Button -$btnProfileRename.Text = T "btn.rename" -$btnProfileRename.Location = New-Object System.Drawing.Point(524, 43) -$btnProfileRename.Size = New-Object System.Drawing.Size(72, 26) - -$btnProfileDelete = New-Object System.Windows.Forms.Button -$btnProfileDelete.Text = T "btn.delete" -$btnProfileDelete.Location = New-Object System.Drawing.Point(600, 43) -$btnProfileDelete.Size = New-Object System.Drawing.Size(62, 26) - -$lblTenantUrl = (& $lbl (T "tenant.url") 20 76) -$txtTenantUrl = New-Object System.Windows.Forms.TextBox -$txtTenantUrl.Location = New-Object System.Drawing.Point(140, 76) -$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 = T "btn.view.sites" -$btnBrowseSites.Location = New-Object System.Drawing.Point(548, 74) -$btnBrowseSites.Size = New-Object System.Drawing.Size(92, 26) - -$lblClientId = (& $lbl (T "client.id") 20 108) -$txtClientId = New-Object System.Windows.Forms.TextBox -$txtClientId.Location = New-Object System.Drawing.Point(140, 108) -$txtClientId.Size = New-Object System.Drawing.Size(400, 22) -$txtClientId.Font = New-Object System.Drawing.Font("Consolas", 9) - -$btnRegisterApp = New-Object System.Windows.Forms.Button -$btnRegisterApp.Text = T "btn.register.app" -$btnRegisterApp.Location = New-Object System.Drawing.Point(548, 106) -$btnRegisterApp.Size = New-Object System.Drawing.Size(92, 26) - -$txtClientId.Add_TextChanged({ - $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text) -}) - -$lblSiteURL = (& $lbl (T "site.url") 20 140) -$txtSiteURL = New-Object System.Windows.Forms.TextBox -$txtSiteURL.Location = New-Object System.Drawing.Point(140, 140) -$txtSiteURL.Size = New-Object System.Drawing.Size(500, 22) - -$lblOutput = (& $lbl (T "output.folder") 20 172) -$txtOutput = New-Object System.Windows.Forms.TextBox -$txtOutput.Location = New-Object System.Drawing.Point(140, 172) -$txtOutput.Size = New-Object System.Drawing.Size(408, 22) -$txtOutput.Text = $PWD.Path - -$btnBrowse = New-Object System.Windows.Forms.Button -$btnBrowse.Text = T "btn.browse" -$btnBrowse.Location = New-Object System.Drawing.Point(558, 170) -$btnBrowse.Size = New-Object System.Drawing.Size(82, 26) - -$sep = New-Object System.Windows.Forms.Panel -$sep.Location = New-Object System.Drawing.Point(20, 206) -$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, 214) -$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 = T "tab.perms" -$tabPerms.BackColor = [System.Drawing.Color]::WhiteSmoke - -$grpPermOpts = New-Group (T "grp.scan.opts") 10 10 615 96 -$chkScanFolders = New-Check (T "chk.scan.folders") 15 24 150 $true -$chkRecursive = New-Check (T "chk.recursive") 175 24 185 - -# Folder depth controls (only active when Scan Folders is checked) -$lblPermDepth = New-Object System.Windows.Forms.Label -$lblPermDepth.Text = T "lbl.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 = T "chk.max.depth" -$chkPermMaxDepth.Location = New-Object System.Drawing.Point(182, 52) -$chkPermMaxDepth.Size = New-Object System.Drawing.Size(180, 20) - -$chkInheritedPerms = New-Check (T "chk.inherited.perms") 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 (T "grp.export.fmt") 10 114 615 58 -$radPermCSV = New-Radio (T "rad.csv.perms") 15 24 280 $true -$radPermHTML = New-Radio (T "rad.html.perms") 305 24 290 -$grpPermFmt.Controls.AddRange(@($radPermCSV, $radPermHTML)) - -$btnGenPerms = New-ActionBtn (T "btn.gen.perms") 10 184 ([System.Drawing.Color]::SteelBlue) -$btnOpenPerms = New-Object System.Windows.Forms.Button -$btnOpenPerms.Text = T "btn.open.perms" -$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 = T "tab.storage" -$tabStorage.BackColor = [System.Drawing.Color]::WhiteSmoke - -$grpStorOpts = New-Group (T "grp.scan.opts") 10 10 615 108 -$chkStorPerLib = New-Check (T "chk.per.lib") 15 24 200 $true -$chkStorSubsites = New-Check (T "chk.subsites") 230 24 170 - -# Folder depth controls (only relevant in per-library mode) -$lblDepth = New-Object System.Windows.Forms.Label -$lblDepth.Text = T "lbl.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 = T "chk.max.depth" -$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 = T "stor.note" -$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 (T "grp.export.fmt") 10 128 615 58 -$radStorCSV = New-Radio (T "rad.csv.perms") 15 24 280 $true -$radStorHTML = New-Radio (T "rad.html.perms") 305 24 290 -$grpStorFmt.Controls.AddRange(@($radStorCSV, $radStorHTML)) - -$msGreen = [System.Drawing.Color]::FromArgb(16,124,16) -$btnGenStorage = New-ActionBtn (T "btn.gen.storage") 10 200 $msGreen -$btnOpenStorage = New-Object System.Windows.Forms.Button -$btnOpenStorage.Text = T "btn.open.storage" -$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 = T "tab.templates" -$tabTemplates.BackColor = [System.Drawing.Color]::WhiteSmoke - -$lblTplDesc = New-Object System.Windows.Forms.Label -$lblTplDesc.Text = T "tpl.desc" -$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 = T "btn.manage.tpl" -$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 = T "tab.search" -$tabSearch.BackColor = [System.Drawing.Color]::WhiteSmoke - -# ── GroupBox Filtres ─────────────────────────────────────────────────────────── -$grpSearchFilters = New-Group (T "grp.search.filters") 10 6 620 170 - -# Row 1 - Extension & Regex -$lblSrchExt = New-Object System.Windows.Forms.Label -$lblSrchExt.Text = T "lbl.extensions" -$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 = T "ph.extensions" - -$lblSrchRegex = New-Object System.Windows.Forms.Label -$lblSrchRegex.Text = T "lbl.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 = T "ph.regex" - -# Row 2 - Created dates -$chkSrchCrA = New-Object System.Windows.Forms.CheckBox -$chkSrchCrA.Text = T "chk.created.after" -$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 = T "chk.created.before" -$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 = T "chk.modified.after" -$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 = T "chk.modified.before" -$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 = T "lbl.created.by" -$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 = T "ph.created.by" - -$lblSrchModBy = New-Object System.Windows.Forms.Label -$lblSrchModBy.Text = T "lbl.modified.by" -$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 = T "ph.modified.by" - -# Row 5 - Library filter -$lblSrchLib = New-Object System.Windows.Forms.Label -$lblSrchLib.Text = T "lbl.library" -$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 = T "ph.library" - -$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 (T "grp.search.fmt") 10 180 620 48 -$radSrchCSV = New-Radio (T "rad.csv.perms") 15 22 130 $true -$radSrchHTML = New-Radio (T "rad.html.perms") 160 22 180 -$lblSrchMax = New-Object System.Windows.Forms.Label -$lblSrchMax.Text = T "lbl.max.results" -$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 (T "btn.run.search") 10 232 ([System.Drawing.Color]::FromArgb(0, 120, 212)) -$btnOpenSearch = New-Object System.Windows.Forms.Button -$btnOpenSearch.Text = T "btn.open.search" -$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 = T "tab.dupes" -$tabDupes.BackColor = [System.Drawing.Color]::WhiteSmoke - -# ── GroupBox: Type de doublons (y=4, h=44 → bottom 48) ────────────────────── -$grpDupType = New-Group (T "grp.dup.type") 10 4 638 44 -$radDupFiles = New-Radio (T "rad.dup.files") 10 16 190 $true -$radDupFolders = New-Radio (T "rad.dup.folders") 210 16 190 -$grpDupType.Controls.AddRange(@($radDupFiles, $radDupFolders)) - -# ── GroupBox: Critères de comparaison (y=52, h=88 → bottom 140) ───────────── -$grpDupCrit = New-Group (T "grp.dup.criteria") 10 52 638 88 - -$lblDupNote = New-Object System.Windows.Forms.Label -$lblDupNote.Text = T "lbl.dup.note" -$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 (T "chk.dup.size") 10 34 148 $true -$chkDupCreated = New-Check (T "chk.dup.created") 164 34 208 -$chkDupModified = New-Check (T "chk.dup.modified") 378 34 226 - -# Row 2 - criteres dossiers uniquement -$chkDupSubCount = New-Check (T "chk.dup.subfolders") 10 60 210 -$chkDupFileCount = New-Check (T "chk.dup.filecount") 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 (T "grp.options") 10 144 638 44 -$chkDupSubsites = New-Check (T "chk.include.subsites") 10 18 192 -$lblDupLib = New-Object System.Windows.Forms.Label -$lblDupLib.Text = T "lbl.library" -$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 = T "ph.dup.lib" -$grpDupOpts.Controls.AddRange(@($chkDupSubsites, $lblDupLib, $txtDupLib)) - -# ── GroupBox: Format (y=192, h=40 → bottom 232) ────────────────────────────── -$grpDupFmt = New-Group (T "grp.export.fmt") 10 192 638 40 -$radDupCSV = New-Radio (T "rad.csv.perms") 10 16 130 $true -$radDupHTML = New-Radio (T "rad.html.perms") 155 16 200 -$grpDupFmt.Controls.AddRange(@($radDupCSV, $radDupHTML)) - -# ── Buttons (y=236 → bottom 270, within 284px inner) ───────────────────────── -$btnScanDupes = New-ActionBtn (T "btn.run.scan") 10 236 ([System.Drawing.Color]::FromArgb(136, 0, 21)) -$btnOpenDupes = New-Object System.Windows.Forms.Button -$btnOpenDupes.Text = T "btn.open.results" -$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)) - -# ══════════════════════════════════════════════════════════════════════════════ -# Tab 6 – Transfer -# ══════════════════════════════════════════════════════════════════════════════ -$tabTransfer = New-Object System.Windows.Forms.TabPage -$tabTransfer.Text = T "tab.transfer" - -# ── GroupBox: Source (y=4, h=74) ───────────────────────────────────────────── -$grpXferSrc = New-Group (T "grp.xfer.source") 10 4 620 74 - -$lblXferSrcSite = New-Object System.Windows.Forms.Label -$lblXferSrcSite.Text = T "lbl.xfer.site" -$lblXferSrcSite.Location = New-Object System.Drawing.Point(10, 20) -$lblXferSrcSite.Size = New-Object System.Drawing.Size(145, 22) -$lblXferSrcSite.TextAlign = "MiddleLeft" - -$txtXferSrcSite = New-Object System.Windows.Forms.TextBox -$txtXferSrcSite.Location = New-Object System.Drawing.Point(160, 20) -$txtXferSrcSite.Size = New-Object System.Drawing.Size(448, 22) -$txtXferSrcSite.PlaceholderText = T "ph.xfer.site" - -$lblXferSrcLib = New-Object System.Windows.Forms.Label -$lblXferSrcLib.Text = T "lbl.xfer.library" -$lblXferSrcLib.Location = New-Object System.Drawing.Point(10, 46) -$lblXferSrcLib.Size = New-Object System.Drawing.Size(145, 22) -$lblXferSrcLib.TextAlign = "MiddleLeft" - -$txtXferSrcLib = New-Object System.Windows.Forms.TextBox -$txtXferSrcLib.Location = New-Object System.Drawing.Point(160, 46) -$txtXferSrcLib.Size = New-Object System.Drawing.Size(448, 22) -$txtXferSrcLib.PlaceholderText = T "ph.xfer.library" - -$grpXferSrc.Controls.AddRange(@($lblXferSrcSite, $txtXferSrcSite, $lblXferSrcLib, $txtXferSrcLib)) - -# ── GroupBox: Destination (y=82, h=74) ─────────────────────────────────────── -$grpXferDst = New-Group (T "grp.xfer.dest") 10 82 620 74 - -$lblXferDstSite = New-Object System.Windows.Forms.Label -$lblXferDstSite.Text = T "lbl.xfer.site" -$lblXferDstSite.Location = New-Object System.Drawing.Point(10, 20) -$lblXferDstSite.Size = New-Object System.Drawing.Size(145, 22) -$lblXferDstSite.TextAlign = "MiddleLeft" - -$txtXferDstSite = New-Object System.Windows.Forms.TextBox -$txtXferDstSite.Location = New-Object System.Drawing.Point(160, 20) -$txtXferDstSite.Size = New-Object System.Drawing.Size(448, 22) -$txtXferDstSite.PlaceholderText = T "ph.xfer.site" - -$lblXferDstLib = New-Object System.Windows.Forms.Label -$lblXferDstLib.Text = T "lbl.xfer.library" -$lblXferDstLib.Location = New-Object System.Drawing.Point(10, 46) -$lblXferDstLib.Size = New-Object System.Drawing.Size(145, 22) -$lblXferDstLib.TextAlign = "MiddleLeft" - -$txtXferDstLib = New-Object System.Windows.Forms.TextBox -$txtXferDstLib.Location = New-Object System.Drawing.Point(160, 46) -$txtXferDstLib.Size = New-Object System.Drawing.Size(448, 22) -$txtXferDstLib.PlaceholderText = T "ph.xfer.library" - -$grpXferDst.Controls.AddRange(@($lblXferDstSite, $txtXferDstSite, $lblXferDstLib, $txtXferDstLib)) - -# ── GroupBox: Options (y=160, h=96) ────────────────────────────────────────── -$grpXferOpts = New-Group (T "grp.xfer.options") 10 160 620 96 - -$chkXferRecursive = New-Check (T "chk.xfer.recursive") 10 18 250 $true -$chkXferOverwrite = New-Check (T "chk.xfer.overwrite") 270 18 180 -$chkXferCreateFolders = New-Check (T "chk.xfer.create.folders") 460 18 155 $true - -$lblXferFmt = New-Object System.Windows.Forms.Label -$lblXferFmt.Text = T "lbl.xfer.report.fmt" -$lblXferFmt.Location = New-Object System.Drawing.Point(10, 42) -$lblXferFmt.Size = New-Object System.Drawing.Size(55, 20) - -$radXferCsv = New-Radio "CSV" 68 42 55 $true -$radXferHtml = New-Radio "HTML" 125 42 60 $false - -$btnXferCsvImport = New-Object System.Windows.Forms.Button -$btnXferCsvImport.Text = T "btn.xfer.csv" -$btnXferCsvImport.Location = New-Object System.Drawing.Point(250, 40) -$btnXferCsvImport.Size = New-Object System.Drawing.Size(118, 24) - -$lblXferCsvInfo = New-Object System.Windows.Forms.Label -$lblXferCsvInfo.Text = "" -$lblXferCsvInfo.Location = New-Object System.Drawing.Point(374, 43) -$lblXferCsvInfo.Size = New-Object System.Drawing.Size(175, 18) -$lblXferCsvInfo.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 212) - -$btnXferCsvClear = New-Object System.Windows.Forms.Button -$btnXferCsvClear.Text = T "btn.xfer.csv.clear" -$btnXferCsvClear.Location = New-Object System.Drawing.Point(554, 40) -$btnXferCsvClear.Size = New-Object System.Drawing.Size(55, 24) -$btnXferCsvClear.Visible = $false - -$lblXferNote = New-Object System.Windows.Forms.Label -$lblXferNote.Text = T "xfer.note" -$lblXferNote.Location = New-Object System.Drawing.Point(10, 72) -$lblXferNote.Size = New-Object System.Drawing.Size(600, 16) -$lblXferNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic) -$lblXferNote.ForeColor = [System.Drawing.Color]::DimGray - -$grpXferOpts.Controls.AddRange(@($chkXferRecursive, $chkXferOverwrite, $chkXferCreateFolders, - $lblXferFmt, $radXferCsv, $radXferHtml, - $btnXferCsvImport, $lblXferCsvInfo, $btnXferCsvClear, - $lblXferNote)) - -# ── Buttons (y=260) ────────────────────────────────────────────────────────── -$btnXferStart = New-ActionBtn (T "btn.xfer.start") 10 260 ([System.Drawing.Color]::FromArgb(0, 120, 60)) - -$btnXferVerify = New-Object System.Windows.Forms.Button -$btnXferVerify.Text = T "btn.xfer.verify" -$btnXferVerify.Location = New-Object System.Drawing.Point(175, 260) -$btnXferVerify.Size = New-Object System.Drawing.Size(130, 34) -$btnXferVerify.BackColor = [System.Drawing.Color]::FromArgb(0, 120, 212) -$btnXferVerify.ForeColor = [System.Drawing.Color]::White -$btnXferVerify.FlatStyle = "Flat" -$btnXferVerify.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) - -$btnXferOpen = New-Object System.Windows.Forms.Button -$btnXferOpen.Text = T "btn.xfer.open" -$btnXferOpen.Location = New-Object System.Drawing.Point(315, 260) -$btnXferOpen.Size = New-Object System.Drawing.Size(130, 34) -$btnXferOpen.Enabled = $false - -$tabTransfer.Controls.AddRange(@($grpXferSrc, $grpXferDst, $grpXferOpts, $btnXferStart, $btnXferVerify, $btnXferOpen)) - -# ══════════════════════════════════════════════════════════════════════════════ -# Tab 7 – Bulk Create -# ══════════════════════════════════════════════════════════════════════════════ -$tabBulk = New-Object System.Windows.Forms.TabPage -$tabBulk.Text = T "tab.bulk" - -# ── GroupBox: Site list ────────────────────────────────────────────────────── -$grpBulkList = New-Group (T "grp.bulk.list") 10 4 620 230 - -$lvBulk = New-Object System.Windows.Forms.ListView -$lvBulk.Location = New-Object System.Drawing.Point(10, 18) -$lvBulk.Size = New-Object System.Drawing.Size(598, 168) -$lvBulk.View = "Details" -$lvBulk.FullRowSelect = $true -$lvBulk.GridLines = $true -$lvBulk.Font = New-Object System.Drawing.Font("Segoe UI", 8.5) -$lvBulk.Columns.Add((T "bulk.col.name"), 120) | Out-Null -$lvBulk.Columns.Add((T "bulk.col.alias"), 90) | Out-Null -$lvBulk.Columns.Add((T "bulk.col.type"), 60) | Out-Null -$lvBulk.Columns.Add((T "bulk.col.template"), 90) | Out-Null -$lvBulk.Columns.Add((T "bulk.col.owners"), 110) | Out-Null -$lvBulk.Columns.Add((T "bulk.col.members"), 110) | Out-Null - -$btnBulkAdd = New-Object System.Windows.Forms.Button -$btnBulkAdd.Text = T "btn.bulk.add" -$btnBulkAdd.Location = New-Object System.Drawing.Point(10, 192) -$btnBulkAdd.Size = New-Object System.Drawing.Size(120, 28) - -$btnBulkCsv = New-Object System.Windows.Forms.Button -$btnBulkCsv.Text = T "btn.bulk.csv" -$btnBulkCsv.Location = New-Object System.Drawing.Point(138, 192) -$btnBulkCsv.Size = New-Object System.Drawing.Size(120, 28) - -$btnBulkRemove = New-Object System.Windows.Forms.Button -$btnBulkRemove.Text = T "btn.bulk.remove" -$btnBulkRemove.Location = New-Object System.Drawing.Point(266, 192) -$btnBulkRemove.Size = New-Object System.Drawing.Size(90, 28) - -$btnBulkClear = New-Object System.Windows.Forms.Button -$btnBulkClear.Text = T "btn.bulk.clear" -$btnBulkClear.Location = New-Object System.Drawing.Point(364, 192) -$btnBulkClear.Size = New-Object System.Drawing.Size(90, 28) - -$grpBulkList.Controls.AddRange(@($lvBulk, $btnBulkAdd, $btnBulkCsv, $btnBulkRemove, $btnBulkClear)) - -# ── Create button ──────────────────────────────────────────────────────────── -$btnBulkCreate = New-ActionBtn (T "btn.bulk.create") 10 240 ([System.Drawing.Color]::FromArgb(0, 120, 60)) -$btnBulkCreate.Size = New-Object System.Drawing.Size(200, 34) - -$tabBulk.Controls.AddRange(@($grpBulkList, $btnBulkCreate)) - -# ══════════════════════════════════════════════════════════════════════════════ -# Tab 8 – Structure (folder tree from CSV) -# ══════════════════════════════════════════════════════════════════════════════ -$tabStruct = New-Object System.Windows.Forms.TabPage -$tabStruct.Text = T "tab.structure" - -# ── CSV import + target (single row) ─────────────────────────────────────── -$grpStructCsv = New-Group (T "grp.struct.csv") 10 4 620 52 - -$lblStructDesc = New-Object System.Windows.Forms.Label -$lblStructDesc.Text = T "lbl.struct.desc" -$lblStructDesc.Location = New-Object System.Drawing.Point(10, 20) -$lblStructDesc.Size = New-Object System.Drawing.Size(460, 20) - -$btnStructCsv = New-Object System.Windows.Forms.Button -$btnStructCsv.Text = T "btn.struct.csv" -$btnStructCsv.Location = New-Object System.Drawing.Point(490, 18) -$btnStructCsv.Size = New-Object System.Drawing.Size(118, 26) - -$grpStructCsv.Controls.AddRange(@($lblStructDesc, $btnStructCsv)) - -# ── Preview ──────────────────────────────────────────────────────────────── -$grpStructPreview = New-Group (T "grp.struct.preview") 10 58 620 148 - -$tvStruct = New-Object System.Windows.Forms.TreeView -$tvStruct.Location = New-Object System.Drawing.Point(10, 18) -$tvStruct.Size = New-Object System.Drawing.Size(598, 120) -$tvStruct.Font = New-Object System.Drawing.Font("Segoe UI", 9) -$tvStruct.ShowLines = $true -$tvStruct.ShowPlusMinus = $true - -$grpStructPreview.Controls.Add($tvStruct) - -# ── Target + Buttons (single row) ───────────────────────────────────────── -$lblStructLib = New-Object System.Windows.Forms.Label -$lblStructLib.Text = T "lbl.struct.library" -$lblStructLib.Location = New-Object System.Drawing.Point(12, 214) -$lblStructLib.Size = New-Object System.Drawing.Size(110, 20) - -$txtStructLib = New-Object System.Windows.Forms.TextBox -$txtStructLib.Location = New-Object System.Drawing.Point(124, 212) -$txtStructLib.Size = New-Object System.Drawing.Size(200, 22) -$txtStructLib.PlaceholderText = T "ph.struct.library" - -$btnStructCreate = New-ActionBtn (T "btn.struct.create") 340 208 ([System.Drawing.Color]::FromArgb(0, 120, 212)) -$btnStructCreate.Size = New-Object System.Drawing.Size(180, 30) - -$btnStructClear = New-Object System.Windows.Forms.Button -$btnStructClear.Text = T "btn.struct.clear" -$btnStructClear.Location = New-Object System.Drawing.Point(528, 208) -$btnStructClear.Size = New-Object System.Drawing.Size(90, 30) - -$tabStruct.Controls.AddRange(@($grpStructCsv, $grpStructPreview, $lblStructLib, $txtStructLib, $btnStructCreate, $btnStructClear)) - -# ══════════════════════════════════════════════════════════════════════════════ -# Tab 9 – Version Cleanup -# ══════════════════════════════════════════════════════════════════════════════ -$tabVersions = New-Object System.Windows.Forms.TabPage -$tabVersions.Text = T "tab.versions" -$tabVersions.BackColor = [System.Drawing.Color]::WhiteSmoke - -# ── Versions to keep ───────────────────────────────────────────────────────── -$grpVerKeep = New-Group (T "grp.ver.keep") 10 4 620 110 - -$lblVerCount = New-Object System.Windows.Forms.Label -$lblVerCount.Text = T "lbl.ver.count" -$lblVerCount.Location = New-Object System.Drawing.Point(10, 22) -$lblVerCount.Size = New-Object System.Drawing.Size(220, 20) - -$nudVerCount = New-Object System.Windows.Forms.NumericUpDown -$nudVerCount.Location = New-Object System.Drawing.Point(235, 20) -$nudVerCount.Size = New-Object System.Drawing.Size(70, 22) -$nudVerCount.Minimum = 0 -$nudVerCount.Maximum = 500 -$nudVerCount.Value = 5 - -$chkVerDate = New-Check (T "chk.ver.date") 10 50 250 $false - -$radVerBefore = New-Radio (T "rad.ver.before") 30 74 200 $true -$radVerBefore.Enabled = $false -$radVerAfter = New-Radio (T "rad.ver.after") 30 96 200 $false -$radVerAfter.Enabled = $false - -$dtpVer = New-Object System.Windows.Forms.DateTimePicker -$dtpVer.Location = New-Object System.Drawing.Point(235, 74) -$dtpVer.Size = New-Object System.Drawing.Size(150, 22) -$dtpVer.Format = [System.Windows.Forms.DateTimePickerFormat]::Short -$dtpVer.Enabled = $false - -$chkVerDate.Add_CheckedChanged({ - $on = $chkVerDate.Checked - $radVerBefore.Enabled = $on - $radVerAfter.Enabled = $on - $dtpVer.Enabled = $on -}) - -$grpVerKeep.Controls.AddRange(@($lblVerCount, $nudVerCount, $chkVerDate, $radVerBefore, $radVerAfter, $dtpVer)) - -# ── Scope ───────────────────────────────────────────────────────────────────── -$grpVerScope = New-Group (T "grp.ver.scope") 10 118 620 76 - -$lblVerLib = New-Object System.Windows.Forms.Label -$lblVerLib.Text = T "lbl.ver.library" -$lblVerLib.Location = New-Object System.Drawing.Point(10, 22) -$lblVerLib.Size = New-Object System.Drawing.Size(150, 20) - -$txtVerLib = New-Object System.Windows.Forms.TextBox -$txtVerLib.Location = New-Object System.Drawing.Point(164, 20) -$txtVerLib.Size = New-Object System.Drawing.Size(230, 22) -$txtVerLib.PlaceholderText = T "ph.ver.library" - -$chkVerRecursive = New-Check (T "chk.ver.recursive") 10 48 260 $true -$chkVerSubsites = New-Check (T "chk.ver.subsites") 280 48 200 $false - -$grpVerScope.Controls.AddRange(@($lblVerLib, $txtVerLib, $chkVerRecursive, $chkVerSubsites)) - -# ── Options + Buttons ───────────────────────────────────────────────────────── -$chkVerDryRun = New-Check (T "chk.ver.dryrun") 12 200 350 $true - -$btnVerRun = New-ActionBtn (T "btn.ver.run") 10 228 ([System.Drawing.Color]::FromArgb(180, 60, 20)) -$btnVerRun.Size = New-Object System.Drawing.Size(180, 30) - -$btnVerOpen = New-Object System.Windows.Forms.Button -$btnVerOpen.Text = T "btn.ver.open" -$btnVerOpen.Location = New-Object System.Drawing.Point(200, 228) -$btnVerOpen.Size = New-Object System.Drawing.Size(130, 30) -$btnVerOpen.Enabled = $false - -$tabVersions.Controls.AddRange(@($grpVerKeep, $grpVerScope, $chkVerDryRun, $btnVerRun, $btnVerOpen)) - -$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes, $tabTransfer, $tabBulk, $tabStruct, $tabVersions)) - -# ── Progress bar ─────────────────────────────────────────────────────────────── -$progressBar = New-Object System.Windows.Forms.ProgressBar -$progressBar.Location = New-Object System.Drawing.Point(20, 540) -$progressBar.Size = New-Object System.Drawing.Size(642, 16) -$progressBar.Style = "Continuous" -$progressBar.Minimum = 0 -$progressBar.Maximum = 100 -$progressBar.Value = 0 -$progressBar.Visible = $false - -# Animation timer: sweeps 0→100 then resets, driven by UI-thread timer -$script:_AnimTimer = New-Object System.Windows.Forms.Timer -$script:_AnimTimer.Interval = 30 # ~33 fps -$script:_AnimTimer.Add_Tick({ - $v = $progressBar.Value + 2 - if ($v -gt 100) { $v = 0 } - $progressBar.Value = $v -}) - -function Start-ProgressAnim { - $progressBar.Value = 0 - $progressBar.Visible = $true - $script:_AnimTimer.Start() -} -function Stop-ProgressAnim { - $script:_AnimTimer.Stop() - $progressBar.Value = 0 - $progressBar.Visible = $false -} - -# ── Log ──────────────────────────────────────────────────────────────────────── -$lblLog = New-Object System.Windows.Forms.Label -$lblLog.Text = T "lbl.log" -$lblLog.Location = New-Object System.Drawing.Point(20, 564) -$lblLog.Size = New-Object System.Drawing.Size(60, 20) - -$txtLog = New-Object System.Windows.Forms.RichTextBox -$txtLog.Location = New-Object System.Drawing.Point(20, 584) -$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 = @() -$script:_SiteCache = @() - -# Si l'utilisateur re-saisit manuellement, effacer la multi-sélection du picker -$txtSiteURL.Add_TextChanged({ - if ($txtSiteURL.Enabled -and $script:SelectedSites.Count -gt 0) { - $script:SelectedSites = @() - $btnBrowseSites.Text = T "btn.view.sites" - } -}) - -$form.Controls.AddRange(@( - $menuStrip, - $lblProfile, $cboProfile, - $btnProfileNew, $btnProfileSave, $btnProfileRename, $btnProfileDelete, - $lblTenantUrl, $txtTenantUrl, $btnBrowseSites, - $lblClientId, $txtClientId, $btnRegisterApp, - $lblSiteURL, $txtSiteURL, - $lblOutput, $txtOutput, $btnBrowse, - $sep, $tabs, - $progressBar, - $lblLog, $txtLog -)) - -# ── i18n control registration ────────────────────────────────────────────────── -$script:i18nMap = [System.Collections.Generic.Dictionary[string,object]]::new() -$script:i18nTabs = [System.Collections.Generic.Dictionary[string,object]]::new() -$script:i18nMenus = [System.Collections.Generic.Dictionary[string,object]]::new() - -$_reg = { - param($dict, $ctrl, $key) - $dict[[System.Guid]::NewGuid().ToString()] = [PSCustomObject]@{ Control = $ctrl; Key = $key } -} - -# Main labels & buttons -& $_reg $script:i18nMap $lblProfile "profile" -& $_reg $script:i18nMap $btnProfileNew "btn.new" -& $_reg $script:i18nMap $btnProfileSave "btn.save" -& $_reg $script:i18nMap $btnProfileRename "btn.rename" -& $_reg $script:i18nMap $btnProfileDelete "btn.delete" -& $_reg $script:i18nMap $btnBrowseSites "btn.view.sites" -& $_reg $script:i18nMap $btnRegisterApp "btn.register.app" -& $_reg $script:i18nMap $lblTenantUrl "tenant.url" -& $_reg $script:i18nMap $lblClientId "client.id" -& $_reg $script:i18nMap $lblSiteURL "site.url" -& $_reg $script:i18nMap $lblOutput "output.folder" -& $_reg $script:i18nMap $btnBrowse "btn.browse" -& $_reg $script:i18nMap $lblLog "lbl.log" - -# Permissions tab controls -& $_reg $script:i18nMap $grpPermOpts "grp.scan.opts" -& $_reg $script:i18nMap $chkScanFolders "chk.scan.folders" -& $_reg $script:i18nMap $chkRecursive "chk.recursive" -& $_reg $script:i18nMap $lblPermDepth "lbl.folder.depth" -& $_reg $script:i18nMap $chkPermMaxDepth "chk.max.depth" -& $_reg $script:i18nMap $chkInheritedPerms "chk.inherited.perms" -& $_reg $script:i18nMap $grpPermFmt "grp.export.fmt" -& $_reg $script:i18nMap $radPermCSV "rad.csv.perms" -& $_reg $script:i18nMap $radPermHTML "rad.html.perms" -& $_reg $script:i18nMap $btnGenPerms "btn.gen.perms" -& $_reg $script:i18nMap $btnOpenPerms "btn.open.perms" - -# Storage tab controls -& $_reg $script:i18nMap $grpStorOpts "grp.scan.opts" -& $_reg $script:i18nMap $chkStorPerLib "chk.per.lib" -& $_reg $script:i18nMap $chkStorSubsites "chk.subsites" -& $_reg $script:i18nMap $lblDepth "lbl.folder.depth" -& $_reg $script:i18nMap $chkMaxDepth "chk.max.depth" -& $_reg $script:i18nMap $lblStorNote "stor.note" -& $_reg $script:i18nMap $grpStorFmt "grp.export.fmt" -& $_reg $script:i18nMap $radStorCSV "rad.csv.perms" -& $_reg $script:i18nMap $radStorHTML "rad.html.perms" -& $_reg $script:i18nMap $btnGenStorage "btn.gen.storage" -& $_reg $script:i18nMap $btnOpenStorage "btn.open.storage" - -# Templates tab controls -& $_reg $script:i18nMap $lblTplDesc "tpl.desc" -& $_reg $script:i18nMap $btnOpenTplMgr "btn.manage.tpl" - -# Search tab controls -& $_reg $script:i18nMap $grpSearchFilters "grp.search.filters" -& $_reg $script:i18nMap $lblSrchExt "lbl.extensions" -& $_reg $script:i18nMap $lblSrchRegex "lbl.regex" -& $_reg $script:i18nMap $chkSrchCrA "chk.created.after" -& $_reg $script:i18nMap $chkSrchCrB "chk.created.before" -& $_reg $script:i18nMap $chkSrchModA "chk.modified.after" -& $_reg $script:i18nMap $chkSrchModB "chk.modified.before" -& $_reg $script:i18nMap $lblSrchCrBy "lbl.created.by" -& $_reg $script:i18nMap $lblSrchModBy "lbl.modified.by" -& $_reg $script:i18nMap $lblSrchLib "lbl.library" -& $_reg $script:i18nMap $grpSearchFmt "grp.search.fmt" -& $_reg $script:i18nMap $lblSrchMax "lbl.max.results" -& $_reg $script:i18nMap $btnSearch "btn.run.search" -& $_reg $script:i18nMap $btnOpenSearch "btn.open.search" - -# Duplicates tab controls -& $_reg $script:i18nMap $grpDupType "grp.dup.type" -& $_reg $script:i18nMap $radDupFiles "rad.dup.files" -& $_reg $script:i18nMap $radDupFolders "rad.dup.folders" -& $_reg $script:i18nMap $grpDupCrit "grp.dup.criteria" -& $_reg $script:i18nMap $lblDupNote "lbl.dup.note" -& $_reg $script:i18nMap $chkDupSize "chk.dup.size" -& $_reg $script:i18nMap $chkDupCreated "chk.dup.created" -& $_reg $script:i18nMap $chkDupModified "chk.dup.modified" -& $_reg $script:i18nMap $chkDupSubCount "chk.dup.subfolders" -& $_reg $script:i18nMap $chkDupFileCount "chk.dup.filecount" -& $_reg $script:i18nMap $grpDupOpts "grp.options" -& $_reg $script:i18nMap $chkDupSubsites "chk.include.subsites" -& $_reg $script:i18nMap $lblDupLib "lbl.library" -& $_reg $script:i18nMap $btnScanDupes "btn.run.scan" -& $_reg $script:i18nMap $btnOpenDupes "btn.open.results" - -# Transfer tab controls -& $_reg $script:i18nMap $grpXferSrc "grp.xfer.source" -& $_reg $script:i18nMap $lblXferSrcSite "lbl.xfer.site" -& $_reg $script:i18nMap $lblXferSrcLib "lbl.xfer.library" -& $_reg $script:i18nMap $grpXferDst "grp.xfer.dest" -& $_reg $script:i18nMap $lblXferDstSite "lbl.xfer.site" -& $_reg $script:i18nMap $lblXferDstLib "lbl.xfer.library" -& $_reg $script:i18nMap $grpXferOpts "grp.xfer.options" -& $_reg $script:i18nMap $chkXferRecursive "chk.xfer.recursive" -& $_reg $script:i18nMap $chkXferOverwrite "chk.xfer.overwrite" -& $_reg $script:i18nMap $chkXferCreateFolders "chk.xfer.create.folders" -& $_reg $script:i18nMap $lblXferFmt "lbl.xfer.report.fmt" -& $_reg $script:i18nMap $btnXferCsvImport "btn.xfer.csv" -& $_reg $script:i18nMap $btnXferCsvClear "btn.xfer.csv.clear" -& $_reg $script:i18nMap $lblXferNote "xfer.note" -& $_reg $script:i18nMap $btnXferStart "btn.xfer.start" -& $_reg $script:i18nMap $btnXferVerify "btn.xfer.verify" -& $_reg $script:i18nMap $btnXferOpen "btn.xfer.open" - -# Bulk Create tab controls -& $_reg $script:i18nMap $grpBulkList "grp.bulk.list" -& $_reg $script:i18nMap $btnBulkAdd "btn.bulk.add" -& $_reg $script:i18nMap $btnBulkCsv "btn.bulk.csv" -& $_reg $script:i18nMap $btnBulkRemove "btn.bulk.remove" -& $_reg $script:i18nMap $btnBulkClear "btn.bulk.clear" -& $_reg $script:i18nMap $btnBulkCreate "btn.bulk.create" -& $_reg $script:i18nMap $lblStructDesc "lbl.struct.desc" -& $_reg $script:i18nMap $btnStructCsv "btn.struct.csv" -& $_reg $script:i18nMap $lblStructLib "lbl.struct.library" -& $_reg $script:i18nMap $btnStructCreate "btn.struct.create" -& $_reg $script:i18nMap $btnStructClear "btn.struct.clear" -# Version Cleanup tab -& $_reg $script:i18nMap $lblVerCount "lbl.ver.count" -& $_reg $script:i18nMap $chkVerDate "chk.ver.date" -& $_reg $script:i18nMap $radVerBefore "rad.ver.before" -& $_reg $script:i18nMap $radVerAfter "rad.ver.after" -& $_reg $script:i18nMap $lblVerLib "lbl.ver.library" -& $_reg $script:i18nMap $chkVerRecursive "chk.ver.recursive" -& $_reg $script:i18nMap $chkVerSubsites "chk.ver.subsites" -& $_reg $script:i18nMap $chkVerDryRun "chk.ver.dryrun" -& $_reg $script:i18nMap $btnVerRun "btn.ver.run" -& $_reg $script:i18nMap $btnVerOpen "btn.ver.open" -& $_reg $script:i18nMap $grpVerKeep "grp.ver.keep" -& $_reg $script:i18nMap $grpVerScope "grp.ver.scope" - -# Tab pages -& $_reg $script:i18nTabs $tabPerms "tab.perms" -& $_reg $script:i18nTabs $tabStorage "tab.storage" -& $_reg $script:i18nTabs $tabTemplates "tab.templates" -& $_reg $script:i18nTabs $tabSearch "tab.search" -& $_reg $script:i18nTabs $tabDupes "tab.dupes" -& $_reg $script:i18nTabs $tabTransfer "tab.transfer" -& $_reg $script:i18nTabs $tabBulk "tab.bulk" -& $_reg $script:i18nTabs $tabStruct "tab.structure" -& $_reg $script:i18nTabs $tabVersions "tab.versions" - -# Menu items -& $_reg $script:i18nMenus $menuSettings "menu.settings" -& $_reg $script:i18nMenus $menuJsonFolder "menu.json.folder" -& $_reg $script:i18nMenus $menuLang "menu.language" - -# Placeholder texts -$script:i18nPlaceholders = [System.Collections.Generic.Dictionary[string,object]]::new() -& $_reg $script:i18nPlaceholders $txtSrchExt "ph.extensions" -& $_reg $script:i18nPlaceholders $txtSrchRegex "ph.regex" -& $_reg $script:i18nPlaceholders $txtSrchCrBy "ph.created.by" -& $_reg $script:i18nPlaceholders $txtSrchModBy "ph.modified.by" -& $_reg $script:i18nPlaceholders $txtSrchLib "ph.library" -& $_reg $script:i18nPlaceholders $txtDupLib "ph.dup.lib" -& $_reg $script:i18nPlaceholders $txtStructLib "ph.struct.library" -& $_reg $script:i18nPlaceholders $txtXferSrcSite "ph.xfer.site" -& $_reg $script:i18nPlaceholders $txtXferSrcLib "ph.xfer.library" -& $_reg $script:i18nPlaceholders $txtXferDstSite "ph.xfer.site" -& $_reg $script:i18nPlaceholders $txtXferDstLib "ph.xfer.library" -& $_reg $script:i18nPlaceholders $txtVerLib "ph.ver.library" - -#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 } -}) - -$menuJsonFolder.Add_Click({ - $dlg = New-Object System.Windows.Forms.FolderBrowserDialog - $dlg.Description = T "dlg.json.folder.desc" - $dlg.SelectedPath = if ($script:DataFolder -and (Test-Path $script:DataFolder)) { - $script:DataFolder - } else { - if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } - } - if ($dlg.ShowDialog() -ne "OK") { return } - $newDir = $dlg.SelectedPath - if (-not (Test-Path $newDir)) { - $msg = (T "dlg.folder.not.found") -f $newDir - $res = [System.Windows.Forms.MessageBox]::Show( - $msg, (T "dlg.folder.not.found.title"), "YesNo", "Question") - if ($res -eq "Yes") { - try { New-Item -ItemType Directory -Path $newDir | Out-Null } - catch { - [System.Windows.Forms.MessageBox]::Show( - $_.Exception.Message, "Error", "OK", "Error") - return - } - } else { return } - } - $script:DataFolder = $newDir - Save-Settings -DataFolder $newDir -Lang $script:CurrentLang - Refresh-ProfileList - $n = (Load-Templates).Count - $lblTplCount.Text = "$n $(T 'tpl.count')" -}) - -# ── Language menu handlers ───────────────────────────────────────────────────── -function Switch-AppLanguage([string]$code) { - Load-Language $code - Update-UILanguage - foreach ($mi in $menuLang.DropDownItems) { - if ($mi -is [System.Windows.Forms.ToolStripMenuItem]) { - $mi.Checked = ($mi.Tag -eq $script:CurrentLang) - } - } - Save-Settings -DataFolder $script:DataFolder -Lang $script:CurrentLang - $n = (Load-Templates).Count - $lblTplCount.Text = "$n $(T 'tpl.count')" -} - -$menuLangEn.Add_Click({ Switch-AppLanguage "en" }) -foreach ($mi in @($menuLang.DropDownItems | Where-Object { $_ -is [System.Windows.Forms.ToolStripMenuItem] -and $_.Tag -ne "en" })) { - $mi.Add_Click({ Switch-AppLanguage $args[0].Tag }) -} - -$btnRegisterApp.Add_Click({ - $tenantUrl = $txtTenantUrl.Text.Trim() - if ([string]::IsNullOrWhiteSpace($tenantUrl)) { - [System.Windows.Forms.MessageBox]::Show( - (T "reg.err.no.tenant"), (T "reg.title"), "OK", "Warning") - return - } - $confirm = [System.Windows.Forms.MessageBox]::Show( - ((T "reg.confirm") -f $tenantUrl), - (T "reg.title"), "YesNo", "Question") - if ($confirm -ne "Yes") { return } - - # ── Derive tenant identifier ────────────────────────────────────────────── - if ($tenantUrl -match 'https://([^.]+)\.sharepoint\.com') { - $tenantId = "$($Matches[1]).onmicrosoft.com" - } else { - [System.Windows.Forms.MessageBox]::Show( - (T "reg.err.tenant"), (T "reg.title"), "OK", "Error") - return - } - - $btnRegisterApp.Enabled = $false - $btnRegisterApp.Text = T "reg.in.progress" - Write-Log "Registering app on $tenantId ..." - - # ── Write a temp script and launch a real PowerShell console ────────────── - $resultFile = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_RegResult.json" - if (Test-Path $resultFile) { Remove-Item $resultFile -Force } - $script:_regResultFile = $resultFile - - $scriptContent = @" -`$Host.UI.RawUI.WindowTitle = "SharePoint Toolbox - App Registration" -try { - Import-Module PnP.PowerShell -ErrorAction Stop -} catch { - Write-Host "ERROR: PnP.PowerShell module not found." -ForegroundColor Red - Write-Host `$_.Exception.Message -ForegroundColor Red - @{ Error = `$_.Exception.Message } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8 - Read-Host "Press Enter to close" - exit -} -Write-Host "Registering app on $tenantId ..." -ForegroundColor Cyan -Write-Host "A browser window will open for authentication." -ForegroundColor Yellow -Write-Host "" -try { - `$result = Register-PnPEntraIDAppForInteractiveLogin `` - -ApplicationName "SharePoint Toolbox" `` - -Tenant "$tenantId" - `$clientId = `$result.'AzureAppId/ClientId' - if (`$clientId) { - Write-Host "Success! Client ID: `$clientId" -ForegroundColor Green - } else { - Write-Host "WARNING: No Client ID returned." -ForegroundColor Yellow - } - @{ ClientId = `$clientId } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8 -} catch { - Write-Host "ERROR: `$(`$_.Exception.Message)" -ForegroundColor Red - @{ Error = `$_.Exception.Message } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8 -} -Read-Host "Press Enter to close" -"@ - $scriptFile = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_RegApp.ps1" - $scriptContent | Set-Content -Path $scriptFile -Encoding UTF8 - - $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source - if (-not $pwshPath) { - $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text) - $btnRegisterApp.Text = T "btn.register.app" - Write-Log "PowerShell 7+ (pwsh) not found." "Red" - [System.Windows.Forms.MessageBox]::Show( - (T "reg.err.nopwsh"), (T "reg.title"), "OK", "Error") - return - } - Start-Process $pwshPath -ArgumentList "-ExecutionPolicy Bypass -File `"$scriptFile`"" - - # ── Timer polls for the result file ────────────────────────────────────── - $tmr = New-Object System.Windows.Forms.Timer - $tmr.Interval = 500 - $script:_regTimer = $tmr - - $tmr.Add_Tick({ - if (Test-Path $script:_regResultFile) { - $script:_regTimer.Stop(); $script:_regTimer.Dispose() - $btnRegisterApp.Text = T "btn.register.app" - - try { - $res = Get-Content $script:_regResultFile -Raw | ConvertFrom-Json - Remove-Item $script:_regResultFile -Force -ErrorAction SilentlyContinue - } catch { - Write-Log "Failed to read registration result." "Red" - $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text) - return - } - - if ($res.Error) { - Write-Log "App registration failed: $($res.Error)" "Red" - $btnRegisterApp.Enabled = $true - [System.Windows.Forms.MessageBox]::Show( - ((T "reg.err.failed") -f $res.Error), - (T "reg.title"), "OK", "Error") - } elseif ($res.ClientId) { - $script:txtClientId.Text = $res.ClientId - Write-Log "App registered. Client ID: $($res.ClientId)" - [System.Windows.Forms.MessageBox]::Show( - ((T "reg.success") -f $res.ClientId), - (T "reg.title"), "OK", "Information") - } else { - Write-Log "Registration returned no Client ID." "Red" - $btnRegisterApp.Enabled = $true - [System.Windows.Forms.MessageBox]::Show( - (T "reg.err.no.id"), (T "reg.title"), "OK", "Error") - } - } else { - $dot = "." * (([System.DateTime]::Now.Second % 4) + 1) - $btnRegisterApp.Text = (T "reg.in.progress") -replace '\.\.\.$', $dot - } - }) - $tmr.Start() -}) - -$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 ` - -InitialSites $script:_SiteCache ` - -PreSelected $script:SelectedSites - if ($null -eq $selected) { return } # Cancel — ne rien changer - - $script:SelectedSites = @($selected) - $n = $selected.Count - - if ($n -gt 1) { - # Plusieurs sites : griser le champ - $txtSiteURL.Text = "" - $txtSiteURL.Enabled = $false - $txtSiteURL.BackColor = [System.Drawing.Color]::FromArgb(224, 224, 224) - $btnBrowseSites.Text = "Sites ($n)" - } elseif ($n -eq 1) { - $txtSiteURL.Text = $selected[0] - $txtSiteURL.Enabled = $true - $txtSiteURL.BackColor = [System.Drawing.Color]::White - $btnBrowseSites.Text = T "btn.view.sites" - } else { - # 0 sélectionnés (OK avec rien) — réinitialiser - $txtSiteURL.Text = "" - $txtSiteURL.Enabled = $true - $txtSiteURL.BackColor = [System.Drawing.Color]::White - $btnBrowseSites.Text = T "btn.view.sites" - } -}) - -# ── 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() - Start-ProgressAnim - - $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 - Stop-ProgressAnim -}) - -$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 - Stop-ProgressAnim - 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() - Start-ProgressAnim - - $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() - Start-ProgressAnim - 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 - Stop-ProgressAnim - - 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() - Start-ProgressAnim - 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) { - # Ignore version history entries (SharePoint stores them under /_vti_history/) - if ($h.Path -match '/_vti_history/') { continue } - $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 - Stop-ProgressAnim - - 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 } -}) - -# ── Transfer ────────────────────────────────────────────────────────────────── - -# ── CSV Import for bulk transfers ───────────────────────────────────────────── -$script:_XferCsvEntries = [System.Collections.Generic.List[object]]::new() - -$btnXferCsvImport.Add_Click({ - $ofd = New-Object System.Windows.Forms.OpenFileDialog - $ofd.Filter = "CSV Files (*.csv)|*.csv" - $ofd.Title = "Import Transfer CSV" - if ($ofd.ShowDialog() -ne "OK") { return } - - $script:_XferCsvEntries.Clear() - try { - $rows = Import-Csv -Path $ofd.FileName -Delimiter ';' -Encoding UTF8 - foreach ($r in $rows) { - $src = ($r.PSObject.Properties | Where-Object { $_.Name -match '^SourceSite$' }).Value - $dst = ($r.PSObject.Properties | Where-Object { $_.Name -match '^DestSite$' }).Value - $sl = ($r.PSObject.Properties | Where-Object { $_.Name -match '^SourceLibrary$' }).Value - $dl = ($r.PSObject.Properties | Where-Object { $_.Name -match '^DestLibrary$' }).Value - if ($src -and $sl) { - $script:_XferCsvEntries.Add(@{ - SrcSite = $src.Trim() - DstSite = if ($dst) { $dst.Trim() } else { $src.Trim() } - SrcLib = $sl.Trim() - DstLib = if ($dl) { $dl.Trim() } else { $sl.Trim() } - }) - } - } - $n = $script:_XferCsvEntries.Count - $lblXferCsvInfo.Text = (T "lbl.xfer.csv.info") -f $n - $btnXferCsvClear.Visible = $true - $txtXferSrcSite.Enabled = $false; $txtXferSrcLib.Enabled = $false - $txtXferDstSite.Enabled = $false; $txtXferDstLib.Enabled = $false - Write-Log "CSV loaded: $n transfer(s)" "White" - } catch { - Write-Log "CSV import error: $($_.Exception.Message)" "Red" - } -}) - -$btnXferCsvClear.Add_Click({ - $script:_XferCsvEntries.Clear() - $lblXferCsvInfo.Text = "" - $btnXferCsvClear.Visible = $false - $txtXferSrcSite.Enabled = $true; $txtXferSrcLib.Enabled = $true - $txtXferDstSite.Enabled = $true; $txtXferDstLib.Enabled = $true -}) - -# ── Helper: build transfer jobs list (from CSV or manual fields) ────────────── -function Get-XferJobs { - if ($script:_XferCsvEntries.Count -gt 0) { - return @($script:_XferCsvEntries) - } - $srcSite = $txtXferSrcSite.Text.Trim() - $dstSite = $txtXferDstSite.Text.Trim() - $srcLib = $txtXferSrcLib.Text.Trim() - $dstLib = $txtXferDstLib.Text.Trim() - if (-not $srcSite) { Write-Log "URL du site source requis." "Red"; return @() } - if (-not $dstSite) { Write-Log "URL du site destination requis." "Red"; return @() } - if (-not $srcLib) { Write-Log "Bibliotheque source requise." "Red"; return @() } - if (-not $dstLib) { $dstLib = $srcLib } - if ($srcSite -eq $dstSite -and $srcLib -eq $dstLib) { - Write-Log "Source et destination identiques." "Red"; return @() - } - return @(@{ SrcSite = $srcSite; DstSite = $dstSite; SrcLib = $srcLib; DstLib = $dstLib }) -} - -# ── Helper: export transfer report ──────────────────────────────────────────── -function Export-XferReport([System.Collections.Generic.List[object]]$Results, [string]$OutDir, [string]$Prefix, [bool]$AsHtml) { - if ($Results.Count -eq 0) { return $null } - if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null } - $stamp = Get-Date -Format "yyyyMMdd_HHmmss" - - if (-not $AsHtml) { - $f = Join-Path $OutDir "${Prefix}_$stamp.csv" - $Results | Export-Csv -Path $f -NoTypeInformation -Encoding UTF8 - return $f - } - - # HTML report - $f = Join-Path $OutDir "${Prefix}_$stamp.html" - $okN = @($Results | Where-Object { $_.Status -eq "OK" }).Count - $errN = @($Results | Where-Object { $_.Status -eq "ERROR" }).Count - $skipN = @($Results | Where-Object { $_.Status -eq "SKIPPED" }).Count - $missN = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count - $mmN = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count - $exN = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count - - $rows = "" - foreach ($r in $Results) { - $color = switch ($r.Status) { - "OK" { "#e6f4ea" } - "ERROR" { "#fce8e6" } - "SKIPPED" { "#fff3cd" } - "MISSING" { "#fce8e6" } - "SIZE_MISMATCH" { "#fff3cd" } - "EXTRA" { "#e8f0fe" } - default { "#ffffff" } - } - $srcSz = if ($null -ne $r.SourceSize) { '{0:N0}' -f $r.SourceSize } else { "-" } - $dstSz = if ($null -ne $r.DestSize) { '{0:N0}' -f $r.DestSize } else { "-" } - $msg = if ($r.Message) { [System.Web.HttpUtility]::HtmlEncode($r.Message) } else { "" } - $rows += "$([System.Web.HttpUtility]::HtmlEncode($r.SourceSite))$([System.Web.HttpUtility]::HtmlEncode($r.File))$($r.Status)$srcSz$dstSz$msg`n" - } - - $html = @" -Transfer Report - - -

Transfer Report

-
-OK: $okNError: $errN -Skipped: $skipN / Mismatch: $mmNMissing: $missN -Extra: $exN
- - -$rows
SiteFileStatusSource SizeDest SizeMessage
-"@ - $html | Set-Content -Path $f -Encoding UTF8 - return $f -} - -# ── Transfer Start ──────────────────────────────────────────────────────────── - -$btnXferStart.Add_Click({ - $clientId = $txtClientId.Text.Trim() - if (-not $clientId) { Write-Log "Client ID requis." "Red"; return } - - $jobs = Get-XferJobs - if ($jobs.Count -eq 0) { return } - - $recursive = $chkXferRecursive.Checked - $overwrite = $chkXferOverwrite.Checked - $createFolder = $chkXferCreateFolders.Checked - $outDir = $txtOutput.Text.Trim() - if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } } - $asHtml = $radXferHtml.Checked - - $params = @{ - ClientId = $clientId - Jobs = @($jobs) - Recursive = $recursive - Overwrite = $overwrite - CreateFolders = $createFolder - OutFolder = $outDir - AsHtml = $asHtml - } - - $btnXferStart.Enabled = $false - $btnXferVerify.Enabled = $false - $btnXferOpen.Enabled = $false - $txtLog.Clear() - Start-ProgressAnim - Write-Log "=== TRANSFER ($($jobs.Count) job(s)) ===" "White" - foreach ($j in $jobs) { - Write-Log " $($j.SrcSite)/$($j.SrcLib) -> $($j.DstSite)/$($j.DstLib)" "Gray" - } - Write-Log "Recursive: $recursive Overwrite: $overwrite Create folders: $createFolder" "Gray" - Write-Log ("-" * 52) "DarkGray" - - $bgTransfer = { - param($Params, $Sync) - function BgLog([string]$m, [string]$c = "LightGreen") { - $Sync.Queue.Enqueue(@{ Text = $m; Color = $c }) - } - - function Get-AllSPFiles([string]$BasePath, [bool]$Recurse, [string]$Rel = "") { - $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue) - foreach ($f in $files) { - if ($f.ServerRelativeUrl -match '/_vti_history/') { continue } - [PSCustomObject]@{ - Name = $f.Name - ServerRelativeUrl = $f.ServerRelativeUrl - Length = $f.Length - RelativePath = "$Rel$($f.Name)" - RelativeFolder = $Rel - } - } - if ($Recurse) { - $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue) - foreach ($d in $folders) { - if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue } - Get-AllSPFiles "$BasePath/$($d.Name)" $Recurse "$Rel$($d.Name)/" - } - } - } - - try { - Import-Module PnP.PowerShell -ErrorAction Stop - $report = [System.Collections.Generic.List[object]]::new() - $totalOk = 0; $totalErr = 0; $totalSkip = 0 - - foreach ($job in $Params.Jobs) { - BgLog "--- $($job.SrcSite) / $($job.SrcLib) -> $($job.DstSite) / $($job.DstLib) ---" "White" - - # Enumerate source - BgLog "Connecting to source..." "Cyan" - Connect-PnPOnline -Url $job.SrcSite -Interactive -ClientId $Params.ClientId - $srcFiles = @(Get-AllSPFiles $job.SrcLib $Params.Recursive) - BgLog " $($srcFiles.Count) file(s) found" "LightGreen" - - if ($srcFiles.Count -eq 0) { - BgLog " No files to transfer." "DarkOrange" - continue - } - - # Download to temp - $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_Xfer_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$([guid]::NewGuid().ToString('N').Substring(0,6))" - New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null - - $idx = 0 - foreach ($f in $srcFiles) { - $idx++ - $localDir = Join-Path $tempRoot $f.RelativeFolder - if (-not (Test-Path $localDir)) { New-Item -ItemType Directory -Path $localDir -Force | Out-Null } - try { - Get-PnPFile -Url $f.ServerRelativeUrl -Path $localDir -FileName $f.Name -AsFile -Force - } catch { - BgLog " ERROR downloading $($f.RelativePath): $($_.Exception.Message)" "Red" - $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message="Download: $($_.Exception.Message)" }) - $totalErr++ - continue - } - } - - # Upload to destination - BgLog "Connecting to destination..." "Cyan" - Connect-PnPOnline -Url $job.DstSite -Interactive -ClientId $Params.ClientId - - $idx = 0 - foreach ($f in $srcFiles) { - $idx++ - $dstFolder = if ($f.RelativeFolder) { - "$($job.DstLib.TrimEnd('/'))/$($f.RelativeFolder.TrimEnd('/'))" - } else { $job.DstLib } - - # Check if dest folder exists / create if needed - if ($Params.CreateFolders) { - try { Resolve-PnPFolder -SiteRelativePath $dstFolder -ErrorAction Stop | Out-Null } catch { - BgLog " ERROR creating folder $dstFolder : $($_.Exception.Message)" "Red" - $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message="Folder creation: $($_.Exception.Message)" }) - $totalErr++ - continue - } - } - - $localFile = Join-Path (Join-Path $tempRoot $f.RelativeFolder) $f.Name - if (-not (Test-Path $localFile)) { continue } - - # Check for existing file if not overwriting - if (-not $Params.Overwrite) { - try { - $existing = Get-PnPFile -Url "$dstFolder/$($f.Name)" -ErrorAction SilentlyContinue - if ($existing) { - BgLog " [$idx/$($srcFiles.Count)] SKIPPED (exists): $($f.RelativePath)" "DarkOrange" - $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="SKIPPED"; SourceSize=$f.Length; DestSize=$null; Message="File already exists" }) - $totalSkip++ - continue - } - } catch {} - } - - try { - Add-PnPFile -Path $localFile -Folder $dstFolder -ErrorAction Stop | Out-Null - BgLog " [$idx/$($srcFiles.Count)] OK: $($f.RelativePath)" "LightGreen" - $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="OK"; SourceSize=$f.Length; DestSize=$f.Length; Message="" }) - $totalOk++ - } catch { - BgLog " [$idx/$($srcFiles.Count)] ERROR: $($f.RelativePath) - $($_.Exception.Message)" "Red" - $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message=$_.Exception.Message }) - $totalErr++ - } - } - - # Cleanup temp - if ($tempRoot -and (Test-Path $tempRoot)) { - Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue - } - } - - $Sync.Report = @($report) - $Sync.TotalOk = $totalOk - $Sync.TotalErr = $totalErr - $Sync.TotalSkip = $totalSkip - } 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 - Report = $null - TotalOk = 0 - TotalErr = 0 - TotalSkip = 0 - }) - $script:_XferSync = $sync - $script:_XferParams = $params - - $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($bgTransfer) - [void]$ps.AddArgument($params) - [void]$ps.AddArgument($sync) - $script:_XferRS = $rs - $script:_XferPS = $ps - $script:_XferHnd = $ps.BeginInvoke() - - $tmr = New-Object System.Windows.Forms.Timer - $tmr.Interval = 250 - $script:_XferTimer = $tmr - $tmr.Add_Tick({ - while ($script:_XferSync.Queue.Count -gt 0) { - $m = $script:_XferSync.Queue.Dequeue() - Write-Log $m.Text $m.Color - } - if ($script:_XferSync.Done) { - $script:_XferTimer.Stop(); $script:_XferTimer.Dispose() - while ($script:_XferSync.Queue.Count -gt 0) { - $m = $script:_XferSync.Queue.Dequeue(); Write-Log $m.Text $m.Color - } - try { [void]$script:_XferPS.EndInvoke($script:_XferHnd) } catch {} - try { $script:_XferRS.Close(); $script:_XferRS.Dispose() } catch {} - $btnXferStart.Enabled = $true - $btnXferVerify.Enabled = $true - Stop-ProgressAnim - - if ($script:_XferSync.Error) { - Write-Log "Echec du transfert : $($script:_XferSync.Error)" "Red" - return - } - - $ok = $script:_XferSync.TotalOk; $er = $script:_XferSync.TotalErr; $sk = $script:_XferSync.TotalSkip - Write-Log "=== TRANSFER COMPLETE: $ok OK, $er error(s), $sk skipped ===" "White" - - # Generate report - $report = $script:_XferSync.Report - if ($report -and $report.Count -gt 0) { - $p = $script:_XferParams - $rptFile = Export-XferReport ([System.Collections.Generic.List[object]]$report) $p.OutFolder "Transfer" $p.AsHtml - if ($rptFile) { - Write-Log "Report: $rptFile" "White" - $script:_XferLastReport = $rptFile - $btnXferOpen.Enabled = $true - } - } - } - }) - $tmr.Start() -}) - -# ── Verify ──────────────────────────────────────────────────────────────────── - -$btnXferVerify.Add_Click({ - $clientId = $txtClientId.Text.Trim() - if (-not $clientId) { Write-Log "Client ID requis." "Red"; return } - - $jobs = Get-XferJobs - if ($jobs.Count -eq 0) { return } - - $recursive = $chkXferRecursive.Checked - $outDir = $txtOutput.Text.Trim() - if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } } - $asHtml = $radXferHtml.Checked - - $params = @{ - ClientId = $clientId - Jobs = @($jobs) - Recursive = $recursive - OutFolder = $outDir - AsHtml = $asHtml - } - - $btnXferStart.Enabled = $false - $btnXferVerify.Enabled = $false - $btnXferOpen.Enabled = $false - $txtLog.Clear() - Start-ProgressAnim - Write-Log "=== VERIFICATION ($($jobs.Count) job(s)) ===" "White" - Write-Log ("-" * 52) "DarkGray" - - $bgVerify = { - param($Params, $Sync) - function BgLog([string]$m, [string]$c = "LightGreen") { - $Sync.Queue.Enqueue(@{ Text = $m; Color = $c }) - } - - function Get-AllSPFiles([string]$BasePath, [bool]$Recurse, [string]$Rel = "") { - $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue) - foreach ($f in $files) { - if ($f.ServerRelativeUrl -match '/_vti_history/') { continue } - [PSCustomObject]@{ - Name = $f.Name - Length = $f.Length - RelativePath = "$Rel$($f.Name)" - } - } - if ($Recurse) { - $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue) - foreach ($d in $folders) { - if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue } - Get-AllSPFiles "$BasePath/$($d.Name)" $Recurse "$Rel$($d.Name)/" - } - } - } - - try { - Import-Module PnP.PowerShell -ErrorAction Stop - $allResults = [System.Collections.Generic.List[object]]::new() - - foreach ($job in $Params.Jobs) { - BgLog "--- Verifying $($job.SrcSite)/$($job.SrcLib) vs $($job.DstSite)/$($job.DstLib) ---" "White" - - BgLog "Connecting to source..." "Cyan" - Connect-PnPOnline -Url $job.SrcSite -Interactive -ClientId $Params.ClientId - $srcFiles = @(Get-AllSPFiles $job.SrcLib $Params.Recursive) - BgLog " $($srcFiles.Count) source file(s)" "LightGreen" - $srcMap = @{}; foreach ($f in $srcFiles) { $srcMap[$f.RelativePath] = $f } - - BgLog "Connecting to destination..." "Cyan" - Connect-PnPOnline -Url $job.DstSite -Interactive -ClientId $Params.ClientId - $dstFiles = @(Get-AllSPFiles $job.DstLib $Params.Recursive) - BgLog " $($dstFiles.Count) destination file(s)" "LightGreen" - $dstMap = @{}; foreach ($f in $dstFiles) { $dstMap[$f.RelativePath] = $f } - - foreach ($key in $srcMap.Keys) { - $src = $srcMap[$key] - if ($dstMap.ContainsKey($key)) { - $dst = $dstMap[$key] - $st = if ([long]$src.Length -eq [long]$dst.Length) { "OK" } else { "SIZE_MISMATCH" } - $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status=$st; SourceSize=[long]$src.Length; DestSize=[long]$dst.Length; Message="" }) - } else { - $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status="MISSING"; SourceSize=[long]$src.Length; DestSize=$null; Message="" }) - } - } - foreach ($key in $dstMap.Keys) { - if (-not $srcMap.ContainsKey($key)) { - $dst = $dstMap[$key] - $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status="EXTRA"; SourceSize=$null; DestSize=[long]$dst.Length; Message="" }) - } - } - - $okN = @($allResults | Where-Object { $_.Status -eq "OK" }).Count - $missN = @($allResults | Where-Object { $_.Status -eq "MISSING" }).Count - $mmN = @($allResults | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count - $exN = @($allResults | Where-Object { $_.Status -eq "EXTRA" }).Count - BgLog " Results: $okN OK, $missN missing, $mmN size mismatch, $exN extra" "White" - } - - $Sync.VerifyResults = @($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 - VerifyResults = $null - }) - $script:_VerSync = $sync - $script:_VerParams = $params - - $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($bgVerify) - [void]$ps.AddArgument($params) - [void]$ps.AddArgument($sync) - $script:_VerRS = $rs - $script:_VerPS = $ps - $script:_VerHnd = $ps.BeginInvoke() - - $tmr = New-Object System.Windows.Forms.Timer - $tmr.Interval = 250 - $script:_VerTimer = $tmr - $tmr.Add_Tick({ - while ($script:_VerSync.Queue.Count -gt 0) { - $m = $script:_VerSync.Queue.Dequeue() - Write-Log $m.Text $m.Color - } - if ($script:_VerSync.Done) { - $script:_VerTimer.Stop(); $script:_VerTimer.Dispose() - while ($script:_VerSync.Queue.Count -gt 0) { - $m = $script:_VerSync.Queue.Dequeue(); Write-Log $m.Text $m.Color - } - try { [void]$script:_VerPS.EndInvoke($script:_VerHnd) } catch {} - try { $script:_VerRS.Close(); $script:_VerRS.Dispose() } catch {} - $btnXferStart.Enabled = $true - $btnXferVerify.Enabled = $true - Stop-ProgressAnim - - if ($script:_VerSync.Error) { - Write-Log "Echec de la verification : $($script:_VerSync.Error)" "Red" - return - } - - $results = $script:_VerSync.VerifyResults - if (-not $results -or $results.Count -eq 0) { - Write-Log "Aucun fichier a comparer." "Orange" - return - } - - $p = $script:_VerParams - $rptFile = Export-XferReport ([System.Collections.Generic.List[object]]$results) $p.OutFolder "TransferVerify" $p.AsHtml - if ($rptFile) { - Write-Log "Report: $rptFile" "White" - $script:_XferLastReport = $rptFile - $btnXferOpen.Enabled = $true - } - } - }) - $tmr.Start() -}) - -# ── Open Transfer Report ────────────────────────────────────────────────────── - -$btnXferOpen.Add_Click({ - $f = $script:_XferLastReport - if ($f -and (Test-Path $f)) { Start-Process $f } -}) - -# ── Bulk Create ─────────────────────────────────────────────────────────────── - -# Helper: add a site entry hashtable to the ListView -function Add-BulkListItem([hashtable]$entry) { - $lvi = New-Object System.Windows.Forms.ListViewItem($entry.Name) - $lvi.SubItems.Add($entry.Alias) | Out-Null - $lvi.SubItems.Add($entry.Type) | Out-Null - $lvi.SubItems.Add($entry.Template)| Out-Null - $lvi.SubItems.Add($entry.Owners) | Out-Null - $lvi.SubItems.Add($entry.Members) | Out-Null - $lvi.Tag = $entry - $lvBulk.Items.Add($lvi) | Out-Null -} - -# Double-click to edit -$lvBulk.Add_DoubleClick({ - if ($lvBulk.SelectedItems.Count -eq 0) { return } - $sel = $lvBulk.SelectedItems[0] - $edited = Show-BulkSiteDialog -Owner $form -Existing $sel.Tag - if ($edited) { - $sel.Text = $edited.Name - $sel.SubItems[1].Text = $edited.Alias - $sel.SubItems[2].Text = $edited.Type - $sel.SubItems[3].Text = $edited.Template - $sel.SubItems[4].Text = $edited.Owners - $sel.SubItems[5].Text = $edited.Members - $sel.Tag = $edited - } -}) - -$btnBulkAdd.Add_Click({ - $entry = Show-BulkSiteDialog -Owner $form - if ($entry) { Add-BulkListItem $entry } -}) - -$btnBulkCsv.Add_Click({ - $ofd = New-Object System.Windows.Forms.OpenFileDialog - $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*" - if ($ofd.ShowDialog($form) -ne "OK") { return } - # Try semicolon first (handles commas inside fields), fall back to comma - $content = Get-Content $ofd.FileName -Raw - if ($content -match ';') { - $rows = Import-Csv $ofd.FileName -Delimiter ';' - } else { - $rows = Import-Csv $ofd.FileName - } - $count = 0 - foreach ($r in $rows) { - # Read columns via PSObject properties (case-insensitive) - $props = @{} - foreach ($p in $r.PSObject.Properties) { $props[$p.Name.ToLower()] = "$($p.Value)".Trim() } - - $name = if ($props['name']) { $props['name'] } elseif ($props['title']) { $props['title'] } else { "" } - $alias = if ($props['alias']) { $props['alias'] } elseif ($props['url']) { $props['url'] } else { "" } - $type = if ($props['type']) { $props['type'] } else { "Team" } - $tpl = if ($props['template']) { $props['template'] } else { "" } - $own = if ($props['owners']) { $props['owners'] } elseif ($props['owner']) { $props['owner'] } else { "" } - $mem = if ($props['members']) { $props['members'] } else { "" } - - # Name is required; skip empty rows - if (-not $name) { continue } - # Auto-generate alias from name if not provided - if (-not $alias) { - $alias = $name.ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', '' - } - # Normalize type - if ($type -match '^[Cc]omm') { $type = "Communication" } else { $type = "Team" } - Add-BulkListItem @{ - Name = $name - Alias = $alias - Type = $type - Template = $tpl - Owners = $own - Members = $mem - } - $count++ - } - Write-Log "$count site(s) imported from CSV." "LightGreen" -}) - -$btnBulkRemove.Add_Click({ - while ($lvBulk.SelectedItems.Count -gt 0) { - $lvBulk.Items.Remove($lvBulk.SelectedItems[0]) - } -}) - -$btnBulkClear.Add_Click({ - $lvBulk.Items.Clear() -}) - -$btnBulkCreate.Add_Click({ - $clientId = $txtClientId.Text.Trim() - $tenantUrl = $txtTenantUrl.Text.Trim() - - if (-not $clientId) { Write-Log "Client ID requis." "Red"; return } - if (-not $tenantUrl) { Write-Log "Tenant URL requis." "Red"; return } - if ($lvBulk.Items.Count -eq 0) { Write-Log "Aucun site dans la liste." "Red"; return } - - # Collect entries - $entries = @() - foreach ($lvi in $lvBulk.Items) { $entries += $lvi.Tag } - - # Load all templates once - $allTemplates = @{} - foreach ($t in (Load-Templates)) { $allTemplates[$t.name] = $t } - - $params = @{ - ClientId = $clientId - TenantUrl = $tenantUrl - Entries = $entries - Templates = $allTemplates - } - - $btnBulkCreate.Enabled = $false - $btnBulkAdd.Enabled = $false - $btnBulkCsv.Enabled = $false - $btnBulkRemove.Enabled = $false - $btnBulkClear.Enabled = $false - $txtLog.Clear() - Start-ProgressAnim - Write-Log "=== BULK SITE CREATION ===" "White" - Write-Log "Sites to create: $($entries.Count)" "Gray" - Write-Log ("-" * 52) "DarkGray" - - $bgBulk = { - param($Params, $Sync) - function BgLog([string]$m, [string]$c = "LightGreen") { - $Sync.Queue.Enqueue(@{ Text = $m; Color = $c }) - } - - # Folder-tree helper (same as template manager) - function Apply-FolderTreeBg([array]$folders, [string]$parentUrl) { - foreach ($fd in $folders) { - try { - Add-PnPFolder -Name $fd.name -Folder $parentUrl -ErrorAction SilentlyContinue | Out-Null - } catch {} - if ($fd.subfolders -and $fd.subfolders.Count -gt 0) { - Apply-FolderTreeBg $fd.subfolders "$parentUrl/$($fd.name)" - } - } - } - - try { - Import-Module PnP.PowerShell -ErrorAction Stop - - $adminUrl = if ($Params.TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') { - "$($Matches[1])-admin$($Matches[2])" - } else { $Params.TenantUrl } - $base = if ($Params.TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $Params.TenantUrl } - - BgLog "Connexion au tenant admin..." "Yellow" - Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId - - $total = $Params.Entries.Count - $idx = 0 - - foreach ($entry in $Params.Entries) { - $idx++ - $name = $entry.Name - $alias = $entry.Alias - $isTeam = $entry.Type -ne "Communication" - $ownerRaw = "$($entry.Owners)" - $memberRaw = "$($entry.Members)" - $owners = [string[]]@($ownerRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' }) - $members = [string[]]@($memberRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' }) - $tplName = $entry.Template - - BgLog "[$idx/$total] Creating '$name' (alias: $alias, type: $($entry.Type))..." "White" - BgLog " DEBUG owners raw='$ownerRaw' parsed=[$($owners -join '|')] count=$($owners.Count)" "Gray" - BgLog " DEBUG members raw='$memberRaw' parsed=[$($members -join '|')] count=$($members.Count)" "Gray" - - # TeamSite requires at least one owner - if ($isTeam -and $owners.Count -eq 0) { - BgLog " ERREUR : TeamSite requires at least one owner — skipping '$name'" "Red" - $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: no owner" }) - $Sync.ErrCount++ - continue - } - - # Update status - $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Creating..." }) - - try { - # Create the site WITHOUT owners/members (PnP bug: odata.bind empty array) - # Current user becomes default owner; we add owners/members after creation - Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId - if ($isTeam) { - BgLog " Creating TeamSite '$alias' (owners/members added after)..." "DarkGray" - $newUrl = New-PnPSite -Type TeamSite -Title $name -Alias $alias -Wait - } else { - BgLog " Creating CommunicationSite '$alias'..." "DarkGray" - $newUrl = New-PnPSite -Type CommunicationSite -Title $name -Url "$base/sites/$alias" -Wait - } - BgLog " Site cree : $newUrl" "LightGreen" - - # Connect to the new site for owners/members/template - Connect-PnPOnline -Url $newUrl -Interactive -ClientId $Params.ClientId - - # Assign owners & members post-creation - if ($isTeam) { - $groupId = $null - try { $groupId = (Get-PnPSite -Includes GroupId).GroupId.Guid } catch {} - if ($groupId) { - foreach ($o in $owners) { - try { - Add-PnPMicrosoft365GroupOwner -Identity $groupId -Users $o -ErrorAction Stop - BgLog " Owner added: $o" "Cyan" - } catch { BgLog " Warn owner '$o': $($_.Exception.Message)" "DarkYellow" } - } - foreach ($m in $members) { - try { - Add-PnPMicrosoft365GroupMember -Identity $groupId -Users $m -ErrorAction Stop - BgLog " Member added: $m" "Cyan" - } catch { BgLog " Warn member '$m': $($_.Exception.Message)" "DarkYellow" } - } - } else { - BgLog " Could not get M365 GroupId — owners/members not assigned" "DarkYellow" - } - } else { - # CommunicationSite — classic SharePoint groups - if ($owners.Count -gt 0) { - $ownerGrp = Get-PnPGroup | Where-Object { $_.Title -like "*Propri*" -or $_.Title -like "*Owner*" } | Select-Object -First 1 - if ($ownerGrp) { - foreach ($o in $owners) { - try { Add-PnPGroupMember -LoginName $o -Group $ownerGrp.Title -ErrorAction SilentlyContinue } catch {} - } - } - } - if ($members.Count -gt 0) { - $memberGrp = Get-PnPGroup | Where-Object { $_.Title -like "*Membre*" -or $_.Title -like "*Member*" } | Select-Object -First 1 - if ($memberGrp) { - foreach ($m in $members) { - try { Add-PnPGroupMember -LoginName $m -Group $memberGrp.Title -ErrorAction SilentlyContinue } catch {} - } - } - } - } - - # Apply template if specified - if ($tplName -and $Params.Templates.ContainsKey($tplName)) { - $tpl = $Params.Templates[$tplName] - BgLog " Applying template '$tplName'..." "Yellow" - - # Settings - if ($tpl.options.settings -and $tpl.settings -and $tpl.settings.description) { - Set-PnPWeb -Description $tpl.settings.description - } - # Style - if ($tpl.options.style -and $tpl.style -and $tpl.style.logoUrl) { - Set-PnPWeb -SiteLogoUrl $tpl.style.logoUrl - } - # Structure - if ($tpl.options.structure -and $tpl.structure -and $tpl.structure.Count -gt 0) { - foreach ($lib in $tpl.structure) { - $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 - } catch {} - } - if ($lib.folders -and $lib.folders.Count -gt 0) { - $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue - if ($targetList) { - $listRf = Get-PnPProperty -ClientObject $targetList -Property RootFolder - $libBase = $listRf.ServerRelativeUrl.TrimEnd('/') - Apply-FolderTreeBg $lib.folders $libBase - } - } - } - BgLog " Structure applied." "Cyan" - } - } - - - - $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "OK" }) - $Sync.CreatedSites.Add([PSCustomObject]@{ - Name = $name - Alias = $alias - Type = $entry.Type - URL = $newUrl - }) - $Sync.OkCount++ - - } catch { - $errMsg = $_.Exception.Message - BgLog " ERREUR : $errMsg" "Red" - $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: $errMsg" }) - $Sync.ErrCount++ - } - } - - BgLog "Termine : $($Sync.OkCount) OK, $($Sync.ErrCount) erreur(s)" "White" - } catch { - $Sync.Error = $_.Exception.Message - BgLog "Erreur globale : $($_.Exception.Message)" "Red" - } finally { - $Sync.Done = $true - } - } - - $sync = [hashtable]::Synchronized(@{ - Queue = [System.Collections.Generic.Queue[object]]::new() - Done = $false - Error = $null - OkCount = 0 - ErrCount = 0 - CreatedSites = [System.Collections.Generic.List[object]]::new() - }) - $script:_BulkSync = $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($bgBulk) - [void]$ps.AddArgument($params) - [void]$ps.AddArgument($sync) - $script:_BulkRS = $rs - $script:_BulkPS = $ps - $script:_BulkHnd = $ps.BeginInvoke() - - $tmr = New-Object System.Windows.Forms.Timer - $tmr.Interval = 250 - $script:_BulkTimer = $tmr - $tmr.Add_Tick({ - while ($script:_BulkSync.Queue.Count -gt 0) { - $m = $script:_BulkSync.Queue.Dequeue() - # Status update messages update the ListView item - if ($m.Text -eq "##STATUS##") { - $i = $m.Index - if ($i -lt $lvBulk.Items.Count) { - $lvi = $lvBulk.Items[$i] - # Use the first column text to show status via ForeColor - if ($m.Value -eq "OK") { - $lvi.ForeColor = [System.Drawing.Color]::Green - } elseif ($m.Value -like "Error*") { - $lvi.ForeColor = [System.Drawing.Color]::Red - } else { - $lvi.ForeColor = [System.Drawing.Color]::DarkOrange - } - } - } else { - Write-Log $m.Text $m.Color - } - } - if ($script:_BulkSync.Done) { - $script:_BulkTimer.Stop(); $script:_BulkTimer.Dispose() - while ($script:_BulkSync.Queue.Count -gt 0) { - $m = $script:_BulkSync.Queue.Dequeue() - if ($m.Text -ne "##STATUS##") { Write-Log $m.Text $m.Color } - } - try { [void]$script:_BulkPS.EndInvoke($script:_BulkHnd) } catch {} - try { $script:_BulkRS.Close(); $script:_BulkRS.Dispose() } catch {} - $btnBulkCreate.Enabled = $true - $btnBulkAdd.Enabled = $true - $btnBulkCsv.Enabled = $true - $btnBulkRemove.Enabled = $true - $btnBulkClear.Enabled = $true - Stop-ProgressAnim - - if ($script:_BulkSync.Error) { - Write-Log "Echec : $($script:_BulkSync.Error)" "Red" - return - } - # Export CSV report of created sites - if ($script:_BulkSync.CreatedSites.Count -gt 0) { - $outDir = $txtOutput.Text.Trim() - if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } } - if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } - $stamp = Get-Date -Format "yyyyMMdd_HHmmss" - $csvFile = Join-Path $outDir "BulkCreate_$stamp.csv" - $script:_BulkSync.CreatedSites | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 - Write-Log "Rapport CSV : $csvFile" "White" - } - Write-Log "=== BULK CREATE COMPLETE: $($script:_BulkSync.OkCount) OK, $($script:_BulkSync.ErrCount) error(s) ===" "White" - } - }) - $tmr.Start() -}) - -#endregion - -#region ===== Structure (folder tree from CSV) ===== - -# Store the parsed folder paths -$script:_StructPaths = @() - -function Build-StructTree([string]$csvPath) { - # Auto-detect delimiter - $raw = Get-Content $csvPath -Raw - $delim = if ($raw -match ';') { ';' } else { ',' } - $rows = Import-Csv $csvPath -Delimiter $delim - - $paths = [System.Collections.Generic.List[string]]::new() - foreach ($r in $rows) { - $cols = @($r.PSObject.Properties | ForEach-Object { "$($_.Value)".Trim() }) - # Build path from non-empty columns - $parts = @($cols | Where-Object { $_ -ne '' }) - if ($parts.Count -gt 0) { - # Add all intermediate paths to ensure parents exist - for ($i = 1; $i -le $parts.Count; $i++) { - $p = ($parts[0..($i-1)] -join '/') - if (-not $paths.Contains($p)) { $paths.Add($p) } - } - } - } - $script:_StructPaths = @($paths | Sort-Object) - return $script:_StructPaths -} - -function Populate-StructTreeView([string[]]$paths) { - $tvStruct.Nodes.Clear() - $nodeMap = @{} - foreach ($p in $paths) { - $parts = $p -split '/' - $parentKey = if ($parts.Count -gt 1) { ($parts[0..($parts.Count - 2)] -join '/') } else { '' } - $name = $parts[-1] - $node = New-Object System.Windows.Forms.TreeNode($name) - $node.Tag = $p - if ($parentKey -and $nodeMap.ContainsKey($parentKey)) { - $nodeMap[$parentKey].Nodes.Add($node) | Out-Null - } else { - $tvStruct.Nodes.Add($node) | Out-Null - } - $nodeMap[$p] = $node - } - $tvStruct.ExpandAll() -} - -$btnStructCsv.Add_Click({ - $ofd = New-Object System.Windows.Forms.OpenFileDialog - $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*" - if ($ofd.ShowDialog($form) -ne "OK") { return } - try { - $paths = Build-StructTree $ofd.FileName - Populate-StructTreeView $paths - Write-Log "$($paths.Count) folder(s) loaded from CSV." "LightGreen" - } catch { - Write-Log "CSV error: $($_.Exception.Message)" "Red" - } -}) - -$btnStructClear.Add_Click({ - $tvStruct.Nodes.Clear() - $script:_StructPaths = @() - Write-Log "Structure cleared." "Gray" -}) - -$btnStructCreate.Add_Click({ - $siteUrl = $txtSiteUrl.Text.Trim() - $clientId = $txtClientId.Text.Trim() - $library = $txtStructLib.Text.Trim() - - if (-not $siteUrl) { Write-Log "Site URL required." "Red"; return } - if (-not $clientId) { Write-Log "Client ID required." "Red"; return } - if (-not $library) { Write-Log "Target library required." "Red"; return } - if ($script:_StructPaths.Count -eq 0) { Write-Log "No structure loaded. Load a CSV first." "Red"; return } - - $btnStructCreate.Enabled = $false - $btnStructCsv.Enabled = $false - Start-ProgressAnim - Write-Log "=== CREATING FOLDER STRUCTURE ===" "White" - Write-Log "Target: $siteUrl / $library" "Gray" - Write-Log "Folders to create: $($script:_StructPaths.Count)" "Gray" - Write-Log ("-" * 52) "DarkGray" - - try { - Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId - - # Get the library root - $list = Get-PnPList -Identity $library -ErrorAction Stop - $rf = Get-PnPProperty -ClientObject $list -Property RootFolder - $base = $rf.ServerRelativeUrl.TrimEnd('/') - - $ok = 0 - $err = 0 - $total = $script:_StructPaths.Count - foreach ($p in $script:_StructPaths) { - $folderPath = "$base/$p" - try { - # Resolve-PnPFolder creates the full path recursively - Resolve-PnPFolder -SiteRelativePath "$library/$p" -ErrorAction Stop | Out-Null - $ok++ - Write-Log " OK: $p" "LightGreen" - } catch { - $err++ - Write-Log " FAIL: $p — $($_.Exception.Message)" "Red" - } - } - Write-Log "=== STRUCTURE COMPLETE: $ok OK, $err error(s) ===" "White" - } catch { - Write-Log "Error: $($_.Exception.Message)" "Red" - } finally { - $btnStructCreate.Enabled = $true - $btnStructCsv.Enabled = $true - Stop-ProgressAnim - } -}) - -# ── Version Cleanup handlers ───────────────────────────────────────────────── -$script:_VerReport = $null - -$btnVerOpen.Add_Click({ - if ($script:_VerReport -and (Test-Path $script:_VerReport)) { - Start-Process $script:_VerReport - } -}) - -$btnVerRun.Add_Click({ - # --- Gather all selected site URLs --- - $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) { - @($script:SelectedSites) - } else { - @($txtSiteURL.Text.Trim()) - } - $siteUrls = @($siteUrls | Where-Object { $_ }) - if ($siteUrls.Count -eq 0) { Write-Log "Site URL required." "Red"; return } - - $clientId = $txtClientId.Text.Trim() - if (-not $clientId) { Write-Log "Client ID required." "Red"; return } - - $keepCount = [int]$nudVerCount.Value - $useDate = $chkVerDate.Checked - $dateBefore = $radVerBefore.Checked # true = keep before, false = keep after - $cutoffDate = $dtpVer.Value - $library = $txtVerLib.Text.Trim() - $recursive = $chkVerRecursive.Checked - $subsites = $chkVerSubsites.Checked - $dryRun = $chkVerDryRun.Checked - - $btnVerRun.Enabled = $false - Start-ProgressAnim - $modeLabel = if ($dryRun) { "DRY RUN" } else { "LIVE" } - Write-Log "=== VERSION CLEANUP ($modeLabel) ===" "White" - Write-Log "Keep: $keepCount version(s)" "Gray" - if ($useDate) { - $dir = if ($dateBefore) { "before" } else { "after" } - Write-Log "Date filter: keep versions $dir $($cutoffDate.ToString('yyyy-MM-dd'))" "Gray" - } - Write-Log ("-" * 52) "DarkGray" - - $report = [System.Collections.Generic.List[object]]::new() - $totalDeleted = 0 - $totalKept = 0 - $totalErrors = 0 - - try { - foreach ($siteUrl in $siteUrls) { - Write-Log "Connecting to $siteUrl ..." "Gray" - Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId - - # Collect site URLs to process (main + subsites) - $sitesToProcess = @($siteUrl) - if ($subsites) { - try { - $subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue - foreach ($sw in $subs) { $sitesToProcess += $sw.Url } - } catch {} - } - - foreach ($currentSite in $sitesToProcess) { - if ($currentSite -ne $siteUrl) { - try { Connect-PnPOnline -Url $currentSite -Interactive -ClientId $clientId } catch { - Write-Log " Cannot connect to subsite $currentSite — skipped" "DarkOrange" - continue - } - } - Write-Log "Processing site: $currentSite" "White" - - # Get target lists - $lists = @() - if ($library) { - try { $lists = @(Get-PnPList -Identity $library -ErrorAction Stop) } catch { - Write-Log " Library '$library' not found — skipped" "DarkOrange" - continue - } - } else { - $lists = Get-PnPList | Where-Object { $_.BaseTemplate -eq 101 -and $_.Hidden -eq $false } - } - - foreach ($list in $lists) { - Write-Log " Library: $($list.Title)" "Gray" - try { - $camlQuery = "5000" - if (-not $recursive) { - $camlQuery = "5000" - } - $items = Get-PnPListItem -List $list.Title -Query $camlQuery -ErrorAction Stop | - Where-Object { $_.FileSystemObjectType -eq "File" } - } catch { - Write-Log " Error listing files: $($_.Exception.Message)" "Red" - $totalErrors++ - continue - } - - foreach ($item in $items) { - try { - $file = $item.FieldValues["FileRef"] - $versions = Get-PnPFileVersion -Url $file -ErrorAction Stop - - if ($versions.Count -le $keepCount) { continue } - - # Sort versions oldest first (by VersionLabel numeric) - $sorted = $versions | Sort-Object { [double]$_.VersionLabel } - - # Determine which versions to delete - $toDelete = @() - foreach ($v in $sorted) { - # Always keep the last $keepCount versions - $idx = [array]::IndexOf($sorted, $v) - $remaining = $sorted.Count - $idx - if ($remaining -le $keepCount) { break } - - # Apply date filter if enabled - if ($useDate) { - $vDate = [datetime]$v.Created - if ($dateBefore) { - # Keep versions before cutoff → delete versions ON or AFTER cutoff - if ($vDate -lt $cutoffDate) { continue } - } else { - # Keep versions after cutoff → delete versions BEFORE cutoff - if ($vDate -ge $cutoffDate) { continue } - } - } - - $toDelete += $v - } - - if ($toDelete.Count -eq 0) { continue } - - $fileName = Split-Path $file -Leaf - foreach ($v in $toDelete) { - if ($dryRun) { - Write-Log " [DRY] Would delete v$($v.VersionLabel) of $fileName ($($v.Created))" "DarkOrange" - } else { - try { - Remove-PnPFileVersion -Url $file -Identity $v.Id -Force -ErrorAction Stop - Write-Log " Deleted v$($v.VersionLabel) of $fileName" "LightGreen" - } catch { - Write-Log " Error deleting v$($v.VersionLabel) of $fileName — $($_.Exception.Message)" "Red" - $totalErrors++ - } - } - $totalDeleted++ - } - - $kept = $sorted.Count - $toDelete.Count - $totalKept += $kept - - $report.Add([PSCustomObject]@{ - Site = $currentSite - Library = $list.Title - File = $file - TotalVer = $sorted.Count - Deleted = $toDelete.Count - Kept = $kept - }) - } catch { - $totalErrors++ - } - } - } - } - } - - # Export CSV report - if ($report.Count -gt 0) { - $outDir = $txtOutput.Text.Trim() - if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } } - if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } - $stamp = Get-Date -Format "yyyyMMdd_HHmmss" - $prefix = if ($dryRun) { "VersionCleanup_DryRun" } else { "VersionCleanup" } - $csvFile = Join-Path $outDir "${prefix}_$stamp.csv" - $report | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 - $script:_VerReport = $csvFile - $btnVerOpen.Enabled = $true - Write-Log "Report: $csvFile" "White" - } - - Write-Log "=== VERSION CLEANUP COMPLETE: $totalDeleted deleted, $totalKept kept, $totalErrors error(s) ===" "White" - } catch { - Write-Log "Error: $($_.Exception.Message)" "Red" - } finally { - $btnVerRun.Enabled = $true - Stop-ProgressAnim - } -}) - -#endregion - -# ── Initialisation : chargement des settings ─────────────────────────────── -$_settings = Load-Settings -$script:DataFolder = if ($_settings.dataFolder -and (Test-Path $_settings.dataFolder)) { - $_settings.dataFolder -} elseif ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } - -# Load saved language (applies T() translations and updates all registered controls) -$_savedLang = if ($_settings.lang) { $_settings.lang } else { "en" } -if ($_savedLang -ne "en") { - Load-Language $_savedLang - Update-UILanguage - foreach ($mi in $menuLang.DropDownItems) { - if ($mi -is [System.Windows.Forms.ToolStripMenuItem]) { - $mi.Checked = ($mi.Tag -eq $script:CurrentLang) - } - } -} - -Refresh-ProfileList -$n = (Load-Templates).Count -$lblTplCount.Text = "$n $(T 'tpl.count')" - -[System.Windows.Forms.Application]::Run($form) diff --git a/release.ps1 b/release.ps1 deleted file mode 100644 index 5919be6..0000000 --- a/release.ps1 +++ /dev/null @@ -1,111 +0,0 @@ -<# -.SYNOPSIS - Build, tag, and publish a release to Gitea. -.PARAMETER Version - Version tag (e.g. "v2.0.0"). Required. -.PARAMETER Token - Gitea API token. If not provided, reads from GITEA_TOKEN env var. -.EXAMPLE - .\release.ps1 -Version v2.0.0 - .\release.ps1 -Version v2.1.0 -Token "your_token_here" -#> -param( - [Parameter(Mandatory)][string]$Version, - [string]$Token = $env:GITEA_TOKEN -) - -$ErrorActionPreference = "Stop" - -$GiteaUrl = "https://git.azuze.fr" -$Repo = "kawa/Sharepoint-Toolbox" -$Project = "SharepointToolbox/SharepointToolbox.csproj" -$PublishDir = "publish" -$ZipName = "SharePoint_Toolbox_$Version.zip" - -# -- Validate -- -if (-not $Token) { - Write-Error "No token provided. Pass -Token or set GITEA_TOKEN env var." - exit 1 -} - -if (git tag -l $Version) { - Write-Error "Tag $Version already exists." - exit 1 -} - -# -- Build -- -Write-Host "" -Write-Host ">> Building Release..." -ForegroundColor Cyan -dotnet publish $Project -c Release -p:PublishSingleFile=true -o $PublishDir -if ($LASTEXITCODE -ne 0) { exit 1 } - -# -- Package -- -Write-Host "" -Write-Host ">> Packaging $ZipName..." -ForegroundColor Cyan -$staging = "release_staging" -if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } -New-Item -ItemType Directory -Path $staging | Out-Null -New-Item -ItemType Directory -Path "$staging/examples" | Out-Null - -Copy-Item "$PublishDir/SharepointToolbox.exe" "$staging/" -Copy-Item "SharepointToolbox/Resources/*.csv" "$staging/examples/" - -if (Test-Path $ZipName) { Remove-Item $ZipName } -Compress-Archive -Path "$staging/*" -DestinationPath $ZipName -Remove-Item $staging -Recurse -Force - -$zipSize = [math]::Round((Get-Item $ZipName).Length / 1MB, 1) -Write-Host " Created $ZipName ($zipSize MB)" -ForegroundColor Green - -# -- Tag and Push -- -Write-Host "" -Write-Host ">> Tagging $Version and pushing..." -ForegroundColor Cyan -git tag $Version -git push kawa main --tags - -# -- Create Release -- -Write-Host "" -Write-Host ">> Creating Gitea release..." -ForegroundColor Cyan - -$releaseBody = "" - -$jsonObj = @{ tag_name = $Version; name = "SharePoint Toolbox $Version"; body = $releaseBody } -$jsonPath = [System.IO.Path]::GetTempFileName() -$utf8NoBom = New-Object System.Text.UTF8Encoding($false) -[System.IO.File]::WriteAllText($jsonPath, ($jsonObj | ConvertTo-Json -Depth 3), $utf8NoBom) - -$releaseJson = curl.exe -s -w "`n%{http_code}" -X POST "$GiteaUrl/api/v1/repos/$Repo/releases" -H "Authorization: token $Token" -H "Content-Type: application/json" -d "@$jsonPath" -Remove-Item $jsonPath - -$lines = $releaseJson -split "`n" -$httpCode = $lines[-1] -$responseBody = ($lines[0..($lines.Length-2)] -join "`n") - -if ($httpCode -ne "201") { - Write-Host " HTTP $httpCode - $responseBody" -ForegroundColor Red - Write-Error "Release creation failed." - exit 1 -} - -$releaseId = ($responseBody | ConvertFrom-Json).id -Write-Host " Release created (ID: $releaseId)" -ForegroundColor Green - -# -- Upload Asset -- -Write-Host "" -Write-Host ">> Uploading $ZipName..." -ForegroundColor Cyan -$uploadUrl = "$GiteaUrl/api/v1/repos/$Repo/releases/$releaseId/assets" - -curl.exe -sf -X POST $uploadUrl -H "Authorization: token $Token" -F "attachment=@$ZipName" | Out-Null - -if ($LASTEXITCODE -ne 0) { - Write-Error "Asset upload failed." - exit 1 -} - -# -- Cleanup -- -Remove-Item $ZipName -Remove-Item $PublishDir -Recurse -Force - -Write-Host "" -Write-Host ">> Done! Release published at:" -ForegroundColor Green -Write-Host " $GiteaUrl/$Repo/releases/tag/$Version" -ForegroundColor Yellow