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
"@
$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
"@
$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
"@
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
"@
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)