diff --git a/Sharepoint_ToolBox.ps1 b/Sharepoint_ToolBox.ps1
deleted file mode 100644
index 08003d8..0000000
--- a/Sharepoint_ToolBox.ps1
+++ /dev/null
@@ -1,6408 +0,0 @@
-Add-Type -AssemblyName System.Windows.Forms
-Add-Type -AssemblyName System.Drawing
-
-#region ===== Shared Helpers =====
-
-function Write-Log {
- param([string]$Message, [string]$Color = "LightGreen")
- if ($script:LogBox -and !$script:LogBox.IsDisposed) {
- $script:LogBox.SelectionStart = $script:LogBox.TextLength
- $script:LogBox.SelectionLength = 0
- $script:LogBox.SelectionColor = [System.Drawing.Color]::$Color
- $script:LogBox.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Message`n")
- $script:LogBox.ScrollToCaret()
- [System.Windows.Forms.Application]::DoEvents()
- }
- Write-Host $Message
-}
-
-function EscHtml([string]$s) {
- return $s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"'
-}
-
-function Format-Bytes([long]$b) {
- if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" }
- if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" }
- if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" }
- return "$b B"
-}
-
-function Validate-Inputs {
- if ([string]::IsNullOrWhiteSpace($script:txtClientId.Text)) {
- $msg = T "validate.missing.clientid.hint"
- [System.Windows.Forms.MessageBox]::Show($msg, (T "validate.missing.title"), "OK", "Warning")
- return $false
- }
- $hasSites = ($script:SelectedSites -and $script:SelectedSites.Count -gt 0)
- if (-not $hasSites -and [string]::IsNullOrWhiteSpace($script:txtSiteURL.Text)) {
- [System.Windows.Forms.MessageBox]::Show(
- "Please enter a Site URL or select sites via 'Voir les sites'.",
- "Missing Field", "OK", "Warning")
- return $false
- }
- return $true
-}
-
-#endregion
-
-#region ===== Profile Management =====
-
-function Get-ProfilesFilePath {
- $dir = if ($script:DataFolder) { $script:DataFolder }
- elseif ($PSScriptRoot) { $PSScriptRoot }
- else { $PWD.Path }
- return Join-Path $dir "Sharepoint_Export_profiles.json"
-}
-
-function Load-Profiles {
- $path = Get-ProfilesFilePath
- if (Test-Path $path) {
- try {
- $data = Get-Content $path -Raw | ConvertFrom-Json
- if ($data.profiles) { return @($data.profiles) }
- } catch {}
- }
- return @()
-}
-
-function Save-Profiles {
- param([array]$Profiles)
- $path = Get-ProfilesFilePath
- @{ profiles = @($Profiles) } | ConvertTo-Json -Depth 5 | Set-Content $path -Encoding UTF8
-}
-
-function Show-InputDialog {
- param(
- [string]$Prompt,
- [string]$Title,
- [string]$Default = "",
- [System.Windows.Forms.Form]$Owner = $null
- )
- $dlg = New-Object System.Windows.Forms.Form
- $dlg.Text = $Title
- $dlg.Size = New-Object System.Drawing.Size(380, 140)
- $dlg.StartPosition = "CenterParent"
- $dlg.FormBorderStyle = "FixedDialog"
- $dlg.MaximizeBox = $false
- $dlg.MinimizeBox = $false
- $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke
- $lbl = New-Object System.Windows.Forms.Label
- $lbl.Text = $Prompt; $lbl.Location = New-Object System.Drawing.Point(10,10)
- $lbl.Size = New-Object System.Drawing.Size(340,20)
- $txt = New-Object System.Windows.Forms.TextBox
- $txt.Text = $Default; $txt.Location = New-Object System.Drawing.Point(10,36)
- $txt.Size = New-Object System.Drawing.Size(340,22)
- $btnOK = New-Object System.Windows.Forms.Button
- $btnOK.Text = "OK"; $btnOK.Location = New-Object System.Drawing.Point(152,70)
- $btnOK.Size = New-Object System.Drawing.Size(80,26); $btnOK.DialogResult = "OK"
- $dlg.AcceptButton = $btnOK
- $btnCancel = New-Object System.Windows.Forms.Button
- $btnCancel.Text = "Annuler"; $btnCancel.Location = New-Object System.Drawing.Point(246,70)
- $btnCancel.Size = New-Object System.Drawing.Size(104,26); $btnCancel.DialogResult = "Cancel"
- $dlg.CancelButton = $btnCancel
- $dlg.Controls.AddRange(@($lbl, $txt, $btnOK, $btnCancel))
- $result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() }
- if ($result -eq "OK") { return $txt.Text.Trim() }
- return $null
-}
-
-function Refresh-ProfileList {
- $script:Profiles = Load-Profiles
- $script:cboProfile.Items.Clear()
- foreach ($p in $script:Profiles) { [void]$script:cboProfile.Items.Add($p.name) }
- if ($script:cboProfile.Items.Count -gt 0) { $script:cboProfile.SelectedIndex = 0 }
-}
-
-function Apply-Profile {
- param([int]$idx)
- if ($idx -lt 0 -or $idx -ge $script:Profiles.Count) { return }
- $p = $script:Profiles[$idx]
- if ($p.clientId) { $script:txtClientId.Text = $p.clientId }
- if ($p.tenantUrl) { $script:txtTenantUrl.Text = $p.tenantUrl }
- # Reset site selection when switching profile
- $script:SelectedSites = @()
- $script:btnBrowseSites.Text = "Voir les sites"
-}
-
-#endregion
-
-#region ===== Settings =====
-
-function Get-SettingsFilePath {
- $dir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
- return Join-Path $dir "Sharepoint_Settings.json"
-}
-
-function Load-Settings {
- $path = Get-SettingsFilePath
- if (Test-Path $path) {
- try {
- $data = Get-Content $path -Raw | ConvertFrom-Json
- return $data
- } catch {}
- }
- return [PSCustomObject]@{ dataFolder = ""; lang = "en" }
-}
-
-function Save-Settings {
- param([string]$DataFolder, [string]$Lang = "en")
- $path = Get-SettingsFilePath
- [PSCustomObject]@{ dataFolder = $DataFolder; lang = $Lang } |
- ConvertTo-Json | Set-Content $path -Encoding UTF8
-}
-
-#endregion
-
-#region ===== Site Picker =====
-
-# All state in $script:_pkl; accessible from any event handler (no closure tricks needed)
-
-function _Pkl-FormatMB([long]$mb) {
- if ($mb -ge 1024) { return "$([math]::Round($mb / 1024, 1)) GB" }
- if ($mb -gt 0) { return "$mb MB" }
- return "-"
-}
-
-function _Pkl-Sort {
- $col = $script:_pkl.SortCol
- $desc = -not $script:_pkl.SortAsc
- $script:_pkl.AllSites = @(switch ($col) {
- 0 { $script:_pkl.AllSites | Sort-Object -Property Title -Descending:$desc }
- 1 { $script:_pkl.AllSites | Sort-Object -Property { [int][bool]$_.IsTeamsConnected } -Descending:$desc }
- 2 { $script:_pkl.AllSites | Sort-Object -Property { [long]$_.StorageUsageCurrent } -Descending:$desc }
- default { $script:_pkl.AllSites | Sort-Object -Property Title -Descending:$desc }
- })
- $lv = $script:_pkl.Lv
- $dir = if (-not $desc) { " ^" } else { " v" }
- $script:_pkl.ColNames | ForEach-Object -Begin { $i = 0 } -Process {
- $lv.Columns[$i].Text = $_ + $(if ($i -eq $col) { $dir } else { "" })
- $i++
- }
-}
-
-function _Pkl-Repopulate {
- $filter = $script:_pkl.TxtFilter.Text.ToLower()
- $lv = $script:_pkl.Lv
- $script:_pkl.SuppressCheck = $true
- $lv.BeginUpdate()
- $lv.Items.Clear()
- $visible = if ($filter) {
- $script:_pkl.AllSites | Where-Object {
- $_.Title.ToLower().Contains($filter) -or $_.Url.ToLower().Contains($filter)
- }
- } else { $script:_pkl.AllSites }
- foreach ($s in $visible) {
- $teams = if ($s.IsTeamsConnected) { "Oui" } else { "Non" }
- $stor = _Pkl-FormatMB ([long]$s.StorageUsageCurrent)
- $item = New-Object System.Windows.Forms.ListViewItem($s.Title)
- $item.Tag = $s.Url
- [void]$item.SubItems.Add($teams)
- [void]$item.SubItems.Add($stor)
- [void]$item.SubItems.Add($s.Url)
- [void]$lv.Items.Add($item)
- $item.Checked = $script:_pkl.CheckedUrls.Contains($s.Url)
- }
- $lv.EndUpdate()
- $script:_pkl.SuppressCheck = $false
- $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " +
- "$($lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
- $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Gray
-}
-
-function Show-SitePicker {
- param(
- [string]$TenantUrl,
- [string]$ClientId,
- [System.Windows.Forms.Form]$Owner = $null,
- [object[]]$InitialSites = @(),
- [string[]]$PreSelected = @()
- )
-
- $dlg = New-Object System.Windows.Forms.Form
- $dlg.Text = "Sites SharePoint -- $TenantUrl"
- $dlg.Size = New-Object System.Drawing.Size(900, 580)
- $dlg.StartPosition = "CenterParent"
- $dlg.FormBorderStyle = "Sizable"
- $dlg.MinimumSize = New-Object System.Drawing.Size(600, 440)
- $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke
-
- # -- Top bar --
- $lblFilter = New-Object System.Windows.Forms.Label
- $lblFilter.Text = "Filtrer :"
- $lblFilter.Location = New-Object System.Drawing.Point(10, 12)
- $lblFilter.Size = New-Object System.Drawing.Size(52, 22)
- $lblFilter.TextAlign = "MiddleLeft"
-
- $txtFilter = New-Object System.Windows.Forms.TextBox
- $txtFilter.Location = New-Object System.Drawing.Point(66, 10)
- $txtFilter.Size = New-Object System.Drawing.Size(570, 22)
- $txtFilter.Font = New-Object System.Drawing.Font("Segoe UI", 9)
- $txtFilter.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
-
- $btnLoad = New-Object System.Windows.Forms.Button
- $btnLoad.Text = "Charger les sites"
- $btnLoad.Location = New-Object System.Drawing.Point(648, 8)
- $btnLoad.Size = New-Object System.Drawing.Size(148, 26)
- $btnLoad.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right"
- $btnLoad.BackColor = [System.Drawing.Color]::SteelBlue
- $btnLoad.ForeColor = [System.Drawing.Color]::White
- $btnLoad.FlatStyle = "Flat"
- $btnLoad.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
- # -- Site list (ListView with columns) --
- $lv = New-Object System.Windows.Forms.ListView
- $lv.Location = New-Object System.Drawing.Point(10, 44)
- $lv.Size = New-Object System.Drawing.Size(864, 400)
- $lv.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
- $lv.View = [System.Windows.Forms.View]::Details
- $lv.CheckBoxes = $true
- $lv.FullRowSelect = $true
- $lv.GridLines = $true
- $lv.Font = New-Object System.Drawing.Font("Segoe UI", 9)
- $lv.HeaderStyle = [System.Windows.Forms.ColumnHeaderStyle]::Clickable
-
- [void]$lv.Columns.Add("Nom", 380)
- [void]$lv.Columns.Add("Equipe Teams", 90)
- [void]$lv.Columns.Add("Stockage", 100)
- [void]$lv.Columns.Add("URL", 280)
-
- # -- Status bar --
- $lblStatus = New-Object System.Windows.Forms.Label
- $lblStatus.Text = "Cliquez sur 'Charger les sites' pour recuperer la liste du tenant."
- $lblStatus.Location = New-Object System.Drawing.Point(10, 456)
- $lblStatus.Size = New-Object System.Drawing.Size(860, 18)
- $lblStatus.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left,Right"
- $lblStatus.ForeColor = [System.Drawing.Color]::Gray
- $lblStatus.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
-
- # -- Bottom buttons --
- $btnSelAll = New-Object System.Windows.Forms.Button
- $btnSelAll.Text = "Tout selectionner"
- $btnSelAll.Location = New-Object System.Drawing.Point(10, 484)
- $btnSelAll.Size = New-Object System.Drawing.Size(130, 26)
- $btnSelAll.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left"
-
- $btnSelNone = New-Object System.Windows.Forms.Button
- $btnSelNone.Text = "Tout decocher"
- $btnSelNone.Location = New-Object System.Drawing.Point(148, 484)
- $btnSelNone.Size = New-Object System.Drawing.Size(110, 26)
- $btnSelNone.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left"
-
- $btnOK = New-Object System.Windows.Forms.Button
- $btnOK.Text = "OK"
- $btnOK.Location = New-Object System.Drawing.Point(694, 484)
- $btnOK.Size = New-Object System.Drawing.Size(90, 26)
- $btnOK.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Right"
- $btnOK.DialogResult = "OK"
- $btnOK.BackColor = [System.Drawing.Color]::SteelBlue
- $btnOK.ForeColor = [System.Drawing.Color]::White
- $btnOK.FlatStyle = "Flat"
- $btnOK.Enabled = $false
-
- $btnDlgCancel = New-Object System.Windows.Forms.Button
- $btnDlgCancel.Text = "Annuler"
- $btnDlgCancel.Location = New-Object System.Drawing.Point(794, 484)
- $btnDlgCancel.Size = New-Object System.Drawing.Size(90, 26)
- $btnDlgCancel.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Right"
- $btnDlgCancel.DialogResult = "Cancel"
-
- $dlg.AcceptButton = $btnOK
- $dlg.CancelButton = $btnDlgCancel
- $dlg.Controls.AddRange(@($lblFilter, $txtFilter, $btnLoad, $lv, $lblStatus,
- $btnSelAll, $btnSelNone, $btnOK, $btnDlgCancel))
-
- # Init script-scope state (modal dialog - no concurrency issue)
- $script:_pkl = @{
- AllSites = @()
- CheckedUrls = [System.Collections.Generic.HashSet[string]]::new(
- [System.StringComparer]::OrdinalIgnoreCase)
- Lv = $lv
- TxtFilter = $txtFilter
- LblStatus = $lblStatus
- BtnOK = $btnOK
- BtnLoad = $btnLoad
- SuppressCheck = $false
- SortCol = 0
- SortAsc = $true
- ColNames = @("Nom", "Equipe Teams", "Stockage", "URL")
- Sync = $null
- Timer = $null
- RS = $null
- PS = $null
- Hnd = $null
- AdminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
- "$($Matches[1])-admin$($Matches[2])"
- } else { $null }
- ClientId = $ClientId
- }
-
- # Pre-populate from cache + restore previous selection
- if ($InitialSites -and $InitialSites.Count -gt 0) {
- $script:_pkl.AllSites = @($InitialSites)
- foreach ($url in $PreSelected) { [void]$script:_pkl.CheckedUrls.Add($url) }
- $btnLoad.Text = "Recharger"
- _Pkl-Sort
- _Pkl-Repopulate
- $script:_pkl.BtnOK.Enabled = $true
- }
-
- # ItemChecked fires AFTER the checked state changes (unlike ItemCheck)
- $lv.Add_ItemChecked({
- param($s, $e)
- if ($script:_pkl.SuppressCheck) { return }
- $url = $e.Item.Tag
- if ($e.Item.Checked) { [void]$script:_pkl.CheckedUrls.Add($url) }
- else { [void]$script:_pkl.CheckedUrls.Remove($url) }
- $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " +
- "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
- })
-
- $lv.Add_ColumnClick({
- param($s, $e)
- if ($script:_pkl.SortCol -eq $e.Column) {
- $script:_pkl.SortAsc = -not $script:_pkl.SortAsc
- } else {
- $script:_pkl.SortCol = $e.Column
- $script:_pkl.SortAsc = $true
- }
- _Pkl-Sort
- _Pkl-Repopulate
- })
-
- $txtFilter.Add_TextChanged({ _Pkl-Repopulate })
-
- $btnSelAll.Add_Click({
- $script:_pkl.CheckedUrls.Clear()
- $script:_pkl.SuppressCheck = $true
- foreach ($item in $script:_pkl.Lv.Items) {
- $item.Checked = $true
- [void]$script:_pkl.CheckedUrls.Add($item.Tag)
- }
- $script:_pkl.SuppressCheck = $false
- $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " +
- "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
- })
-
- $btnSelNone.Add_Click({
- $script:_pkl.CheckedUrls.Clear()
- $script:_pkl.SuppressCheck = $true
- foreach ($item in $script:_pkl.Lv.Items) { $item.Checked = $false }
- $script:_pkl.SuppressCheck = $false
- $script:_pkl.LblStatus.Text = "0 coche(s) -- " +
- "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
- })
-
- $btnLoad.Add_Click({
- if (-not $script:_pkl.AdminUrl) {
- [System.Windows.Forms.MessageBox]::Show(
- "URL Tenant invalide (attendu: https://xxx.sharepoint.com).",
- "Erreur", "OK", "Error")
- return
- }
- $script:_pkl.BtnLoad.Enabled = $false
- $script:_pkl.BtnOK.Enabled = $false
- $script:_pkl.Lv.Items.Clear()
- $script:_pkl.AllSites = @()
- $script:_pkl.LblStatus.Text = "Connexion a $($script:_pkl.AdminUrl) ..."
- $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::DarkOrange
-
- $sync = [hashtable]::Synchronized(@{ Done = $false; Error = $null; Sites = $null })
- $script:_pkl.Sync = $sync
-
- $bgFetch = {
- param($AdminUrl, $ClientId, $Sync)
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
- $Sync.Sites = @(
- Get-PnPTenantSite |
- Select-Object Title, Url, IsTeamsConnected, StorageUsageCurrent |
- Sort-Object Title
- )
- } catch { $Sync.Error = $_.Exception.Message }
- finally { $Sync.Done = $true }
- }
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgFetch)
- [void]$ps.AddArgument($script:_pkl.AdminUrl)
- [void]$ps.AddArgument($script:_pkl.ClientId)
- [void]$ps.AddArgument($sync)
- $script:_pkl.RS = $rs
- $script:_pkl.PS = $ps
- $script:_pkl.Hnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 300
- $script:_pkl.Timer = $tmr
-
- $tmr.Add_Tick({
- if ($script:_pkl.Sync.Done) {
- $script:_pkl.Timer.Stop(); $script:_pkl.Timer.Dispose()
- try { [void]$script:_pkl.PS.EndInvoke($script:_pkl.Hnd) } catch {}
- try { $script:_pkl.RS.Close(); $script:_pkl.RS.Dispose() } catch {}
- $script:_pkl.BtnLoad.Enabled = $true
- if ($script:_pkl.Sync.Error) {
- $script:_pkl.LblStatus.Text = "Erreur: $($script:_pkl.Sync.Error)"
- $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Red
- } else {
- $script:_pkl.AllSites = @($script:_pkl.Sync.Sites)
- $script:_SiteCache = @($script:_pkl.Sync.Sites)
- _Pkl-Sort
- _Pkl-Repopulate
- $script:_pkl.BtnLoad.Text = "Recharger"
- $script:_pkl.BtnOK.Enabled = ($script:_pkl.Lv.Items.Count -gt 0)
- }
- } else {
- $dot = "." * (([System.DateTime]::Now.Second % 4) + 1)
- $script:_pkl.LblStatus.Text = "Chargement$dot"
- }
- })
- $tmr.Start()
- })
-
- $result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() }
-
- if ($result -eq "OK") { return ,@($script:_pkl.CheckedUrls) }
- return $null
-}
-
-#endregion
-
-#region ===== Template Management =====
-
-function Get-TemplatesFilePath {
- $dir = if ($script:DataFolder) { $script:DataFolder }
- elseif ($PSScriptRoot) { $PSScriptRoot }
- else { $PWD.Path }
- return Join-Path $dir "Sharepoint_Templates.json"
-}
-
-function Load-Templates {
- $path = Get-TemplatesFilePath
- if (Test-Path $path) {
- try {
- $data = Get-Content $path -Raw | ConvertFrom-Json
- if ($data.templates) { return @($data.templates) }
- } catch {}
- }
- return @()
-}
-
-function Save-Templates {
- param([array]$Templates)
- $path = Get-TemplatesFilePath
- @{ templates = @($Templates) } | ConvertTo-Json -Depth 20 | Set-Content $path -Encoding UTF8
-}
-
-# Script-scope helpers (accessible from all event handlers - no closure tricks)
-function _Tpl-Repopulate {
- $lv = $script:_tpl.Lv
- $lv.BeginUpdate()
- $lv.Items.Clear()
- foreach ($t in $script:_tpl.Templates) {
- $opts = @()
- if ($t.options.structure) { $opts += "Arborescence" }
- if ($t.options.permissions) { $opts += "Permissions" }
- if ($t.options.settings) { $opts += "Parametres" }
- if ($t.options.style) { $opts += "Style" }
- $item = New-Object System.Windows.Forms.ListViewItem($t.name)
- $item.Tag = $t
- $srcText = if ($t.sourceUrl) { $t.sourceUrl } else { "" }
- $datText = if ($t.capturedAt) { $t.capturedAt } else { "" }
- [void]$item.SubItems.Add($srcText)
- [void]$item.SubItems.Add($datText)
- [void]$item.SubItems.Add(($opts -join ", "))
- [void]$lv.Items.Add($item)
- }
- $lv.EndUpdate()
- $script:_tpl.BtnDelete.Enabled = $false
- $script:_tpl.BtnCreate.Enabled = $false
-}
-
-function _Tpl-RefreshCombo {
- $cbo = $script:_tpl.CboCrTpl
- $cbo.Items.Clear()
- foreach ($t in $script:_tpl.Templates) { [void]$cbo.Items.Add($t.name) }
- if ($cbo.Items.Count -gt 0) { $cbo.SelectedIndex = 0 }
-}
-
-function _Tpl-Log {
- param([System.Windows.Forms.RichTextBox]$Box, [string]$Msg, [string]$Color = "LightGreen")
- $Box.SelectionStart = $Box.TextLength
- $Box.SelectionLength = 0
- $Box.SelectionColor = [System.Drawing.Color]::$Color
- $Box.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Msg`n")
- $Box.ScrollToCaret()
-}
-
-function Show-TemplateManager {
- param(
- [string]$DefaultSiteUrl = "",
- [string]$ClientId = "",
- [string]$TenantUrl = "",
- [System.Windows.Forms.Form]$Owner = $null
- )
-
- $dlg = New-Object System.Windows.Forms.Form
- $dlg.Text = "Gestionnaire de Templates SharePoint"
- $dlg.Size = New-Object System.Drawing.Size(980, 700)
- $dlg.StartPosition = "CenterParent"
- $dlg.FormBorderStyle = "Sizable"
- $dlg.MinimumSize = New-Object System.Drawing.Size(700, 560)
- $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke
-
- # ── Template list ──────────────────────────────────────────────────────
- $grpList = New-Object System.Windows.Forms.GroupBox
- $grpList.Text = "Templates enregistres"
- $grpList.Location = New-Object System.Drawing.Point(10, 8)
- $grpList.Size = New-Object System.Drawing.Size(950, 174)
- $grpList.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
- $grpList.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
- $lv = New-Object System.Windows.Forms.ListView
- $lv.Location = New-Object System.Drawing.Point(8, 22)
- $lv.Size = New-Object System.Drawing.Size(820, 138)
- $lv.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
- $lv.View = [System.Windows.Forms.View]::Details
- $lv.FullRowSelect = $true
- $lv.GridLines = $true
- $lv.MultiSelect = $false
- $lv.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Regular)
- [void]$lv.Columns.Add("Nom", 200)
- [void]$lv.Columns.Add("Site source", 280)
- [void]$lv.Columns.Add("Date", 120)
- [void]$lv.Columns.Add("Options capturees", 200)
-
- $btnDelete = New-Object System.Windows.Forms.Button
- $btnDelete.Text = "Supprimer"
- $btnDelete.Location = New-Object System.Drawing.Point(836, 22)
- $btnDelete.Size = New-Object System.Drawing.Size(106, 26)
- $btnDelete.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right"
- $btnDelete.Enabled = $false
-
- $btnCreate = New-Object System.Windows.Forms.Button
- $btnCreate.Text = "Creer depuis ce template >"
- $btnCreate.Location = New-Object System.Drawing.Point(836, 56)
- $btnCreate.Size = New-Object System.Drawing.Size(106, 42)
- $btnCreate.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right"
- $btnCreate.Enabled = $false
- $btnCreate.BackColor = [System.Drawing.Color]::SteelBlue
- $btnCreate.ForeColor = [System.Drawing.Color]::White
- $btnCreate.FlatStyle = "Flat"
- $btnCreate.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Bold)
-
- $grpList.Controls.AddRange(@($lv, $btnDelete, $btnCreate))
-
- # ── Bottom tabs ────────────────────────────────────────────────────────
- $btabs = New-Object System.Windows.Forms.TabControl
- $btabs.Location = New-Object System.Drawing.Point(10, 190)
- $btabs.Size = New-Object System.Drawing.Size(950, 464)
- $btabs.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
- $btabs.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-
- # ── Tab: Capturer ──────────────────────────────────────────────────────
- $tabCap = New-Object System.Windows.Forms.TabPage
- $tabCap.Text = " Capturer un template "
- $tabCap.BackColor = [System.Drawing.Color]::WhiteSmoke
-
- $mkLbl = {
- param($t,$x,$y,$w=110)
- $l = New-Object System.Windows.Forms.Label
- $l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y)
- $l.Size = New-Object System.Drawing.Size($w,22); $l.TextAlign = "MiddleLeft"; $l
- }
-
- $lblCapSrc = & $mkLbl "Site source :" 10 18
- $txtCapSrc = New-Object System.Windows.Forms.TextBox
- $txtCapSrc.Location = New-Object System.Drawing.Point(128, 18)
- $txtCapSrc.Size = New-Object System.Drawing.Size(560, 22)
- $txtCapSrc.Text = $DefaultSiteUrl
- $txtCapSrc.Font = New-Object System.Drawing.Font("Consolas", 9)
-
- $lblCapName = & $mkLbl "Nom du template :" 10 48 120
- $txtCapName = New-Object System.Windows.Forms.TextBox
- $txtCapName.Location = New-Object System.Drawing.Point(134, 48)
- $txtCapName.Size = New-Object System.Drawing.Size(300, 22)
- $txtCapName.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-
- $grpOpts = New-Object System.Windows.Forms.GroupBox
- $grpOpts.Text = "Elements a capturer"
- $grpOpts.Location = New-Object System.Drawing.Point(10, 80)
- $grpOpts.Size = New-Object System.Drawing.Size(680, 68)
- $grpOpts.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
- $mkChk = {
- param($t,$x,$y,$w,$checked=$false)
- $c = New-Object System.Windows.Forms.CheckBox
- $c.Text = $t; $c.Location = New-Object System.Drawing.Point($x,$y)
- $c.Size = New-Object System.Drawing.Size($w,20)
- $c.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Regular)
- $c.Checked = $checked; $c
- }
-
- $chkCapStruct = & $mkChk "Arborescence (bibliotheques et dossiers)" 10 22 300 $true
- $chkCapPerms = & $mkChk "Permissions (groupes et roles)" 320 22 260
- $chkCapSettings = & $mkChk "Parametres du site (titre, langue...)" 10 44 300 $true
- $chkCapStyle = & $mkChk "Style (logo)" 320 44 200
- $grpOpts.Controls.AddRange(@($chkCapStruct, $chkCapPerms, $chkCapSettings, $chkCapStyle))
-
- $btnCapture = New-Object System.Windows.Forms.Button
- $btnCapture.Text = "Lancer la capture"
- $btnCapture.Location = New-Object System.Drawing.Point(10, 162)
- $btnCapture.Size = New-Object System.Drawing.Size(155, 34)
- $btnCapture.BackColor = [System.Drawing.Color]::FromArgb(16,124,16)
- $btnCapture.ForeColor = [System.Drawing.Color]::White
- $btnCapture.FlatStyle = "Flat"
- $btnCapture.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
- $txtCapLog = New-Object System.Windows.Forms.RichTextBox
- $txtCapLog.Location = New-Object System.Drawing.Point(10, 206)
- $txtCapLog.Size = New-Object System.Drawing.Size(918, 208)
- $txtCapLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
- $txtCapLog.ReadOnly = $true
- $txtCapLog.BackColor = [System.Drawing.Color]::Black
- $txtCapLog.ForeColor = [System.Drawing.Color]::LightGreen
- $txtCapLog.Font = New-Object System.Drawing.Font("Consolas", 8)
- $txtCapLog.ScrollBars = "Vertical"
-
- $tabCap.Controls.AddRange(@($lblCapSrc, $txtCapSrc, $lblCapName, $txtCapName,
- $grpOpts, $btnCapture, $txtCapLog))
-
- # ── Tab: Creer depuis template ─────────────────────────────────────────
- $tabCr = New-Object System.Windows.Forms.TabPage
- $tabCr.Text = " Creer depuis un template "
- $tabCr.BackColor = [System.Drawing.Color]::WhiteSmoke
-
- $lblCrTpl = & $mkLbl "Template :" 10 18
- $cboCrTpl = New-Object System.Windows.Forms.ComboBox
- $cboCrTpl.Location = New-Object System.Drawing.Point(128, 16)
- $cboCrTpl.Size = New-Object System.Drawing.Size(400, 24)
- $cboCrTpl.DropDownStyle = "DropDownList"
- $cboCrTpl.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-
- $lblCrTitle = & $mkLbl "Titre du site :" 10 48
- $txtCrTitle = New-Object System.Windows.Forms.TextBox
- $txtCrTitle.Location = New-Object System.Drawing.Point(128, 46)
- $txtCrTitle.Size = New-Object System.Drawing.Size(300, 22)
- $txtCrTitle.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-
- $lblCrAlias = & $mkLbl "Alias URL :" 10 78
- $txtCrAlias = New-Object System.Windows.Forms.TextBox
- $txtCrAlias.Location = New-Object System.Drawing.Point(128, 76)
- $txtCrAlias.Size = New-Object System.Drawing.Size(200, 22)
- $txtCrAlias.Font = New-Object System.Drawing.Font("Consolas", 9)
-
- $lblCrAliasHint = New-Object System.Windows.Forms.Label
- $lblCrAliasHint.Text = "(lettres, chiffres, tirets uniquement)"
- $lblCrAliasHint.Location = New-Object System.Drawing.Point(336, 78)
- $lblCrAliasHint.Size = New-Object System.Drawing.Size(250, 20)
- $lblCrAliasHint.ForeColor = [System.Drawing.Color]::Gray
- $lblCrAliasHint.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
-
- $lblCrType = & $mkLbl "Type :" 10 108
- $radCrTeam = New-Object System.Windows.Forms.RadioButton
- $radCrTeam.Text = "Team Site (avec groupe M365)"
- $radCrTeam.Location = New-Object System.Drawing.Point(128, 108)
- $radCrTeam.Size = New-Object System.Drawing.Size(220, 22)
- $radCrTeam.Checked = $true
-
- $radCrComm = New-Object System.Windows.Forms.RadioButton
- $radCrComm.Text = "Communication Site"
- $radCrComm.Location = New-Object System.Drawing.Point(358, 108)
- $radCrComm.Size = New-Object System.Drawing.Size(200, 22)
-
- $lblCrOwners = & $mkLbl "Proprietaires :" 10 138
- $txtCrOwners = New-Object System.Windows.Forms.TextBox
- $txtCrOwners.Location = New-Object System.Drawing.Point(128, 136)
- $txtCrOwners.Size = New-Object System.Drawing.Size(550, 22)
- $txtCrOwners.Font = New-Object System.Drawing.Font("Segoe UI", 9)
- $txtCrOwners.PlaceholderText = "user1@domain.com, user2@domain.com"
-
- $lblCrMembers = & $mkLbl "Membres :" 10 168
- $txtCrMembers = New-Object System.Windows.Forms.TextBox
- $txtCrMembers.Location = New-Object System.Drawing.Point(128, 166)
- $txtCrMembers.Size = New-Object System.Drawing.Size(410, 22)
- $txtCrMembers.Font = New-Object System.Drawing.Font("Segoe UI", 9)
- $txtCrMembers.PlaceholderText = "user@domain.com, ..."
-
- $btnCrCsv = New-Object System.Windows.Forms.Button
- $btnCrCsv.Text = "Importer CSV..."
- $btnCrCsv.Location = New-Object System.Drawing.Point(546, 164)
- $btnCrCsv.Size = New-Object System.Drawing.Size(120, 26)
-
- $grpApply = New-Object System.Windows.Forms.GroupBox
- $grpApply.Text = "Appliquer depuis le template"
- $grpApply.Location = New-Object System.Drawing.Point(10, 200)
- $grpApply.Size = New-Object System.Drawing.Size(680, 60)
- $grpApply.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
- $chkApplyStruct = & $mkChk "Arborescence" 10 22 140 $true
- $chkApplyPerms = & $mkChk "Permissions" 158 22 120
- $chkApplySettings = & $mkChk "Parametres" 286 22 120 $true
- $chkApplyStyle = & $mkChk "Style" 414 22 100
- $grpApply.Controls.AddRange(@($chkApplyStruct, $chkApplyPerms, $chkApplySettings, $chkApplyStyle))
-
- $btnCreateSite = New-Object System.Windows.Forms.Button
- $btnCreateSite.Text = "Creer le site"
- $btnCreateSite.Location = New-Object System.Drawing.Point(10, 272)
- $btnCreateSite.Size = New-Object System.Drawing.Size(155, 34)
- $btnCreateSite.BackColor = [System.Drawing.Color]::SteelBlue
- $btnCreateSite.ForeColor = [System.Drawing.Color]::White
- $btnCreateSite.FlatStyle = "Flat"
- $btnCreateSite.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
- $txtCreateLog = New-Object System.Windows.Forms.RichTextBox
- $txtCreateLog.Location = New-Object System.Drawing.Point(10, 316)
- $txtCreateLog.Size = New-Object System.Drawing.Size(918, 100)
- $txtCreateLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
- $txtCreateLog.ReadOnly = $true
- $txtCreateLog.BackColor = [System.Drawing.Color]::Black
- $txtCreateLog.ForeColor = [System.Drawing.Color]::LightGreen
- $txtCreateLog.Font = New-Object System.Drawing.Font("Consolas", 8)
- $txtCreateLog.ScrollBars = "Vertical"
-
- $tabCr.Controls.AddRange(@(
- $lblCrTpl, $cboCrTpl,
- $lblCrTitle, $txtCrTitle,
- $lblCrAlias, $txtCrAlias, $lblCrAliasHint,
- $lblCrType, $radCrTeam, $radCrComm,
- $lblCrOwners, $txtCrOwners,
- $lblCrMembers, $txtCrMembers, $btnCrCsv,
- $grpApply,
- $btnCreateSite, $txtCreateLog
- ))
-
- $btabs.TabPages.AddRange(@($tabCap, $tabCr))
- $dlg.Controls.AddRange(@($grpList, $btabs))
-
- # ── Init state ─────────────────────────────────────────────────────────
- $script:_tpl = @{
- Lv = $lv
- BtnDelete = $btnDelete
- BtnCreate = $btnCreate
- CboCrTpl = $cboCrTpl
- TxtCapLog = $txtCapLog
- TxtCreateLog = $txtCreateLog
- Templates = @(Load-Templates)
- ClientId = $ClientId
- TenantUrl = $TenantUrl
- CapSync = $null; CapTimer = $null; CapRS = $null; CapPS = $null; CapHnd = $null
- CrSync = $null; CrTimer = $null; CrRS = $null; CrPS = $null; CrHnd = $null
- CapTplName = ""; CapSrcUrl = ""; CapOpts = $null
- }
- _Tpl-Repopulate
- _Tpl-RefreshCombo
-
- # ── Event handlers ─────────────────────────────────────────────────────
- $lv.Add_SelectedIndexChanged({
- $sel = $script:_tpl.Lv.SelectedItems.Count -gt 0
- $script:_tpl.BtnDelete.Enabled = $sel
- $script:_tpl.BtnCreate.Enabled = $sel
- })
-
- $btnDelete.Add_Click({
- $item = $script:_tpl.Lv.SelectedItems[0]
- if (-not $item) { return }
- $name = $item.Text
- $res = [System.Windows.Forms.MessageBox]::Show(
- "Supprimer le template '$name' ?", "Confirmer", "YesNo", "Warning")
- if ($res -ne "Yes") { return }
- $script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $name })
- Save-Templates -Templates $script:_tpl.Templates
- _Tpl-Repopulate
- _Tpl-RefreshCombo
- })
-
- $btnCreate.Add_Click({
- $item = $script:_tpl.Lv.SelectedItems[0]
- if (-not $item) { return }
- $t = $item.Tag
- $idx = [array]::IndexOf(($script:_tpl.Templates | ForEach-Object { $_.name }), $t.name)
- if ($idx -ge 0) { $script:_tpl.CboCrTpl.SelectedIndex = $idx }
- $btabs.SelectedTab = $tabCr
- })
-
- # Auto-generate alias from title
- $txtCrTitle.Add_TextChanged({
- $a = $txtCrTitle.Text -replace '[^a-zA-Z0-9 \-]','' -replace '\s+','-' -replace '\-+','-'
- $a = $a.ToLower().Trim('-')
- if ($a.Length -gt 64) { $a = $a.Substring(0,64) }
- $txtCrAlias.Text = $a
- })
-
- # CSV import for members
- $btnCrCsv.Add_Click({
- $ofd = New-Object System.Windows.Forms.OpenFileDialog
- $ofd.Filter = "CSV (*.csv)|*.csv|Tous (*.*)|*.*"
- $ofd.Title = "Importer des membres depuis CSV"
- if ($ofd.ShowDialog() -ne "OK") { return }
- try {
- $rows = Import-Csv $ofd.FileName
- $emails = $rows | ForEach-Object {
- $r = $_
- $v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email } `
- elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn } `
- elseif ($r.UserPrincipalName) { $r.UserPrincipalName } `
- else { $r.userprincipalname }
- $v
- } | Where-Object { $_ } | Select-Object -Unique
- $txtCrMembers.Text = ($emails -join ", ")
- [System.Windows.Forms.MessageBox]::Show(
- "$($emails.Count) membre(s) importe(s).", "Import CSV", "OK", "Information")
- } catch {
- [System.Windows.Forms.MessageBox]::Show(
- "Erreur CSV: $($_.Exception.Message)", "Erreur", "OK", "Error")
- }
- })
-
- # ── Capture button ─────────────────────────────────────────────────────
- $btnCapture.Add_Click({
- $srcUrl = $txtCapSrc.Text.Trim()
- $tplName = $txtCapName.Text.Trim()
- if ([string]::IsNullOrWhiteSpace($srcUrl)) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir l'URL du site source.", "Champ manquant", "OK", "Warning")
- return
- }
- if ([string]::IsNullOrWhiteSpace($tplName)) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir un nom pour le template.", "Champ manquant", "OK", "Warning")
- return
- }
- if ([string]::IsNullOrWhiteSpace($script:_tpl.ClientId)) {
- [System.Windows.Forms.MessageBox]::Show("Client ID manquant dans le formulaire principal.", "Erreur", "OK", "Warning")
- return
- }
-
- # Stash locals so timer tick can read them from script scope
- $script:_tpl.CapTplName = $tplName
- $script:_tpl.CapSrcUrl = $srcUrl
- $script:_tpl.CapOpts = @{
- structure = $chkCapStruct.Checked
- permissions = $chkCapPerms.Checked
- settings = $chkCapSettings.Checked
- style = $chkCapStyle.Checked
- }
-
- $btnCapture.Enabled = $false
- $script:_tpl.TxtCapLog.Clear()
-
- $bgCapture = {
- param($SiteUrl, $ClientId, $Opts, $Sync)
- function BgLog([string]$m, [string]$c="LightGreen") {
- $Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c })
- }
- function Collect-Folders([string]$SiteRelUrl) {
- $out = [System.Collections.Generic.List[object]]::new()
- try {
- $items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue
- foreach ($fi in $items) {
- if ($fi.Name -match '^_|^Forms$') { continue }
- $sub = Collect-Folders "$SiteRelUrl/$($fi.Name)"
- $out.Add(@{ name=$fi.Name; subfolders=@($sub) })
- }
- } catch {}
- return @($out)
- }
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- BgLog "Connexion a $SiteUrl..." "Yellow"
- Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId
- $web = Get-PnPWeb -Includes Title,Description,Language,SiteLogoUrl
- $wSrl = $web.ServerRelativeUrl.TrimEnd('/')
- $result = @{ settings=@{}; style=@{}; structure=@(); permissions=@(); folderPermissions=@() }
-
- if ($Opts.settings) {
- BgLog "Capture des parametres..." "Yellow"
- $result.settings = @{
- title = $web.Title
- description = $web.Description
- language = [int]$web.Language
- }
- BgLog " Titre: $($web.Title) | Langue: $($web.Language)" "Cyan"
- }
-
- if ($Opts.style) {
- BgLog "Capture du style..." "Yellow"
- $result.style = @{ logoUrl = $web.SiteLogoUrl }
- BgLog " Logo: $($web.SiteLogoUrl)" "Cyan"
- }
-
- if ($Opts.structure) {
- BgLog "Capture de l'arborescence..." "Yellow"
- $lists = Get-PnPList | Where-Object {
- !$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList")
- }
- $struct = [System.Collections.Generic.List[object]]::new()
- foreach ($list in $lists) {
- $rf = Get-PnPProperty -ClientObject $list -Property RootFolder
- $srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
- $fld = Collect-Folders $srl
- $struct.Add(@{
- name = $list.Title
- type = $list.BaseType.ToString()
- template = [int]$list.BaseTemplate
- rootSiteRel = $srl # site-relative URL of library root
- folders = @($fld)
- })
- BgLog " [$($list.BaseType)] $($list.Title) ($srl) - $($fld.Count) dossier(s)" "Cyan"
- }
- $result.structure = @($struct)
- }
-
- if ($Opts.permissions) {
- BgLog "Capture des groupes site..." "Yellow"
- $groups = Get-PnPSiteGroup
- $permArr = [System.Collections.Generic.List[object]]::new()
- foreach ($g in $groups) {
- try {
- $members = @(Get-PnPGroupMember -Identity $g.LoginName -ErrorAction SilentlyContinue |
- Where-Object { $_.Title -ne "System Account" } |
- Select-Object -ExpandProperty LoginName)
- $roles = @(Get-PnPGroupPermissions -Identity $g.LoginName -ErrorAction SilentlyContinue |
- Select-Object -ExpandProperty Name)
- $permArr.Add(@{
- groupName = $g.Title
- loginName = $g.LoginName
- roles = @($roles)
- members = @($members)
- })
- BgLog " Groupe: $($g.Title) - $($members.Count) membre(s)" "Cyan"
- } catch {}
- }
- $result.permissions = @($permArr)
-
- # Capture des permissions uniques sur les dossiers
- BgLog "Scan des permissions sur les dossiers..." "Yellow"
- $scanLists = Get-PnPList | Where-Object {
- !$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList")
- }
- $folderPerms = [System.Collections.Generic.List[object]]::new()
- $ctx = Get-PnPContext
- foreach ($scanList in $scanLists) {
- $rf = Get-PnPProperty -ClientObject $scanList -Property RootFolder
- $listSrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
- try {
- $items = Get-PnPListItem -List $scanList -PageSize 2000 -ErrorAction SilentlyContinue |
- Where-Object { $_.FileSystemObjectType -eq "Folder" }
- foreach ($folder in $items) {
- try {
- $hasUnique = Get-PnPProperty -ClientObject $folder -Property HasUniqueRoleAssignments
- if (-not $hasUnique) { continue }
- $ra = Get-PnPProperty -ClientObject $folder -Property RoleAssignments
- $folderRoleArr = [System.Collections.Generic.List[object]]::new()
- foreach ($assignment in $ra) {
- Get-PnPProperty -ClientObject $assignment.Member -Property Title,LoginName | Out-Null
- Get-PnPProperty -ClientObject $assignment -Property RoleDefinitionBindings | Out-Null
- $rNames = @($assignment.RoleDefinitionBindings |
- Where-Object { $_.Name -ne "Limited Access" } |
- Select-Object -ExpandProperty Name)
- if ($rNames.Count -gt 0) {
- $folderRoleArr.Add(@{
- principal = $assignment.Member.Title
- loginName = $assignment.Member.LoginName
- roles = @($rNames)
- })
- }
- }
- $fileRef = $folder.FieldValues.FileRef
- $relPath = $fileRef.Substring($wSrl.Length).TrimStart('/')
- $folderPerms.Add(@{
- listSiteRel = $listSrl
- path = $relPath
- perms = @($folderRoleArr)
- })
- BgLog " Perms uniques: $relPath ($($folderRoleArr.Count) entree(s))" "Cyan"
- } catch {}
- }
- } catch { BgLog " Liste ignoree '$($scanList.Title)': $($_.Exception.Message)" "DarkGray" }
- }
- $result.folderPermissions = @($folderPerms)
- BgLog " $($folderPerms.Count) dossier(s) avec permissions uniques captures" "LightGreen"
- }
-
- $Sync.Result = $result
- BgLog "=== Capture terminee ! ===" "White"
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur: $($_.Exception.Message)" "Red"
- } finally { $Sync.Done = $true }
- }
-
- $sync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false; Error = $null; Result = $null
- })
- $script:_tpl.CapSync = $sync
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgCapture)
- [void]$ps.AddArgument($srcUrl)
- [void]$ps.AddArgument($script:_tpl.ClientId)
- [void]$ps.AddArgument($script:_tpl.CapOpts)
- [void]$ps.AddArgument($sync)
- $script:_tpl.CapRS = $rs
- $script:_tpl.CapPS = $ps
- $script:_tpl.CapHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 200
- $script:_tpl.CapTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_tpl.CapSync.Queue.Count -gt 0) {
- $m = $script:_tpl.CapSync.Queue.Dequeue()
- _Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color
- }
- if ($script:_tpl.CapSync.Done) {
- $script:_tpl.CapTimer.Stop(); $script:_tpl.CapTimer.Dispose()
- while ($script:_tpl.CapSync.Queue.Count -gt 0) {
- $m = $script:_tpl.CapSync.Queue.Dequeue()
- _Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color
- }
- try { [void]$script:_tpl.CapPS.EndInvoke($script:_tpl.CapHnd) } catch {}
- try { $script:_tpl.CapRS.Close(); $script:_tpl.CapRS.Dispose() } catch {}
- $btnCapture.Enabled = $true
- if (-not $script:_tpl.CapSync.Error -and $script:_tpl.CapSync.Result) {
- $r = $script:_tpl.CapSync.Result
- $newTpl = [PSCustomObject]@{
- name = $script:_tpl.CapTplName
- sourceUrl = $script:_tpl.CapSrcUrl
- capturedAt = (Get-Date -Format 'dd/MM/yyyy HH:mm')
- options = $script:_tpl.CapOpts
- settings = $r.settings
- style = $r.style
- structure = $r.structure
- permissions = $r.permissions
- folderPermissions = $r.folderPermissions
- }
- $n = $script:_tpl.CapTplName
- $script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $n }) + $newTpl
- Save-Templates -Templates $script:_tpl.Templates
- _Tpl-Repopulate
- _Tpl-RefreshCombo
- [System.Windows.Forms.MessageBox]::Show(
- "Template '$n' sauvegarde avec succes.",
- "Capture reussie", "OK", "Information")
- }
- }
- })
- $tmr.Start()
- })
-
- # ── Create site button ─────────────────────────────────────────────────
- $btnCreateSite.Add_Click({
- $tplIdx = $cboCrTpl.SelectedIndex
- if ($tplIdx -lt 0 -or $tplIdx -ge $script:_tpl.Templates.Count) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez selectionner un template.", "Aucun template", "OK", "Warning")
- return
- }
- $tpl = $script:_tpl.Templates[$tplIdx]
- $title = $txtCrTitle.Text.Trim()
- $alias = $txtCrAlias.Text.Trim()
- $owners = @($txtCrOwners.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
- $members = @($txtCrMembers.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
-
- if ([string]::IsNullOrWhiteSpace($title)) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir le titre du site.", "Champ manquant", "OK", "Warning"); return
- }
- if ([string]::IsNullOrWhiteSpace($alias)) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir un alias URL.", "Champ manquant", "OK", "Warning"); return
- }
- if ([string]::IsNullOrWhiteSpace($script:_tpl.TenantUrl)) {
- [System.Windows.Forms.MessageBox]::Show("Tenant URL manquant dans le formulaire principal.", "Erreur", "OK", "Warning"); return
- }
- if ($owners.Count -eq 0) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir au moins un proprietaire.", "Champ manquant", "OK", "Warning"); return
- }
-
- $applyOpts = @{
- structure = $chkApplyStruct.Checked -and $tpl.options.structure
- permissions = $chkApplyPerms.Checked -and $tpl.options.permissions
- settings = $chkApplySettings.Checked -and $tpl.options.settings
- style = $chkApplyStyle.Checked -and $tpl.options.style
- }
- $isTeam = $radCrTeam.Checked
-
- $btnCreateSite.Enabled = $false
- $script:_tpl.TxtCreateLog.Clear()
-
- $bgCreate = {
- param($TenantUrl, $ClientId, $Title, $Alias, $IsTeam, $Owners, $Members, $ApplyOpts, $Tpl, $Sync)
- function BgLog([string]$m, [string]$c="LightGreen") {
- $Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c })
- }
- function Apply-FolderTree([object[]]$Folders, [string]$ParentSrl) {
- foreach ($f in $Folders) {
- try {
- Add-PnPFolder -Name $f.name -Folder $ParentSrl -ErrorAction SilentlyContinue | Out-Null
- BgLog " + $($f.name)" "Cyan"
- } catch {}
- if ($f.subfolders -and $f.subfolders.Count -gt 0) {
- Apply-FolderTree $f.subfolders "$ParentSrl/$($f.name)"
- }
- }
- }
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- $adminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
- "$($Matches[1])-admin$($Matches[2])"
- } else { $TenantUrl }
-
- BgLog "Connexion au tenant admin..." "Yellow"
- Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $ClientId
-
- BgLog "Creation du site '$Title' (alias: $Alias)..." "Yellow"
- $newUrl = if ($IsTeam) {
- New-PnPSite -Type TeamSite -Title $Title -Alias $Alias -Owners $Owners -Wait
- } else {
- $base = if ($TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $TenantUrl }
- New-PnPSite -Type CommunicationSite -Title $Title -Url "$base/sites/$Alias" -Wait
- }
- $Sync.NewSiteUrl = $newUrl
- BgLog "Site cree : $newUrl" "LightGreen"
-
- BgLog "Connexion au nouveau site..." "Yellow"
- Connect-PnPOnline -Url $newUrl -Interactive -ClientId $ClientId
- $web = Get-PnPWeb
- $wSrl = $web.ServerRelativeUrl.TrimEnd('/')
-
- if ($ApplyOpts.settings -and $Tpl.settings -and $Tpl.settings.description) {
- BgLog "Application des parametres..." "Yellow"
- Set-PnPWeb -Description $Tpl.settings.description
- }
-
- if ($ApplyOpts.style -and $Tpl.style -and $Tpl.style.logoUrl) {
- BgLog "Application du style (logo)..." "Yellow"
- Set-PnPWeb -SiteLogoUrl $Tpl.style.logoUrl
- }
-
- if ($ApplyOpts.structure -and $Tpl.structure -and $Tpl.structure.Count -gt 0) {
- BgLog "Application de l'arborescence..." "Yellow"
- foreach ($lib in $Tpl.structure) {
- BgLog " Bibliotheque: $($lib.name)" "Yellow"
- $existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
- if (-not $existing) {
- try {
- $tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") {
- [Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary
- } else {
- [Microsoft.SharePoint.Client.ListTemplateType]::GenericList
- }
- New-PnPList -Title $lib.name -Template $tplType | Out-Null
- BgLog " Creee." "Cyan"
- } catch { BgLog " Ignoree: $($_.Exception.Message)" "DarkGray" }
- } else { BgLog " Deja existante." "DarkGray" }
-
- if ($lib.folders -and $lib.folders.Count -gt 0) {
- # Get actual root folder URL from target list (avoids display-name vs URL mismatch)
- $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
- if ($targetList) {
- $listRf = Get-PnPProperty -ClientObject $targetList -Property RootFolder
- $libBase = $listRf.ServerRelativeUrl.TrimEnd('/')
- BgLog " Base URL: $libBase" "DarkGray"
- Apply-FolderTree $lib.folders $libBase
- } else {
- BgLog " Liste '$($lib.name)' non trouvee, dossiers ignores." "DarkGray"
- }
- }
- }
- }
-
- if ($Members -and $Members.Count -gt 0) {
- BgLog "Ajout de $($Members.Count) membre(s)..." "Yellow"
- $memberGroup = Get-PnPGroup | Where-Object { $_.Title -like "*Membres*" -or $_.Title -like "*Members*" } | Select-Object -First 1
- if ($memberGroup) {
- foreach ($m in $Members) {
- try {
- Add-PnPGroupMember -LoginName $m -Group $memberGroup.Title -ErrorAction SilentlyContinue
- BgLog " + $m" "Cyan"
- } catch { BgLog " Ignore $m" "DarkGray" }
- }
- } else { BgLog " Groupe membres non trouve, ajout ignore." "DarkGray" }
- }
-
- # Apply folder-level unique permissions
- $fpList = $Tpl.folderPermissions
- if ($ApplyOpts.permissions -and $fpList -and $fpList.Count -gt 0) {
- BgLog "Application des permissions sur les dossiers ($($fpList.Count))..." "Yellow"
-
- # Build map: source library rootSiteRel -> target library server-relative URL
- $libMap = @{}
- foreach ($lib in $Tpl.structure) {
- if (-not $lib.rootSiteRel) { continue }
- $tgtLib = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
- if ($tgtLib) {
- $tgtRf = Get-PnPProperty -ClientObject $tgtLib -Property RootFolder
- $libMap[$lib.rootSiteRel] = $tgtRf.ServerRelativeUrl.TrimEnd('/')
- }
- }
-
- $ctx = Get-PnPContext
- foreach ($fp in $fpList) {
- # Compute folder SRL on target using the library map
- $srcLibSrl = $fp.listSiteRel
- $tgtLibBase = $libMap[$srcLibSrl]
- if (-not $tgtLibBase) {
- BgLog " Bibliotheque non mappee pour '$srcLibSrl', dossier ignore." "DarkGray"
- continue
- }
- # Folder path relative to library root (strip the lib prefix from fp.path)
- $folderRelToLib = $fp.path.Substring($srcLibSrl.Length).TrimStart('/')
- $folderSrl = "$tgtLibBase/$folderRelToLib"
-
- try {
- $folder = $ctx.Web.GetFolderByServerRelativeUrl($folderSrl)
- $folderItem = $folder.ListItemAllFields
- $ctx.Load($folderItem)
- $ctx.ExecuteQuery()
-
- # Break inheritance and clear all inherited permissions
- $folderItem.BreakRoleInheritance($false, $false)
- $ctx.ExecuteQuery()
-
- # Apply each captured permission entry
- foreach ($perm in $fp.perms) {
- # Skip system accounts and source-specific SP groups (detected by failed EnsureUser)
- try {
- $principal = $ctx.Web.EnsureUser($perm.loginName)
- $ctx.Load($principal)
- $ctx.ExecuteQuery()
- foreach ($roleName in $perm.roles) {
- try {
- $roleDef = $ctx.Web.RoleDefinitions.GetByName($roleName)
- $bindings = New-Object Microsoft.SharePoint.Client.RoleDefinitionBindingCollection($ctx)
- $bindings.Add($roleDef)
- $folderItem.RoleAssignments.Add($principal, $bindings) | Out-Null
- $ctx.ExecuteQuery()
- BgLog " + $($perm.principal) [$roleName]" "Cyan"
- } catch { BgLog " Role '$roleName' ignore pour '$($perm.principal)'" "DarkGray" }
- }
- } catch {
- BgLog " Principal ignore (groupe source ou compte systeme) : '$($perm.principal)'" "DarkGray"
- }
- }
- BgLog " OK: $folderRelToLib" "Cyan"
- } catch { BgLog " Dossier ignore '$folderRelToLib': $($_.Exception.Message)" "DarkGray" }
- }
- }
-
- BgLog "=== Site cree avec succes ! ===" "White"
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur: $($_.Exception.Message)" "Red"
- } finally { $Sync.Done = $true }
- }
-
- $sync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false; Error = $null; NewSiteUrl = $null
- })
- $script:_tpl.CrSync = $sync
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgCreate)
- [void]$ps.AddArgument($script:_tpl.TenantUrl)
- [void]$ps.AddArgument($script:_tpl.ClientId)
- [void]$ps.AddArgument($title)
- [void]$ps.AddArgument($alias)
- [void]$ps.AddArgument($isTeam)
- [void]$ps.AddArgument($owners)
- [void]$ps.AddArgument($members)
- [void]$ps.AddArgument($applyOpts)
- [void]$ps.AddArgument($tpl)
- [void]$ps.AddArgument($sync)
- $script:_tpl.CrRS = $rs
- $script:_tpl.CrPS = $ps
- $script:_tpl.CrHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 200
- $script:_tpl.CrTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_tpl.CrSync.Queue.Count -gt 0) {
- $m = $script:_tpl.CrSync.Queue.Dequeue()
- _Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color
- }
- if ($script:_tpl.CrSync.Done) {
- $script:_tpl.CrTimer.Stop(); $script:_tpl.CrTimer.Dispose()
- while ($script:_tpl.CrSync.Queue.Count -gt 0) {
- $m = $script:_tpl.CrSync.Queue.Dequeue()
- _Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color
- }
- try { [void]$script:_tpl.CrPS.EndInvoke($script:_tpl.CrHnd) } catch {}
- try { $script:_tpl.CrRS.Close(); $script:_tpl.CrRS.Dispose() } catch {}
- $btnCreateSite.Enabled = $true
- if ($script:_tpl.CrSync.NewSiteUrl -and -not $script:_tpl.CrSync.Error) {
- $url = $script:_tpl.CrSync.NewSiteUrl
- $res = [System.Windows.Forms.MessageBox]::Show(
- "Site cree avec succes !`n`n$url`n`nOuvrir dans le navigateur ?",
- "Succes", "YesNo", "Information")
- if ($res -eq "Yes") { Start-Process $url }
- }
- }
- })
- $tmr.Start()
- })
-
- if ($Owner) { [void]$dlg.ShowDialog($Owner) } else { [void]$dlg.ShowDialog() }
- $dlg.Dispose()
-}
-
-#endregion
-
-#region ===== HTML Export: Permissions =====
-
-function Merge-PermissionRows([array]$Data) {
- # Groups rows that share the same Users + Permissions + GrantedThrough into one merged row.
- # Uses [ordered] to preserve insertion order without a separate list.
- $map = [ordered]@{}
- foreach ($row in $Data) {
- $key = "$($row.Users)|$($row.Permissions)|$($row.GrantedThrough)"
- if (-not $map.Contains($key)) {
- $map[$key] = [PSCustomObject]@{
- Locations = @()
- Permissions = $row.Permissions
- GrantedThrough = $row.GrantedThrough
- Type = $row.Type
- Users = $row.Users
- UserLogins = if ($row.UserLogins) { $row.UserLogins } else { "" }
- }
- }
- $map[$key].Locations += [PSCustomObject]@{
- Object = [string]$row.Object
- Title = [string]$row.Title
- URL = if ($row.URL) { [string]$row.URL } else { "" }
- HasUniquePermissions = $row.HasUniquePermissions
- }
- }
- return @($map.Values)
-}
-
-function Export-PermissionsToHTML {
- param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath)
-
- $generated = Get-Date -Format 'dd/MM/yyyy HH:mm'
- $count = $Data.Count
- $uniqueCount = ($Data | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true } | Measure-Object).Count
- $userCount = ($Data | ForEach-Object { $_.Users -split '; ' } | Where-Object { $_ } | Sort-Object -Unique | Measure-Object).Count
-
- # Build pills HTML for a list of names + emails
- function Build-Pills([string[]]$Names, [string[]]$Emails) {
- $html = ""
- for ($i = 0; $i -lt $Names.Count; $i++) {
- $n = EscHtml $Names[$i]
- $e = if ($Emails -and $i -lt $Emails.Count) { EscHtml $Emails[$i] } else { "" }
- $html += "$n"
- }
- return $html
- }
-
- $mergedRows = Merge-PermissionRows $Data
- $script:grpIdx = 0
- $rows = ""
- foreach ($mrow in $mergedRows) {
- $locs = @($mrow.Locations)
-
- # Dominant type badge (use first location's type - entries in a merged group are typically the same type)
- $dominantType = $locs[0].Object
- $badgeClass = switch -Regex ($dominantType) {
- "Site Collection" { "bc"; break }
- "^Site$" { "bs"; break }
- "Folder" { "bf"; break }
- Default { "bl" }
- }
-
- # Name / locations cell + Unique Permissions cell
- if ($locs.Count -eq 1) {
- $loc = $locs[0]
- $isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true)
- $uqClass = if ($isUnique) { "uq" } else { "inh" }
- $uqText = if ($isUnique) { "✓ Unique" } else { "Inherited" }
- $nameCell = if ($loc.URL) { "$(EscHtml $loc.Title)" } else { EscHtml $loc.Title }
- $uqCell = "$uqText"
- } else {
- $uqTotal = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count
- $locHtml = "
"
- foreach ($loc in $locs) {
- $isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true)
- $uqMark = if ($isUnique) { "
✓" } else { "
~" }
- $locBc = switch -Regex ($loc.Object) {
- "Site Collection" { "bc"; break }
- "^Site$" { "bs"; break }
- "Folder" { "bf"; break }
- Default { "bl" }
- }
- $locLink = if ($loc.URL) { "
$(EscHtml $loc.Title)" } else { EscHtml $loc.Title }
- $locHtml += "
$(EscHtml $loc.Object) $locLink $uqMark
"
- }
- $locHtml += "
"
- $nameCell = $locHtml
- $uqCell = "$($locs.Count) emplacements
($uqTotal uniques)"
- }
-
- # Build Users / Members cell
- $names = if ($mrow.Users) { @($mrow.Users -split '; ') } else { @() }
- $emails = if ($mrow.UserLogins) { @($mrow.UserLogins -split '; ') } else { @() }
- $usersCell = ""
- if ($names.Count -gt 0) {
- $pills = Build-Pills $names $emails
- if ($mrow.GrantedThrough -match '^SharePoint Group: (.+)$') {
- $grpName = EscHtml $Matches[1]
- $gid = "g$($script:grpIdx)"
- $script:grpIdx++
- $usersCell = ""
- } 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
-
-
-
-
-
$uniqueCount
Unique Permission Sets
-
$userCount
Distinct Users / Groups
-
-
-
-
-| Type | Name | Users / Members | Permission Level | Granted Through | Unique Permissions |
-
-$rows
-
-
-
-
-
-
-
-
-"@
- $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 += " | Folder | Files | Size | Versions | Last Modified | "
- $html += "$(Build-FolderRows $sf.SubFolders)
|
`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 += " | "
- $rows += "$(EscHtml $row.LastModified) |
`n"
-
- if ($hasFolders) {
- $rows += ""
- $rows += " | Folder | Files | Size | Versions | Last Modified | "
- $rows += "$(Build-FolderRows $row.SubFolders)
|
`n"
- }
- }
-
-$html = @"
-
-
-Storage - $(EscHtml $SiteTitle)
-
-
-
-
-
SharePoint Storage Metrics
-
-
-
-
$(Format-Bytes $totalBytes)
Total Storage Used
-
$(Format-Bytes $totalVersionBytes)
Version Storage
-
-
$libCount
Libraries / Sites Scanned
-
-
-
-
-| Library | Site | Files | Size | Versions | Share of Total | Last Modified |
-
-$rows
-
-
-
-
-"@
- $html | Out-File -FilePath $OutputPath -Encoding UTF8
-}
-
-#endregion
-
-#region ===== PnP: Permissions =====
-
-Function Get-PnPPermissions([Microsoft.SharePoint.Client.SecurableObject]$Object) {
- Switch ($Object.TypedObject.ToString()) {
- "Microsoft.SharePoint.Client.Web" {
- $ObjectType = "Site"
- $ObjectURL = $Object.URL
- $ObjectTitle = $Object.Title
- }
- "Microsoft.SharePoint.Client.ListItem" {
- $ObjectType = "Folder"
- $Folder = Get-PnPProperty -ClientObject $Object -Property Folder
- $ObjectTitle = $Object.Folder.Name
- $ObjectURL = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $Object.Folder.ServerRelativeUrl)
- }
- Default {
- $ObjectType = $Object.BaseType
- $ObjectTitle = $Object.Title
- $RootFolder = Get-PnPProperty -ClientObject $Object -Property RootFolder
- $ObjectURL = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $RootFolder.ServerRelativeUrl)
- }
- }
-
- Get-PnPProperty -ClientObject $Object -Property HasUniqueRoleAssignments, RoleAssignments
- $HasUniquePermissions = $Object.HasUniqueRoleAssignments
-
- Foreach ($RoleAssignment in $Object.RoleAssignments) {
- Get-PnPProperty -ClientObject $RoleAssignment -Property RoleDefinitionBindings, Member
- $PermissionType = $RoleAssignment.Member.PrincipalType
- $PermissionLevels = ($RoleAssignment.RoleDefinitionBindings | Select-Object -ExpandProperty Name |
- Where-Object { $_ -ne "Limited Access" }) -join "; "
- If ($PermissionLevels.Length -eq 0) { Continue }
-
- $entry = [PSCustomObject]@{
- Object = $ObjectType
- Title = $ObjectTitle
- URL = $ObjectURL
- HasUniquePermissions = $HasUniquePermissions
- Permissions = $PermissionLevels
- GrantedThrough = ""
- Type = $PermissionType
- Users = ""
- UserLogins = ""
- }
-
- If ($PermissionType -eq "SharePointGroup") {
- $grpLogin = $RoleAssignment.Member.LoginName
- If ($grpLogin -match '^SharingLinks\.' -or $grpLogin -eq 'Limited Access System Group') { Continue }
- $GroupMembers = Get-PnPGroupMember -Identity $grpLogin
- If ($GroupMembers.count -eq 0) { Continue }
- $filteredMembers = @($GroupMembers | Where-Object { $_.Title -ne "System Account" })
- $GroupUsers = ($filteredMembers | Select-Object -ExpandProperty Title) -join "; "
- $GroupEmails = ($filteredMembers | Select-Object -ExpandProperty Email) -join "; "
- If ($GroupUsers.Length -eq 0) { Continue }
- $entry.Users = $GroupUsers
- $entry.UserLogins = $GroupEmails
- $entry.GrantedThrough = "SharePoint Group: $grpLogin"
- }
- Else {
- $entry.Users = $RoleAssignment.Member.Title
- $entry.UserLogins = $RoleAssignment.Member.Email
- $entry.GrantedThrough = "Direct Permissions"
- }
-
- $script:AllPermissions += $entry
- }
-}
-
-Function Generate-PnPSitePermissionRpt {
- [cmdletbinding()]
- Param (
- [String] $SiteURL,
- [String] $ReportFile,
- [switch] $Recursive,
- [switch] $ScanFolders,
- [switch] $IncludeInheritedPermissions
- )
- Try {
- Write-Log "Connecting to SharePoint... (browser window will open)" "Yellow"
- [System.Windows.Forms.Application]::DoEvents()
- Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $script:pnpCiD
- $Web = Get-PnPWeb
- [System.Windows.Forms.Application]::DoEvents()
-
- Write-Log "Getting Site Collection Administrators..." "Yellow"
- $SiteAdmins = Get-PnPSiteCollectionAdmin
- $script:AllPermissions += [PSCustomObject]@{
- Object = "Site Collection"
- Title = $Web.Title
- URL = $Web.URL
- HasUniquePermissions = "TRUE"
- Users = ($SiteAdmins | Select-Object -ExpandProperty Title) -join "; "
- UserLogins = ($SiteAdmins | Select-Object -ExpandProperty Email) -join "; "
- Type = "Site Collection Administrators"
- Permissions = "Site Owner"
- GrantedThrough = "Direct Permissions"
- }
-
- Function Get-PnPFolderPermission([Microsoft.SharePoint.Client.List]$List) {
- Write-Log "`t`tScanning folders in: $($List.Title)" "Yellow"
- $ListItems = Get-PnPListItem -List $List -PageSize 2000
- $Folders = $ListItems | Where-Object {
- ($_.FileSystemObjectType -eq "Folder") -and
- ($_.FieldValues.FileLeafRef -ne "Forms") -and
- (-Not($_.FieldValues.FileLeafRef.StartsWith("_")))
- }
- # Apply folder depth filter (999 = maximum / no limit)
- If ($script:PermFolderDepth -lt 999) {
- $rf = Get-PnPProperty -ClientObject $List -Property RootFolder
- $rootSrl = $rf.ServerRelativeUrl.TrimEnd('/')
- $Folders = $Folders | Where-Object {
- $relPath = $_.FieldValues.FileRef.Substring($rootSrl.Length).TrimStart('/')
- ($relPath -split '/').Count -le $script:PermFolderDepth
- }
- }
- $i = 0
- ForEach ($Folder in $Folders) {
- If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $Folder }
- Else {
- If ((Get-PnPProperty -ClientObject $Folder -Property HasUniqueRoleAssignments) -eq $true) {
- Get-PnPPermissions -Object $Folder
- }
- }
- $i++
- [System.Windows.Forms.Application]::DoEvents()
- }
- }
-
- Function Get-PnPListPermission([Microsoft.SharePoint.Client.Web]$Web) {
- $Lists = Get-PnPProperty -ClientObject $Web -Property Lists
- $ExcludedLists = @(
- "Access Requests","App Packages","appdata","appfiles","Apps in Testing","Cache Profiles",
- "Composed Looks","Content and Structure Reports","Content type publishing error log",
- "Converted Forms","Device Channels","Form Templates","fpdatasources",
- "Get started with Apps for Office and SharePoint","List Template Gallery",
- "Long Running Operation Status","Maintenance Log Library","Images","site collection images",
- "Master Docs","Master Page Gallery","MicroFeed","NintexFormXml","Quick Deploy Items",
- "Relationships List","Reusable Content","Reporting Metadata","Reporting Templates",
- "Search Config List","Site Assets","Preservation Hold Library","Site Pages",
- "Solution Gallery","Style Library","Suggested Content Browser Locations","Theme Gallery",
- "TaxonomyHiddenList","User Information List","Web Part Gallery","wfpub","wfsvc",
- "Workflow History","Workflow Tasks","Pages"
- )
- $c = 0
- ForEach ($List in $Lists) {
- If ($List.Hidden -eq $false -and $ExcludedLists -notcontains $List.Title) {
- $c++
- Write-Log "`tList ($c/$($Lists.Count)): $($List.Title)" "Cyan"
- [System.Windows.Forms.Application]::DoEvents()
- If ($ScanFolders) { Get-PnPFolderPermission -List $List }
- If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $List }
- Else {
- If ((Get-PnPProperty -ClientObject $List -Property HasUniqueRoleAssignments) -eq $true) {
- Get-PnPPermissions -Object $List
- }
- }
- }
- }
- }
-
- Function Get-PnPWebPermission([Microsoft.SharePoint.Client.Web]$Web) {
- Write-Log "Processing web: $($Web.URL)" "Yellow"
- [System.Windows.Forms.Application]::DoEvents()
- Get-PnPPermissions -Object $Web
- Write-Log "`tScanning lists and libraries..." "Yellow"
- Get-PnPListPermission($Web)
- If ($Recursive) {
- $Subwebs = Get-PnPProperty -ClientObject $Web -Property Webs
- Foreach ($Subweb in $web.Webs) {
- If ($IncludeInheritedPermissions) { Get-PnPWebPermission($Subweb) }
- Else {
- If ((Get-PnPProperty -ClientObject $SubWeb -Property HasUniqueRoleAssignments) -eq $true) {
- Get-PnPWebPermission($Subweb)
- }
- }
- }
- }
- }
-
- Get-PnPWebPermission $Web
-
- # Export based on chosen format
- Write-Log "Writing output file..." "Yellow"
- If ($script:PermFormat -eq "HTML") {
- $outPath = [System.IO.Path]::ChangeExtension($ReportFile, ".html")
- Export-PermissionsToHTML -Data $script:AllPermissions -SiteTitle $Web.Title -SiteURL $Web.URL -OutputPath $outPath
- $script:PermOutputFile = $outPath
- }
- Else {
- $mergedPerms = Merge-PermissionRows $script:AllPermissions
- $mergedPerms | ForEach-Object {
- $locs = @($_.Locations)
- $uqN = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count
- [PSCustomObject]@{
- Object = ($locs | Select-Object -ExpandProperty Object -Unique) -join ', '
- Title = ($locs | Select-Object -ExpandProperty Title) -join ' | '
- URL = ($locs | Select-Object -ExpandProperty URL) -join ' | '
- HasUniquePermissions = if ($locs.Count -eq 1) { $locs[0].HasUniquePermissions } else { "$uqN/$($locs.Count) uniques" }
- Users = $_.Users
- UserLogins = $_.UserLogins
- Type = $_.Type
- Permissions = $_.Permissions
- GrantedThrough = $_.GrantedThrough
- }
- } | Export-Csv -Path $ReportFile -NoTypeInformation
- $script:PermOutputFile = $ReportFile
- }
-
- Write-Log "Report generated successfully!" "LightGreen"
- }
- Catch {
- Write-Log "Error: $($_.Exception.Message)" "Red"
- throw
- }
-}
-
-#endregion
-
-#region ===== PnP: Storage Metrics =====
-
-function Get-SiteStorageMetrics {
- param([string]$SiteURL, [switch]$IncludeSubsites, [switch]$PerLibrary)
-
- $script:storageResults = @()
-
- # Recursively collects subfolders up to $MaxDepth levels deep
- function Collect-FolderStorage([string]$FolderSiteRelUrl, [string]$WebBaseUrl, [int]$CurrentDepth) {
- if ($CurrentDepth -ge $script:FolderDepth) { return @() }
- $result = @()
- try {
- $items = Get-PnPFolderItem -FolderSiteRelativeUrl $FolderSiteRelUrl -ItemType Folder
- foreach ($fi in $items) {
- $fiSrl = "$FolderSiteRelUrl/$($fi.Name)"
- try {
- $fiSm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $fiSrl
- $children = Collect-FolderStorage -FolderSiteRelUrl $fiSrl -WebBaseUrl $WebBaseUrl -CurrentDepth ($CurrentDepth + 1)
- $result += [PSCustomObject]@{
- Name = $fi.Name
- URL = "$($WebBaseUrl.TrimEnd('/'))/$fiSrl"
- ItemCount = $fiSm.TotalFileCount
- SizeBytes = $fiSm.TotalSize
- LastModified = if ($fiSm.LastModified) { $fiSm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
- SubFolders = $children
- }
- } catch {}
- }
- } catch {}
- return $result
- }
-
- function Collect-WebStorage([string]$WebUrl) {
- # Connect to this specific web (token is cached, no extra browser prompts)
- Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD
- $webObj = Get-PnPWeb
- $wTitle = $webObj.Title
- $wUrl = $webObj.Url
- # ServerRelativeUrl of the web (e.g. "/sites/MySite") - used to compute site-relative paths
- $wSrl = $webObj.ServerRelativeUrl.TrimEnd('/')
-
- if ($PerLibrary) {
- $lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden }
- foreach ($list in $lists) {
- $rf = Get-PnPProperty -ClientObject $list -Property RootFolder
- try {
- # Convert server-relative (/sites/X/LibName) to site-relative (LibName)
- $siteRelUrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
- $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $siteRelUrl
- $libUrl = "$($wUrl.TrimEnd('/'))/$siteRelUrl"
-
- # Recursively collect subfolders up to the configured depth
- $subFolders = Collect-FolderStorage -FolderSiteRelUrl $siteRelUrl -WebBaseUrl $wUrl -CurrentDepth 0
-
- $script:storageResults += [PSCustomObject]@{
- SiteTitle = $wTitle
- SiteURL = $wUrl
- Library = $list.Title
- LibraryURL = $libUrl
- ItemCount = $sm.TotalFileCount
- SizeBytes = $sm.TotalSize
- SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
- SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
- LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
- SubFolders = $subFolders
- }
- Write-Log "`t $($list.Title): $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files, $($subFolders.Count) folders)" "Cyan"
- }
- catch { Write-Log "`t Skipped '$($list.Title)': $($_.Exception.Message)" "DarkGray" }
- }
- }
- else {
- try {
- $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/"
- $script:storageResults += [PSCustomObject]@{
- SiteTitle = $wTitle
- SiteURL = $wUrl
- Library = "(All Libraries)"
- LibraryURL = $wUrl
- ItemCount = $sm.TotalFileCount
- SizeBytes = $sm.TotalSize
- SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
- SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
- LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
- }
- Write-Log "`t${wTitle}: $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files)" "Cyan"
- }
- catch { Write-Log "`tSkipped '${wTitle}': $($_.Exception.Message)" "DarkGray" }
- }
-
- if ($IncludeSubsites) {
- $subWebs = Get-PnPSubWeb
- foreach ($sub in $subWebs) {
- Write-Log "`tProcessing subsite: $($sub.Title)" "Yellow"
- Collect-WebStorage $sub.Url
- # Reconnect to parent so subsequent sibling subsites resolve correctly
- Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD
- }
- }
- }
-
- Write-Log "Collecting storage metrics for: $SiteURL" "Yellow"
- Collect-WebStorage $SiteURL
- return $script:storageResults
-}
-
-#endregion
-
-#region ===== File Search =====
-
-function Export-SearchResultsToHTML {
- param([array]$Results, [string]$KQL, [string]$SiteUrl)
- $rows = ""
- foreach ($r in $Results) {
- $title = EscHtml ($r.Title -replace '^$', '(sans titre)')
- $path = EscHtml ($r.Path -replace '^$', '')
- $ext = EscHtml ($r.FileExtension -replace '^$', '')
- $created = EscHtml ($r.Created -replace '^$', '')
- $modif = EscHtml ($r.LastModifiedTime -replace '^$', '')
- $author = EscHtml ($r.Author -replace '^$', '')
- $modby = EscHtml ($r.ModifiedBy -replace '^$', '')
- $sizeB = [long]($r.Size -replace '[^0-9]','0' -replace '^$','0')
- $sizeStr = EscHtml (Format-Bytes $sizeB)
- $href = EscHtml $r.Path
- $rows += "| $title | "
- $rows += "$ext | "
- $rows += "$created | "
- $rows += "$modif | "
- $rows += "$author | $modby | "
- $rows += "$sizeStr |
`n"
- }
- $count = $Results.Count
- $date = Get-Date -Format "dd/MM/yyyy HH:mm"
- $kqlEsc = EscHtml $KQL
- $siteEsc = EscHtml $SiteUrl
-
-$html = @"
-
-
-Recherche de fichiers
-
-
-
-
Recherche de fichiers SharePoint
-
-
-
-Requete KQL : $kqlEsc
-
-
-
-
-| Nom du fichier |
-Extension |
-Cree le |
-Modifie le |
-Cree par |
-Modifie par |
-Taille |
-
-$rows
-
-
-
-
-"@
- 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
- ▼
-
-
-
-| Chemin | $thSz$thCr$thMod$thSub$thFil
-
$rows
-
-"@
- }
-
-$html = @"
-
-
-Doublons $modeLabel - SharePoint
-
-
-
-
Doublons de $modeLabel — SharePoint
-
-
-
-
$totalGroups
Groupes de doublons
-
$totalItems
$modeLabel en double (total)
-
-
-$cards
-
-
-
-"@
- return $html
-}
-
-#endregion
-
-#region ===== Transfer =====
-
-function Export-TransferVerifyToHTML {
- param([array]$Results, [string]$SrcSite, [string]$SrcLib, [string]$DstSite, [string]$DstLib)
-
- $okCount = @($Results | Where-Object { $_.Status -eq "OK" }).Count
- $missingCount = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count
- $mismatchCount = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
- $extraCount = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count
- $totalCount = $Results.Count
- $date = Get-Date -Format "dd/MM/yyyy HH:mm"
-
- $rows = ""
- foreach ($r in $Results) {
- $statusClass = switch ($r.Status) {
- "OK" { "status-ok" }
- "MISSING" { "status-missing" }
- "SIZE_MISMATCH" { "status-mismatch" }
- "EXTRA" { "status-extra" }
- default { "" }
- }
- $statusLabel = switch ($r.Status) {
- "OK" { "OK" }
- "MISSING" { "MISSING" }
- "SIZE_MISMATCH" { "SIZE MISMATCH" }
- "EXTRA" { "EXTRA" }
- default { $r.Status }
- }
- $name = EscHtml $r.Name
- $relPath = EscHtml $r.RelativePath
- $srcSize = if ($null -ne $r.SourceSize) { EscHtml (Format-Bytes $r.SourceSize) } else { "-" }
- $dstSize = if ($null -ne $r.DestSize) { EscHtml (Format-Bytes $r.DestSize) } else { "-" }
- $srcRaw = if ($null -ne $r.SourceSize) { $r.SourceSize } else { 0 }
- $dstRaw = if ($null -ne $r.DestSize) { $r.DestSize } else { 0 }
- $rows += ""
- $rows += "| $statusLabel | "
- $rows += "$name | "
- $rows += "$relPath | "
- $rows += "$srcSize | "
- $rows += "$dstSize |
`n"
- }
-
- $srcEsc = EscHtml "$SrcSite/$SrcLib"
- $dstEsc = EscHtml "$DstSite/$DstLib"
-
-$html = @"
-
-
-Transfer Verification Report
-
-
-
-
Transfer Verification Report
-
Source: $srcEsc → Destination: $dstEsc — $date
-
-
-
-
-
-
$mismatchCount
Size mismatch
-
-
-
-
-
-
-| Status |
-File Name |
-Relative Path |
-Source Size |
-Dest Size |
-
-$rows
-
-
-
-
-"@
- return $html
-}
-
-#endregion
-
-#region ===== Bulk Site Creation =====
-
-function Show-BulkSiteDialog {
- param(
- [System.Windows.Forms.Form]$Owner = $null,
- [hashtable]$Existing = $null # pass to edit an existing entry
- )
-
- $templates = @(Load-Templates)
-
- $dlg = New-Object System.Windows.Forms.Form
- $dlg.Text = if ($Existing) { T "bulk.dlg.title.edit" } else { T "bulk.dlg.title" }
- $dlg.Size = New-Object System.Drawing.Size(520, 400)
- $dlg.StartPosition = "CenterParent"
- $dlg.FormBorderStyle = "FixedDialog"
- $dlg.MaximizeBox = $false
- $dlg.MinimizeBox = $false
- if ($Owner) { $dlg.Owner = $Owner }
-
- $y = 14
-
- # Site name
- $lblName = New-Object System.Windows.Forms.Label
- $lblName.Text = T "bulk.lbl.name"
- $lblName.Location = New-Object System.Drawing.Point(14, $y)
- $lblName.Size = New-Object System.Drawing.Size(480, 18)
- $y += 20
- $txtName = New-Object System.Windows.Forms.TextBox
- $txtName.Location = New-Object System.Drawing.Point(14, $y)
- $txtName.Size = New-Object System.Drawing.Size(476, 22)
- $y += 30
-
- # URL alias
- $lblAlias = New-Object System.Windows.Forms.Label
- $lblAlias.Text = T "bulk.lbl.alias"
- $lblAlias.Location = New-Object System.Drawing.Point(14, $y)
- $lblAlias.Size = New-Object System.Drawing.Size(480, 18)
- $y += 20
- $txtAlias = New-Object System.Windows.Forms.TextBox
- $txtAlias.Location = New-Object System.Drawing.Point(14, $y)
- $txtAlias.Size = New-Object System.Drawing.Size(476, 22)
- $y += 30
-
- # Auto-generate alias from name
- $txtName.Add_TextChanged({
- if (-not $txtAlias.Tag) {
- $raw = $txtName.Text.Trim().ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', ''
- $txtAlias.Text = $raw
- }
- })
- $txtAlias.Add_TextChanged({ if ($txtAlias.Focused) { $txtAlias.Tag = "manual" } })
-
- # Site type
- $lblType = New-Object System.Windows.Forms.Label
- $lblType.Text = T "bulk.lbl.type"
- $lblType.Location = New-Object System.Drawing.Point(14, $y)
- $lblType.Size = New-Object System.Drawing.Size(150, 18)
- $radTeam = New-Object System.Windows.Forms.RadioButton
- $radTeam.Text = T "bulk.rad.team"
- $radTeam.Location = New-Object System.Drawing.Point(170, ($y - 2))
- $radTeam.Size = New-Object System.Drawing.Size(140, 22)
- $radTeam.Checked = $true
- $radComm = New-Object System.Windows.Forms.RadioButton
- $radComm.Text = T "bulk.rad.comm"
- $radComm.Location = New-Object System.Drawing.Point(320, ($y - 2))
- $radComm.Size = New-Object System.Drawing.Size(170, 22)
- $y += 28
-
- # Template
- $lblTpl = New-Object System.Windows.Forms.Label
- $lblTpl.Text = T "bulk.lbl.template"
- $lblTpl.Location = New-Object System.Drawing.Point(14, $y)
- $lblTpl.Size = New-Object System.Drawing.Size(150, 18)
- $cboTpl = New-Object System.Windows.Forms.ComboBox
- $cboTpl.DropDownStyle = "DropDownList"
- $cboTpl.Location = New-Object System.Drawing.Point(170, ($y - 2))
- $cboTpl.Size = New-Object System.Drawing.Size(320, 22)
- $cboTpl.Items.Add((T "bulk.none")) | Out-Null
- foreach ($t in $templates) { $cboTpl.Items.Add($t.name) | Out-Null }
- $cboTpl.SelectedIndex = 0
- $y += 30
-
- # Owners
- $lblOwners = New-Object System.Windows.Forms.Label
- $lblOwners.Text = T "bulk.lbl.owners"
- $lblOwners.Location = New-Object System.Drawing.Point(14, $y)
- $lblOwners.Size = New-Object System.Drawing.Size(480, 18)
- $y += 20
- $txtOwners = New-Object System.Windows.Forms.TextBox
- $txtOwners.Location = New-Object System.Drawing.Point(14, $y)
- $txtOwners.Size = New-Object System.Drawing.Size(476, 22)
- $txtOwners.PlaceholderText = T "bulk.ph.owners"
- $y += 30
-
- # Members
- $lblMembers = New-Object System.Windows.Forms.Label
- $lblMembers.Text = T "bulk.lbl.members"
- $lblMembers.Location = New-Object System.Drawing.Point(14, $y)
- $lblMembers.Size = New-Object System.Drawing.Size(380, 18)
-
- $btnCsvMembers = New-Object System.Windows.Forms.Button
- $btnCsvMembers.Text = T "bulk.btn.csv.members"
- $btnCsvMembers.Location = New-Object System.Drawing.Point(394, ($y - 4))
- $btnCsvMembers.Size = New-Object System.Drawing.Size(96, 24)
- $btnCsvMembers.Add_Click({
- $ofd = New-Object System.Windows.Forms.OpenFileDialog
- $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
- if ($ofd.ShowDialog($dlg) -eq "OK") {
- $rows = Import-Csv $ofd.FileName
- $emails = $rows | ForEach-Object {
- $r = $_
- $v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email }
- elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn }
- elseif ($r.UserPrincipalName) { $r.UserPrincipalName }
- else { $r.userprincipalname }
- $v
- } | Where-Object { $_ } | Select-Object -Unique
- if ($emails.Count -gt 0) {
- $existing = $txtMembers.Text.Trim()
- if ($existing) { $txtMembers.Text = "$existing, $($emails -join ', ')" }
- else { $txtMembers.Text = $emails -join ", " }
- }
- }
- })
-
- $y += 20
- $txtMembers = New-Object System.Windows.Forms.TextBox
- $txtMembers.Location = New-Object System.Drawing.Point(14, $y)
- $txtMembers.Size = New-Object System.Drawing.Size(476, 22)
- $txtMembers.PlaceholderText = T "bulk.ph.members"
- $y += 36
-
- # OK / Cancel
- $btnOk = New-Object System.Windows.Forms.Button
- $btnOk.Text = "OK"
- $btnOk.Location = New-Object System.Drawing.Point(310, $y)
- $btnOk.Size = New-Object System.Drawing.Size(85, 30)
- $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
- $dlg.AcceptButton = $btnOk
-
- $btnCancel = New-Object System.Windows.Forms.Button
- $btnCancel.Text = "Cancel"
- $btnCancel.Location = New-Object System.Drawing.Point(405, $y)
- $btnCancel.Size = New-Object System.Drawing.Size(85, 30)
- $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
- $dlg.CancelButton = $btnCancel
-
- # Pre-fill if editing
- if ($Existing) {
- $txtName.Text = $Existing.Name
- $txtAlias.Tag = "manual"
- $txtAlias.Text = $Existing.Alias
- if ($Existing.Type -eq "Communication") { $radComm.Checked = $true }
- if ($Existing.Template) {
- $idx = $cboTpl.Items.IndexOf($Existing.Template)
- if ($idx -ge 0) { $cboTpl.SelectedIndex = $idx }
- }
- $txtOwners.Text = $Existing.Owners
- $txtMembers.Text = $Existing.Members
- }
-
- $dlg.Controls.AddRange(@($lblName, $txtName, $lblAlias, $txtAlias,
- $lblType, $radTeam, $radComm, $lblTpl, $cboTpl,
- $lblOwners, $txtOwners, $lblMembers, $btnCsvMembers, $txtMembers,
- $btnOk, $btnCancel))
-
- $result = $dlg.ShowDialog($Owner)
- if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
- $name = $txtName.Text.Trim()
- $alias = $txtAlias.Text.Trim()
- if (-not $name -or -not $alias) { return $null }
- return @{
- Name = $name
- Alias = $alias
- Type = if ($radTeam.Checked) { "Team" } else { "Communication" }
- Template = if ($cboTpl.SelectedIndex -gt 0) { $cboTpl.SelectedItem } else { "" }
- Owners = $txtOwners.Text.Trim()
- Members = $txtMembers.Text.Trim()
- }
- }
- return $null
-}
-
-#endregion
-
-#region ===== Internationalization =====
-
-$script:LangDefault = @{
- "profile" = "Profile:"
- "tenant.url" = "Tenant URL:"
- "client.id" = "Client ID:"
- "site.url" = "Site URL:"
- "output.folder" = "Output Folder:"
- "btn.new" = "New"
- "btn.save" = "Save"
- "btn.rename" = "Rename"
- "btn.delete" = "Del."
- "btn.view.sites" = "View Sites"
- "btn.browse" = "Browse..."
- "tab.perms" = " Permissions "
- "tab.storage" = " Storage "
- "tab.templates" = " Templates "
- "tab.search" = " File Search "
- "tab.dupes" = " Duplicates "
- "grp.scan.opts" = "Scan Options"
- "chk.scan.folders" = "Scan Folders"
- "chk.recursive" = "Recursive (subsites)"
- "lbl.folder.depth" = "Folder depth:"
- "chk.max.depth" = "Maximum (all levels)"
- "chk.inherited.perms" = "Include Inherited Permissions"
- "grp.export.fmt" = "Export Format"
- "rad.csv.perms" = "CSV"
- "rad.html.perms" = "HTML"
- "btn.gen.perms" = "Generate Report"
- "btn.open.perms" = "Open Report"
- "chk.per.lib" = "Per-Library Breakdown"
- "chk.subsites" = "Include Subsites"
- "stor.note" = "Note: deeper folder scans on large sites may take several minutes."
- "btn.gen.storage" = "Generate Metrics"
- "btn.open.storage" = "Open Report"
- "tpl.desc" = "Create templates from an existing site and apply them to create new sites."
- "btn.manage.tpl" = "Manage templates..."
- "tpl.count" = "template(s) saved - click to manage"
- "grp.search.filters" = "Search Filters"
- "lbl.extensions" = "Extension(s):"
- "lbl.regex" = "Name / Regex:"
- "chk.created.after" = "Created after:"
- "chk.created.before" = "Created before:"
- "chk.modified.after" = "Modified after:"
- "chk.modified.before" = "Modified before:"
- "lbl.created.by" = "Created by:"
- "lbl.modified.by" = "Modified by:"
- "lbl.library" = "Library:"
- "grp.search.fmt" = "Export Format"
- "lbl.max.results" = "Max results:"
- "btn.run.search" = "Run Search"
- "btn.open.search" = "Open Results"
- "grp.dup.type" = "Duplicate Type"
- "rad.dup.files" = "Duplicate files"
- "rad.dup.folders" = "Duplicate folders"
- "grp.dup.criteria" = "Comparison Criteria"
- "lbl.dup.note" = "Name is always the primary criterion. Check additional criteria:"
- "chk.dup.size" = "Same size"
- "chk.dup.created" = "Same creation date"
- "chk.dup.modified" = "Same modification date"
- "chk.dup.subfolders" = "Same subfolder count"
- "chk.dup.filecount" = "Same file count"
- "grp.options" = "Options"
- "chk.include.subsites" = "Include subsites"
- "btn.run.scan" = "Run Scan"
- "btn.open.results" = "Open Results"
- "lbl.log" = "Log:"
- "menu.settings" = "Settings"
- "menu.json.folder" = "JSON Data Folder..."
- "menu.language" = "Language"
- "dlg.json.folder.desc" = "Select the storage folder for JSON files (profiles, templates)"
- "dlg.folder.not.found" = "The folder '{0}' does not exist. Do you want to create it?"
- "dlg.folder.not.found.title"= "Folder not found"
- "msg.lang.applied" = "Language applied: {0}"
- "msg.lang.applied.title" = "Language"
- "ph.extensions" = "docx pdf xlsx"
- "ph.regex" = "Ex: report.* or \.bak$"
- "ph.created.by" = "First Last or email"
- "ph.modified.by" = "First Last or email"
- "ph.library" = "Optional relative path e.g. Shared Documents"
- "ph.dup.lib" = "All (leave empty)"
- "tab.transfer" = " Transfer "
- "grp.xfer.source" = "Source"
- "grp.xfer.dest" = "Destination"
- "lbl.xfer.site" = "Site URL:"
- "lbl.xfer.library" = "Library / Folder:"
- "grp.xfer.options" = "Options"
- "chk.xfer.recursive" = "Include subfolders (recursive)"
- "chk.xfer.overwrite" = "Overwrite existing files"
- "btn.xfer.start" = "Start Transfer"
- "btn.xfer.verify" = "Verify"
- "btn.xfer.open" = "Open Report"
- "ph.xfer.site" = "https://tenant.sharepoint.com/sites/xxx"
- "ph.xfer.library" = "Shared Documents/subfolder"
- "xfer.note" = "Only the current version of each file is transferred (no version history)."
- "chk.xfer.create.folders" = "Create missing folders"
- "btn.xfer.csv" = "Import CSV..."
- "btn.xfer.csv.clear" = "Clear"
- "lbl.xfer.csv.info" = "{0} transfer(s) loaded"
- "lbl.xfer.report.fmt" = "Report:"
- "tab.bulk" = " Bulk Create "
- "grp.bulk.list" = "Sites to create"
- "btn.bulk.add" = "Add Site..."
- "btn.bulk.csv" = "Import CSV..."
- "btn.bulk.remove" = "Remove"
- "btn.bulk.clear" = "Clear All"
- "btn.bulk.create" = "Create All Sites"
- "bulk.col.name" = "Site Name"
- "bulk.col.alias" = "URL Alias"
- "bulk.col.type" = "Type"
- "bulk.col.template" = "Template"
- "bulk.col.owners" = "Owners"
- "bulk.col.members" = "Members"
- "bulk.dlg.title" = "Add Site"
- "bulk.dlg.title.edit" = "Edit Site"
- "bulk.lbl.name" = "Site name:"
- "bulk.lbl.alias" = "URL alias (after /sites/):"
- "bulk.lbl.type" = "Site type:"
- "bulk.rad.team" = "Team Site"
- "bulk.rad.comm" = "Communication Site"
- "bulk.lbl.template" = "Template:"
- "bulk.lbl.owners" = "Owners (comma-separated):"
- "bulk.lbl.members" = "Members (comma-separated):"
- "bulk.btn.csv.members" = "Import CSV..."
- "bulk.none" = "(None)"
- "bulk.ph.owners" = "admin@domain.com, user2@domain.com"
- "bulk.ph.members" = "user@domain.com, ..."
- "bulk.status.pending" = "Pending"
- "bulk.status.creating" = "Creating..."
- "bulk.status.ok" = "OK"
- "bulk.status.error" = "Error"
- "tab.structure" = " Structure "
- "grp.struct.csv" = "CSV Import"
- "lbl.struct.desc" = "Import a CSV to create a folder tree. Each column represents a depth level."
- "btn.struct.csv" = "Load CSV..."
- "grp.struct.preview" = "Preview"
- "grp.struct.target" = "Target"
- "lbl.struct.library" = "Target library:"
- "ph.struct.library" = "Shared Documents"
- "btn.struct.create" = "Create Structure"
- "btn.struct.clear" = "Clear"
- "struct.col.path" = "Full Path"
- "struct.col.depth" = "Depth"
- "tab.versions" = " Versions "
- "grp.ver.keep" = "Versions to Keep"
- "lbl.ver.count" = "Number of versions to keep:"
- "chk.ver.date" = "Also filter by date"
- "rad.ver.before" = "Keep versions before:"
- "rad.ver.after" = "Keep versions after:"
- "grp.ver.scope" = "Scope"
- "lbl.ver.library" = "Library / Folder:"
- "ph.ver.library" = "Shared Documents"
- "chk.ver.recursive" = "Include subfolders (recursive)"
- "chk.ver.subsites" = "Include subsites"
- "chk.ver.dryrun" = "Dry run (preview only, no deletion)"
- "btn.ver.run" = "Clean Versions"
- "btn.ver.open" = "Open Report"
- "btn.register.app" = "Register"
- "reg.title" = "App Registration"
- "reg.offer" = "No Client ID provided. Register a new app on this tenant?"
- "reg.confirm" = "Register 'SharePoint Toolbox' app on tenant {0}?"
- "reg.in.progress" = "Registering..."
- "reg.success" = "App registered successfully!`nClient ID: {0}`nYou can save this profile to reuse it."
- "reg.err.tenant" = "Cannot determine tenant from the provided URL."
- "reg.err.nocmd" = "PnP.PowerShell module does not support app registration. Please register the app manually in Entra ID."
- "reg.err.no.id" = "Registration completed but no Client ID was returned."
- "reg.err.failed" = "Registration failed:`n{0}"
- "reg.err.no.tenant" = "Please enter a Tenant URL first."
- "reg.err.nopwsh" = "PowerShell 7+ (pwsh) is required for app registration but was not found. Install it from https://aka.ms/powershell"
- "validate.missing.clientid" = "Please enter a Client ID."
- "validate.missing.clientid.hint" = "Please enter a Client ID or use the 'Register' button to create one."
- "validate.missing.title" = "Missing Field"
-}
-
-$script:Lang = $null # null = use LangDefault
-
-function T([string]$key) {
- if ($script:Lang -and $script:Lang.$key) { return $script:Lang.$key }
- if ($script:LangDefault.ContainsKey($key)) { return $script:LangDefault[$key] }
- return $key
-}
-
-function Get-LangDir {
- $base = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
- return Join-Path $base "lang"
-}
-
-function Get-LangFiles {
- $dir = Get-LangDir
- if (-not (Test-Path $dir)) { return @() }
- return @(Get-ChildItem -Path $dir -Filter "*.json" | ForEach-Object {
- $code = $_.BaseName
- $name = $code
- try {
- $data = Get-Content $_.FullName -Raw | ConvertFrom-Json
- if ($data.'_name') { $name = $data.'_name' }
- } catch {}
- [PSCustomObject]@{ Code = $code; Name = $name; Path = $_.FullName }
- })
-}
-
-function Load-Language([string]$LangCode) {
- if ([string]::IsNullOrWhiteSpace($LangCode) -or $LangCode -eq "en") {
- $script:Lang = $null
- $script:CurrentLang = "en"
- return
- }
- $dir = Get-LangDir
- $path = Join-Path $dir "$LangCode.json"
- if (-not (Test-Path $path)) { return }
- try {
- $data = Get-Content $path -Raw | ConvertFrom-Json
- $ht = @{}
- $data.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value }
- $script:Lang = $ht
- $script:CurrentLang = $LangCode
- } catch {}
-}
-
-function Update-UILanguage {
- # Main labels
- if ($script:i18nMap) {
- foreach ($kv in $script:i18nMap.GetEnumerator()) {
- $ctrl = $kv.Value.Control
- $key = $kv.Value.Key
- if ($ctrl -and !$ctrl.IsDisposed) { $ctrl.Text = T $key }
- }
- }
- # Tab pages (Text property)
- if ($script:i18nTabs) {
- foreach ($kv in $script:i18nTabs.GetEnumerator()) {
- $tab = $kv.Value.Control
- $key = $kv.Value.Key
- if ($tab -and !$tab.IsDisposed) { $tab.Text = T $key }
- }
- }
- # Menu items
- if ($script:i18nMenus) {
- foreach ($kv in $script:i18nMenus.GetEnumerator()) {
- $mi = $kv.Value.Control
- $key = $kv.Value.Key
- if ($mi) { $mi.Text = T $key }
- }
- }
- # Placeholder texts
- if ($script:i18nPlaceholders) {
- foreach ($kv in $script:i18nPlaceholders.GetEnumerator()) {
- $ctrl = $kv.Value.Control
- $key = $kv.Value.Key
- if ($ctrl -and !$ctrl.IsDisposed) { $ctrl.PlaceholderText = T $key }
- }
- }
-}
-
-$script:CurrentLang = "en"
-
-#endregion
-
-#region ===== GUI =====
-
-$form = New-Object System.Windows.Forms.Form
-$form.Text = "SharePoint Toolbox"
-$form.Size = New-Object System.Drawing.Size(700, 840)
-$form.StartPosition = "CenterScreen"
-$form.FormBorderStyle = "FixedDialog"
-$form.MaximizeBox = $false
-$form.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-# ── MenuStrip ─────────────────────────────────────────────────────────────────
-$menuStrip = New-Object System.Windows.Forms.MenuStrip
-$menuStrip.BackColor = [System.Drawing.Color]::WhiteSmoke
-$menuStrip.RenderMode = [System.Windows.Forms.ToolStripRenderMode]::System
-
-$menuSettings = New-Object System.Windows.Forms.ToolStripMenuItem
-$menuSettings.Text = T "menu.settings"
-$menuJsonFolder = New-Object System.Windows.Forms.ToolStripMenuItem
-$menuJsonFolder.Text = T "menu.json.folder"
-[void]$menuSettings.DropDownItems.Add($menuJsonFolder)
-
-$menuLang = New-Object System.Windows.Forms.ToolStripMenuItem
-$menuLang.Text = T "menu.language"
-$menuLangEn = New-Object System.Windows.Forms.ToolStripMenuItem
-$menuLangEn.Text = "English (US)"
-$menuLangEn.Tag = "en"
-$menuLangEn.Checked = ($script:CurrentLang -eq "en")
-[void]$menuLang.DropDownItems.Add($menuLangEn)
-[void]$menuLang.DropDownItems.Add([System.Windows.Forms.ToolStripSeparator]::new())
-foreach ($lf in (Get-LangFiles)) {
- $mi = New-Object System.Windows.Forms.ToolStripMenuItem
- $mi.Text = $lf.Name
- $mi.Tag = $lf.Code
- $mi.Checked = ($script:CurrentLang -eq $lf.Code)
- [void]$menuLang.DropDownItems.Add($mi)
-}
-[void]$menuStrip.Items.Add($menuSettings)
-[void]$menuStrip.Items.Add($menuLang)
-$form.MainMenuStrip = $menuStrip
-
-# ── Label helper (positions offset +24 to account for MenuStrip) ──────────────
-$lbl = { param($t,$x,$y)
- $l = New-Object System.Windows.Forms.Label
- $l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y)
- $l.Size = New-Object System.Drawing.Size(115,22); $l.TextAlign = "MiddleLeft"; $l
-}
-
-# ── Profile selector ──────────────────────────────────────────────────────────
-$lblProfile = (& $lbl (T "profile") 20 46)
-$cboProfile = New-Object System.Windows.Forms.ComboBox
-$cboProfile.Location = New-Object System.Drawing.Point(140, 44)
-$cboProfile.Size = New-Object System.Drawing.Size(248, 24)
-$cboProfile.DropDownStyle = "DropDownList"
-$cboProfile.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-
-$btnProfileNew = New-Object System.Windows.Forms.Button
-$btnProfileNew.Text = T "btn.new"
-$btnProfileNew.Location = New-Object System.Drawing.Point(396, 43)
-$btnProfileNew.Size = New-Object System.Drawing.Size(60, 26)
-
-$btnProfileSave = New-Object System.Windows.Forms.Button
-$btnProfileSave.Text = T "btn.save"
-$btnProfileSave.Location = New-Object System.Drawing.Point(460, 43)
-$btnProfileSave.Size = New-Object System.Drawing.Size(60, 26)
-
-$btnProfileRename = New-Object System.Windows.Forms.Button
-$btnProfileRename.Text = T "btn.rename"
-$btnProfileRename.Location = New-Object System.Drawing.Point(524, 43)
-$btnProfileRename.Size = New-Object System.Drawing.Size(72, 26)
-
-$btnProfileDelete = New-Object System.Windows.Forms.Button
-$btnProfileDelete.Text = T "btn.delete"
-$btnProfileDelete.Location = New-Object System.Drawing.Point(600, 43)
-$btnProfileDelete.Size = New-Object System.Drawing.Size(62, 26)
-
-$lblTenantUrl = (& $lbl (T "tenant.url") 20 76)
-$txtTenantUrl = New-Object System.Windows.Forms.TextBox
-$txtTenantUrl.Location = New-Object System.Drawing.Point(140, 76)
-$txtTenantUrl.Size = New-Object System.Drawing.Size(400, 22)
-$txtTenantUrl.Font = New-Object System.Drawing.Font("Consolas", 9)
-
-$btnBrowseSites = New-Object System.Windows.Forms.Button
-$btnBrowseSites.Text = T "btn.view.sites"
-$btnBrowseSites.Location = New-Object System.Drawing.Point(548, 74)
-$btnBrowseSites.Size = New-Object System.Drawing.Size(92, 26)
-
-$lblClientId = (& $lbl (T "client.id") 20 108)
-$txtClientId = New-Object System.Windows.Forms.TextBox
-$txtClientId.Location = New-Object System.Drawing.Point(140, 108)
-$txtClientId.Size = New-Object System.Drawing.Size(400, 22)
-$txtClientId.Font = New-Object System.Drawing.Font("Consolas", 9)
-
-$btnRegisterApp = New-Object System.Windows.Forms.Button
-$btnRegisterApp.Text = T "btn.register.app"
-$btnRegisterApp.Location = New-Object System.Drawing.Point(548, 106)
-$btnRegisterApp.Size = New-Object System.Drawing.Size(92, 26)
-
-$txtClientId.Add_TextChanged({
- $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text)
-})
-
-$lblSiteURL = (& $lbl (T "site.url") 20 140)
-$txtSiteURL = New-Object System.Windows.Forms.TextBox
-$txtSiteURL.Location = New-Object System.Drawing.Point(140, 140)
-$txtSiteURL.Size = New-Object System.Drawing.Size(500, 22)
-
-$lblOutput = (& $lbl (T "output.folder") 20 172)
-$txtOutput = New-Object System.Windows.Forms.TextBox
-$txtOutput.Location = New-Object System.Drawing.Point(140, 172)
-$txtOutput.Size = New-Object System.Drawing.Size(408, 22)
-$txtOutput.Text = $PWD.Path
-
-$btnBrowse = New-Object System.Windows.Forms.Button
-$btnBrowse.Text = T "btn.browse"
-$btnBrowse.Location = New-Object System.Drawing.Point(558, 170)
-$btnBrowse.Size = New-Object System.Drawing.Size(82, 26)
-
-$sep = New-Object System.Windows.Forms.Panel
-$sep.Location = New-Object System.Drawing.Point(20, 206)
-$sep.Size = New-Object System.Drawing.Size(642, 1)
-$sep.BackColor = [System.Drawing.Color]::LightGray
-
-# ── TabControl ─────────────────────────────────────────────────────────────────
-$tabs = New-Object System.Windows.Forms.TabControl
-$tabs.Location = New-Object System.Drawing.Point(10, 214)
-$tabs.Size = New-Object System.Drawing.Size(662, 310)
-$tabs.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-
-# helper: GroupBox
-function New-Group($text, $x, $y, $w, $h) {
- $g = New-Object System.Windows.Forms.GroupBox
- $g.Text = $text; $g.Location = New-Object System.Drawing.Point($x,$y)
- $g.Size = New-Object System.Drawing.Size($w,$h); $g
-}
-# helper: CheckBox
-function New-Check($text, $x, $y, $w, $checked=$false) {
- $c = New-Object System.Windows.Forms.CheckBox
- $c.Text = $text; $c.Location = New-Object System.Drawing.Point($x,$y)
- $c.Size = New-Object System.Drawing.Size($w,22); $c.Checked = $checked; $c
-}
-# helper: RadioButton
-function New-Radio($text, $x, $y, $w, $checked=$false) {
- $r = New-Object System.Windows.Forms.RadioButton
- $r.Text = $text; $r.Location = New-Object System.Drawing.Point($x,$y)
- $r.Size = New-Object System.Drawing.Size($w,22); $r.Checked = $checked; $r
-}
-# helper: Action button
-function New-ActionBtn($text, $x, $y, $color) {
- $b = New-Object System.Windows.Forms.Button
- $b.Text = $text; $b.Location = New-Object System.Drawing.Point($x,$y)
- $b.Size = New-Object System.Drawing.Size(155,34); $b.BackColor = $color
- $b.ForeColor = [System.Drawing.Color]::White; $b.FlatStyle = "Flat"
- $b.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Bold); $b
-}
-
-# ══ Tab 1: Permissions ════════════════════════════════════════════════════════
-$tabPerms = New-Object System.Windows.Forms.TabPage
-$tabPerms.Text = T "tab.perms"
-$tabPerms.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-$grpPermOpts = New-Group (T "grp.scan.opts") 10 10 615 96
-$chkScanFolders = New-Check (T "chk.scan.folders") 15 24 150 $true
-$chkRecursive = New-Check (T "chk.recursive") 175 24 185
-
-# Folder depth controls (only active when Scan Folders is checked)
-$lblPermDepth = New-Object System.Windows.Forms.Label
-$lblPermDepth.Text = T "lbl.folder.depth"
-$lblPermDepth.Location = New-Object System.Drawing.Point(15, 50)
-$lblPermDepth.Size = New-Object System.Drawing.Size(100, 22)
-$lblPermDepth.TextAlign = "MiddleLeft"
-
-$nudPermDepth = New-Object System.Windows.Forms.NumericUpDown
-$nudPermDepth.Location = New-Object System.Drawing.Point(118, 50)
-$nudPermDepth.Size = New-Object System.Drawing.Size(52, 22)
-$nudPermDepth.Minimum = 1
-$nudPermDepth.Maximum = 20
-$nudPermDepth.Value = 1
-
-$chkPermMaxDepth = New-Object System.Windows.Forms.CheckBox
-$chkPermMaxDepth.Text = T "chk.max.depth"
-$chkPermMaxDepth.Location = New-Object System.Drawing.Point(182, 52)
-$chkPermMaxDepth.Size = New-Object System.Drawing.Size(180, 20)
-
-$chkInheritedPerms = New-Check (T "chk.inherited.perms") 15 74 230
-$grpPermOpts.Controls.AddRange(@($chkScanFolders, $chkRecursive, $lblPermDepth, $nudPermDepth, $chkPermMaxDepth, $chkInheritedPerms))
-
-# Disable depth controls when Scan Folders is unchecked
-$chkScanFolders.Add_CheckedChanged({
- $on = $chkScanFolders.Checked
- $lblPermDepth.Enabled = $on
- $nudPermDepth.Enabled = $on -and -not $chkPermMaxDepth.Checked
- $chkPermMaxDepth.Enabled = $on
-})
-# When Maximum is checked, grey out the spinner
-$chkPermMaxDepth.Add_CheckedChanged({
- $nudPermDepth.Enabled = $chkScanFolders.Checked -and -not $chkPermMaxDepth.Checked
-})
-
-$grpPermFmt = New-Group (T "grp.export.fmt") 10 114 615 58
-$radPermCSV = New-Radio (T "rad.csv.perms") 15 24 280 $true
-$radPermHTML = New-Radio (T "rad.html.perms") 305 24 290
-$grpPermFmt.Controls.AddRange(@($radPermCSV, $radPermHTML))
-
-$btnGenPerms = New-ActionBtn (T "btn.gen.perms") 10 184 ([System.Drawing.Color]::SteelBlue)
-$btnOpenPerms = New-Object System.Windows.Forms.Button
-$btnOpenPerms.Text = T "btn.open.perms"
-$btnOpenPerms.Location = New-Object System.Drawing.Point(175, 184)
-$btnOpenPerms.Size = New-Object System.Drawing.Size(120, 34)
-$btnOpenPerms.Enabled = $false
-
-$tabPerms.Controls.AddRange(@($grpPermOpts, $grpPermFmt, $btnGenPerms, $btnOpenPerms))
-
-# ══ Tab 2: Storage Metrics ════════════════════════════════════════════════════
-$tabStorage = New-Object System.Windows.Forms.TabPage
-$tabStorage.Text = T "tab.storage"
-$tabStorage.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-$grpStorOpts = New-Group (T "grp.scan.opts") 10 10 615 108
-$chkStorPerLib = New-Check (T "chk.per.lib") 15 24 200 $true
-$chkStorSubsites = New-Check (T "chk.subsites") 230 24 170
-
-# Folder depth controls (only relevant in per-library mode)
-$lblDepth = New-Object System.Windows.Forms.Label
-$lblDepth.Text = T "lbl.folder.depth"
-$lblDepth.Location = New-Object System.Drawing.Point(15, 52)
-$lblDepth.Size = New-Object System.Drawing.Size(100, 22)
-$lblDepth.TextAlign = "MiddleLeft"
-
-$nudDepth = New-Object System.Windows.Forms.NumericUpDown
-$nudDepth.Location = New-Object System.Drawing.Point(118, 52)
-$nudDepth.Size = New-Object System.Drawing.Size(52, 22)
-$nudDepth.Minimum = 1
-$nudDepth.Maximum = 20
-$nudDepth.Value = 1
-
-$chkMaxDepth = New-Object System.Windows.Forms.CheckBox
-$chkMaxDepth.Text = T "chk.max.depth"
-$chkMaxDepth.Location = New-Object System.Drawing.Point(182, 54)
-$chkMaxDepth.Size = New-Object System.Drawing.Size(180, 20)
-
-$lblStorNote = New-Object System.Windows.Forms.Label
-$lblStorNote.Text = T "stor.note"
-$lblStorNote.Location = New-Object System.Drawing.Point(15, 80)
-$lblStorNote.Size = New-Object System.Drawing.Size(580, 18)
-$lblStorNote.ForeColor = [System.Drawing.Color]::Gray
-$lblStorNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
-
-$grpStorOpts.Controls.AddRange(@($chkStorPerLib, $chkStorSubsites, $lblDepth, $nudDepth, $chkMaxDepth, $lblStorNote))
-
-$grpStorFmt = New-Group (T "grp.export.fmt") 10 128 615 58
-$radStorCSV = New-Radio (T "rad.csv.perms") 15 24 280 $true
-$radStorHTML = New-Radio (T "rad.html.perms") 305 24 290
-$grpStorFmt.Controls.AddRange(@($radStorCSV, $radStorHTML))
-
-$msGreen = [System.Drawing.Color]::FromArgb(16,124,16)
-$btnGenStorage = New-ActionBtn (T "btn.gen.storage") 10 200 $msGreen
-$btnOpenStorage = New-Object System.Windows.Forms.Button
-$btnOpenStorage.Text = T "btn.open.storage"
-$btnOpenStorage.Location = New-Object System.Drawing.Point(175, 200)
-$btnOpenStorage.Size = New-Object System.Drawing.Size(120, 34)
-$btnOpenStorage.Enabled = $false
-
-# Disable depth controls when Per-Library is unchecked
-$chkStorPerLib.Add_CheckedChanged({
- $on = $chkStorPerLib.Checked
- $lblDepth.Enabled = $on
- $nudDepth.Enabled = $on -and -not $chkMaxDepth.Checked
- $chkMaxDepth.Enabled = $on
-})
-# When Maximum is checked, grey out the spinner
-$chkMaxDepth.Add_CheckedChanged({
- $nudDepth.Enabled = $chkStorPerLib.Checked -and -not $chkMaxDepth.Checked
-})
-
-$tabStorage.Controls.AddRange(@($grpStorOpts, $grpStorFmt, $btnGenStorage, $btnOpenStorage))
-
-# ══ Tab 3: Templates ══════════════════════════════════════════════════════
-$tabTemplates = New-Object System.Windows.Forms.TabPage
-$tabTemplates.Text = T "tab.templates"
-$tabTemplates.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-$lblTplDesc = New-Object System.Windows.Forms.Label
-$lblTplDesc.Text = T "tpl.desc"
-$lblTplDesc.Location = New-Object System.Drawing.Point(10, 18)
-$lblTplDesc.Size = New-Object System.Drawing.Size(580, 20)
-$lblTplDesc.ForeColor = [System.Drawing.Color]::DimGray
-
-$lblTplCount = New-Object System.Windows.Forms.Label
-$lblTplCount.Name = "lblTplCount"
-$lblTplCount.Location = New-Object System.Drawing.Point(10, 44)
-$lblTplCount.Size = New-Object System.Drawing.Size(380, 20)
-$lblTplCount.ForeColor = [System.Drawing.Color]::DimGray
-$lblTplCount.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
-
-$btnOpenTplMgr = New-Object System.Windows.Forms.Button
-$btnOpenTplMgr.Text = T "btn.manage.tpl"
-$btnOpenTplMgr.Location = New-Object System.Drawing.Point(10, 72)
-$btnOpenTplMgr.Size = New-Object System.Drawing.Size(185, 34)
-$btnOpenTplMgr.BackColor = [System.Drawing.Color]::FromArgb(50, 50, 120)
-$btnOpenTplMgr.ForeColor = [System.Drawing.Color]::White
-$btnOpenTplMgr.FlatStyle = "Flat"
-$btnOpenTplMgr.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
-$tabTemplates.Controls.AddRange(@($lblTplDesc, $lblTplCount, $btnOpenTplMgr))
-
-# ══ Tab 4: Recherche de fichiers ══════════════════════════════════════════════
-$tabSearch = New-Object System.Windows.Forms.TabPage
-$tabSearch.Text = T "tab.search"
-$tabSearch.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-# ── GroupBox Filtres ───────────────────────────────────────────────────────────
-$grpSearchFilters = New-Group (T "grp.search.filters") 10 6 620 170
-
-# Row 1 - Extension & Regex
-$lblSrchExt = New-Object System.Windows.Forms.Label
-$lblSrchExt.Text = T "lbl.extensions"
-$lblSrchExt.Location = New-Object System.Drawing.Point(10, 24)
-$lblSrchExt.Size = New-Object System.Drawing.Size(88, 22)
-$lblSrchExt.TextAlign = "MiddleLeft"
-$txtSrchExt = New-Object System.Windows.Forms.TextBox
-$txtSrchExt.Location = New-Object System.Drawing.Point(100, 24)
-$txtSrchExt.Size = New-Object System.Drawing.Size(120, 22)
-$txtSrchExt.Font = New-Object System.Drawing.Font("Consolas", 9)
-$txtSrchExt.PlaceholderText = T "ph.extensions"
-
-$lblSrchRegex = New-Object System.Windows.Forms.Label
-$lblSrchRegex.Text = T "lbl.regex"
-$lblSrchRegex.Location = New-Object System.Drawing.Point(232, 24)
-$lblSrchRegex.Size = New-Object System.Drawing.Size(88, 22)
-$lblSrchRegex.TextAlign = "MiddleLeft"
-$txtSrchRegex = New-Object System.Windows.Forms.TextBox
-$txtSrchRegex.Location = New-Object System.Drawing.Point(322, 24)
-$txtSrchRegex.Size = New-Object System.Drawing.Size(286, 22)
-$txtSrchRegex.Font = New-Object System.Drawing.Font("Consolas", 9)
-$txtSrchRegex.PlaceholderText = T "ph.regex"
-
-# Row 2 - Created dates
-$chkSrchCrA = New-Object System.Windows.Forms.CheckBox
-$chkSrchCrA.Text = T "chk.created.after"
-$chkSrchCrA.Location = New-Object System.Drawing.Point(10, 52)
-$chkSrchCrA.Size = New-Object System.Drawing.Size(108, 22)
-$dtpSrchCrA = New-Object System.Windows.Forms.DateTimePicker
-$dtpSrchCrA.Location = New-Object System.Drawing.Point(120, 52)
-$dtpSrchCrA.Size = New-Object System.Drawing.Size(130, 22)
-$dtpSrchCrA.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
-$dtpSrchCrA.Enabled = $false
-
-$chkSrchCrB = New-Object System.Windows.Forms.CheckBox
-$chkSrchCrB.Text = T "chk.created.before"
-$chkSrchCrB.Location = New-Object System.Drawing.Point(262, 52)
-$chkSrchCrB.Size = New-Object System.Drawing.Size(108, 22)
-$dtpSrchCrB = New-Object System.Windows.Forms.DateTimePicker
-$dtpSrchCrB.Location = New-Object System.Drawing.Point(372, 52)
-$dtpSrchCrB.Size = New-Object System.Drawing.Size(130, 22)
-$dtpSrchCrB.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
-$dtpSrchCrB.Enabled = $false
-
-$chkSrchCrA.Add_CheckedChanged({ $dtpSrchCrA.Enabled = $chkSrchCrA.Checked })
-$chkSrchCrB.Add_CheckedChanged({ $dtpSrchCrB.Enabled = $chkSrchCrB.Checked })
-
-# Row 3 - Modified dates
-$chkSrchModA = New-Object System.Windows.Forms.CheckBox
-$chkSrchModA.Text = T "chk.modified.after"
-$chkSrchModA.Location = New-Object System.Drawing.Point(10, 80)
-$chkSrchModA.Size = New-Object System.Drawing.Size(108, 22)
-$dtpSrchModA = New-Object System.Windows.Forms.DateTimePicker
-$dtpSrchModA.Location = New-Object System.Drawing.Point(120, 80)
-$dtpSrchModA.Size = New-Object System.Drawing.Size(130, 22)
-$dtpSrchModA.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
-$dtpSrchModA.Enabled = $false
-
-$chkSrchModB = New-Object System.Windows.Forms.CheckBox
-$chkSrchModB.Text = T "chk.modified.before"
-$chkSrchModB.Location = New-Object System.Drawing.Point(262, 80)
-$chkSrchModB.Size = New-Object System.Drawing.Size(108, 22)
-$dtpSrchModB = New-Object System.Windows.Forms.DateTimePicker
-$dtpSrchModB.Location = New-Object System.Drawing.Point(372, 80)
-$dtpSrchModB.Size = New-Object System.Drawing.Size(130, 22)
-$dtpSrchModB.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
-$dtpSrchModB.Enabled = $false
-
-$chkSrchModA.Add_CheckedChanged({ $dtpSrchModA.Enabled = $chkSrchModA.Checked })
-$chkSrchModB.Add_CheckedChanged({ $dtpSrchModB.Enabled = $chkSrchModB.Checked })
-
-# Row 4 - Created by / Modified by
-$lblSrchCrBy = New-Object System.Windows.Forms.Label
-$lblSrchCrBy.Text = T "lbl.created.by"
-$lblSrchCrBy.Location = New-Object System.Drawing.Point(10, 108)
-$lblSrchCrBy.Size = New-Object System.Drawing.Size(70, 22)
-$lblSrchCrBy.TextAlign = "MiddleLeft"
-$txtSrchCrBy = New-Object System.Windows.Forms.TextBox
-$txtSrchCrBy.Location = New-Object System.Drawing.Point(82, 108)
-$txtSrchCrBy.Size = New-Object System.Drawing.Size(168, 22)
-$txtSrchCrBy.PlaceholderText = T "ph.created.by"
-
-$lblSrchModBy = New-Object System.Windows.Forms.Label
-$lblSrchModBy.Text = T "lbl.modified.by"
-$lblSrchModBy.Location = New-Object System.Drawing.Point(262, 108)
-$lblSrchModBy.Size = New-Object System.Drawing.Size(82, 22)
-$lblSrchModBy.TextAlign = "MiddleLeft"
-$txtSrchModBy = New-Object System.Windows.Forms.TextBox
-$txtSrchModBy.Location = New-Object System.Drawing.Point(346, 108)
-$txtSrchModBy.Size = New-Object System.Drawing.Size(168, 22)
-$txtSrchModBy.PlaceholderText = T "ph.modified.by"
-
-# Row 5 - Library filter
-$lblSrchLib = New-Object System.Windows.Forms.Label
-$lblSrchLib.Text = T "lbl.library"
-$lblSrchLib.Location = New-Object System.Drawing.Point(10, 136)
-$lblSrchLib.Size = New-Object System.Drawing.Size(88, 22)
-$lblSrchLib.TextAlign = "MiddleLeft"
-$txtSrchLib = New-Object System.Windows.Forms.TextBox
-$txtSrchLib.Location = New-Object System.Drawing.Point(100, 136)
-$txtSrchLib.Size = New-Object System.Drawing.Size(508, 22)
-$txtSrchLib.PlaceholderText = T "ph.library"
-
-$grpSearchFilters.Controls.AddRange(@(
- $lblSrchExt, $txtSrchExt, $lblSrchRegex, $txtSrchRegex,
- $chkSrchCrA, $dtpSrchCrA, $chkSrchCrB, $dtpSrchCrB,
- $chkSrchModA, $dtpSrchModA, $chkSrchModB, $dtpSrchModB,
- $lblSrchCrBy, $txtSrchCrBy, $lblSrchModBy, $txtSrchModBy,
- $lblSrchLib, $txtSrchLib
-))
-
-# ── GroupBox Format ────────────────────────────────────────────────────────────
-$grpSearchFmt = New-Group (T "grp.search.fmt") 10 180 620 48
-$radSrchCSV = New-Radio (T "rad.csv.perms") 15 22 130 $true
-$radSrchHTML = New-Radio (T "rad.html.perms") 160 22 180
-$lblSrchMax = New-Object System.Windows.Forms.Label
-$lblSrchMax.Text = T "lbl.max.results"
-$lblSrchMax.Location = New-Object System.Drawing.Point(360, 22)
-$lblSrchMax.Size = New-Object System.Drawing.Size(96, 22)
-$lblSrchMax.TextAlign = "MiddleLeft"
-$nudSrchMax = New-Object System.Windows.Forms.NumericUpDown
-$nudSrchMax.Location = New-Object System.Drawing.Point(458, 22)
-$nudSrchMax.Size = New-Object System.Drawing.Size(70, 22)
-$nudSrchMax.Minimum = 10
-$nudSrchMax.Maximum = 50000
-$nudSrchMax.Value = 500
-$nudSrchMax.Increment = 100
-$grpSearchFmt.Controls.AddRange(@($radSrchCSV, $radSrchHTML, $lblSrchMax, $nudSrchMax))
-
-# ── Buttons ────────────────────────────────────────────────────────────────────
-$btnSearch = New-ActionBtn (T "btn.run.search") 10 232 ([System.Drawing.Color]::FromArgb(0, 120, 212))
-$btnOpenSearch = New-Object System.Windows.Forms.Button
-$btnOpenSearch.Text = T "btn.open.search"
-$btnOpenSearch.Location = New-Object System.Drawing.Point(175, 232)
-$btnOpenSearch.Size = New-Object System.Drawing.Size(130, 34)
-$btnOpenSearch.Enabled = $false
-
-$tabSearch.Controls.AddRange(@($grpSearchFilters, $grpSearchFmt, $btnSearch, $btnOpenSearch))
-
-# ══ Tab 5: Doublons ═══════════════════════════════════════════════════════════
-$tabDupes = New-Object System.Windows.Forms.TabPage
-$tabDupes.Text = T "tab.dupes"
-$tabDupes.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-# ── GroupBox: Type de doublons (y=4, h=44 → bottom 48) ──────────────────────
-$grpDupType = New-Group (T "grp.dup.type") 10 4 638 44
-$radDupFiles = New-Radio (T "rad.dup.files") 10 16 190 $true
-$radDupFolders = New-Radio (T "rad.dup.folders") 210 16 190
-$grpDupType.Controls.AddRange(@($radDupFiles, $radDupFolders))
-
-# ── GroupBox: Critères de comparaison (y=52, h=88 → bottom 140) ─────────────
-$grpDupCrit = New-Group (T "grp.dup.criteria") 10 52 638 88
-
-$lblDupNote = New-Object System.Windows.Forms.Label
-$lblDupNote.Text = T "lbl.dup.note"
-$lblDupNote.Location = New-Object System.Drawing.Point(10, 15)
-$lblDupNote.Size = New-Object System.Drawing.Size(610, 16)
-$lblDupNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
-$lblDupNote.ForeColor = [System.Drawing.Color]::DimGray
-
-# Row 1 - criteres communs
-$chkDupSize = New-Check (T "chk.dup.size") 10 34 148 $true
-$chkDupCreated = New-Check (T "chk.dup.created") 164 34 208
-$chkDupModified = New-Check (T "chk.dup.modified") 378 34 226
-
-# Row 2 - criteres dossiers uniquement
-$chkDupSubCount = New-Check (T "chk.dup.subfolders") 10 60 210
-$chkDupFileCount = New-Check (T "chk.dup.filecount") 226 60 200
-$chkDupSubCount.Enabled = $false
-$chkDupFileCount.Enabled = $false
-
-$grpDupCrit.Controls.AddRange(@($lblDupNote,
- $chkDupSize, $chkDupCreated, $chkDupModified,
- $chkDupSubCount, $chkDupFileCount))
-
-# Toggle folder-only criteria based on radio selection
-$radDupFiles.Add_CheckedChanged({
- $chkDupSubCount.Enabled = -not $radDupFiles.Checked
- $chkDupFileCount.Enabled = -not $radDupFiles.Checked
- if ($radDupFiles.Checked) { $chkDupSubCount.Checked = $false; $chkDupFileCount.Checked = $false }
-})
-$radDupFolders.Add_CheckedChanged({
- $chkDupSubCount.Enabled = $radDupFolders.Checked
- $chkDupFileCount.Enabled = $radDupFolders.Checked
-})
-
-# ── GroupBox: Options (y=144, h=44 → bottom 188) ─────────────────────────────
-$grpDupOpts = New-Group (T "grp.options") 10 144 638 44
-$chkDupSubsites = New-Check (T "chk.include.subsites") 10 18 192
-$lblDupLib = New-Object System.Windows.Forms.Label
-$lblDupLib.Text = T "lbl.library"
-$lblDupLib.Location = New-Object System.Drawing.Point(210, 18)
-$lblDupLib.Size = New-Object System.Drawing.Size(88, 22)
-$lblDupLib.TextAlign = "MiddleLeft"
-$txtDupLib = New-Object System.Windows.Forms.TextBox
-$txtDupLib.Location = New-Object System.Drawing.Point(300, 18)
-$txtDupLib.Size = New-Object System.Drawing.Size(326, 22)
-$txtDupLib.PlaceholderText = T "ph.dup.lib"
-$grpDupOpts.Controls.AddRange(@($chkDupSubsites, $lblDupLib, $txtDupLib))
-
-# ── GroupBox: Format (y=192, h=40 → bottom 232) ──────────────────────────────
-$grpDupFmt = New-Group (T "grp.export.fmt") 10 192 638 40
-$radDupCSV = New-Radio (T "rad.csv.perms") 10 16 130 $true
-$radDupHTML = New-Radio (T "rad.html.perms") 155 16 200
-$grpDupFmt.Controls.AddRange(@($radDupCSV, $radDupHTML))
-
-# ── Buttons (y=236 → bottom 270, within 284px inner) ─────────────────────────
-$btnScanDupes = New-ActionBtn (T "btn.run.scan") 10 236 ([System.Drawing.Color]::FromArgb(136, 0, 21))
-$btnOpenDupes = New-Object System.Windows.Forms.Button
-$btnOpenDupes.Text = T "btn.open.results"
-$btnOpenDupes.Location = New-Object System.Drawing.Point(175, 236)
-$btnOpenDupes.Size = New-Object System.Drawing.Size(130, 34)
-$btnOpenDupes.Enabled = $false
-
-$tabDupes.Controls.AddRange(@($grpDupType, $grpDupCrit, $grpDupOpts, $grpDupFmt, $btnScanDupes, $btnOpenDupes))
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Tab 6 – Transfer
-# ══════════════════════════════════════════════════════════════════════════════
-$tabTransfer = New-Object System.Windows.Forms.TabPage
-$tabTransfer.Text = T "tab.transfer"
-
-# ── GroupBox: Source (y=4, h=74) ─────────────────────────────────────────────
-$grpXferSrc = New-Group (T "grp.xfer.source") 10 4 620 74
-
-$lblXferSrcSite = New-Object System.Windows.Forms.Label
-$lblXferSrcSite.Text = T "lbl.xfer.site"
-$lblXferSrcSite.Location = New-Object System.Drawing.Point(10, 20)
-$lblXferSrcSite.Size = New-Object System.Drawing.Size(145, 22)
-$lblXferSrcSite.TextAlign = "MiddleLeft"
-
-$txtXferSrcSite = New-Object System.Windows.Forms.TextBox
-$txtXferSrcSite.Location = New-Object System.Drawing.Point(160, 20)
-$txtXferSrcSite.Size = New-Object System.Drawing.Size(448, 22)
-$txtXferSrcSite.PlaceholderText = T "ph.xfer.site"
-
-$lblXferSrcLib = New-Object System.Windows.Forms.Label
-$lblXferSrcLib.Text = T "lbl.xfer.library"
-$lblXferSrcLib.Location = New-Object System.Drawing.Point(10, 46)
-$lblXferSrcLib.Size = New-Object System.Drawing.Size(145, 22)
-$lblXferSrcLib.TextAlign = "MiddleLeft"
-
-$txtXferSrcLib = New-Object System.Windows.Forms.TextBox
-$txtXferSrcLib.Location = New-Object System.Drawing.Point(160, 46)
-$txtXferSrcLib.Size = New-Object System.Drawing.Size(448, 22)
-$txtXferSrcLib.PlaceholderText = T "ph.xfer.library"
-
-$grpXferSrc.Controls.AddRange(@($lblXferSrcSite, $txtXferSrcSite, $lblXferSrcLib, $txtXferSrcLib))
-
-# ── GroupBox: Destination (y=82, h=74) ───────────────────────────────────────
-$grpXferDst = New-Group (T "grp.xfer.dest") 10 82 620 74
-
-$lblXferDstSite = New-Object System.Windows.Forms.Label
-$lblXferDstSite.Text = T "lbl.xfer.site"
-$lblXferDstSite.Location = New-Object System.Drawing.Point(10, 20)
-$lblXferDstSite.Size = New-Object System.Drawing.Size(145, 22)
-$lblXferDstSite.TextAlign = "MiddleLeft"
-
-$txtXferDstSite = New-Object System.Windows.Forms.TextBox
-$txtXferDstSite.Location = New-Object System.Drawing.Point(160, 20)
-$txtXferDstSite.Size = New-Object System.Drawing.Size(448, 22)
-$txtXferDstSite.PlaceholderText = T "ph.xfer.site"
-
-$lblXferDstLib = New-Object System.Windows.Forms.Label
-$lblXferDstLib.Text = T "lbl.xfer.library"
-$lblXferDstLib.Location = New-Object System.Drawing.Point(10, 46)
-$lblXferDstLib.Size = New-Object System.Drawing.Size(145, 22)
-$lblXferDstLib.TextAlign = "MiddleLeft"
-
-$txtXferDstLib = New-Object System.Windows.Forms.TextBox
-$txtXferDstLib.Location = New-Object System.Drawing.Point(160, 46)
-$txtXferDstLib.Size = New-Object System.Drawing.Size(448, 22)
-$txtXferDstLib.PlaceholderText = T "ph.xfer.library"
-
-$grpXferDst.Controls.AddRange(@($lblXferDstSite, $txtXferDstSite, $lblXferDstLib, $txtXferDstLib))
-
-# ── GroupBox: Options (y=160, h=96) ──────────────────────────────────────────
-$grpXferOpts = New-Group (T "grp.xfer.options") 10 160 620 96
-
-$chkXferRecursive = New-Check (T "chk.xfer.recursive") 10 18 250 $true
-$chkXferOverwrite = New-Check (T "chk.xfer.overwrite") 270 18 180
-$chkXferCreateFolders = New-Check (T "chk.xfer.create.folders") 460 18 155 $true
-
-$lblXferFmt = New-Object System.Windows.Forms.Label
-$lblXferFmt.Text = T "lbl.xfer.report.fmt"
-$lblXferFmt.Location = New-Object System.Drawing.Point(10, 42)
-$lblXferFmt.Size = New-Object System.Drawing.Size(55, 20)
-
-$radXferCsv = New-Radio "CSV" 68 42 55 $true
-$radXferHtml = New-Radio "HTML" 125 42 60 $false
-
-$btnXferCsvImport = New-Object System.Windows.Forms.Button
-$btnXferCsvImport.Text = T "btn.xfer.csv"
-$btnXferCsvImport.Location = New-Object System.Drawing.Point(250, 40)
-$btnXferCsvImport.Size = New-Object System.Drawing.Size(118, 24)
-
-$lblXferCsvInfo = New-Object System.Windows.Forms.Label
-$lblXferCsvInfo.Text = ""
-$lblXferCsvInfo.Location = New-Object System.Drawing.Point(374, 43)
-$lblXferCsvInfo.Size = New-Object System.Drawing.Size(175, 18)
-$lblXferCsvInfo.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 212)
-
-$btnXferCsvClear = New-Object System.Windows.Forms.Button
-$btnXferCsvClear.Text = T "btn.xfer.csv.clear"
-$btnXferCsvClear.Location = New-Object System.Drawing.Point(554, 40)
-$btnXferCsvClear.Size = New-Object System.Drawing.Size(55, 24)
-$btnXferCsvClear.Visible = $false
-
-$lblXferNote = New-Object System.Windows.Forms.Label
-$lblXferNote.Text = T "xfer.note"
-$lblXferNote.Location = New-Object System.Drawing.Point(10, 72)
-$lblXferNote.Size = New-Object System.Drawing.Size(600, 16)
-$lblXferNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
-$lblXferNote.ForeColor = [System.Drawing.Color]::DimGray
-
-$grpXferOpts.Controls.AddRange(@($chkXferRecursive, $chkXferOverwrite, $chkXferCreateFolders,
- $lblXferFmt, $radXferCsv, $radXferHtml,
- $btnXferCsvImport, $lblXferCsvInfo, $btnXferCsvClear,
- $lblXferNote))
-
-# ── Buttons (y=260) ──────────────────────────────────────────────────────────
-$btnXferStart = New-ActionBtn (T "btn.xfer.start") 10 260 ([System.Drawing.Color]::FromArgb(0, 120, 60))
-
-$btnXferVerify = New-Object System.Windows.Forms.Button
-$btnXferVerify.Text = T "btn.xfer.verify"
-$btnXferVerify.Location = New-Object System.Drawing.Point(175, 260)
-$btnXferVerify.Size = New-Object System.Drawing.Size(130, 34)
-$btnXferVerify.BackColor = [System.Drawing.Color]::FromArgb(0, 120, 212)
-$btnXferVerify.ForeColor = [System.Drawing.Color]::White
-$btnXferVerify.FlatStyle = "Flat"
-$btnXferVerify.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
-
-$btnXferOpen = New-Object System.Windows.Forms.Button
-$btnXferOpen.Text = T "btn.xfer.open"
-$btnXferOpen.Location = New-Object System.Drawing.Point(315, 260)
-$btnXferOpen.Size = New-Object System.Drawing.Size(130, 34)
-$btnXferOpen.Enabled = $false
-
-$tabTransfer.Controls.AddRange(@($grpXferSrc, $grpXferDst, $grpXferOpts, $btnXferStart, $btnXferVerify, $btnXferOpen))
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Tab 7 – Bulk Create
-# ══════════════════════════════════════════════════════════════════════════════
-$tabBulk = New-Object System.Windows.Forms.TabPage
-$tabBulk.Text = T "tab.bulk"
-
-# ── GroupBox: Site list ──────────────────────────────────────────────────────
-$grpBulkList = New-Group (T "grp.bulk.list") 10 4 620 230
-
-$lvBulk = New-Object System.Windows.Forms.ListView
-$lvBulk.Location = New-Object System.Drawing.Point(10, 18)
-$lvBulk.Size = New-Object System.Drawing.Size(598, 168)
-$lvBulk.View = "Details"
-$lvBulk.FullRowSelect = $true
-$lvBulk.GridLines = $true
-$lvBulk.Font = New-Object System.Drawing.Font("Segoe UI", 8.5)
-$lvBulk.Columns.Add((T "bulk.col.name"), 120) | Out-Null
-$lvBulk.Columns.Add((T "bulk.col.alias"), 90) | Out-Null
-$lvBulk.Columns.Add((T "bulk.col.type"), 60) | Out-Null
-$lvBulk.Columns.Add((T "bulk.col.template"), 90) | Out-Null
-$lvBulk.Columns.Add((T "bulk.col.owners"), 110) | Out-Null
-$lvBulk.Columns.Add((T "bulk.col.members"), 110) | Out-Null
-
-$btnBulkAdd = New-Object System.Windows.Forms.Button
-$btnBulkAdd.Text = T "btn.bulk.add"
-$btnBulkAdd.Location = New-Object System.Drawing.Point(10, 192)
-$btnBulkAdd.Size = New-Object System.Drawing.Size(120, 28)
-
-$btnBulkCsv = New-Object System.Windows.Forms.Button
-$btnBulkCsv.Text = T "btn.bulk.csv"
-$btnBulkCsv.Location = New-Object System.Drawing.Point(138, 192)
-$btnBulkCsv.Size = New-Object System.Drawing.Size(120, 28)
-
-$btnBulkRemove = New-Object System.Windows.Forms.Button
-$btnBulkRemove.Text = T "btn.bulk.remove"
-$btnBulkRemove.Location = New-Object System.Drawing.Point(266, 192)
-$btnBulkRemove.Size = New-Object System.Drawing.Size(90, 28)
-
-$btnBulkClear = New-Object System.Windows.Forms.Button
-$btnBulkClear.Text = T "btn.bulk.clear"
-$btnBulkClear.Location = New-Object System.Drawing.Point(364, 192)
-$btnBulkClear.Size = New-Object System.Drawing.Size(90, 28)
-
-$grpBulkList.Controls.AddRange(@($lvBulk, $btnBulkAdd, $btnBulkCsv, $btnBulkRemove, $btnBulkClear))
-
-# ── Create button ────────────────────────────────────────────────────────────
-$btnBulkCreate = New-ActionBtn (T "btn.bulk.create") 10 240 ([System.Drawing.Color]::FromArgb(0, 120, 60))
-$btnBulkCreate.Size = New-Object System.Drawing.Size(200, 34)
-
-$tabBulk.Controls.AddRange(@($grpBulkList, $btnBulkCreate))
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Tab 8 – Structure (folder tree from CSV)
-# ══════════════════════════════════════════════════════════════════════════════
-$tabStruct = New-Object System.Windows.Forms.TabPage
-$tabStruct.Text = T "tab.structure"
-
-# ── CSV import + target (single row) ───────────────────────────────────────
-$grpStructCsv = New-Group (T "grp.struct.csv") 10 4 620 52
-
-$lblStructDesc = New-Object System.Windows.Forms.Label
-$lblStructDesc.Text = T "lbl.struct.desc"
-$lblStructDesc.Location = New-Object System.Drawing.Point(10, 20)
-$lblStructDesc.Size = New-Object System.Drawing.Size(460, 20)
-
-$btnStructCsv = New-Object System.Windows.Forms.Button
-$btnStructCsv.Text = T "btn.struct.csv"
-$btnStructCsv.Location = New-Object System.Drawing.Point(490, 18)
-$btnStructCsv.Size = New-Object System.Drawing.Size(118, 26)
-
-$grpStructCsv.Controls.AddRange(@($lblStructDesc, $btnStructCsv))
-
-# ── Preview ────────────────────────────────────────────────────────────────
-$grpStructPreview = New-Group (T "grp.struct.preview") 10 58 620 148
-
-$tvStruct = New-Object System.Windows.Forms.TreeView
-$tvStruct.Location = New-Object System.Drawing.Point(10, 18)
-$tvStruct.Size = New-Object System.Drawing.Size(598, 120)
-$tvStruct.Font = New-Object System.Drawing.Font("Segoe UI", 9)
-$tvStruct.ShowLines = $true
-$tvStruct.ShowPlusMinus = $true
-
-$grpStructPreview.Controls.Add($tvStruct)
-
-# ── Target + Buttons (single row) ─────────────────────────────────────────
-$lblStructLib = New-Object System.Windows.Forms.Label
-$lblStructLib.Text = T "lbl.struct.library"
-$lblStructLib.Location = New-Object System.Drawing.Point(12, 214)
-$lblStructLib.Size = New-Object System.Drawing.Size(110, 20)
-
-$txtStructLib = New-Object System.Windows.Forms.TextBox
-$txtStructLib.Location = New-Object System.Drawing.Point(124, 212)
-$txtStructLib.Size = New-Object System.Drawing.Size(200, 22)
-$txtStructLib.PlaceholderText = T "ph.struct.library"
-
-$btnStructCreate = New-ActionBtn (T "btn.struct.create") 340 208 ([System.Drawing.Color]::FromArgb(0, 120, 212))
-$btnStructCreate.Size = New-Object System.Drawing.Size(180, 30)
-
-$btnStructClear = New-Object System.Windows.Forms.Button
-$btnStructClear.Text = T "btn.struct.clear"
-$btnStructClear.Location = New-Object System.Drawing.Point(528, 208)
-$btnStructClear.Size = New-Object System.Drawing.Size(90, 30)
-
-$tabStruct.Controls.AddRange(@($grpStructCsv, $grpStructPreview, $lblStructLib, $txtStructLib, $btnStructCreate, $btnStructClear))
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Tab 9 – Version Cleanup
-# ══════════════════════════════════════════════════════════════════════════════
-$tabVersions = New-Object System.Windows.Forms.TabPage
-$tabVersions.Text = T "tab.versions"
-$tabVersions.BackColor = [System.Drawing.Color]::WhiteSmoke
-
-# ── Versions to keep ─────────────────────────────────────────────────────────
-$grpVerKeep = New-Group (T "grp.ver.keep") 10 4 620 110
-
-$lblVerCount = New-Object System.Windows.Forms.Label
-$lblVerCount.Text = T "lbl.ver.count"
-$lblVerCount.Location = New-Object System.Drawing.Point(10, 22)
-$lblVerCount.Size = New-Object System.Drawing.Size(220, 20)
-
-$nudVerCount = New-Object System.Windows.Forms.NumericUpDown
-$nudVerCount.Location = New-Object System.Drawing.Point(235, 20)
-$nudVerCount.Size = New-Object System.Drawing.Size(70, 22)
-$nudVerCount.Minimum = 0
-$nudVerCount.Maximum = 500
-$nudVerCount.Value = 5
-
-$chkVerDate = New-Check (T "chk.ver.date") 10 50 250 $false
-
-$radVerBefore = New-Radio (T "rad.ver.before") 30 74 200 $true
-$radVerBefore.Enabled = $false
-$radVerAfter = New-Radio (T "rad.ver.after") 30 96 200 $false
-$radVerAfter.Enabled = $false
-
-$dtpVer = New-Object System.Windows.Forms.DateTimePicker
-$dtpVer.Location = New-Object System.Drawing.Point(235, 74)
-$dtpVer.Size = New-Object System.Drawing.Size(150, 22)
-$dtpVer.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
-$dtpVer.Enabled = $false
-
-$chkVerDate.Add_CheckedChanged({
- $on = $chkVerDate.Checked
- $radVerBefore.Enabled = $on
- $radVerAfter.Enabled = $on
- $dtpVer.Enabled = $on
-})
-
-$grpVerKeep.Controls.AddRange(@($lblVerCount, $nudVerCount, $chkVerDate, $radVerBefore, $radVerAfter, $dtpVer))
-
-# ── Scope ─────────────────────────────────────────────────────────────────────
-$grpVerScope = New-Group (T "grp.ver.scope") 10 118 620 76
-
-$lblVerLib = New-Object System.Windows.Forms.Label
-$lblVerLib.Text = T "lbl.ver.library"
-$lblVerLib.Location = New-Object System.Drawing.Point(10, 22)
-$lblVerLib.Size = New-Object System.Drawing.Size(150, 20)
-
-$txtVerLib = New-Object System.Windows.Forms.TextBox
-$txtVerLib.Location = New-Object System.Drawing.Point(164, 20)
-$txtVerLib.Size = New-Object System.Drawing.Size(230, 22)
-$txtVerLib.PlaceholderText = T "ph.ver.library"
-
-$chkVerRecursive = New-Check (T "chk.ver.recursive") 10 48 260 $true
-$chkVerSubsites = New-Check (T "chk.ver.subsites") 280 48 200 $false
-
-$grpVerScope.Controls.AddRange(@($lblVerLib, $txtVerLib, $chkVerRecursive, $chkVerSubsites))
-
-# ── Options + Buttons ─────────────────────────────────────────────────────────
-$chkVerDryRun = New-Check (T "chk.ver.dryrun") 12 200 350 $true
-
-$btnVerRun = New-ActionBtn (T "btn.ver.run") 10 228 ([System.Drawing.Color]::FromArgb(180, 60, 20))
-$btnVerRun.Size = New-Object System.Drawing.Size(180, 30)
-
-$btnVerOpen = New-Object System.Windows.Forms.Button
-$btnVerOpen.Text = T "btn.ver.open"
-$btnVerOpen.Location = New-Object System.Drawing.Point(200, 228)
-$btnVerOpen.Size = New-Object System.Drawing.Size(130, 30)
-$btnVerOpen.Enabled = $false
-
-$tabVersions.Controls.AddRange(@($grpVerKeep, $grpVerScope, $chkVerDryRun, $btnVerRun, $btnVerOpen))
-
-$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes, $tabTransfer, $tabBulk, $tabStruct, $tabVersions))
-
-# ── Progress bar ───────────────────────────────────────────────────────────────
-$progressBar = New-Object System.Windows.Forms.ProgressBar
-$progressBar.Location = New-Object System.Drawing.Point(20, 540)
-$progressBar.Size = New-Object System.Drawing.Size(642, 16)
-$progressBar.Style = "Continuous"
-$progressBar.Minimum = 0
-$progressBar.Maximum = 100
-$progressBar.Value = 0
-$progressBar.Visible = $false
-
-# Animation timer: sweeps 0→100 then resets, driven by UI-thread timer
-$script:_AnimTimer = New-Object System.Windows.Forms.Timer
-$script:_AnimTimer.Interval = 30 # ~33 fps
-$script:_AnimTimer.Add_Tick({
- $v = $progressBar.Value + 2
- if ($v -gt 100) { $v = 0 }
- $progressBar.Value = $v
-})
-
-function Start-ProgressAnim {
- $progressBar.Value = 0
- $progressBar.Visible = $true
- $script:_AnimTimer.Start()
-}
-function Stop-ProgressAnim {
- $script:_AnimTimer.Stop()
- $progressBar.Value = 0
- $progressBar.Visible = $false
-}
-
-# ── Log ────────────────────────────────────────────────────────────────────────
-$lblLog = New-Object System.Windows.Forms.Label
-$lblLog.Text = T "lbl.log"
-$lblLog.Location = New-Object System.Drawing.Point(20, 564)
-$lblLog.Size = New-Object System.Drawing.Size(60, 20)
-
-$txtLog = New-Object System.Windows.Forms.RichTextBox
-$txtLog.Location = New-Object System.Drawing.Point(20, 584)
-$txtLog.Size = New-Object System.Drawing.Size(642, 208)
-$txtLog.ReadOnly = $true
-$txtLog.BackColor = [System.Drawing.Color]::Black
-$txtLog.ForeColor = [System.Drawing.Color]::LightGreen
-$txtLog.Font = New-Object System.Drawing.Font("Consolas", 9)
-$txtLog.ScrollBars = "Vertical"
-
-$script:LogBox = $txtLog
-$script:txtClientId = $txtClientId
-$script:txtSiteURL = $txtSiteURL
-$script:txtTenantUrl = $txtTenantUrl
-$script:cboProfile = $cboProfile
-$script:btnBrowseSites = $btnBrowseSites
-$script:Profiles = @()
-$script:SelectedSites = @()
-$script:_SiteCache = @()
-
-# Si l'utilisateur re-saisit manuellement, effacer la multi-sélection du picker
-$txtSiteURL.Add_TextChanged({
- if ($txtSiteURL.Enabled -and $script:SelectedSites.Count -gt 0) {
- $script:SelectedSites = @()
- $btnBrowseSites.Text = T "btn.view.sites"
- }
-})
-
-$form.Controls.AddRange(@(
- $menuStrip,
- $lblProfile, $cboProfile,
- $btnProfileNew, $btnProfileSave, $btnProfileRename, $btnProfileDelete,
- $lblTenantUrl, $txtTenantUrl, $btnBrowseSites,
- $lblClientId, $txtClientId, $btnRegisterApp,
- $lblSiteURL, $txtSiteURL,
- $lblOutput, $txtOutput, $btnBrowse,
- $sep, $tabs,
- $progressBar,
- $lblLog, $txtLog
-))
-
-# ── i18n control registration ──────────────────────────────────────────────────
-$script:i18nMap = [System.Collections.Generic.Dictionary[string,object]]::new()
-$script:i18nTabs = [System.Collections.Generic.Dictionary[string,object]]::new()
-$script:i18nMenus = [System.Collections.Generic.Dictionary[string,object]]::new()
-
-$_reg = {
- param($dict, $ctrl, $key)
- $dict[[System.Guid]::NewGuid().ToString()] = [PSCustomObject]@{ Control = $ctrl; Key = $key }
-}
-
-# Main labels & buttons
-& $_reg $script:i18nMap $lblProfile "profile"
-& $_reg $script:i18nMap $btnProfileNew "btn.new"
-& $_reg $script:i18nMap $btnProfileSave "btn.save"
-& $_reg $script:i18nMap $btnProfileRename "btn.rename"
-& $_reg $script:i18nMap $btnProfileDelete "btn.delete"
-& $_reg $script:i18nMap $btnBrowseSites "btn.view.sites"
-& $_reg $script:i18nMap $btnRegisterApp "btn.register.app"
-& $_reg $script:i18nMap $lblTenantUrl "tenant.url"
-& $_reg $script:i18nMap $lblClientId "client.id"
-& $_reg $script:i18nMap $lblSiteURL "site.url"
-& $_reg $script:i18nMap $lblOutput "output.folder"
-& $_reg $script:i18nMap $btnBrowse "btn.browse"
-& $_reg $script:i18nMap $lblLog "lbl.log"
-
-# Permissions tab controls
-& $_reg $script:i18nMap $grpPermOpts "grp.scan.opts"
-& $_reg $script:i18nMap $chkScanFolders "chk.scan.folders"
-& $_reg $script:i18nMap $chkRecursive "chk.recursive"
-& $_reg $script:i18nMap $lblPermDepth "lbl.folder.depth"
-& $_reg $script:i18nMap $chkPermMaxDepth "chk.max.depth"
-& $_reg $script:i18nMap $chkInheritedPerms "chk.inherited.perms"
-& $_reg $script:i18nMap $grpPermFmt "grp.export.fmt"
-& $_reg $script:i18nMap $radPermCSV "rad.csv.perms"
-& $_reg $script:i18nMap $radPermHTML "rad.html.perms"
-& $_reg $script:i18nMap $btnGenPerms "btn.gen.perms"
-& $_reg $script:i18nMap $btnOpenPerms "btn.open.perms"
-
-# Storage tab controls
-& $_reg $script:i18nMap $grpStorOpts "grp.scan.opts"
-& $_reg $script:i18nMap $chkStorPerLib "chk.per.lib"
-& $_reg $script:i18nMap $chkStorSubsites "chk.subsites"
-& $_reg $script:i18nMap $lblDepth "lbl.folder.depth"
-& $_reg $script:i18nMap $chkMaxDepth "chk.max.depth"
-& $_reg $script:i18nMap $lblStorNote "stor.note"
-& $_reg $script:i18nMap $grpStorFmt "grp.export.fmt"
-& $_reg $script:i18nMap $radStorCSV "rad.csv.perms"
-& $_reg $script:i18nMap $radStorHTML "rad.html.perms"
-& $_reg $script:i18nMap $btnGenStorage "btn.gen.storage"
-& $_reg $script:i18nMap $btnOpenStorage "btn.open.storage"
-
-# Templates tab controls
-& $_reg $script:i18nMap $lblTplDesc "tpl.desc"
-& $_reg $script:i18nMap $btnOpenTplMgr "btn.manage.tpl"
-
-# Search tab controls
-& $_reg $script:i18nMap $grpSearchFilters "grp.search.filters"
-& $_reg $script:i18nMap $lblSrchExt "lbl.extensions"
-& $_reg $script:i18nMap $lblSrchRegex "lbl.regex"
-& $_reg $script:i18nMap $chkSrchCrA "chk.created.after"
-& $_reg $script:i18nMap $chkSrchCrB "chk.created.before"
-& $_reg $script:i18nMap $chkSrchModA "chk.modified.after"
-& $_reg $script:i18nMap $chkSrchModB "chk.modified.before"
-& $_reg $script:i18nMap $lblSrchCrBy "lbl.created.by"
-& $_reg $script:i18nMap $lblSrchModBy "lbl.modified.by"
-& $_reg $script:i18nMap $lblSrchLib "lbl.library"
-& $_reg $script:i18nMap $grpSearchFmt "grp.search.fmt"
-& $_reg $script:i18nMap $lblSrchMax "lbl.max.results"
-& $_reg $script:i18nMap $btnSearch "btn.run.search"
-& $_reg $script:i18nMap $btnOpenSearch "btn.open.search"
-
-# Duplicates tab controls
-& $_reg $script:i18nMap $grpDupType "grp.dup.type"
-& $_reg $script:i18nMap $radDupFiles "rad.dup.files"
-& $_reg $script:i18nMap $radDupFolders "rad.dup.folders"
-& $_reg $script:i18nMap $grpDupCrit "grp.dup.criteria"
-& $_reg $script:i18nMap $lblDupNote "lbl.dup.note"
-& $_reg $script:i18nMap $chkDupSize "chk.dup.size"
-& $_reg $script:i18nMap $chkDupCreated "chk.dup.created"
-& $_reg $script:i18nMap $chkDupModified "chk.dup.modified"
-& $_reg $script:i18nMap $chkDupSubCount "chk.dup.subfolders"
-& $_reg $script:i18nMap $chkDupFileCount "chk.dup.filecount"
-& $_reg $script:i18nMap $grpDupOpts "grp.options"
-& $_reg $script:i18nMap $chkDupSubsites "chk.include.subsites"
-& $_reg $script:i18nMap $lblDupLib "lbl.library"
-& $_reg $script:i18nMap $btnScanDupes "btn.run.scan"
-& $_reg $script:i18nMap $btnOpenDupes "btn.open.results"
-
-# Transfer tab controls
-& $_reg $script:i18nMap $grpXferSrc "grp.xfer.source"
-& $_reg $script:i18nMap $lblXferSrcSite "lbl.xfer.site"
-& $_reg $script:i18nMap $lblXferSrcLib "lbl.xfer.library"
-& $_reg $script:i18nMap $grpXferDst "grp.xfer.dest"
-& $_reg $script:i18nMap $lblXferDstSite "lbl.xfer.site"
-& $_reg $script:i18nMap $lblXferDstLib "lbl.xfer.library"
-& $_reg $script:i18nMap $grpXferOpts "grp.xfer.options"
-& $_reg $script:i18nMap $chkXferRecursive "chk.xfer.recursive"
-& $_reg $script:i18nMap $chkXferOverwrite "chk.xfer.overwrite"
-& $_reg $script:i18nMap $chkXferCreateFolders "chk.xfer.create.folders"
-& $_reg $script:i18nMap $lblXferFmt "lbl.xfer.report.fmt"
-& $_reg $script:i18nMap $btnXferCsvImport "btn.xfer.csv"
-& $_reg $script:i18nMap $btnXferCsvClear "btn.xfer.csv.clear"
-& $_reg $script:i18nMap $lblXferNote "xfer.note"
-& $_reg $script:i18nMap $btnXferStart "btn.xfer.start"
-& $_reg $script:i18nMap $btnXferVerify "btn.xfer.verify"
-& $_reg $script:i18nMap $btnXferOpen "btn.xfer.open"
-
-# Bulk Create tab controls
-& $_reg $script:i18nMap $grpBulkList "grp.bulk.list"
-& $_reg $script:i18nMap $btnBulkAdd "btn.bulk.add"
-& $_reg $script:i18nMap $btnBulkCsv "btn.bulk.csv"
-& $_reg $script:i18nMap $btnBulkRemove "btn.bulk.remove"
-& $_reg $script:i18nMap $btnBulkClear "btn.bulk.clear"
-& $_reg $script:i18nMap $btnBulkCreate "btn.bulk.create"
-& $_reg $script:i18nMap $lblStructDesc "lbl.struct.desc"
-& $_reg $script:i18nMap $btnStructCsv "btn.struct.csv"
-& $_reg $script:i18nMap $lblStructLib "lbl.struct.library"
-& $_reg $script:i18nMap $btnStructCreate "btn.struct.create"
-& $_reg $script:i18nMap $btnStructClear "btn.struct.clear"
-# Version Cleanup tab
-& $_reg $script:i18nMap $lblVerCount "lbl.ver.count"
-& $_reg $script:i18nMap $chkVerDate "chk.ver.date"
-& $_reg $script:i18nMap $radVerBefore "rad.ver.before"
-& $_reg $script:i18nMap $radVerAfter "rad.ver.after"
-& $_reg $script:i18nMap $lblVerLib "lbl.ver.library"
-& $_reg $script:i18nMap $chkVerRecursive "chk.ver.recursive"
-& $_reg $script:i18nMap $chkVerSubsites "chk.ver.subsites"
-& $_reg $script:i18nMap $chkVerDryRun "chk.ver.dryrun"
-& $_reg $script:i18nMap $btnVerRun "btn.ver.run"
-& $_reg $script:i18nMap $btnVerOpen "btn.ver.open"
-& $_reg $script:i18nMap $grpVerKeep "grp.ver.keep"
-& $_reg $script:i18nMap $grpVerScope "grp.ver.scope"
-
-# Tab pages
-& $_reg $script:i18nTabs $tabPerms "tab.perms"
-& $_reg $script:i18nTabs $tabStorage "tab.storage"
-& $_reg $script:i18nTabs $tabTemplates "tab.templates"
-& $_reg $script:i18nTabs $tabSearch "tab.search"
-& $_reg $script:i18nTabs $tabDupes "tab.dupes"
-& $_reg $script:i18nTabs $tabTransfer "tab.transfer"
-& $_reg $script:i18nTabs $tabBulk "tab.bulk"
-& $_reg $script:i18nTabs $tabStruct "tab.structure"
-& $_reg $script:i18nTabs $tabVersions "tab.versions"
-
-# Menu items
-& $_reg $script:i18nMenus $menuSettings "menu.settings"
-& $_reg $script:i18nMenus $menuJsonFolder "menu.json.folder"
-& $_reg $script:i18nMenus $menuLang "menu.language"
-
-# Placeholder texts
-$script:i18nPlaceholders = [System.Collections.Generic.Dictionary[string,object]]::new()
-& $_reg $script:i18nPlaceholders $txtSrchExt "ph.extensions"
-& $_reg $script:i18nPlaceholders $txtSrchRegex "ph.regex"
-& $_reg $script:i18nPlaceholders $txtSrchCrBy "ph.created.by"
-& $_reg $script:i18nPlaceholders $txtSrchModBy "ph.modified.by"
-& $_reg $script:i18nPlaceholders $txtSrchLib "ph.library"
-& $_reg $script:i18nPlaceholders $txtDupLib "ph.dup.lib"
-& $_reg $script:i18nPlaceholders $txtStructLib "ph.struct.library"
-& $_reg $script:i18nPlaceholders $txtXferSrcSite "ph.xfer.site"
-& $_reg $script:i18nPlaceholders $txtXferSrcLib "ph.xfer.library"
-& $_reg $script:i18nPlaceholders $txtXferDstSite "ph.xfer.site"
-& $_reg $script:i18nPlaceholders $txtXferDstLib "ph.xfer.library"
-& $_reg $script:i18nPlaceholders $txtVerLib "ph.ver.library"
-
-#endregion
-
-#region ===== Event Handlers =====
-
-# ── Profile Management ─────────────────────────────────────────────────────────
-$cboProfile.Add_SelectedIndexChanged({
- $idx = $cboProfile.SelectedIndex
- Apply-Profile -idx $idx
-})
-
-$btnProfileNew.Add_Click({
- $name = Show-InputDialog -Prompt "Nom du profil :" -Title "Nouveau profil" -Default "Nouveau profil" -Owner $form
- if ([string]::IsNullOrWhiteSpace($name)) { return }
- $newProfile = [PSCustomObject]@{
- name = $name
- clientId = $txtClientId.Text.Trim()
- tenantUrl = $txtTenantUrl.Text.Trim()
- }
- $list = @($script:Profiles) + $newProfile
- Save-Profiles -Profiles $list
- Refresh-ProfileList
- $idx = $cboProfile.Items.IndexOf($name)
- if ($idx -ge 0) { $cboProfile.SelectedIndex = $idx }
-})
-
-$btnProfileSave.Add_Click({
- $idx = $cboProfile.SelectedIndex
- if ($idx -lt 0) {
- [System.Windows.Forms.MessageBox]::Show("Selectionnez d'abord un profil ou creez-en un nouveau.", "Aucun profil selectionne", "OK", "Warning")
- return
- }
- $script:Profiles[$idx].clientId = $txtClientId.Text.Trim()
- if (-not $script:Profiles[$idx].PSObject.Properties['tenantUrl']) {
- $script:Profiles[$idx] | Add-Member -NotePropertyName tenantUrl -NotePropertyValue ""
- }
- $script:Profiles[$idx].tenantUrl = $txtTenantUrl.Text.Trim()
- Save-Profiles -Profiles $script:Profiles
- [System.Windows.Forms.MessageBox]::Show("Profil '$($script:Profiles[$idx].name)' sauvegarde.", "Sauvegarde", "OK", "Information")
-})
-
-$btnProfileRename.Add_Click({
- $idx = $cboProfile.SelectedIndex
- if ($idx -lt 0) { return }
- $oldName = $script:Profiles[$idx].name
- $newName = Show-InputDialog -Prompt "Nouveau nom du profil :" -Title "Renommer le profil" -Default $oldName -Owner $form
- if ([string]::IsNullOrWhiteSpace($newName) -or $newName -eq $oldName) { return }
- $script:Profiles[$idx].name = $newName
- Save-Profiles -Profiles $script:Profiles
- Refresh-ProfileList
- $idx2 = $cboProfile.Items.IndexOf($newName)
- if ($idx2 -ge 0) { $cboProfile.SelectedIndex = $idx2 }
-})
-
-$btnProfileDelete.Add_Click({
- $idx = $cboProfile.SelectedIndex
- if ($idx -lt 0) { return }
- $name = $script:Profiles[$idx].name
- $res = [System.Windows.Forms.MessageBox]::Show("Supprimer le profil '$name' ?", "Confirmer la suppression", "YesNo", "Warning")
- if ($res -ne "Yes") { return }
- $list = @($script:Profiles | Where-Object { $_.name -ne $name })
- Save-Profiles -Profiles $list
- Refresh-ProfileList
-})
-
-$btnBrowse.Add_Click({
- $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
- $dlg.SelectedPath = $txtOutput.Text
- if ($dlg.ShowDialog() -eq "OK") { $txtOutput.Text = $dlg.SelectedPath }
-})
-
-$menuJsonFolder.Add_Click({
- $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
- $dlg.Description = T "dlg.json.folder.desc"
- $dlg.SelectedPath = if ($script:DataFolder -and (Test-Path $script:DataFolder)) {
- $script:DataFolder
- } else {
- if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
- }
- if ($dlg.ShowDialog() -ne "OK") { return }
- $newDir = $dlg.SelectedPath
- if (-not (Test-Path $newDir)) {
- $msg = (T "dlg.folder.not.found") -f $newDir
- $res = [System.Windows.Forms.MessageBox]::Show(
- $msg, (T "dlg.folder.not.found.title"), "YesNo", "Question")
- if ($res -eq "Yes") {
- try { New-Item -ItemType Directory -Path $newDir | Out-Null }
- catch {
- [System.Windows.Forms.MessageBox]::Show(
- $_.Exception.Message, "Error", "OK", "Error")
- return
- }
- } else { return }
- }
- $script:DataFolder = $newDir
- Save-Settings -DataFolder $newDir -Lang $script:CurrentLang
- Refresh-ProfileList
- $n = (Load-Templates).Count
- $lblTplCount.Text = "$n $(T 'tpl.count')"
-})
-
-# ── Language menu handlers ─────────────────────────────────────────────────────
-function Switch-AppLanguage([string]$code) {
- Load-Language $code
- Update-UILanguage
- foreach ($mi in $menuLang.DropDownItems) {
- if ($mi -is [System.Windows.Forms.ToolStripMenuItem]) {
- $mi.Checked = ($mi.Tag -eq $script:CurrentLang)
- }
- }
- Save-Settings -DataFolder $script:DataFolder -Lang $script:CurrentLang
- $n = (Load-Templates).Count
- $lblTplCount.Text = "$n $(T 'tpl.count')"
-}
-
-$menuLangEn.Add_Click({ Switch-AppLanguage "en" })
-foreach ($mi in @($menuLang.DropDownItems | Where-Object { $_ -is [System.Windows.Forms.ToolStripMenuItem] -and $_.Tag -ne "en" })) {
- $mi.Add_Click({ Switch-AppLanguage $args[0].Tag })
-}
-
-$btnRegisterApp.Add_Click({
- $tenantUrl = $txtTenantUrl.Text.Trim()
- if ([string]::IsNullOrWhiteSpace($tenantUrl)) {
- [System.Windows.Forms.MessageBox]::Show(
- (T "reg.err.no.tenant"), (T "reg.title"), "OK", "Warning")
- return
- }
- $confirm = [System.Windows.Forms.MessageBox]::Show(
- ((T "reg.confirm") -f $tenantUrl),
- (T "reg.title"), "YesNo", "Question")
- if ($confirm -ne "Yes") { return }
-
- # ── Derive tenant identifier ──────────────────────────────────────────────
- if ($tenantUrl -match 'https://([^.]+)\.sharepoint\.com') {
- $tenantId = "$($Matches[1]).onmicrosoft.com"
- } else {
- [System.Windows.Forms.MessageBox]::Show(
- (T "reg.err.tenant"), (T "reg.title"), "OK", "Error")
- return
- }
-
- $btnRegisterApp.Enabled = $false
- $btnRegisterApp.Text = T "reg.in.progress"
- Write-Log "Registering app on $tenantId ..."
-
- # ── Write a temp script and launch a real PowerShell console ──────────────
- $resultFile = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_RegResult.json"
- if (Test-Path $resultFile) { Remove-Item $resultFile -Force }
- $script:_regResultFile = $resultFile
-
- $scriptContent = @"
-`$Host.UI.RawUI.WindowTitle = "SharePoint Toolbox - App Registration"
-try {
- Import-Module PnP.PowerShell -ErrorAction Stop
-} catch {
- Write-Host "ERROR: PnP.PowerShell module not found." -ForegroundColor Red
- Write-Host `$_.Exception.Message -ForegroundColor Red
- @{ Error = `$_.Exception.Message } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8
- Read-Host "Press Enter to close"
- exit
-}
-Write-Host "Registering app on $tenantId ..." -ForegroundColor Cyan
-Write-Host "A browser window will open for authentication." -ForegroundColor Yellow
-Write-Host ""
-try {
- `$result = Register-PnPEntraIDAppForInteractiveLogin ``
- -ApplicationName "SharePoint Toolbox" ``
- -Tenant "$tenantId"
- `$clientId = `$result.'AzureAppId/ClientId'
- if (`$clientId) {
- Write-Host "Success! Client ID: `$clientId" -ForegroundColor Green
- } else {
- Write-Host "WARNING: No Client ID returned." -ForegroundColor Yellow
- }
- @{ ClientId = `$clientId } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8
-} catch {
- Write-Host "ERROR: `$(`$_.Exception.Message)" -ForegroundColor Red
- @{ Error = `$_.Exception.Message } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8
-}
-Read-Host "Press Enter to close"
-"@
- $scriptFile = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_RegApp.ps1"
- $scriptContent | Set-Content -Path $scriptFile -Encoding UTF8
-
- $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
- if (-not $pwshPath) {
- $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text)
- $btnRegisterApp.Text = T "btn.register.app"
- Write-Log "PowerShell 7+ (pwsh) not found." "Red"
- [System.Windows.Forms.MessageBox]::Show(
- (T "reg.err.nopwsh"), (T "reg.title"), "OK", "Error")
- return
- }
- Start-Process $pwshPath -ArgumentList "-ExecutionPolicy Bypass -File `"$scriptFile`""
-
- # ── Timer polls for the result file ──────────────────────────────────────
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 500
- $script:_regTimer = $tmr
-
- $tmr.Add_Tick({
- if (Test-Path $script:_regResultFile) {
- $script:_regTimer.Stop(); $script:_regTimer.Dispose()
- $btnRegisterApp.Text = T "btn.register.app"
-
- try {
- $res = Get-Content $script:_regResultFile -Raw | ConvertFrom-Json
- Remove-Item $script:_regResultFile -Force -ErrorAction SilentlyContinue
- } catch {
- Write-Log "Failed to read registration result." "Red"
- $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text)
- return
- }
-
- if ($res.Error) {
- Write-Log "App registration failed: $($res.Error)" "Red"
- $btnRegisterApp.Enabled = $true
- [System.Windows.Forms.MessageBox]::Show(
- ((T "reg.err.failed") -f $res.Error),
- (T "reg.title"), "OK", "Error")
- } elseif ($res.ClientId) {
- $script:txtClientId.Text = $res.ClientId
- Write-Log "App registered. Client ID: $($res.ClientId)"
- [System.Windows.Forms.MessageBox]::Show(
- ((T "reg.success") -f $res.ClientId),
- (T "reg.title"), "OK", "Information")
- } else {
- Write-Log "Registration returned no Client ID." "Red"
- $btnRegisterApp.Enabled = $true
- [System.Windows.Forms.MessageBox]::Show(
- (T "reg.err.no.id"), (T "reg.title"), "OK", "Error")
- }
- } else {
- $dot = "." * (([System.DateTime]::Now.Second % 4) + 1)
- $btnRegisterApp.Text = (T "reg.in.progress") -replace '\.\.\.$', $dot
- }
- })
- $tmr.Start()
-})
-
-$btnBrowseSites.Add_Click({
- $tenantUrl = $txtTenantUrl.Text.Trim()
- $clientId = $txtClientId.Text.Trim()
- if ([string]::IsNullOrWhiteSpace($tenantUrl)) {
- [System.Windows.Forms.MessageBox]::Show(
- "Veuillez renseigner le Tenant URL (ex: https://contoso.sharepoint.com).",
- "Tenant URL manquant", "OK", "Warning")
- return
- }
- if ([string]::IsNullOrWhiteSpace($clientId)) {
- [System.Windows.Forms.MessageBox]::Show(
- "Veuillez renseigner le Client ID.", "Client ID manquant", "OK", "Warning")
- return
- }
- $selected = Show-SitePicker -TenantUrl $tenantUrl -ClientId $clientId -Owner $form `
- -InitialSites $script:_SiteCache `
- -PreSelected $script:SelectedSites
- if ($null -eq $selected) { return } # Cancel — ne rien changer
-
- $script:SelectedSites = @($selected)
- $n = $selected.Count
-
- if ($n -gt 1) {
- # Plusieurs sites : griser le champ
- $txtSiteURL.Text = ""
- $txtSiteURL.Enabled = $false
- $txtSiteURL.BackColor = [System.Drawing.Color]::FromArgb(224, 224, 224)
- $btnBrowseSites.Text = "Sites ($n)"
- } elseif ($n -eq 1) {
- $txtSiteURL.Text = $selected[0]
- $txtSiteURL.Enabled = $true
- $txtSiteURL.BackColor = [System.Drawing.Color]::White
- $btnBrowseSites.Text = T "btn.view.sites"
- } else {
- # 0 sélectionnés (OK avec rien) — réinitialiser
- $txtSiteURL.Text = ""
- $txtSiteURL.Enabled = $true
- $txtSiteURL.BackColor = [System.Drawing.Color]::White
- $btnBrowseSites.Text = T "btn.view.sites"
- }
-})
-
-# ── Permissions ────────────────────────────────────────────────────────────────
-$btnGenPerms.Add_Click({
- if (-not (Validate-Inputs)) { return }
-
- # Build site list: picker selection takes priority, else txtSiteURL
- $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
- @($script:SelectedSites)
- } else {
- @($txtSiteURL.Text.Trim())
- }
- $siteUrls = @($siteUrls | Where-Object { $_ })
-
- $script:pnpCiD = $txtClientId.Text.Trim()
- $script:PermFormat = if ($radPermHTML.Checked) { "HTML" } else { "CSV" }
-
- $script:PermFolderDepth = if (-not $chkScanFolders.Checked) { 0 }
- elseif ($chkPermMaxDepth.Checked) { 999 }
- else { [int]$nudPermDepth.Value }
-
- $btnGenPerms.Enabled = $false
- $btnOpenPerms.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
-
- $depthLabel = if ($script:PermFolderDepth -ge 999) { "Maximum" } elseif ($script:PermFolderDepth -eq 0) { "N/A (folder scan off)" } else { $script:PermFolderDepth }
- Write-Log "=== PERMISSIONS REPORT ===" "White"
- Write-Log "Sites : $($siteUrls.Count)" "Gray"
- Write-Log "Format : $($script:PermFormat)" "Gray"
- Write-Log "Folder depth : $depthLabel" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- $lastFile = $null
- foreach ($SiteURL in $siteUrls) {
- $SiteName = ($SiteURL -split '/')[-1]; if (!$SiteName) { $SiteName = "root" }
- $csvPath = Join-Path $txtOutput.Text "$SiteName-Permissions.csv"
- $script:PermOutputFile = $csvPath
- $script:AllPermissions = @()
-
- Write-Log ""
- Write-Log "--- Site: $SiteURL" "White"
-
- $params = @{ SiteURL = $SiteURL; ReportFile = $csvPath }
- if ($chkScanFolders.Checked) { $params.ScanFolders = $true }
- if ($chkRecursive.Checked) { $params.Recursive = $true }
- if ($chkInheritedPerms.Checked) { $params.IncludeInheritedPermissions = $true }
-
- try {
- Generate-PnPSitePermissionRpt @params
- Write-Log ("-" * 52) "DarkGray"
- Write-Log "Done! $($script:AllPermissions.Count) entries -- Saved: $csvPath" "Cyan"
- $lastFile = $csvPath
- $btnOpenPerms.Enabled = $true
- }
- catch { Write-Log "Failed ($SiteURL): $($_.Exception.Message)" "Red" }
- }
-
- $script:PermOutputFile = $lastFile
- $btnGenPerms.Enabled = $true
- Stop-ProgressAnim
-})
-
-$btnOpenPerms.Add_Click({
- if ($script:PermOutputFile -and (Test-Path $script:PermOutputFile)) {
- Start-Process $script:PermOutputFile
- }
-})
-
-# ── Storage ────────────────────────────────────────────────────────────────────
-
-# Background worker scriptblock (shared across all site scans)
-$script:_StorBgWork = {
- param($SiteURL, $ClientId, $InclSub, $PerLib, $FolderDepth, $Sync)
-
- $script:_bgSync = $Sync
- $script:_bgDepth = $FolderDepth
-
- function BgLog([string]$msg, [string]$color = "LightGreen") {
- $script:_bgSync.Queue.Enqueue([PSCustomObject]@{ Text = $msg; Color = $color })
- }
- function Format-Bytes([long]$b) {
- if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" }
- if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" }
- if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" }
- return "$b B"
- }
- function Collect-FolderStorage([string]$SiteRelUrl, [string]$WebBaseUrl, [int]$Depth) {
- if ($Depth -ge $script:_bgDepth) { return @() }
- $out = [System.Collections.Generic.List[object]]::new()
- try {
- $items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue
- foreach ($fi in $items) {
- $childUrl = "$SiteRelUrl/$($fi.Name)"
- try {
- $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $childUrl -ErrorAction SilentlyContinue
- $sub = Collect-FolderStorage -SiteRelUrl $childUrl -WebBaseUrl $WebBaseUrl -Depth ($Depth + 1)
- $out.Add([PSCustomObject]@{
- Name = $fi.Name
- URL = "$($WebBaseUrl.TrimEnd('/'))/$childUrl"
- ItemCount = $sm.TotalFileCount
- SizeBytes = $sm.TotalSize
- VersionSizeBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
- LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
- SubFolders = $sub
- })
- } catch {}
- }
- } catch {}
- return @($out)
- }
- $script:_bgResults = [System.Collections.Generic.List[object]]::new()
- function Collect-WebStorage([string]$WebUrl, [bool]$PerLib, [bool]$InclSub, [string]$ClientId) {
- Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId
- $web = Get-PnPWeb
- $wUrl = $web.Url
- $wSrl = $web.ServerRelativeUrl.TrimEnd('/')
- BgLog "Site : $($web.Title)" "Yellow"
- if ($PerLib) {
- $lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden }
- BgLog "`t$($lists.Count) bibliotheque(s) trouvee(s)" "Gray"
- foreach ($list in $lists) {
- $rf = Get-PnPProperty -ClientObject $list -Property RootFolder
- try {
- $srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
- $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $srl
- $libUrl = "$($wUrl.TrimEnd('/'))/$srl"
- $subs = Collect-FolderStorage -SiteRelUrl $srl -WebBaseUrl $wUrl -Depth 0
- $verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
- $script:_bgResults.Add([PSCustomObject]@{
- SiteTitle = $web.Title
- SiteURL = $wUrl
- Library = $list.Title
- LibraryURL = $libUrl
- ItemCount = $sm.TotalFileCount
- SizeBytes = $sm.TotalSize
- VersionSizeBytes = $verBytes
- SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
- VersionSizeMB = [math]::Round($verBytes / 1MB, 1)
- SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
- LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
- SubFolders = $subs
- })
- BgLog "`t $($list.Title): $(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] ($($sm.TotalFileCount) files)" "Cyan"
- } catch { BgLog "`t '$($list.Title)' skipped: $($_.Exception.Message)" "DarkGray" }
- }
- } else {
- try {
- $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/"
- $verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
- $script:_bgResults.Add([PSCustomObject]@{
- SiteTitle = $web.Title
- SiteURL = $wUrl
- Library = "(All Libraries)"
- LibraryURL = $wUrl
- ItemCount = $sm.TotalFileCount
- SizeBytes = $sm.TotalSize
- VersionSizeBytes = $verBytes
- SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
- VersionSizeMB = [math]::Round($verBytes / 1MB, 1)
- SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
- LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
- })
- BgLog "`t$(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] -- $($sm.TotalFileCount) files" "Cyan"
- } catch { BgLog "`tIgnored: $($_.Exception.Message)" "DarkGray" }
- }
- if ($InclSub) {
- $subwebs = Get-PnPSubWeb
- foreach ($sub in $subwebs) {
- BgLog "`tSubsite : $($sub.Title)" "Yellow"
- Collect-WebStorage -WebUrl $sub.Url -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId
- Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId
- }
- }
- }
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- Collect-WebStorage -WebUrl $SiteURL -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId
- $web = Get-PnPWeb
- $Sync.WebTitle = $web.Title
- $Sync.WebURL = $web.URL
- $Sync.Data = @($script:_bgResults)
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Error : $($_.Exception.Message)" "Red"
- } finally {
- $Sync.Done = $true
- }
-}
-
-# Launches scan for next site in queue; called from button click and timer Done block
-function Start-NextStorageScan {
- if ($script:_StorSiteQueue.Count -eq 0) {
- Write-Log "=== All sites processed ===" "Cyan"
- $btnGenStorage.Enabled = $true
- Stop-ProgressAnim
- return
- }
-
- $nextUrl = $script:_StorSiteQueue.Dequeue()
- $siteName = ($nextUrl -split '/')[-1]; if (!$siteName) { $siteName = "root" }
- $ext = if ($script:_StorFmt -eq "HTML") { ".html" } else { ".csv" }
- $outFile = Join-Path $script:_StorOutFolder "$siteName-Storage$ext"
- $script:_StorOut = $outFile
-
- Write-Log ""
- Write-Log "--- Site: $nextUrl" "White"
- Write-Log " Output: $outFile" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- $script:_StorSyn = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false
- Error = $null
- Data = $null
- WebTitle = ""
- WebURL = ""
- })
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($script:_StorBgWork)
- [void]$ps.AddArgument($nextUrl)
- [void]$ps.AddArgument($script:_StorClientId)
- [void]$ps.AddArgument($script:_StorInclSub)
- [void]$ps.AddArgument($script:_StorPerLib)
- [void]$ps.AddArgument($script:_StorDepth)
- [void]$ps.AddArgument($script:_StorSyn)
- $script:_StorRS = $rs
- $script:_StorPS = $ps
- $script:_StorHnd = $ps.BeginInvoke()
-
- $script:_StorTimer = New-Object System.Windows.Forms.Timer
- $script:_StorTimer.Interval = 200
- $script:_StorTimer.Add_Tick({
- while ($script:_StorSyn.Queue.Count -gt 0) {
- $m = $script:_StorSyn.Queue.Dequeue()
- Write-Log $m.Text $m.Color
- }
- if ($script:_StorSyn.Done) {
- $script:_StorTimer.Stop(); $script:_StorTimer.Dispose()
- while ($script:_StorSyn.Queue.Count -gt 0) {
- $m = $script:_StorSyn.Queue.Dequeue(); Write-Log $m.Text $m.Color
- }
- try { [void]$script:_StorPS.EndInvoke($script:_StorHnd) } catch {}
- try { $script:_StorRS.Close(); $script:_StorRS.Dispose() } catch {}
-
- if ($script:_StorSyn.Error) {
- Write-Log "Failed: $($script:_StorSyn.Error)" "Red"
- } elseif ($script:_StorSyn.Data -and $script:_StorSyn.Data.Count -gt 0) {
- $data = $script:_StorSyn.Data
- Write-Log "Writing output..." "Yellow"
- if ($script:_StorFmt -eq "HTML") {
- Export-StorageToHTML -Data $data -SiteTitle $script:_StorSyn.WebTitle `
- -SiteURL $script:_StorSyn.WebURL -OutputPath $script:_StorOut
- } else {
- $data | Select-Object SiteTitle,SiteURL,Library,ItemCount,SizeMB,VersionSizeMB,SizeGB,LastModified |
- Export-Csv -Path $script:_StorOut -NoTypeInformation
- }
- Write-Log "Done! $($data.Count) libs -- $(Format-Bytes (($data | Measure-Object -Property SizeBytes -Sum).Sum))" "Cyan"
- Write-Log "Saved: $script:_StorOut" "White"
- $script:_StorLastOut = $script:_StorOut
- $btnOpenStorage.Enabled = $true
- } else {
- Write-Log "No data -- check permissions or URL." "Orange"
- }
- # Process next site in queue (if any)
- Start-NextStorageScan
- }
- })
- $script:_StorTimer.Start()
-}
-
-$btnGenStorage.Add_Click({
- if (-not (Validate-Inputs)) { return }
-
- # Build site list: picker selection takes priority, else txtSiteURL
- $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
- @($script:SelectedSites)
- } else {
- @($txtSiteURL.Text.Trim())
- }
- $siteUrls = @($siteUrls | Where-Object { $_ })
-
- # Store common scan parameters as script vars (read by Start-NextStorageScan)
- $script:_StorClientId = $txtClientId.Text.Trim()
- $script:pnpCiD = $script:_StorClientId
- $script:_StorInclSub = $chkStorSubsites.Checked
- $script:_StorPerLib = $chkStorPerLib.Checked
- $script:_StorDepth = if (-not $chkStorPerLib.Checked) { 0 } elseif ($chkMaxDepth.Checked) { 999 } else { [int]$nudDepth.Value }
- $script:_StorFmt = if ($radStorHTML.Checked) { "HTML" } else { "CSV" }
- $script:_StorOutFolder = $txtOutput.Text
- $script:_StorLastOut = $null
-
- # Build queue
- $script:_StorSiteQueue = [System.Collections.Generic.Queue[string]]::new()
- foreach ($u in $siteUrls) { $script:_StorSiteQueue.Enqueue($u) }
-
- $btnGenStorage.Enabled = $false
- $btnOpenStorage.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
-
- $depthLabel = if ($script:_StorDepth -ge 999) { "Maximum" } elseif ($script:_StorDepth -eq 0) { "N/A" } else { $script:_StorDepth }
- Write-Log "=== STORAGE METRICS ===" "White"
- Write-Log "Sites : $($siteUrls.Count)" "Gray"
- Write-Log "Format : $($script:_StorFmt)" "Gray"
- Write-Log "Folder depth : $depthLabel" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- Start-NextStorageScan
-})
-
-$btnOpenStorage.Add_Click({
- $f = $script:_StorLastOut
- if ($f -and (Test-Path $f)) { Start-Process $f }
-})
-
-# ── Templates ───────────────────────────────────────────────────────────────
-$btnOpenTplMgr.Add_Click({
- Show-TemplateManager `
- -DefaultSiteUrl $txtSiteURL.Text.Trim() `
- -ClientId $txtClientId.Text.Trim() `
- -TenantUrl $txtTenantUrl.Text.Trim() `
- -Owner $form
- # Refresh count after dialog closes
- $n = (Load-Templates).Count
- $lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer"
-})
-
-# ── Recherche de fichiers ───────────────────────────────────────────────────
-$btnSearch.Add_Click({
- if (-not (Validate-Inputs)) { return }
-
- $siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
- $script:SelectedSites[0]
- } else { $txtSiteURL.Text.Trim() }
-
- if ([string]::IsNullOrWhiteSpace($siteUrl)) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning")
- return
- }
-
- # Validate regex before launching
- $regexStr = $txtSrchRegex.Text.Trim()
- if ($regexStr) {
- try { [void][System.Text.RegularExpressions.Regex]::new($regexStr) }
- catch {
- [System.Windows.Forms.MessageBox]::Show(
- "Expression reguliere invalide :`n$($_.Exception.Message)",
- "Regex invalide", "OK", "Error")
- return
- }
- }
-
- $filters = @{
- Extensions = @($txtSrchExt.Text.Trim() -split '[,\s]+' | Where-Object { $_ } | ForEach-Object { $_.TrimStart('.').ToLower() })
- Regex = $regexStr
- CreatedAfter = if ($chkSrchCrA.Checked) { $dtpSrchCrA.Value.Date } else { $null }
- CreatedBefore = if ($chkSrchCrB.Checked) { $dtpSrchCrB.Value.Date } else { $null }
- ModifiedAfter = if ($chkSrchModA.Checked) { $dtpSrchModA.Value.Date } else { $null }
- ModifiedBefore= if ($chkSrchModB.Checked) { $dtpSrchModB.Value.Date } else { $null }
- CreatedBy = $txtSrchCrBy.Text.Trim()
- ModifiedBy = $txtSrchModBy.Text.Trim()
- Library = $txtSrchLib.Text.Trim()
- MaxResults = [int]$nudSrchMax.Value
- Format = if ($radSrchHTML.Checked) { "HTML" } else { "CSV" }
- OutFolder = $txtOutput.Text.Trim()
- SiteUrl = $siteUrl
- ClientId = $txtClientId.Text.Trim()
- }
-
- $btnSearch.Enabled = $false
- $btnOpenSearch.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
- Write-Log "=== RECHERCHE DE FICHIERS ===" "White"
- Write-Log "Site : $siteUrl" "Gray"
- if ($filters.Extensions.Count -gt 0) { Write-Log "Extensions : $($filters.Extensions -join ', ')" "Gray" }
- if ($filters.Regex) { Write-Log "Regex : $($filters.Regex)" "Gray" }
- if ($filters.CreatedAfter) { Write-Log "Cree apres : $($filters.CreatedAfter.ToString('dd/MM/yyyy'))" "Gray" }
- if ($filters.CreatedBefore) { Write-Log "Cree avant : $($filters.CreatedBefore.ToString('dd/MM/yyyy'))" "Gray" }
- if ($filters.ModifiedAfter) { Write-Log "Modifie apres: $($filters.ModifiedAfter.ToString('dd/MM/yyyy'))" "Gray" }
- if ($filters.ModifiedBefore){ Write-Log "Modifie avant: $($filters.ModifiedBefore.ToString('dd/MM/yyyy'))" "Gray" }
- if ($filters.CreatedBy) { Write-Log "Cree par : $($filters.CreatedBy)" "Gray" }
- if ($filters.ModifiedBy) { Write-Log "Modifie par : $($filters.ModifiedBy)" "Gray" }
- if ($filters.Library) { Write-Log "Bibliotheque : $($filters.Library)" "Gray" }
- Write-Log "Max resultats: $($filters.MaxResults)" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- $bgSearch = {
- param($Filters, $Sync)
- function BgLog([string]$m, [string]$c = "LightGreen") {
- $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
- }
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId
-
- # Build KQL query
- $kqlParts = @("ContentType:Document")
- if ($Filters.Extensions -and $Filters.Extensions.Count -gt 0) {
- $extParts = $Filters.Extensions | ForEach-Object { "FileExtension:$_" }
- $kqlParts += "($($extParts -join ' OR '))"
- }
- if ($Filters.CreatedAfter) { $kqlParts += "Created>=$($Filters.CreatedAfter.ToString('yyyy-MM-dd'))" }
- if ($Filters.CreatedBefore) { $kqlParts += "Created<=$($Filters.CreatedBefore.ToString('yyyy-MM-dd'))" }
- if ($Filters.ModifiedAfter) { $kqlParts += "Write>=$($Filters.ModifiedAfter.ToString('yyyy-MM-dd'))" }
- if ($Filters.ModifiedBefore) { $kqlParts += "Write<=$($Filters.ModifiedBefore.ToString('yyyy-MM-dd'))" }
- if ($Filters.CreatedBy) { $kqlParts += "Author:""$($Filters.CreatedBy)""" }
- if ($Filters.ModifiedBy) { $kqlParts += "ModifiedBy:""$($Filters.ModifiedBy)""" }
- if ($Filters.Library) {
- $libPath = "$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))"
- $kqlParts += "Path:""$libPath*"""
- }
-
- $kql = $kqlParts -join " AND "
- BgLog "Requete KQL : $kql" "Yellow"
- $Sync.KQL = $kql
-
- $selectProps = @("Title","Path","Author","LastModifiedTime","FileExtension","Created","ModifiedBy","Size")
- $allResults = [System.Collections.Generic.List[object]]::new()
- $startRow = 0
- $batchSize = 500
-
- do {
- $batch = Submit-PnPSearchQuery -Query $kql `
- -StartRow $startRow -MaxResults $batchSize `
- -SelectProperties $selectProps -TrimDuplicates $false
- $hits = @($batch.ResultRows)
- foreach ($h in $hits) { $allResults.Add($h) }
- BgLog " Lot $([math]::Floor($startRow/$batchSize)+1) : $($hits.Count) resultats (total: $($allResults.Count))" "Cyan"
- $startRow += $batchSize
- } while ($hits.Count -eq $batchSize -and $allResults.Count -lt $Filters.MaxResults)
-
- # Client-side regex filter on file path/name
- if ($Filters.Regex) {
- $rx = [System.Text.RegularExpressions.Regex]::new(
- $Filters.Regex,
- [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
- $allResults = [System.Collections.Generic.List[object]]@(
- $allResults | Where-Object { $rx.IsMatch($_.Path) }
- )
- BgLog " Apres filtre regex : $($allResults.Count) resultats" "Cyan"
- }
-
- # Cap at MaxResults
- if ($allResults.Count -gt $Filters.MaxResults) {
- $allResults = [System.Collections.Generic.List[object]]@(
- $allResults | Select-Object -First $Filters.MaxResults
- )
- }
-
- BgLog "$($allResults.Count) fichier(s) trouves" "LightGreen"
- $Sync.Results = @($allResults)
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur : $($_.Exception.Message)" "Red"
- } finally {
- $Sync.Done = $true
- }
- }
-
- $sync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false
- Error = $null
- Results = $null
- KQL = ""
- })
- $script:_SrchSync = $sync
- $script:_SrchFilters = $filters
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgSearch)
- [void]$ps.AddArgument($filters)
- [void]$ps.AddArgument($sync)
- $script:_SrchRS = $rs
- $script:_SrchPS = $ps
- $script:_SrchHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 250
- $script:_SrchTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_SrchSync.Queue.Count -gt 0) {
- $m = $script:_SrchSync.Queue.Dequeue()
- Write-Log $m.Text $m.Color
- }
- if ($script:_SrchSync.Done) {
- $script:_SrchTimer.Stop(); $script:_SrchTimer.Dispose()
- while ($script:_SrchSync.Queue.Count -gt 0) {
- $m = $script:_SrchSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
- }
- try { [void]$script:_SrchPS.EndInvoke($script:_SrchHnd) } catch {}
- try { $script:_SrchRS.Close(); $script:_SrchRS.Dispose() } catch {}
- $btnSearch.Enabled = $true
- Stop-ProgressAnim
-
- if ($script:_SrchSync.Error) {
- Write-Log "Echec : $($script:_SrchSync.Error)" "Red"
- return
- }
-
- $results = $script:_SrchSync.Results
- $kql = $script:_SrchSync.KQL
- $f = $script:_SrchFilters
-
- if (-not $results -or $results.Count -eq 0) {
- Write-Log "Aucun fichier trouve avec ces criteres." "Orange"
- return
- }
-
- $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
- $outDir = $f.OutFolder
- if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
-
- if ($f.Format -eq "HTML") {
- $outFile = Join-Path $outDir "FileSearch_$stamp.html"
- $html = Export-SearchResultsToHTML -Results $results -KQL $kql -SiteUrl $f.SiteUrl
- $html | Set-Content -Path $outFile -Encoding UTF8
- } else {
- $outFile = Join-Path $outDir "FileSearch_$stamp.csv"
- $results | ForEach-Object {
- [PSCustomObject]@{
- Title = $_.Title
- Path = $_.Path
- FileExtension = $_.FileExtension
- Created = $_.Created
- LastModifiedTime= $_.LastModifiedTime
- Author = $_.Author
- ModifiedBy = $_.ModifiedBy
- SizeBytes = $_.Size
- }
- } | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8
- }
-
- Write-Log "Sauvegarde : $outFile" "White"
- $script:_SrchLastOut = $outFile
- $btnOpenSearch.Enabled = $true
- }
- })
- $tmr.Start()
-})
-
-$btnOpenSearch.Add_Click({
- $f = $script:_SrchLastOut
- if ($f -and (Test-Path $f)) { Start-Process $f }
-})
-
-# ── Scan de doublons ────────────────────────────────────────────────────────
-$btnScanDupes.Add_Click({
- if (-not (Validate-Inputs)) { return }
-
- $siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
- $script:SelectedSites[0]
- } else { $txtSiteURL.Text.Trim() }
- if ([string]::IsNullOrWhiteSpace($siteUrl)) {
- [System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning")
- return
- }
-
- $dupFilters = @{
- Mode = if ($radDupFolders.Checked) { "Folders" } else { "Files" }
- MatchSize = $chkDupSize.Checked
- MatchCreated = $chkDupCreated.Checked
- MatchMod = $chkDupModified.Checked
- MatchSubDir = $chkDupSubCount.Checked
- MatchFiles = $chkDupFileCount.Checked
- IncludeSubs = $chkDupSubsites.Checked
- Library = $txtDupLib.Text.Trim()
- Format = if ($radDupHTML.Checked) { "HTML" } else { "CSV" }
- OutFolder = $txtOutput.Text.Trim()
- SiteUrl = $siteUrl
- ClientId = $txtClientId.Text.Trim()
- }
-
- $btnScanDupes.Enabled = $false
- $btnOpenDupes.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
- Write-Log "=== SCAN DE DOUBLONS ===" "White"
- Write-Log "Mode : $($dupFilters.Mode)" "Gray"
- Write-Log "Site : $siteUrl" "Gray"
- Write-Log "Criteres : Nom (toujours)$(if($dupFilters.MatchSize){', Taille'})$(if($dupFilters.MatchCreated){', Cree le'})$(if($dupFilters.MatchMod){', Modifie le'})$(if($dupFilters.MatchSubDir){', Nb sous-doss.'})$(if($dupFilters.MatchFiles){', Nb fichiers'})" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- $bgDupScan = {
- param($Filters, $Sync)
- function BgLog([string]$m, [string]$c = "LightGreen") {
- $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
- }
- function MakeKey($name, $item, $f) {
- $parts = @($name.ToLower())
- if ($f.MatchSize -and $null -ne $item.SizeBytes) { $parts += [string]$item.SizeBytes }
- if ($f.MatchCreated -and $null -ne $item.CreatedDay) { $parts += $item.CreatedDay }
- if ($f.MatchMod -and $null -ne $item.ModifiedDay){ $parts += $item.ModifiedDay }
- if ($f.MatchSubDir -and $null -ne $item.FolderCount){ $parts += [string]$item.FolderCount }
- if ($f.MatchFiles -and $null -ne $item.FileCount) { $parts += [string]$item.FileCount }
- return $parts -join "|"
- }
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId
-
- $allItems = [System.Collections.Generic.List[object]]::new()
-
- if ($Filters.Mode -eq "Files") {
- # ── Files: use Search API ──────────────────────────────────
- $kql = "ContentType:Document"
- if ($Filters.Library) {
- $kql += " AND Path:""$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))*"""
- }
- BgLog "Requete KQL : $kql" "Yellow"
- $startRow = 0; $batchSize = 500
- do {
- $batch = Submit-PnPSearchQuery -Query $kql `
- -StartRow $startRow -MaxResults $batchSize `
- -SelectProperties @("Title","Path","Author","LastModifiedTime","FileExtension","Created","Size") `
- -TrimDuplicates $false
- $hits = @($batch.ResultRows)
- foreach ($h in $hits) {
- # Ignore version history entries (SharePoint stores them under /_vti_history/)
- if ($h.Path -match '/_vti_history/') { continue }
- $fname = [System.IO.Path]::GetFileName($h.Path)
- try { $crDay = ([DateTime]$h.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" }
- try { $modDay = ([DateTime]$h.LastModifiedTime).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" }
- $sizeB = [long]($h.Size -replace '[^0-9]','0' -replace '^$','0')
- $allItems.Add([PSCustomObject]@{
- Name = $fname
- Path = $h.Path
- Library = ""
- SizeBytes = $sizeB
- Created = $h.Created
- Modified = $h.LastModifiedTime
- CreatedDay = $crDay
- ModifiedDay = $modDay
- })
- }
- BgLog " $($hits.Count) fichiers recuperes (total: $($allItems.Count))" "Cyan"
- $startRow += $batchSize
- } while ($hits.Count -eq $batchSize)
-
- } else {
- # ── Folders: use Get-PnPListItem ───────────────────────────
- $webUrls = @($Filters.SiteUrl)
- if ($Filters.IncludeSubs) {
- $subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
- if ($subs) { $webUrls += @($subs | Select-Object -ExpandProperty Url) }
- }
-
- foreach ($webUrl in $webUrls) {
- BgLog "Scan web : $webUrl" "Yellow"
- Connect-PnPOnline -Url $webUrl -Interactive -ClientId $Filters.ClientId
- $lists = Get-PnPList | Where-Object {
- !$_.Hidden -and $_.BaseType -eq "DocumentLibrary" -and
- (-not $Filters.Library -or $_.Title -like "*$($Filters.Library)*")
- }
- foreach ($list in $lists) {
- BgLog " Bibliotheque : $($list.Title)" "Cyan"
- try {
- $folderItems = Get-PnPListItem -List $list -PageSize 2000 -ErrorAction SilentlyContinue |
- Where-Object { $_.FileSystemObjectType -eq "Folder" }
- foreach ($fi in $folderItems) {
- $fv = $fi.FieldValues
- $fname = $fv.FileLeafRef
- try { $crDay = ([DateTime]$fv.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" }
- try { $modDay = ([DateTime]$fv.Modified).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" }
- $subCount = [int]($fv.FolderChildCount)
- $fileCount = [int]($fv.ItemChildCount) - $subCount
- if ($fileCount -lt 0) { $fileCount = 0 }
- $allItems.Add([PSCustomObject]@{
- Name = $fname
- Path = "$($Filters.SiteUrl.TrimEnd('/'))/$($fv.FileRef.TrimStart('/'))"
- Library = $list.Title
- SizeBytes = $null
- Created = $fv.Created
- Modified = $fv.Modified
- CreatedDay = $crDay
- ModifiedDay = $modDay
- FolderCount = $subCount
- FileCount = $fileCount
- })
- }
- BgLog " $($folderItems.Count) dossier(s)" "Cyan"
- } catch { BgLog " Ignore : $($_.Exception.Message)" "DarkGray" }
- }
- }
- }
-
- BgLog "$($allItems.Count) element(s) collecte(s), recherche des doublons..." "Yellow"
-
- # Group by computed key and keep only groups with ≥ 2
- $grouped = $allItems | Group-Object { MakeKey $_.Name $_ $Filters } |
- Where-Object { $_.Count -ge 2 } |
- ForEach-Object {
- [PSCustomObject]@{
- Key = $_.Name
- Name = $_.Group[0].Name
- Items = @($_.Group)
- }
- }
-
- BgLog "$($grouped.Count) groupe(s) de doublons trouve(s)" "LightGreen"
- $Sync.Groups = @($grouped)
-
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur : $($_.Exception.Message)" "Red"
- } finally { $Sync.Done = $true }
- }
-
- $dupSync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false; Error = $null; Groups = $null
- })
- $script:_DupSync = $dupSync
- $script:_DupFilters = $dupFilters
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgDupScan)
- [void]$ps.AddArgument($dupFilters)
- [void]$ps.AddArgument($dupSync)
- $script:_DupRS = $rs
- $script:_DupPS = $ps
- $script:_DupHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 250
- $script:_DupTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_DupSync.Queue.Count -gt 0) {
- $m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
- }
- if ($script:_DupSync.Done) {
- $script:_DupTimer.Stop(); $script:_DupTimer.Dispose()
- while ($script:_DupSync.Queue.Count -gt 0) {
- $m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
- }
- try { [void]$script:_DupPS.EndInvoke($script:_DupHnd) } catch {}
- try { $script:_DupRS.Close(); $script:_DupRS.Dispose() } catch {}
- $btnScanDupes.Enabled = $true
- Stop-ProgressAnim
-
- if ($script:_DupSync.Error) {
- Write-Log "Echec : $($script:_DupSync.Error)" "Red"
- return
- }
-
- $groups = $script:_DupSync.Groups
- if (-not $groups -or $groups.Count -eq 0) {
- Write-Log "Aucun doublon detecte avec ces criteres." "Orange"
- return
- }
-
- $f = $script:_DupFilters
- $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
- $outDir = $f.OutFolder
- if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
-
- if ($f.Format -eq "HTML") {
- $outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.html"
- $html = Export-DuplicatesToHTML -Groups $groups -Mode $f.Mode -SiteUrl $f.SiteUrl
- $html | Set-Content -Path $outFile -Encoding UTF8
- } else {
- $outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.csv"
- $groups | ForEach-Object {
- $grp = $_
- $grp.Items | ForEach-Object {
- [PSCustomObject]@{
- DuplicateGroup = $grp.Name
- Name = $_.Name
- Path = $_.Path
- Library = $_.Library
- SizeBytes = $_.SizeBytes
- Created = $_.Created
- Modified = $_.Modified
- FolderCount = $_.FolderCount
- FileCount = $_.FileCount
- }
- }
- } | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8
- }
-
- Write-Log "Sauvegarde : $outFile" "White"
- $script:_DupLastOut = $outFile
- $btnOpenDupes.Enabled = $true
- }
- })
- $tmr.Start()
-})
-
-$btnOpenDupes.Add_Click({
- $f = $script:_DupLastOut
- if ($f -and (Test-Path $f)) { Start-Process $f }
-})
-
-# ── Transfer ──────────────────────────────────────────────────────────────────
-
-# ── CSV Import for bulk transfers ─────────────────────────────────────────────
-$script:_XferCsvEntries = [System.Collections.Generic.List[object]]::new()
-
-$btnXferCsvImport.Add_Click({
- $ofd = New-Object System.Windows.Forms.OpenFileDialog
- $ofd.Filter = "CSV Files (*.csv)|*.csv"
- $ofd.Title = "Import Transfer CSV"
- if ($ofd.ShowDialog() -ne "OK") { return }
-
- $script:_XferCsvEntries.Clear()
- try {
- $rows = Import-Csv -Path $ofd.FileName -Delimiter ';' -Encoding UTF8
- foreach ($r in $rows) {
- $src = ($r.PSObject.Properties | Where-Object { $_.Name -match '^SourceSite$' }).Value
- $dst = ($r.PSObject.Properties | Where-Object { $_.Name -match '^DestSite$' }).Value
- $sl = ($r.PSObject.Properties | Where-Object { $_.Name -match '^SourceLibrary$' }).Value
- $dl = ($r.PSObject.Properties | Where-Object { $_.Name -match '^DestLibrary$' }).Value
- if ($src -and $sl) {
- $script:_XferCsvEntries.Add(@{
- SrcSite = $src.Trim()
- DstSite = if ($dst) { $dst.Trim() } else { $src.Trim() }
- SrcLib = $sl.Trim()
- DstLib = if ($dl) { $dl.Trim() } else { $sl.Trim() }
- })
- }
- }
- $n = $script:_XferCsvEntries.Count
- $lblXferCsvInfo.Text = (T "lbl.xfer.csv.info") -f $n
- $btnXferCsvClear.Visible = $true
- $txtXferSrcSite.Enabled = $false; $txtXferSrcLib.Enabled = $false
- $txtXferDstSite.Enabled = $false; $txtXferDstLib.Enabled = $false
- Write-Log "CSV loaded: $n transfer(s)" "White"
- } catch {
- Write-Log "CSV import error: $($_.Exception.Message)" "Red"
- }
-})
-
-$btnXferCsvClear.Add_Click({
- $script:_XferCsvEntries.Clear()
- $lblXferCsvInfo.Text = ""
- $btnXferCsvClear.Visible = $false
- $txtXferSrcSite.Enabled = $true; $txtXferSrcLib.Enabled = $true
- $txtXferDstSite.Enabled = $true; $txtXferDstLib.Enabled = $true
-})
-
-# ── Helper: build transfer jobs list (from CSV or manual fields) ──────────────
-function Get-XferJobs {
- if ($script:_XferCsvEntries.Count -gt 0) {
- return @($script:_XferCsvEntries)
- }
- $srcSite = $txtXferSrcSite.Text.Trim()
- $dstSite = $txtXferDstSite.Text.Trim()
- $srcLib = $txtXferSrcLib.Text.Trim()
- $dstLib = $txtXferDstLib.Text.Trim()
- if (-not $srcSite) { Write-Log "URL du site source requis." "Red"; return @() }
- if (-not $dstSite) { Write-Log "URL du site destination requis." "Red"; return @() }
- if (-not $srcLib) { Write-Log "Bibliotheque source requise." "Red"; return @() }
- if (-not $dstLib) { $dstLib = $srcLib }
- if ($srcSite -eq $dstSite -and $srcLib -eq $dstLib) {
- Write-Log "Source et destination identiques." "Red"; return @()
- }
- return @(@{ SrcSite = $srcSite; DstSite = $dstSite; SrcLib = $srcLib; DstLib = $dstLib })
-}
-
-# ── Helper: export transfer report ────────────────────────────────────────────
-function Export-XferReport([System.Collections.Generic.List[object]]$Results, [string]$OutDir, [string]$Prefix, [bool]$AsHtml) {
- if ($Results.Count -eq 0) { return $null }
- if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
- $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
-
- if (-not $AsHtml) {
- $f = Join-Path $OutDir "${Prefix}_$stamp.csv"
- $Results | Export-Csv -Path $f -NoTypeInformation -Encoding UTF8
- return $f
- }
-
- # HTML report
- $f = Join-Path $OutDir "${Prefix}_$stamp.html"
- $okN = @($Results | Where-Object { $_.Status -eq "OK" }).Count
- $errN = @($Results | Where-Object { $_.Status -eq "ERROR" }).Count
- $skipN = @($Results | Where-Object { $_.Status -eq "SKIPPED" }).Count
- $missN = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count
- $mmN = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
- $exN = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count
-
- $rows = ""
- foreach ($r in $Results) {
- $color = switch ($r.Status) {
- "OK" { "#e6f4ea" }
- "ERROR" { "#fce8e6" }
- "SKIPPED" { "#fff3cd" }
- "MISSING" { "#fce8e6" }
- "SIZE_MISMATCH" { "#fff3cd" }
- "EXTRA" { "#e8f0fe" }
- default { "#ffffff" }
- }
- $srcSz = if ($null -ne $r.SourceSize) { '{0:N0}' -f $r.SourceSize } else { "-" }
- $dstSz = if ($null -ne $r.DestSize) { '{0:N0}' -f $r.DestSize } else { "-" }
- $msg = if ($r.Message) { [System.Web.HttpUtility]::HtmlEncode($r.Message) } else { "" }
- $rows += "| $([System.Web.HttpUtility]::HtmlEncode($r.SourceSite)) | $([System.Web.HttpUtility]::HtmlEncode($r.File)) | $($r.Status) | $srcSz | $dstSz | $msg |
`n"
- }
-
- $html = @"
-Transfer Report
-
-
-Transfer Report
-
-OK: $okNError: $errN
-Skipped: $skipN / Mismatch: $mmNMissing: $missN
-Extra: $exN
-
-| Site | File | Status | Source Size | Dest Size | Message |
-$rows
-"@
- $html | Set-Content -Path $f -Encoding UTF8
- return $f
-}
-
-# ── Transfer Start ────────────────────────────────────────────────────────────
-
-$btnXferStart.Add_Click({
- $clientId = $txtClientId.Text.Trim()
- if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }
-
- $jobs = Get-XferJobs
- if ($jobs.Count -eq 0) { return }
-
- $recursive = $chkXferRecursive.Checked
- $overwrite = $chkXferOverwrite.Checked
- $createFolder = $chkXferCreateFolders.Checked
- $outDir = $txtOutput.Text.Trim()
- if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
- $asHtml = $radXferHtml.Checked
-
- $params = @{
- ClientId = $clientId
- Jobs = @($jobs)
- Recursive = $recursive
- Overwrite = $overwrite
- CreateFolders = $createFolder
- OutFolder = $outDir
- AsHtml = $asHtml
- }
-
- $btnXferStart.Enabled = $false
- $btnXferVerify.Enabled = $false
- $btnXferOpen.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
- Write-Log "=== TRANSFER ($($jobs.Count) job(s)) ===" "White"
- foreach ($j in $jobs) {
- Write-Log " $($j.SrcSite)/$($j.SrcLib) -> $($j.DstSite)/$($j.DstLib)" "Gray"
- }
- Write-Log "Recursive: $recursive Overwrite: $overwrite Create folders: $createFolder" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- $bgTransfer = {
- param($Params, $Sync)
- function BgLog([string]$m, [string]$c = "LightGreen") {
- $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
- }
-
- function Get-AllSPFiles([string]$BasePath, [bool]$Recurse, [string]$Rel = "") {
- $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue)
- foreach ($f in $files) {
- if ($f.ServerRelativeUrl -match '/_vti_history/') { continue }
- [PSCustomObject]@{
- Name = $f.Name
- ServerRelativeUrl = $f.ServerRelativeUrl
- Length = $f.Length
- RelativePath = "$Rel$($f.Name)"
- RelativeFolder = $Rel
- }
- }
- if ($Recurse) {
- $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue)
- foreach ($d in $folders) {
- if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue }
- Get-AllSPFiles "$BasePath/$($d.Name)" $Recurse "$Rel$($d.Name)/"
- }
- }
- }
-
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- $report = [System.Collections.Generic.List[object]]::new()
- $totalOk = 0; $totalErr = 0; $totalSkip = 0
-
- foreach ($job in $Params.Jobs) {
- BgLog "--- $($job.SrcSite) / $($job.SrcLib) -> $($job.DstSite) / $($job.DstLib) ---" "White"
-
- # Enumerate source
- BgLog "Connecting to source..." "Cyan"
- Connect-PnPOnline -Url $job.SrcSite -Interactive -ClientId $Params.ClientId
- $srcFiles = @(Get-AllSPFiles $job.SrcLib $Params.Recursive)
- BgLog " $($srcFiles.Count) file(s) found" "LightGreen"
-
- if ($srcFiles.Count -eq 0) {
- BgLog " No files to transfer." "DarkOrange"
- continue
- }
-
- # Download to temp
- $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_Xfer_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$([guid]::NewGuid().ToString('N').Substring(0,6))"
- New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
-
- $idx = 0
- foreach ($f in $srcFiles) {
- $idx++
- $localDir = Join-Path $tempRoot $f.RelativeFolder
- if (-not (Test-Path $localDir)) { New-Item -ItemType Directory -Path $localDir -Force | Out-Null }
- try {
- Get-PnPFile -Url $f.ServerRelativeUrl -Path $localDir -FileName $f.Name -AsFile -Force
- } catch {
- BgLog " ERROR downloading $($f.RelativePath): $($_.Exception.Message)" "Red"
- $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message="Download: $($_.Exception.Message)" })
- $totalErr++
- continue
- }
- }
-
- # Upload to destination
- BgLog "Connecting to destination..." "Cyan"
- Connect-PnPOnline -Url $job.DstSite -Interactive -ClientId $Params.ClientId
-
- $idx = 0
- foreach ($f in $srcFiles) {
- $idx++
- $dstFolder = if ($f.RelativeFolder) {
- "$($job.DstLib.TrimEnd('/'))/$($f.RelativeFolder.TrimEnd('/'))"
- } else { $job.DstLib }
-
- # Check if dest folder exists / create if needed
- if ($Params.CreateFolders) {
- try { Resolve-PnPFolder -SiteRelativePath $dstFolder -ErrorAction Stop | Out-Null } catch {
- BgLog " ERROR creating folder $dstFolder : $($_.Exception.Message)" "Red"
- $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message="Folder creation: $($_.Exception.Message)" })
- $totalErr++
- continue
- }
- }
-
- $localFile = Join-Path (Join-Path $tempRoot $f.RelativeFolder) $f.Name
- if (-not (Test-Path $localFile)) { continue }
-
- # Check for existing file if not overwriting
- if (-not $Params.Overwrite) {
- try {
- $existing = Get-PnPFile -Url "$dstFolder/$($f.Name)" -ErrorAction SilentlyContinue
- if ($existing) {
- BgLog " [$idx/$($srcFiles.Count)] SKIPPED (exists): $($f.RelativePath)" "DarkOrange"
- $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="SKIPPED"; SourceSize=$f.Length; DestSize=$null; Message="File already exists" })
- $totalSkip++
- continue
- }
- } catch {}
- }
-
- try {
- Add-PnPFile -Path $localFile -Folder $dstFolder -ErrorAction Stop | Out-Null
- BgLog " [$idx/$($srcFiles.Count)] OK: $($f.RelativePath)" "LightGreen"
- $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="OK"; SourceSize=$f.Length; DestSize=$f.Length; Message="" })
- $totalOk++
- } catch {
- BgLog " [$idx/$($srcFiles.Count)] ERROR: $($f.RelativePath) - $($_.Exception.Message)" "Red"
- $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message=$_.Exception.Message })
- $totalErr++
- }
- }
-
- # Cleanup temp
- if ($tempRoot -and (Test-Path $tempRoot)) {
- Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
- }
- }
-
- $Sync.Report = @($report)
- $Sync.TotalOk = $totalOk
- $Sync.TotalErr = $totalErr
- $Sync.TotalSkip = $totalSkip
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur : $($_.Exception.Message)" "Red"
- } finally {
- $Sync.Done = $true
- }
- }
-
- $sync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false
- Error = $null
- Report = $null
- TotalOk = 0
- TotalErr = 0
- TotalSkip = 0
- })
- $script:_XferSync = $sync
- $script:_XferParams = $params
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgTransfer)
- [void]$ps.AddArgument($params)
- [void]$ps.AddArgument($sync)
- $script:_XferRS = $rs
- $script:_XferPS = $ps
- $script:_XferHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 250
- $script:_XferTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_XferSync.Queue.Count -gt 0) {
- $m = $script:_XferSync.Queue.Dequeue()
- Write-Log $m.Text $m.Color
- }
- if ($script:_XferSync.Done) {
- $script:_XferTimer.Stop(); $script:_XferTimer.Dispose()
- while ($script:_XferSync.Queue.Count -gt 0) {
- $m = $script:_XferSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
- }
- try { [void]$script:_XferPS.EndInvoke($script:_XferHnd) } catch {}
- try { $script:_XferRS.Close(); $script:_XferRS.Dispose() } catch {}
- $btnXferStart.Enabled = $true
- $btnXferVerify.Enabled = $true
- Stop-ProgressAnim
-
- if ($script:_XferSync.Error) {
- Write-Log "Echec du transfert : $($script:_XferSync.Error)" "Red"
- return
- }
-
- $ok = $script:_XferSync.TotalOk; $er = $script:_XferSync.TotalErr; $sk = $script:_XferSync.TotalSkip
- Write-Log "=== TRANSFER COMPLETE: $ok OK, $er error(s), $sk skipped ===" "White"
-
- # Generate report
- $report = $script:_XferSync.Report
- if ($report -and $report.Count -gt 0) {
- $p = $script:_XferParams
- $rptFile = Export-XferReport ([System.Collections.Generic.List[object]]$report) $p.OutFolder "Transfer" $p.AsHtml
- if ($rptFile) {
- Write-Log "Report: $rptFile" "White"
- $script:_XferLastReport = $rptFile
- $btnXferOpen.Enabled = $true
- }
- }
- }
- })
- $tmr.Start()
-})
-
-# ── Verify ────────────────────────────────────────────────────────────────────
-
-$btnXferVerify.Add_Click({
- $clientId = $txtClientId.Text.Trim()
- if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }
-
- $jobs = Get-XferJobs
- if ($jobs.Count -eq 0) { return }
-
- $recursive = $chkXferRecursive.Checked
- $outDir = $txtOutput.Text.Trim()
- if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
- $asHtml = $radXferHtml.Checked
-
- $params = @{
- ClientId = $clientId
- Jobs = @($jobs)
- Recursive = $recursive
- OutFolder = $outDir
- AsHtml = $asHtml
- }
-
- $btnXferStart.Enabled = $false
- $btnXferVerify.Enabled = $false
- $btnXferOpen.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
- Write-Log "=== VERIFICATION ($($jobs.Count) job(s)) ===" "White"
- Write-Log ("-" * 52) "DarkGray"
-
- $bgVerify = {
- param($Params, $Sync)
- function BgLog([string]$m, [string]$c = "LightGreen") {
- $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
- }
-
- function Get-AllSPFiles([string]$BasePath, [bool]$Recurse, [string]$Rel = "") {
- $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue)
- foreach ($f in $files) {
- if ($f.ServerRelativeUrl -match '/_vti_history/') { continue }
- [PSCustomObject]@{
- Name = $f.Name
- Length = $f.Length
- RelativePath = "$Rel$($f.Name)"
- }
- }
- if ($Recurse) {
- $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue)
- foreach ($d in $folders) {
- if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue }
- Get-AllSPFiles "$BasePath/$($d.Name)" $Recurse "$Rel$($d.Name)/"
- }
- }
- }
-
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
- $allResults = [System.Collections.Generic.List[object]]::new()
-
- foreach ($job in $Params.Jobs) {
- BgLog "--- Verifying $($job.SrcSite)/$($job.SrcLib) vs $($job.DstSite)/$($job.DstLib) ---" "White"
-
- BgLog "Connecting to source..." "Cyan"
- Connect-PnPOnline -Url $job.SrcSite -Interactive -ClientId $Params.ClientId
- $srcFiles = @(Get-AllSPFiles $job.SrcLib $Params.Recursive)
- BgLog " $($srcFiles.Count) source file(s)" "LightGreen"
- $srcMap = @{}; foreach ($f in $srcFiles) { $srcMap[$f.RelativePath] = $f }
-
- BgLog "Connecting to destination..." "Cyan"
- Connect-PnPOnline -Url $job.DstSite -Interactive -ClientId $Params.ClientId
- $dstFiles = @(Get-AllSPFiles $job.DstLib $Params.Recursive)
- BgLog " $($dstFiles.Count) destination file(s)" "LightGreen"
- $dstMap = @{}; foreach ($f in $dstFiles) { $dstMap[$f.RelativePath] = $f }
-
- foreach ($key in $srcMap.Keys) {
- $src = $srcMap[$key]
- if ($dstMap.ContainsKey($key)) {
- $dst = $dstMap[$key]
- $st = if ([long]$src.Length -eq [long]$dst.Length) { "OK" } else { "SIZE_MISMATCH" }
- $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status=$st; SourceSize=[long]$src.Length; DestSize=[long]$dst.Length; Message="" })
- } else {
- $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status="MISSING"; SourceSize=[long]$src.Length; DestSize=$null; Message="" })
- }
- }
- foreach ($key in $dstMap.Keys) {
- if (-not $srcMap.ContainsKey($key)) {
- $dst = $dstMap[$key]
- $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status="EXTRA"; SourceSize=$null; DestSize=[long]$dst.Length; Message="" })
- }
- }
-
- $okN = @($allResults | Where-Object { $_.Status -eq "OK" }).Count
- $missN = @($allResults | Where-Object { $_.Status -eq "MISSING" }).Count
- $mmN = @($allResults | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
- $exN = @($allResults | Where-Object { $_.Status -eq "EXTRA" }).Count
- BgLog " Results: $okN OK, $missN missing, $mmN size mismatch, $exN extra" "White"
- }
-
- $Sync.VerifyResults = @($allResults)
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur : $($_.Exception.Message)" "Red"
- } finally {
- $Sync.Done = $true
- }
- }
-
- $sync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false
- Error = $null
- VerifyResults = $null
- })
- $script:_VerSync = $sync
- $script:_VerParams = $params
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgVerify)
- [void]$ps.AddArgument($params)
- [void]$ps.AddArgument($sync)
- $script:_VerRS = $rs
- $script:_VerPS = $ps
- $script:_VerHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 250
- $script:_VerTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_VerSync.Queue.Count -gt 0) {
- $m = $script:_VerSync.Queue.Dequeue()
- Write-Log $m.Text $m.Color
- }
- if ($script:_VerSync.Done) {
- $script:_VerTimer.Stop(); $script:_VerTimer.Dispose()
- while ($script:_VerSync.Queue.Count -gt 0) {
- $m = $script:_VerSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
- }
- try { [void]$script:_VerPS.EndInvoke($script:_VerHnd) } catch {}
- try { $script:_VerRS.Close(); $script:_VerRS.Dispose() } catch {}
- $btnXferStart.Enabled = $true
- $btnXferVerify.Enabled = $true
- Stop-ProgressAnim
-
- if ($script:_VerSync.Error) {
- Write-Log "Echec de la verification : $($script:_VerSync.Error)" "Red"
- return
- }
-
- $results = $script:_VerSync.VerifyResults
- if (-not $results -or $results.Count -eq 0) {
- Write-Log "Aucun fichier a comparer." "Orange"
- return
- }
-
- $p = $script:_VerParams
- $rptFile = Export-XferReport ([System.Collections.Generic.List[object]]$results) $p.OutFolder "TransferVerify" $p.AsHtml
- if ($rptFile) {
- Write-Log "Report: $rptFile" "White"
- $script:_XferLastReport = $rptFile
- $btnXferOpen.Enabled = $true
- }
- }
- })
- $tmr.Start()
-})
-
-# ── Open Transfer Report ──────────────────────────────────────────────────────
-
-$btnXferOpen.Add_Click({
- $f = $script:_XferLastReport
- if ($f -and (Test-Path $f)) { Start-Process $f }
-})
-
-# ── Bulk Create ───────────────────────────────────────────────────────────────
-
-# Helper: add a site entry hashtable to the ListView
-function Add-BulkListItem([hashtable]$entry) {
- $lvi = New-Object System.Windows.Forms.ListViewItem($entry.Name)
- $lvi.SubItems.Add($entry.Alias) | Out-Null
- $lvi.SubItems.Add($entry.Type) | Out-Null
- $lvi.SubItems.Add($entry.Template)| Out-Null
- $lvi.SubItems.Add($entry.Owners) | Out-Null
- $lvi.SubItems.Add($entry.Members) | Out-Null
- $lvi.Tag = $entry
- $lvBulk.Items.Add($lvi) | Out-Null
-}
-
-# Double-click to edit
-$lvBulk.Add_DoubleClick({
- if ($lvBulk.SelectedItems.Count -eq 0) { return }
- $sel = $lvBulk.SelectedItems[0]
- $edited = Show-BulkSiteDialog -Owner $form -Existing $sel.Tag
- if ($edited) {
- $sel.Text = $edited.Name
- $sel.SubItems[1].Text = $edited.Alias
- $sel.SubItems[2].Text = $edited.Type
- $sel.SubItems[3].Text = $edited.Template
- $sel.SubItems[4].Text = $edited.Owners
- $sel.SubItems[5].Text = $edited.Members
- $sel.Tag = $edited
- }
-})
-
-$btnBulkAdd.Add_Click({
- $entry = Show-BulkSiteDialog -Owner $form
- if ($entry) { Add-BulkListItem $entry }
-})
-
-$btnBulkCsv.Add_Click({
- $ofd = New-Object System.Windows.Forms.OpenFileDialog
- $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
- if ($ofd.ShowDialog($form) -ne "OK") { return }
- # Try semicolon first (handles commas inside fields), fall back to comma
- $content = Get-Content $ofd.FileName -Raw
- if ($content -match ';') {
- $rows = Import-Csv $ofd.FileName -Delimiter ';'
- } else {
- $rows = Import-Csv $ofd.FileName
- }
- $count = 0
- foreach ($r in $rows) {
- # Read columns via PSObject properties (case-insensitive)
- $props = @{}
- foreach ($p in $r.PSObject.Properties) { $props[$p.Name.ToLower()] = "$($p.Value)".Trim() }
-
- $name = if ($props['name']) { $props['name'] } elseif ($props['title']) { $props['title'] } else { "" }
- $alias = if ($props['alias']) { $props['alias'] } elseif ($props['url']) { $props['url'] } else { "" }
- $type = if ($props['type']) { $props['type'] } else { "Team" }
- $tpl = if ($props['template']) { $props['template'] } else { "" }
- $own = if ($props['owners']) { $props['owners'] } elseif ($props['owner']) { $props['owner'] } else { "" }
- $mem = if ($props['members']) { $props['members'] } else { "" }
-
- # Name is required; skip empty rows
- if (-not $name) { continue }
- # Auto-generate alias from name if not provided
- if (-not $alias) {
- $alias = $name.ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', ''
- }
- # Normalize type
- if ($type -match '^[Cc]omm') { $type = "Communication" } else { $type = "Team" }
- Add-BulkListItem @{
- Name = $name
- Alias = $alias
- Type = $type
- Template = $tpl
- Owners = $own
- Members = $mem
- }
- $count++
- }
- Write-Log "$count site(s) imported from CSV." "LightGreen"
-})
-
-$btnBulkRemove.Add_Click({
- while ($lvBulk.SelectedItems.Count -gt 0) {
- $lvBulk.Items.Remove($lvBulk.SelectedItems[0])
- }
-})
-
-$btnBulkClear.Add_Click({
- $lvBulk.Items.Clear()
-})
-
-$btnBulkCreate.Add_Click({
- $clientId = $txtClientId.Text.Trim()
- $tenantUrl = $txtTenantUrl.Text.Trim()
-
- if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }
- if (-not $tenantUrl) { Write-Log "Tenant URL requis." "Red"; return }
- if ($lvBulk.Items.Count -eq 0) { Write-Log "Aucun site dans la liste." "Red"; return }
-
- # Collect entries
- $entries = @()
- foreach ($lvi in $lvBulk.Items) { $entries += $lvi.Tag }
-
- # Load all templates once
- $allTemplates = @{}
- foreach ($t in (Load-Templates)) { $allTemplates[$t.name] = $t }
-
- $params = @{
- ClientId = $clientId
- TenantUrl = $tenantUrl
- Entries = $entries
- Templates = $allTemplates
- }
-
- $btnBulkCreate.Enabled = $false
- $btnBulkAdd.Enabled = $false
- $btnBulkCsv.Enabled = $false
- $btnBulkRemove.Enabled = $false
- $btnBulkClear.Enabled = $false
- $txtLog.Clear()
- Start-ProgressAnim
- Write-Log "=== BULK SITE CREATION ===" "White"
- Write-Log "Sites to create: $($entries.Count)" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- $bgBulk = {
- param($Params, $Sync)
- function BgLog([string]$m, [string]$c = "LightGreen") {
- $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
- }
-
- # Folder-tree helper (same as template manager)
- function Apply-FolderTreeBg([array]$folders, [string]$parentUrl) {
- foreach ($fd in $folders) {
- try {
- Add-PnPFolder -Name $fd.name -Folder $parentUrl -ErrorAction SilentlyContinue | Out-Null
- } catch {}
- if ($fd.subfolders -and $fd.subfolders.Count -gt 0) {
- Apply-FolderTreeBg $fd.subfolders "$parentUrl/$($fd.name)"
- }
- }
- }
-
- try {
- Import-Module PnP.PowerShell -ErrorAction Stop
-
- $adminUrl = if ($Params.TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
- "$($Matches[1])-admin$($Matches[2])"
- } else { $Params.TenantUrl }
- $base = if ($Params.TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $Params.TenantUrl }
-
- BgLog "Connexion au tenant admin..." "Yellow"
- Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId
-
- $total = $Params.Entries.Count
- $idx = 0
-
- foreach ($entry in $Params.Entries) {
- $idx++
- $name = $entry.Name
- $alias = $entry.Alias
- $isTeam = $entry.Type -ne "Communication"
- $ownerRaw = "$($entry.Owners)"
- $memberRaw = "$($entry.Members)"
- $owners = [string[]]@($ownerRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' })
- $members = [string[]]@($memberRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' })
- $tplName = $entry.Template
-
- BgLog "[$idx/$total] Creating '$name' (alias: $alias, type: $($entry.Type))..." "White"
- BgLog " DEBUG owners raw='$ownerRaw' parsed=[$($owners -join '|')] count=$($owners.Count)" "Gray"
- BgLog " DEBUG members raw='$memberRaw' parsed=[$($members -join '|')] count=$($members.Count)" "Gray"
-
- # TeamSite requires at least one owner
- if ($isTeam -and $owners.Count -eq 0) {
- BgLog " ERREUR : TeamSite requires at least one owner — skipping '$name'" "Red"
- $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: no owner" })
- $Sync.ErrCount++
- continue
- }
-
- # Update status
- $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Creating..." })
-
- try {
- # Create the site WITHOUT owners/members (PnP bug: odata.bind empty array)
- # Current user becomes default owner; we add owners/members after creation
- Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId
- if ($isTeam) {
- BgLog " Creating TeamSite '$alias' (owners/members added after)..." "DarkGray"
- $newUrl = New-PnPSite -Type TeamSite -Title $name -Alias $alias -Wait
- } else {
- BgLog " Creating CommunicationSite '$alias'..." "DarkGray"
- $newUrl = New-PnPSite -Type CommunicationSite -Title $name -Url "$base/sites/$alias" -Wait
- }
- BgLog " Site cree : $newUrl" "LightGreen"
-
- # Connect to the new site for owners/members/template
- Connect-PnPOnline -Url $newUrl -Interactive -ClientId $Params.ClientId
-
- # Assign owners & members post-creation
- if ($isTeam) {
- $groupId = $null
- try { $groupId = (Get-PnPSite -Includes GroupId).GroupId.Guid } catch {}
- if ($groupId) {
- foreach ($o in $owners) {
- try {
- Add-PnPMicrosoft365GroupOwner -Identity $groupId -Users $o -ErrorAction Stop
- BgLog " Owner added: $o" "Cyan"
- } catch { BgLog " Warn owner '$o': $($_.Exception.Message)" "DarkYellow" }
- }
- foreach ($m in $members) {
- try {
- Add-PnPMicrosoft365GroupMember -Identity $groupId -Users $m -ErrorAction Stop
- BgLog " Member added: $m" "Cyan"
- } catch { BgLog " Warn member '$m': $($_.Exception.Message)" "DarkYellow" }
- }
- } else {
- BgLog " Could not get M365 GroupId — owners/members not assigned" "DarkYellow"
- }
- } else {
- # CommunicationSite — classic SharePoint groups
- if ($owners.Count -gt 0) {
- $ownerGrp = Get-PnPGroup | Where-Object { $_.Title -like "*Propri*" -or $_.Title -like "*Owner*" } | Select-Object -First 1
- if ($ownerGrp) {
- foreach ($o in $owners) {
- try { Add-PnPGroupMember -LoginName $o -Group $ownerGrp.Title -ErrorAction SilentlyContinue } catch {}
- }
- }
- }
- if ($members.Count -gt 0) {
- $memberGrp = Get-PnPGroup | Where-Object { $_.Title -like "*Membre*" -or $_.Title -like "*Member*" } | Select-Object -First 1
- if ($memberGrp) {
- foreach ($m in $members) {
- try { Add-PnPGroupMember -LoginName $m -Group $memberGrp.Title -ErrorAction SilentlyContinue } catch {}
- }
- }
- }
- }
-
- # Apply template if specified
- if ($tplName -and $Params.Templates.ContainsKey($tplName)) {
- $tpl = $Params.Templates[$tplName]
- BgLog " Applying template '$tplName'..." "Yellow"
-
- # Settings
- if ($tpl.options.settings -and $tpl.settings -and $tpl.settings.description) {
- Set-PnPWeb -Description $tpl.settings.description
- }
- # Style
- if ($tpl.options.style -and $tpl.style -and $tpl.style.logoUrl) {
- Set-PnPWeb -SiteLogoUrl $tpl.style.logoUrl
- }
- # Structure
- if ($tpl.options.structure -and $tpl.structure -and $tpl.structure.Count -gt 0) {
- foreach ($lib in $tpl.structure) {
- $existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
- if (-not $existing) {
- try {
- $tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") {
- [Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary
- } else {
- [Microsoft.SharePoint.Client.ListTemplateType]::GenericList
- }
- New-PnPList -Title $lib.name -Template $tplType | Out-Null
- } catch {}
- }
- if ($lib.folders -and $lib.folders.Count -gt 0) {
- $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
- if ($targetList) {
- $listRf = Get-PnPProperty -ClientObject $targetList -Property RootFolder
- $libBase = $listRf.ServerRelativeUrl.TrimEnd('/')
- Apply-FolderTreeBg $lib.folders $libBase
- }
- }
- }
- BgLog " Structure applied." "Cyan"
- }
- }
-
-
-
- $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "OK" })
- $Sync.CreatedSites.Add([PSCustomObject]@{
- Name = $name
- Alias = $alias
- Type = $entry.Type
- URL = $newUrl
- })
- $Sync.OkCount++
-
- } catch {
- $errMsg = $_.Exception.Message
- BgLog " ERREUR : $errMsg" "Red"
- $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: $errMsg" })
- $Sync.ErrCount++
- }
- }
-
- BgLog "Termine : $($Sync.OkCount) OK, $($Sync.ErrCount) erreur(s)" "White"
- } catch {
- $Sync.Error = $_.Exception.Message
- BgLog "Erreur globale : $($_.Exception.Message)" "Red"
- } finally {
- $Sync.Done = $true
- }
- }
-
- $sync = [hashtable]::Synchronized(@{
- Queue = [System.Collections.Generic.Queue[object]]::new()
- Done = $false
- Error = $null
- OkCount = 0
- ErrCount = 0
- CreatedSites = [System.Collections.Generic.List[object]]::new()
- })
- $script:_BulkSync = $sync
-
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
- $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
- $ps = [System.Management.Automation.PowerShell]::Create()
- $ps.Runspace = $rs
- [void]$ps.AddScript($bgBulk)
- [void]$ps.AddArgument($params)
- [void]$ps.AddArgument($sync)
- $script:_BulkRS = $rs
- $script:_BulkPS = $ps
- $script:_BulkHnd = $ps.BeginInvoke()
-
- $tmr = New-Object System.Windows.Forms.Timer
- $tmr.Interval = 250
- $script:_BulkTimer = $tmr
- $tmr.Add_Tick({
- while ($script:_BulkSync.Queue.Count -gt 0) {
- $m = $script:_BulkSync.Queue.Dequeue()
- # Status update messages update the ListView item
- if ($m.Text -eq "##STATUS##") {
- $i = $m.Index
- if ($i -lt $lvBulk.Items.Count) {
- $lvi = $lvBulk.Items[$i]
- # Use the first column text to show status via ForeColor
- if ($m.Value -eq "OK") {
- $lvi.ForeColor = [System.Drawing.Color]::Green
- } elseif ($m.Value -like "Error*") {
- $lvi.ForeColor = [System.Drawing.Color]::Red
- } else {
- $lvi.ForeColor = [System.Drawing.Color]::DarkOrange
- }
- }
- } else {
- Write-Log $m.Text $m.Color
- }
- }
- if ($script:_BulkSync.Done) {
- $script:_BulkTimer.Stop(); $script:_BulkTimer.Dispose()
- while ($script:_BulkSync.Queue.Count -gt 0) {
- $m = $script:_BulkSync.Queue.Dequeue()
- if ($m.Text -ne "##STATUS##") { Write-Log $m.Text $m.Color }
- }
- try { [void]$script:_BulkPS.EndInvoke($script:_BulkHnd) } catch {}
- try { $script:_BulkRS.Close(); $script:_BulkRS.Dispose() } catch {}
- $btnBulkCreate.Enabled = $true
- $btnBulkAdd.Enabled = $true
- $btnBulkCsv.Enabled = $true
- $btnBulkRemove.Enabled = $true
- $btnBulkClear.Enabled = $true
- Stop-ProgressAnim
-
- if ($script:_BulkSync.Error) {
- Write-Log "Echec : $($script:_BulkSync.Error)" "Red"
- return
- }
- # Export CSV report of created sites
- if ($script:_BulkSync.CreatedSites.Count -gt 0) {
- $outDir = $txtOutput.Text.Trim()
- if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
- if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
- $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
- $csvFile = Join-Path $outDir "BulkCreate_$stamp.csv"
- $script:_BulkSync.CreatedSites | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
- Write-Log "Rapport CSV : $csvFile" "White"
- }
- Write-Log "=== BULK CREATE COMPLETE: $($script:_BulkSync.OkCount) OK, $($script:_BulkSync.ErrCount) error(s) ===" "White"
- }
- })
- $tmr.Start()
-})
-
-#endregion
-
-#region ===== Structure (folder tree from CSV) =====
-
-# Store the parsed folder paths
-$script:_StructPaths = @()
-
-function Build-StructTree([string]$csvPath) {
- # Auto-detect delimiter
- $raw = Get-Content $csvPath -Raw
- $delim = if ($raw -match ';') { ';' } else { ',' }
- $rows = Import-Csv $csvPath -Delimiter $delim
-
- $paths = [System.Collections.Generic.List[string]]::new()
- foreach ($r in $rows) {
- $cols = @($r.PSObject.Properties | ForEach-Object { "$($_.Value)".Trim() })
- # Build path from non-empty columns
- $parts = @($cols | Where-Object { $_ -ne '' })
- if ($parts.Count -gt 0) {
- # Add all intermediate paths to ensure parents exist
- for ($i = 1; $i -le $parts.Count; $i++) {
- $p = ($parts[0..($i-1)] -join '/')
- if (-not $paths.Contains($p)) { $paths.Add($p) }
- }
- }
- }
- $script:_StructPaths = @($paths | Sort-Object)
- return $script:_StructPaths
-}
-
-function Populate-StructTreeView([string[]]$paths) {
- $tvStruct.Nodes.Clear()
- $nodeMap = @{}
- foreach ($p in $paths) {
- $parts = $p -split '/'
- $parentKey = if ($parts.Count -gt 1) { ($parts[0..($parts.Count - 2)] -join '/') } else { '' }
- $name = $parts[-1]
- $node = New-Object System.Windows.Forms.TreeNode($name)
- $node.Tag = $p
- if ($parentKey -and $nodeMap.ContainsKey($parentKey)) {
- $nodeMap[$parentKey].Nodes.Add($node) | Out-Null
- } else {
- $tvStruct.Nodes.Add($node) | Out-Null
- }
- $nodeMap[$p] = $node
- }
- $tvStruct.ExpandAll()
-}
-
-$btnStructCsv.Add_Click({
- $ofd = New-Object System.Windows.Forms.OpenFileDialog
- $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
- if ($ofd.ShowDialog($form) -ne "OK") { return }
- try {
- $paths = Build-StructTree $ofd.FileName
- Populate-StructTreeView $paths
- Write-Log "$($paths.Count) folder(s) loaded from CSV." "LightGreen"
- } catch {
- Write-Log "CSV error: $($_.Exception.Message)" "Red"
- }
-})
-
-$btnStructClear.Add_Click({
- $tvStruct.Nodes.Clear()
- $script:_StructPaths = @()
- Write-Log "Structure cleared." "Gray"
-})
-
-$btnStructCreate.Add_Click({
- $siteUrl = $txtSiteUrl.Text.Trim()
- $clientId = $txtClientId.Text.Trim()
- $library = $txtStructLib.Text.Trim()
-
- if (-not $siteUrl) { Write-Log "Site URL required." "Red"; return }
- if (-not $clientId) { Write-Log "Client ID required." "Red"; return }
- if (-not $library) { Write-Log "Target library required." "Red"; return }
- if ($script:_StructPaths.Count -eq 0) { Write-Log "No structure loaded. Load a CSV first." "Red"; return }
-
- $btnStructCreate.Enabled = $false
- $btnStructCsv.Enabled = $false
- Start-ProgressAnim
- Write-Log "=== CREATING FOLDER STRUCTURE ===" "White"
- Write-Log "Target: $siteUrl / $library" "Gray"
- Write-Log "Folders to create: $($script:_StructPaths.Count)" "Gray"
- Write-Log ("-" * 52) "DarkGray"
-
- try {
- Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
-
- # Get the library root
- $list = Get-PnPList -Identity $library -ErrorAction Stop
- $rf = Get-PnPProperty -ClientObject $list -Property RootFolder
- $base = $rf.ServerRelativeUrl.TrimEnd('/')
-
- $ok = 0
- $err = 0
- $total = $script:_StructPaths.Count
- foreach ($p in $script:_StructPaths) {
- $folderPath = "$base/$p"
- try {
- # Resolve-PnPFolder creates the full path recursively
- Resolve-PnPFolder -SiteRelativePath "$library/$p" -ErrorAction Stop | Out-Null
- $ok++
- Write-Log " OK: $p" "LightGreen"
- } catch {
- $err++
- Write-Log " FAIL: $p — $($_.Exception.Message)" "Red"
- }
- }
- Write-Log "=== STRUCTURE COMPLETE: $ok OK, $err error(s) ===" "White"
- } catch {
- Write-Log "Error: $($_.Exception.Message)" "Red"
- } finally {
- $btnStructCreate.Enabled = $true
- $btnStructCsv.Enabled = $true
- Stop-ProgressAnim
- }
-})
-
-# ── Version Cleanup handlers ─────────────────────────────────────────────────
-$script:_VerReport = $null
-
-$btnVerOpen.Add_Click({
- if ($script:_VerReport -and (Test-Path $script:_VerReport)) {
- Start-Process $script:_VerReport
- }
-})
-
-$btnVerRun.Add_Click({
- # --- Gather all selected site URLs ---
- $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
- @($script:SelectedSites)
- } else {
- @($txtSiteURL.Text.Trim())
- }
- $siteUrls = @($siteUrls | Where-Object { $_ })
- if ($siteUrls.Count -eq 0) { Write-Log "Site URL required." "Red"; return }
-
- $clientId = $txtClientId.Text.Trim()
- if (-not $clientId) { Write-Log "Client ID required." "Red"; return }
-
- $keepCount = [int]$nudVerCount.Value
- $useDate = $chkVerDate.Checked
- $dateBefore = $radVerBefore.Checked # true = keep before, false = keep after
- $cutoffDate = $dtpVer.Value
- $library = $txtVerLib.Text.Trim()
- $recursive = $chkVerRecursive.Checked
- $subsites = $chkVerSubsites.Checked
- $dryRun = $chkVerDryRun.Checked
-
- $btnVerRun.Enabled = $false
- Start-ProgressAnim
- $modeLabel = if ($dryRun) { "DRY RUN" } else { "LIVE" }
- Write-Log "=== VERSION CLEANUP ($modeLabel) ===" "White"
- Write-Log "Keep: $keepCount version(s)" "Gray"
- if ($useDate) {
- $dir = if ($dateBefore) { "before" } else { "after" }
- Write-Log "Date filter: keep versions $dir $($cutoffDate.ToString('yyyy-MM-dd'))" "Gray"
- }
- Write-Log ("-" * 52) "DarkGray"
-
- $report = [System.Collections.Generic.List[object]]::new()
- $totalDeleted = 0
- $totalKept = 0
- $totalErrors = 0
-
- try {
- foreach ($siteUrl in $siteUrls) {
- Write-Log "Connecting to $siteUrl ..." "Gray"
- Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
-
- # Collect site URLs to process (main + subsites)
- $sitesToProcess = @($siteUrl)
- if ($subsites) {
- try {
- $subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
- foreach ($sw in $subs) { $sitesToProcess += $sw.Url }
- } catch {}
- }
-
- foreach ($currentSite in $sitesToProcess) {
- if ($currentSite -ne $siteUrl) {
- try { Connect-PnPOnline -Url $currentSite -Interactive -ClientId $clientId } catch {
- Write-Log " Cannot connect to subsite $currentSite — skipped" "DarkOrange"
- continue
- }
- }
- Write-Log "Processing site: $currentSite" "White"
-
- # Get target lists
- $lists = @()
- if ($library) {
- try { $lists = @(Get-PnPList -Identity $library -ErrorAction Stop) } catch {
- Write-Log " Library '$library' not found — skipped" "DarkOrange"
- continue
- }
- } else {
- $lists = Get-PnPList | Where-Object { $_.BaseTemplate -eq 101 -and $_.Hidden -eq $false }
- }
-
- foreach ($list in $lists) {
- Write-Log " Library: $($list.Title)" "Gray"
- try {
- $camlQuery = "5000"
- if (-not $recursive) {
- $camlQuery = "5000"
- }
- $items = Get-PnPListItem -List $list.Title -Query $camlQuery -ErrorAction Stop |
- Where-Object { $_.FileSystemObjectType -eq "File" }
- } catch {
- Write-Log " Error listing files: $($_.Exception.Message)" "Red"
- $totalErrors++
- continue
- }
-
- foreach ($item in $items) {
- try {
- $file = $item.FieldValues["FileRef"]
- $versions = Get-PnPFileVersion -Url $file -ErrorAction Stop
-
- if ($versions.Count -le $keepCount) { continue }
-
- # Sort versions oldest first (by VersionLabel numeric)
- $sorted = $versions | Sort-Object { [double]$_.VersionLabel }
-
- # Determine which versions to delete
- $toDelete = @()
- foreach ($v in $sorted) {
- # Always keep the last $keepCount versions
- $idx = [array]::IndexOf($sorted, $v)
- $remaining = $sorted.Count - $idx
- if ($remaining -le $keepCount) { break }
-
- # Apply date filter if enabled
- if ($useDate) {
- $vDate = [datetime]$v.Created
- if ($dateBefore) {
- # Keep versions before cutoff → delete versions ON or AFTER cutoff
- if ($vDate -lt $cutoffDate) { continue }
- } else {
- # Keep versions after cutoff → delete versions BEFORE cutoff
- if ($vDate -ge $cutoffDate) { continue }
- }
- }
-
- $toDelete += $v
- }
-
- if ($toDelete.Count -eq 0) { continue }
-
- $fileName = Split-Path $file -Leaf
- foreach ($v in $toDelete) {
- if ($dryRun) {
- Write-Log " [DRY] Would delete v$($v.VersionLabel) of $fileName ($($v.Created))" "DarkOrange"
- } else {
- try {
- Remove-PnPFileVersion -Url $file -Identity $v.Id -Force -ErrorAction Stop
- Write-Log " Deleted v$($v.VersionLabel) of $fileName" "LightGreen"
- } catch {
- Write-Log " Error deleting v$($v.VersionLabel) of $fileName — $($_.Exception.Message)" "Red"
- $totalErrors++
- }
- }
- $totalDeleted++
- }
-
- $kept = $sorted.Count - $toDelete.Count
- $totalKept += $kept
-
- $report.Add([PSCustomObject]@{
- Site = $currentSite
- Library = $list.Title
- File = $file
- TotalVer = $sorted.Count
- Deleted = $toDelete.Count
- Kept = $kept
- })
- } catch {
- $totalErrors++
- }
- }
- }
- }
- }
-
- # Export CSV report
- if ($report.Count -gt 0) {
- $outDir = $txtOutput.Text.Trim()
- if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
- if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
- $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
- $prefix = if ($dryRun) { "VersionCleanup_DryRun" } else { "VersionCleanup" }
- $csvFile = Join-Path $outDir "${prefix}_$stamp.csv"
- $report | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
- $script:_VerReport = $csvFile
- $btnVerOpen.Enabled = $true
- Write-Log "Report: $csvFile" "White"
- }
-
- Write-Log "=== VERSION CLEANUP COMPLETE: $totalDeleted deleted, $totalKept kept, $totalErrors error(s) ===" "White"
- } catch {
- Write-Log "Error: $($_.Exception.Message)" "Red"
- } finally {
- $btnVerRun.Enabled = $true
- Stop-ProgressAnim
- }
-})
-
-#endregion
-
-# ── Initialisation : chargement des settings ───────────────────────────────
-$_settings = Load-Settings
-$script:DataFolder = if ($_settings.dataFolder -and (Test-Path $_settings.dataFolder)) {
- $_settings.dataFolder
-} elseif ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
-
-# Load saved language (applies T() translations and updates all registered controls)
-$_savedLang = if ($_settings.lang) { $_settings.lang } else { "en" }
-if ($_savedLang -ne "en") {
- Load-Language $_savedLang
- Update-UILanguage
- foreach ($mi in $menuLang.DropDownItems) {
- if ($mi -is [System.Windows.Forms.ToolStripMenuItem]) {
- $mi.Checked = ($mi.Tag -eq $script:CurrentLang)
- }
- }
-}
-
-Refresh-ProfileList
-$n = (Load-Templates).Count
-$lblTplCount.Text = "$n $(T 'tpl.count')"
-
-[System.Windows.Forms.Application]::Run($form)