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

SharePoint Permissions Report

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

SharePoint Storage Metrics

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

Recherche de fichiers SharePoint

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

Doublons de $modeLabel — SharePoint

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