Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
#region ===== Shared Helpers =====
function Write-Log {
param([string]$Message, [string]$Color = "LightGreen")
if ($script:LogBox -and !$script:LogBox.IsDisposed) {
$script:LogBox.SelectionStart = $script:LogBox.TextLength
$script:LogBox.SelectionLength = 0
$script:LogBox.SelectionColor = [System.Drawing.Color]::$Color
$script:LogBox.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Message`n")
$script:LogBox.ScrollToCaret()
[System.Windows.Forms.Application]::DoEvents()
}
Write-Host $Message
}
function EscHtml([string]$s) {
return $s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"'
}
function Format-Bytes([long]$b) {
if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" }
if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" }
if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" }
return "$b B"
}
function Validate-Inputs {
if ([string]::IsNullOrWhiteSpace($script:txtClientId.Text)) {
[System.Windows.Forms.MessageBox]::Show("Please enter a Client ID.", "Missing Field", "OK", "Warning")
return $false
}
$hasSites = ($script:SelectedSites -and $script:SelectedSites.Count -gt 0)
if (-not $hasSites -and [string]::IsNullOrWhiteSpace($script:txtSiteURL.Text)) {
[System.Windows.Forms.MessageBox]::Show(
"Please enter a Site URL or select sites via 'Voir les sites'.",
"Missing Field", "OK", "Warning")
return $false
}
return $true
}
#endregion
#region ===== Profile Management =====
function Get-ProfilesFilePath {
$dir = if ($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 = "" }
}
function Save-Settings {
param([string]$DataFolder)
$path = Get-SettingsFilePath
[PSCustomObject]@{ dataFolder = $DataFolder } |
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
)
$dlg = New-Object System.Windows.Forms.Form
$dlg.Text = "Sites SharePoint -- $TenantUrl"
$dlg.Size = New-Object System.Drawing.Size(900, 580)
$dlg.StartPosition = "CenterParent"
$dlg.FormBorderStyle = "Sizable"
$dlg.MinimumSize = New-Object System.Drawing.Size(600, 440)
$dlg.BackColor = [System.Drawing.Color]::WhiteSmoke
# -- Top bar --
$lblFilter = New-Object System.Windows.Forms.Label
$lblFilter.Text = "Filtrer :"
$lblFilter.Location = New-Object System.Drawing.Point(10, 12)
$lblFilter.Size = New-Object System.Drawing.Size(52, 22)
$lblFilter.TextAlign = "MiddleLeft"
$txtFilter = New-Object System.Windows.Forms.TextBox
$txtFilter.Location = New-Object System.Drawing.Point(66, 10)
$txtFilter.Size = New-Object System.Drawing.Size(570, 22)
$txtFilter.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$txtFilter.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
$btnLoad = New-Object System.Windows.Forms.Button
$btnLoad.Text = "Charger les sites"
$btnLoad.Location = New-Object System.Drawing.Point(648, 8)
$btnLoad.Size = New-Object System.Drawing.Size(148, 26)
$btnLoad.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right"
$btnLoad.BackColor = [System.Drawing.Color]::SteelBlue
$btnLoad.ForeColor = [System.Drawing.Color]::White
$btnLoad.FlatStyle = "Flat"
$btnLoad.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
# -- Site list (ListView with columns) --
$lv = New-Object System.Windows.Forms.ListView
$lv.Location = New-Object System.Drawing.Point(10, 44)
$lv.Size = New-Object System.Drawing.Size(864, 400)
$lv.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
$lv.View = [System.Windows.Forms.View]::Details
$lv.CheckBoxes = $true
$lv.FullRowSelect = $true
$lv.GridLines = $true
$lv.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$lv.HeaderStyle = [System.Windows.Forms.ColumnHeaderStyle]::Clickable
[void]$lv.Columns.Add("Nom", 380)
[void]$lv.Columns.Add("Equipe Teams", 90)
[void]$lv.Columns.Add("Stockage", 100)
[void]$lv.Columns.Add("URL", 280)
# -- Status bar --
$lblStatus = New-Object System.Windows.Forms.Label
$lblStatus.Text = "Cliquez sur 'Charger les sites' pour recuperer la liste du tenant."
$lblStatus.Location = New-Object System.Drawing.Point(10, 456)
$lblStatus.Size = New-Object System.Drawing.Size(860, 18)
$lblStatus.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left,Right"
$lblStatus.ForeColor = [System.Drawing.Color]::Gray
$lblStatus.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
# -- Bottom buttons --
$btnSelAll = New-Object System.Windows.Forms.Button
$btnSelAll.Text = "Tout selectionner"
$btnSelAll.Location = New-Object System.Drawing.Point(10, 484)
$btnSelAll.Size = New-Object System.Drawing.Size(130, 26)
$btnSelAll.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left"
$btnSelNone = New-Object System.Windows.Forms.Button
$btnSelNone.Text = "Tout decocher"
$btnSelNone.Location = New-Object System.Drawing.Point(148, 484)
$btnSelNone.Size = New-Object System.Drawing.Size(110, 26)
$btnSelNone.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Left"
$btnOK = New-Object System.Windows.Forms.Button
$btnOK.Text = "OK"
$btnOK.Location = New-Object System.Drawing.Point(694, 484)
$btnOK.Size = New-Object System.Drawing.Size(90, 26)
$btnOK.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Right"
$btnOK.DialogResult = "OK"
$btnOK.BackColor = [System.Drawing.Color]::SteelBlue
$btnOK.ForeColor = [System.Drawing.Color]::White
$btnOK.FlatStyle = "Flat"
$btnOK.Enabled = $false
$btnDlgCancel = New-Object System.Windows.Forms.Button
$btnDlgCancel.Text = "Annuler"
$btnDlgCancel.Location = New-Object System.Drawing.Point(794, 484)
$btnDlgCancel.Size = New-Object System.Drawing.Size(90, 26)
$btnDlgCancel.Anchor = [System.Windows.Forms.AnchorStyles]"Bottom,Right"
$btnDlgCancel.DialogResult = "Cancel"
$dlg.AcceptButton = $btnOK
$dlg.CancelButton = $btnDlgCancel
$dlg.Controls.AddRange(@($lblFilter, $txtFilter, $btnLoad, $lv, $lblStatus,
$btnSelAll, $btnSelNone, $btnOK, $btnDlgCancel))
# Init script-scope state (modal dialog — no concurrency issue)
$script:_pkl = @{
AllSites = @()
CheckedUrls = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase)
Lv = $lv
TxtFilter = $txtFilter
LblStatus = $lblStatus
BtnOK = $btnOK
BtnLoad = $btnLoad
SuppressCheck = $false
SortCol = 0
SortAsc = $true
ColNames = @("Nom", "Equipe Teams", "Stockage", "URL")
Sync = $null
Timer = $null
RS = $null
PS = $null
Hnd = $null
AdminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
"$($Matches[1])-admin$($Matches[2])"
} else { $null }
ClientId = $ClientId
}
# ItemChecked fires AFTER the checked state changes (unlike ItemCheck)
$lv.Add_ItemChecked({
param($s, $e)
if ($script:_pkl.SuppressCheck) { return }
$url = $e.Item.Tag
if ($e.Item.Checked) { [void]$script:_pkl.CheckedUrls.Add($url) }
else { [void]$script:_pkl.CheckedUrls.Remove($url) }
$script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " +
"$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
})
$lv.Add_ColumnClick({
param($s, $e)
if ($script:_pkl.SortCol -eq $e.Column) {
$script:_pkl.SortAsc = -not $script:_pkl.SortAsc
} else {
$script:_pkl.SortCol = $e.Column
$script:_pkl.SortAsc = $true
}
_Pkl-Sort
_Pkl-Repopulate
})
$txtFilter.Add_TextChanged({ _Pkl-Repopulate })
$btnSelAll.Add_Click({
$script:_pkl.CheckedUrls.Clear()
$script:_pkl.SuppressCheck = $true
foreach ($item in $script:_pkl.Lv.Items) {
$item.Checked = $true
[void]$script:_pkl.CheckedUrls.Add($item.Tag)
}
$script:_pkl.SuppressCheck = $false
$script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s) -- " +
"$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
})
$btnSelNone.Add_Click({
$script:_pkl.CheckedUrls.Clear()
$script:_pkl.SuppressCheck = $true
foreach ($item in $script:_pkl.Lv.Items) { $item.Checked = $false }
$script:_pkl.SuppressCheck = $false
$script:_pkl.LblStatus.Text = "0 coche(s) -- " +
"$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
})
$btnLoad.Add_Click({
if (-not $script:_pkl.AdminUrl) {
[System.Windows.Forms.MessageBox]::Show(
"URL Tenant invalide (attendu: https://xxx.sharepoint.com).",
"Erreur", "OK", "Error")
return
}
$script:_pkl.BtnLoad.Enabled = $false
$script:_pkl.BtnOK.Enabled = $false
$script:_pkl.Lv.Items.Clear()
$script:_pkl.AllSites = @()
$script:_pkl.LblStatus.Text = "Connexion a $($script:_pkl.AdminUrl) ..."
$script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::DarkOrange
$sync = [hashtable]::Synchronized(@{ Done = $false; Error = $null; Sites = $null })
$script:_pkl.Sync = $sync
$bgFetch = {
param($AdminUrl, $ClientId, $Sync)
try {
Import-Module PnP.PowerShell -ErrorAction Stop
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
$Sync.Sites = @(
Get-PnPTenantSite |
Select-Object Title, Url, IsTeamsConnected, StorageUsageCurrent |
Sort-Object Title
)
} catch { $Sync.Error = $_.Exception.Message }
finally { $Sync.Done = $true }
}
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($bgFetch)
[void]$ps.AddArgument($script:_pkl.AdminUrl)
[void]$ps.AddArgument($script:_pkl.ClientId)
[void]$ps.AddArgument($sync)
$script:_pkl.RS = $rs
$script:_pkl.PS = $ps
$script:_pkl.Hnd = $ps.BeginInvoke()
$tmr = New-Object System.Windows.Forms.Timer
$tmr.Interval = 300
$script:_pkl.Timer = $tmr
$tmr.Add_Tick({
if ($script:_pkl.Sync.Done) {
$script:_pkl.Timer.Stop(); $script:_pkl.Timer.Dispose()
try { [void]$script:_pkl.PS.EndInvoke($script:_pkl.Hnd) } catch {}
try { $script:_pkl.RS.Close(); $script:_pkl.RS.Dispose() } catch {}
$script:_pkl.BtnLoad.Enabled = $true
if ($script:_pkl.Sync.Error) {
$script:_pkl.LblStatus.Text = "Erreur: $($script:_pkl.Sync.Error)"
$script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Red
} else {
$script:_pkl.AllSites = @($script:_pkl.Sync.Sites)
_Pkl-Sort
_Pkl-Repopulate
$script:_pkl.BtnOK.Enabled = ($script:_pkl.Lv.Items.Count -gt 0)
}
} else {
$dot = "." * (([System.DateTime]::Now.Second % 4) + 1)
$script:_pkl.LblStatus.Text = "Chargement$dot"
}
})
$tmr.Start()
})
$result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() }
if ($result -eq "OK") { return @($script:_pkl.CheckedUrls) }
return @()
}
#endregion
#region ===== Template Management =====
function Get-TemplatesFilePath {
$dir = if ($script:DataFolder) { $script:DataFolder }
elseif ($PSScriptRoot) { $PSScriptRoot }
else { $PWD.Path }
return Join-Path $dir "Sharepoint_Templates.json"
}
function Load-Templates {
$path = Get-TemplatesFilePath
if (Test-Path $path) {
try {
$data = Get-Content $path -Raw | ConvertFrom-Json
if ($data.templates) { return @($data.templates) }
} catch {}
}
return @()
}
function Save-Templates {
param([array]$Templates)
$path = Get-TemplatesFilePath
@{ templates = @($Templates) } | ConvertTo-Json -Depth 20 | Set-Content $path -Encoding UTF8
}
# Script-scope helpers (accessible from all event handlers — no closure tricks)
function _Tpl-Repopulate {
$lv = $script:_tpl.Lv
$lv.BeginUpdate()
$lv.Items.Clear()
foreach ($t in $script:_tpl.Templates) {
$opts = @()
if ($t.options.structure) { $opts += "Arborescence" }
if ($t.options.permissions) { $opts += "Permissions" }
if ($t.options.settings) { $opts += "Parametres" }
if ($t.options.style) { $opts += "Style" }
$item = New-Object System.Windows.Forms.ListViewItem($t.name)
$item.Tag = $t
$srcText = if ($t.sourceUrl) { $t.sourceUrl } else { "" }
$datText = if ($t.capturedAt) { $t.capturedAt } else { "" }
[void]$item.SubItems.Add($srcText)
[void]$item.SubItems.Add($datText)
[void]$item.SubItems.Add(($opts -join ", "))
[void]$lv.Items.Add($item)
}
$lv.EndUpdate()
$script:_tpl.BtnDelete.Enabled = $false
$script:_tpl.BtnCreate.Enabled = $false
}
function _Tpl-RefreshCombo {
$cbo = $script:_tpl.CboCrTpl
$cbo.Items.Clear()
foreach ($t in $script:_tpl.Templates) { [void]$cbo.Items.Add($t.name) }
if ($cbo.Items.Count -gt 0) { $cbo.SelectedIndex = 0 }
}
function _Tpl-Log {
param([System.Windows.Forms.RichTextBox]$Box, [string]$Msg, [string]$Color = "LightGreen")
$Box.SelectionStart = $Box.TextLength
$Box.SelectionLength = 0
$Box.SelectionColor = [System.Drawing.Color]::$Color
$Box.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Msg`n")
$Box.ScrollToCaret()
}
function Show-TemplateManager {
param(
[string]$DefaultSiteUrl = "",
[string]$ClientId = "",
[string]$TenantUrl = "",
[System.Windows.Forms.Form]$Owner = $null
)
$dlg = New-Object System.Windows.Forms.Form
$dlg.Text = "Gestionnaire de Templates SharePoint"
$dlg.Size = New-Object System.Drawing.Size(980, 700)
$dlg.StartPosition = "CenterParent"
$dlg.FormBorderStyle = "Sizable"
$dlg.MinimumSize = New-Object System.Drawing.Size(700, 560)
$dlg.BackColor = [System.Drawing.Color]::WhiteSmoke
# ── Template list ──────────────────────────────────────────────────────
$grpList = New-Object System.Windows.Forms.GroupBox
$grpList.Text = "Templates enregistres"
$grpList.Location = New-Object System.Drawing.Point(10, 8)
$grpList.Size = New-Object System.Drawing.Size(950, 174)
$grpList.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
$grpList.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$lv = New-Object System.Windows.Forms.ListView
$lv.Location = New-Object System.Drawing.Point(8, 22)
$lv.Size = New-Object System.Drawing.Size(820, 138)
$lv.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
$lv.View = [System.Windows.Forms.View]::Details
$lv.FullRowSelect = $true
$lv.GridLines = $true
$lv.MultiSelect = $false
$lv.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Regular)
[void]$lv.Columns.Add("Nom", 200)
[void]$lv.Columns.Add("Site source", 280)
[void]$lv.Columns.Add("Date", 120)
[void]$lv.Columns.Add("Options capturees", 200)
$btnDelete = New-Object System.Windows.Forms.Button
$btnDelete.Text = "Supprimer"
$btnDelete.Location = New-Object System.Drawing.Point(836, 22)
$btnDelete.Size = New-Object System.Drawing.Size(106, 26)
$btnDelete.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right"
$btnDelete.Enabled = $false
$btnCreate = New-Object System.Windows.Forms.Button
$btnCreate.Text = "Creer depuis ce template >"
$btnCreate.Location = New-Object System.Drawing.Point(836, 56)
$btnCreate.Size = New-Object System.Drawing.Size(106, 42)
$btnCreate.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Right"
$btnCreate.Enabled = $false
$btnCreate.BackColor = [System.Drawing.Color]::SteelBlue
$btnCreate.ForeColor = [System.Drawing.Color]::White
$btnCreate.FlatStyle = "Flat"
$btnCreate.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Bold)
$grpList.Controls.AddRange(@($lv, $btnDelete, $btnCreate))
# ── Bottom tabs ────────────────────────────────────────────────────────
$btabs = New-Object System.Windows.Forms.TabControl
$btabs.Location = New-Object System.Drawing.Point(10, 190)
$btabs.Size = New-Object System.Drawing.Size(950, 464)
$btabs.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
$btabs.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# ── Tab: Capturer ──────────────────────────────────────────────────────
$tabCap = New-Object System.Windows.Forms.TabPage
$tabCap.Text = " Capturer un template "
$tabCap.BackColor = [System.Drawing.Color]::WhiteSmoke
$mkLbl = {
param($t,$x,$y,$w=110)
$l = New-Object System.Windows.Forms.Label
$l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y)
$l.Size = New-Object System.Drawing.Size($w,22); $l.TextAlign = "MiddleLeft"; $l
}
$lblCapSrc = & $mkLbl "Site source :" 10 18
$txtCapSrc = New-Object System.Windows.Forms.TextBox
$txtCapSrc.Location = New-Object System.Drawing.Point(128, 18)
$txtCapSrc.Size = New-Object System.Drawing.Size(560, 22)
$txtCapSrc.Text = $DefaultSiteUrl
$txtCapSrc.Font = New-Object System.Drawing.Font("Consolas", 9)
$lblCapName = & $mkLbl "Nom du template :" 10 48 120
$txtCapName = New-Object System.Windows.Forms.TextBox
$txtCapName.Location = New-Object System.Drawing.Point(134, 48)
$txtCapName.Size = New-Object System.Drawing.Size(300, 22)
$txtCapName.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$grpOpts = New-Object System.Windows.Forms.GroupBox
$grpOpts.Text = "Elements a capturer"
$grpOpts.Location = New-Object System.Drawing.Point(10, 80)
$grpOpts.Size = New-Object System.Drawing.Size(680, 68)
$grpOpts.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$mkChk = {
param($t,$x,$y,$w,$checked=$false)
$c = New-Object System.Windows.Forms.CheckBox
$c.Text = $t; $c.Location = New-Object System.Drawing.Point($x,$y)
$c.Size = New-Object System.Drawing.Size($w,20)
$c.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Regular)
$c.Checked = $checked; $c
}
$chkCapStruct = & $mkChk "Arborescence (bibliotheques et dossiers)" 10 22 300 $true
$chkCapPerms = & $mkChk "Permissions (groupes et roles)" 320 22 260
$chkCapSettings = & $mkChk "Parametres du site (titre, langue...)" 10 44 300 $true
$chkCapStyle = & $mkChk "Style (logo)" 320 44 200
$grpOpts.Controls.AddRange(@($chkCapStruct, $chkCapPerms, $chkCapSettings, $chkCapStyle))
$btnCapture = New-Object System.Windows.Forms.Button
$btnCapture.Text = "Lancer la capture"
$btnCapture.Location = New-Object System.Drawing.Point(10, 162)
$btnCapture.Size = New-Object System.Drawing.Size(155, 34)
$btnCapture.BackColor = [System.Drawing.Color]::FromArgb(16,124,16)
$btnCapture.ForeColor = [System.Drawing.Color]::White
$btnCapture.FlatStyle = "Flat"
$btnCapture.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$txtCapLog = New-Object System.Windows.Forms.RichTextBox
$txtCapLog.Location = New-Object System.Drawing.Point(10, 206)
$txtCapLog.Size = New-Object System.Drawing.Size(918, 208)
$txtCapLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
$txtCapLog.ReadOnly = $true
$txtCapLog.BackColor = [System.Drawing.Color]::Black
$txtCapLog.ForeColor = [System.Drawing.Color]::LightGreen
$txtCapLog.Font = New-Object System.Drawing.Font("Consolas", 8)
$txtCapLog.ScrollBars = "Vertical"
$tabCap.Controls.AddRange(@($lblCapSrc, $txtCapSrc, $lblCapName, $txtCapName,
$grpOpts, $btnCapture, $txtCapLog))
# ── Tab: Creer depuis template ─────────────────────────────────────────
$tabCr = New-Object System.Windows.Forms.TabPage
$tabCr.Text = " Creer depuis un template "
$tabCr.BackColor = [System.Drawing.Color]::WhiteSmoke
$lblCrTpl = & $mkLbl "Template :" 10 18
$cboCrTpl = New-Object System.Windows.Forms.ComboBox
$cboCrTpl.Location = New-Object System.Drawing.Point(128, 16)
$cboCrTpl.Size = New-Object System.Drawing.Size(400, 24)
$cboCrTpl.DropDownStyle = "DropDownList"
$cboCrTpl.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$lblCrTitle = & $mkLbl "Titre du site :" 10 48
$txtCrTitle = New-Object System.Windows.Forms.TextBox
$txtCrTitle.Location = New-Object System.Drawing.Point(128, 46)
$txtCrTitle.Size = New-Object System.Drawing.Size(300, 22)
$txtCrTitle.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$lblCrAlias = & $mkLbl "Alias URL :" 10 78
$txtCrAlias = New-Object System.Windows.Forms.TextBox
$txtCrAlias.Location = New-Object System.Drawing.Point(128, 76)
$txtCrAlias.Size = New-Object System.Drawing.Size(200, 22)
$txtCrAlias.Font = New-Object System.Drawing.Font("Consolas", 9)
$lblCrAliasHint = New-Object System.Windows.Forms.Label
$lblCrAliasHint.Text = "(lettres, chiffres, tirets uniquement)"
$lblCrAliasHint.Location = New-Object System.Drawing.Point(336, 78)
$lblCrAliasHint.Size = New-Object System.Drawing.Size(250, 20)
$lblCrAliasHint.ForeColor = [System.Drawing.Color]::Gray
$lblCrAliasHint.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
$lblCrType = & $mkLbl "Type :" 10 108
$radCrTeam = New-Object System.Windows.Forms.RadioButton
$radCrTeam.Text = "Team Site (avec groupe M365)"
$radCrTeam.Location = New-Object System.Drawing.Point(128, 108)
$radCrTeam.Size = New-Object System.Drawing.Size(220, 22)
$radCrTeam.Checked = $true
$radCrComm = New-Object System.Windows.Forms.RadioButton
$radCrComm.Text = "Communication Site"
$radCrComm.Location = New-Object System.Drawing.Point(358, 108)
$radCrComm.Size = New-Object System.Drawing.Size(200, 22)
$lblCrOwners = & $mkLbl "Proprietaires :" 10 138
$txtCrOwners = New-Object System.Windows.Forms.TextBox
$txtCrOwners.Location = New-Object System.Drawing.Point(128, 136)
$txtCrOwners.Size = New-Object System.Drawing.Size(550, 22)
$txtCrOwners.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$txtCrOwners.PlaceholderText = "user1@domain.com, user2@domain.com"
$lblCrMembers = & $mkLbl "Membres :" 10 168
$txtCrMembers = New-Object System.Windows.Forms.TextBox
$txtCrMembers.Location = New-Object System.Drawing.Point(128, 166)
$txtCrMembers.Size = New-Object System.Drawing.Size(410, 22)
$txtCrMembers.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$txtCrMembers.PlaceholderText = "user@domain.com, ..."
$btnCrCsv = New-Object System.Windows.Forms.Button
$btnCrCsv.Text = "Importer CSV..."
$btnCrCsv.Location = New-Object System.Drawing.Point(546, 164)
$btnCrCsv.Size = New-Object System.Drawing.Size(120, 26)
$grpApply = New-Object System.Windows.Forms.GroupBox
$grpApply.Text = "Appliquer depuis le template"
$grpApply.Location = New-Object System.Drawing.Point(10, 200)
$grpApply.Size = New-Object System.Drawing.Size(680, 60)
$grpApply.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$chkApplyStruct = & $mkChk "Arborescence" 10 22 140 $true
$chkApplyPerms = & $mkChk "Permissions" 158 22 120
$chkApplySettings = & $mkChk "Parametres" 286 22 120 $true
$chkApplyStyle = & $mkChk "Style" 414 22 100
$grpApply.Controls.AddRange(@($chkApplyStruct, $chkApplyPerms, $chkApplySettings, $chkApplyStyle))
$btnCreateSite = New-Object System.Windows.Forms.Button
$btnCreateSite.Text = "Creer le site"
$btnCreateSite.Location = New-Object System.Drawing.Point(10, 272)
$btnCreateSite.Size = New-Object System.Drawing.Size(155, 34)
$btnCreateSite.BackColor = [System.Drawing.Color]::SteelBlue
$btnCreateSite.ForeColor = [System.Drawing.Color]::White
$btnCreateSite.FlatStyle = "Flat"
$btnCreateSite.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$txtCreateLog = New-Object System.Windows.Forms.RichTextBox
$txtCreateLog.Location = New-Object System.Drawing.Point(10, 316)
$txtCreateLog.Size = New-Object System.Drawing.Size(918, 100)
$txtCreateLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
$txtCreateLog.ReadOnly = $true
$txtCreateLog.BackColor = [System.Drawing.Color]::Black
$txtCreateLog.ForeColor = [System.Drawing.Color]::LightGreen
$txtCreateLog.Font = New-Object System.Drawing.Font("Consolas", 8)
$txtCreateLog.ScrollBars = "Vertical"
$tabCr.Controls.AddRange(@(
$lblCrTpl, $cboCrTpl,
$lblCrTitle, $txtCrTitle,
$lblCrAlias, $txtCrAlias, $lblCrAliasHint,
$lblCrType, $radCrTeam, $radCrComm,
$lblCrOwners, $txtCrOwners,
$lblCrMembers, $txtCrMembers, $btnCrCsv,
$grpApply,
$btnCreateSite, $txtCreateLog
))
$btabs.TabPages.AddRange(@($tabCap, $tabCr))
$dlg.Controls.AddRange(@($grpList, $btabs))
# ── Init state ─────────────────────────────────────────────────────────
$script:_tpl = @{
Lv = $lv
BtnDelete = $btnDelete
BtnCreate = $btnCreate
CboCrTpl = $cboCrTpl
TxtCapLog = $txtCapLog
TxtCreateLog = $txtCreateLog
Templates = @(Load-Templates)
ClientId = $ClientId
TenantUrl = $TenantUrl
CapSync = $null; CapTimer = $null; CapRS = $null; CapPS = $null; CapHnd = $null
CrSync = $null; CrTimer = $null; CrRS = $null; CrPS = $null; CrHnd = $null
CapTplName = ""; CapSrcUrl = ""; CapOpts = $null
}
_Tpl-Repopulate
_Tpl-RefreshCombo
# ── Event handlers ─────────────────────────────────────────────────────
$lv.Add_SelectedIndexChanged({
$sel = $script:_tpl.Lv.SelectedItems.Count -gt 0
$script:_tpl.BtnDelete.Enabled = $sel
$script:_tpl.BtnCreate.Enabled = $sel
})
$btnDelete.Add_Click({
$item = $script:_tpl.Lv.SelectedItems[0]
if (-not $item) { return }
$name = $item.Text
$res = [System.Windows.Forms.MessageBox]::Show(
"Supprimer le template '$name' ?", "Confirmer", "YesNo", "Warning")
if ($res -ne "Yes") { return }
$script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $name })
Save-Templates -Templates $script:_tpl.Templates
_Tpl-Repopulate
_Tpl-RefreshCombo
})
$btnCreate.Add_Click({
$item = $script:_tpl.Lv.SelectedItems[0]
if (-not $item) { return }
$t = $item.Tag
$idx = [array]::IndexOf(($script:_tpl.Templates | ForEach-Object { $_.name }), $t.name)
if ($idx -ge 0) { $script:_tpl.CboCrTpl.SelectedIndex = $idx }
$btabs.SelectedTab = $tabCr
})
# Auto-generate alias from title
$txtCrTitle.Add_TextChanged({
$a = $txtCrTitle.Text -replace '[^a-zA-Z0-9 \-]','' -replace '\s+','-' -replace '\-+','-'
$a = $a.ToLower().Trim('-')
if ($a.Length -gt 64) { $a = $a.Substring(0,64) }
$txtCrAlias.Text = $a
})
# CSV import for members
$btnCrCsv.Add_Click({
$ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.Filter = "CSV (*.csv)|*.csv|Tous (*.*)|*.*"
$ofd.Title = "Importer des membres depuis CSV"
if ($ofd.ShowDialog() -ne "OK") { return }
try {
$rows = Import-Csv $ofd.FileName
$emails = $rows | ForEach-Object {
$r = $_
$v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email } `
elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn } `
elseif ($r.UserPrincipalName) { $r.UserPrincipalName } `
else { $r.userprincipalname }
$v
} | Where-Object { $_ } | Select-Object -Unique
$txtCrMembers.Text = ($emails -join ", ")
[System.Windows.Forms.MessageBox]::Show(
"$($emails.Count) membre(s) importe(s).", "Import CSV", "OK", "Information")
} catch {
[System.Windows.Forms.MessageBox]::Show(
"Erreur CSV: $($_.Exception.Message)", "Erreur", "OK", "Error")
}
})
# ── Capture button ─────────────────────────────────────────────────────
$btnCapture.Add_Click({
$srcUrl = $txtCapSrc.Text.Trim()
$tplName = $txtCapName.Text.Trim()
if ([string]::IsNullOrWhiteSpace($srcUrl)) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir l'URL du site source.", "Champ manquant", "OK", "Warning")
return
}
if ([string]::IsNullOrWhiteSpace($tplName)) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir un nom pour le template.", "Champ manquant", "OK", "Warning")
return
}
if ([string]::IsNullOrWhiteSpace($script:_tpl.ClientId)) {
[System.Windows.Forms.MessageBox]::Show("Client ID manquant dans le formulaire principal.", "Erreur", "OK", "Warning")
return
}
# Stash locals so timer tick can read them from script scope
$script:_tpl.CapTplName = $tplName
$script:_tpl.CapSrcUrl = $srcUrl
$script:_tpl.CapOpts = @{
structure = $chkCapStruct.Checked
permissions = $chkCapPerms.Checked
settings = $chkCapSettings.Checked
style = $chkCapStyle.Checked
}
$btnCapture.Enabled = $false
$script:_tpl.TxtCapLog.Clear()
$bgCapture = {
param($SiteUrl, $ClientId, $Opts, $Sync)
function BgLog([string]$m, [string]$c="LightGreen") {
$Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c })
}
function Collect-Folders([string]$SiteRelUrl) {
$out = [System.Collections.Generic.List[object]]::new()
try {
$items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue
foreach ($fi in $items) {
if ($fi.Name -match '^_|^Forms$') { continue }
$sub = Collect-Folders "$SiteRelUrl/$($fi.Name)"
$out.Add(@{ name=$fi.Name; subfolders=@($sub) })
}
} catch {}
return @($out)
}
try {
Import-Module PnP.PowerShell -ErrorAction Stop
BgLog "Connexion a $SiteUrl..." "Yellow"
Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId
$web = Get-PnPWeb -Includes Title,Description,Language,SiteLogoUrl
$wSrl = $web.ServerRelativeUrl.TrimEnd('/')
$result = @{ settings=@{}; style=@{}; structure=@(); permissions=@(); folderPermissions=@() }
if ($Opts.settings) {
BgLog "Capture des parametres..." "Yellow"
$result.settings = @{
title = $web.Title
description = $web.Description
language = [int]$web.Language
}
BgLog " Titre: $($web.Title) | Langue: $($web.Language)" "Cyan"
}
if ($Opts.style) {
BgLog "Capture du style..." "Yellow"
$result.style = @{ logoUrl = $web.SiteLogoUrl }
BgLog " Logo: $($web.SiteLogoUrl)" "Cyan"
}
if ($Opts.structure) {
BgLog "Capture de l'arborescence..." "Yellow"
$lists = Get-PnPList | Where-Object {
!$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList")
}
$struct = [System.Collections.Generic.List[object]]::new()
foreach ($list in $lists) {
$rf = Get-PnPProperty -ClientObject $list -Property RootFolder
$srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
$fld = Collect-Folders $srl
$struct.Add(@{
name = $list.Title
type = $list.BaseType.ToString()
template = [int]$list.BaseTemplate
rootSiteRel = $srl # site-relative URL of library root
folders = @($fld)
})
BgLog " [$($list.BaseType)] $($list.Title) ($srl) — $($fld.Count) dossier(s)" "Cyan"
}
$result.structure = @($struct)
}
if ($Opts.permissions) {
BgLog "Capture des groupes site..." "Yellow"
$groups = Get-PnPSiteGroup
$permArr = [System.Collections.Generic.List[object]]::new()
foreach ($g in $groups) {
try {
$members = @(Get-PnPGroupMember -Identity $g.LoginName -ErrorAction SilentlyContinue |
Where-Object { $_.Title -ne "System Account" } |
Select-Object -ExpandProperty LoginName)
$roles = @(Get-PnPGroupPermissions -Identity $g.LoginName -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name)
$permArr.Add(@{
groupName = $g.Title
loginName = $g.LoginName
roles = @($roles)
members = @($members)
})
BgLog " Groupe: $($g.Title) — $($members.Count) membre(s)" "Cyan"
} catch {}
}
$result.permissions = @($permArr)
# Capture des permissions uniques sur les dossiers
BgLog "Scan des permissions sur les dossiers..." "Yellow"
$scanLists = Get-PnPList | Where-Object {
!$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList")
}
$folderPerms = [System.Collections.Generic.List[object]]::new()
$ctx = Get-PnPContext
foreach ($scanList in $scanLists) {
$rf = Get-PnPProperty -ClientObject $scanList -Property RootFolder
$listSrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
try {
$items = Get-PnPListItem -List $scanList -PageSize 2000 -ErrorAction SilentlyContinue |
Where-Object { $_.FileSystemObjectType -eq "Folder" }
foreach ($folder in $items) {
try {
$hasUnique = Get-PnPProperty -ClientObject $folder -Property HasUniqueRoleAssignments
if (-not $hasUnique) { continue }
$ra = Get-PnPProperty -ClientObject $folder -Property RoleAssignments
$folderRoleArr = [System.Collections.Generic.List[object]]::new()
foreach ($assignment in $ra) {
Get-PnPProperty -ClientObject $assignment.Member -Property Title,LoginName | Out-Null
Get-PnPProperty -ClientObject $assignment -Property RoleDefinitionBindings | Out-Null
$rNames = @($assignment.RoleDefinitionBindings |
Where-Object { $_.Name -ne "Limited Access" } |
Select-Object -ExpandProperty Name)
if ($rNames.Count -gt 0) {
$folderRoleArr.Add(@{
principal = $assignment.Member.Title
loginName = $assignment.Member.LoginName
roles = @($rNames)
})
}
}
$fileRef = $folder.FieldValues.FileRef
$relPath = $fileRef.Substring($wSrl.Length).TrimStart('/')
$folderPerms.Add(@{
listSiteRel = $listSrl
path = $relPath
perms = @($folderRoleArr)
})
BgLog " Perms uniques: $relPath ($($folderRoleArr.Count) entree(s))" "Cyan"
} catch {}
}
} catch { BgLog " Liste ignoree '$($scanList.Title)': $($_.Exception.Message)" "DarkGray" }
}
$result.folderPermissions = @($folderPerms)
BgLog " $($folderPerms.Count) dossier(s) avec permissions uniques captures" "LightGreen"
}
$Sync.Result = $result
BgLog "=== Capture terminee ! ===" "White"
} catch {
$Sync.Error = $_.Exception.Message
BgLog "Erreur: $($_.Exception.Message)" "Red"
} finally { $Sync.Done = $true }
}
$sync = [hashtable]::Synchronized(@{
Queue = [System.Collections.Generic.Queue[object]]::new()
Done = $false; Error = $null; Result = $null
})
$script:_tpl.CapSync = $sync
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($bgCapture)
[void]$ps.AddArgument($srcUrl)
[void]$ps.AddArgument($script:_tpl.ClientId)
[void]$ps.AddArgument($script:_tpl.CapOpts)
[void]$ps.AddArgument($sync)
$script:_tpl.CapRS = $rs
$script:_tpl.CapPS = $ps
$script:_tpl.CapHnd = $ps.BeginInvoke()
$tmr = New-Object System.Windows.Forms.Timer
$tmr.Interval = 200
$script:_tpl.CapTimer = $tmr
$tmr.Add_Tick({
while ($script:_tpl.CapSync.Queue.Count -gt 0) {
$m = $script:_tpl.CapSync.Queue.Dequeue()
_Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color
}
if ($script:_tpl.CapSync.Done) {
$script:_tpl.CapTimer.Stop(); $script:_tpl.CapTimer.Dispose()
while ($script:_tpl.CapSync.Queue.Count -gt 0) {
$m = $script:_tpl.CapSync.Queue.Dequeue()
_Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color
}
try { [void]$script:_tpl.CapPS.EndInvoke($script:_tpl.CapHnd) } catch {}
try { $script:_tpl.CapRS.Close(); $script:_tpl.CapRS.Dispose() } catch {}
$btnCapture.Enabled = $true
if (-not $script:_tpl.CapSync.Error -and $script:_tpl.CapSync.Result) {
$r = $script:_tpl.CapSync.Result
$newTpl = [PSCustomObject]@{
name = $script:_tpl.CapTplName
sourceUrl = $script:_tpl.CapSrcUrl
capturedAt = (Get-Date -Format 'dd/MM/yyyy HH:mm')
options = $script:_tpl.CapOpts
settings = $r.settings
style = $r.style
structure = $r.structure
permissions = $r.permissions
folderPermissions = $r.folderPermissions
}
$n = $script:_tpl.CapTplName
$script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $n }) + $newTpl
Save-Templates -Templates $script:_tpl.Templates
_Tpl-Repopulate
_Tpl-RefreshCombo
[System.Windows.Forms.MessageBox]::Show(
"Template '$n' sauvegarde avec succes.",
"Capture reussie", "OK", "Information")
}
}
})
$tmr.Start()
})
# ── Create site button ─────────────────────────────────────────────────
$btnCreateSite.Add_Click({
$tplIdx = $cboCrTpl.SelectedIndex
if ($tplIdx -lt 0 -or $tplIdx -ge $script:_tpl.Templates.Count) {
[System.Windows.Forms.MessageBox]::Show("Veuillez selectionner un template.", "Aucun template", "OK", "Warning")
return
}
$tpl = $script:_tpl.Templates[$tplIdx]
$title = $txtCrTitle.Text.Trim()
$alias = $txtCrAlias.Text.Trim()
$owners = @($txtCrOwners.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$members = @($txtCrMembers.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ([string]::IsNullOrWhiteSpace($title)) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir le titre du site.", "Champ manquant", "OK", "Warning"); return
}
if ([string]::IsNullOrWhiteSpace($alias)) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir un alias URL.", "Champ manquant", "OK", "Warning"); return
}
if ([string]::IsNullOrWhiteSpace($script:_tpl.TenantUrl)) {
[System.Windows.Forms.MessageBox]::Show("Tenant URL manquant dans le formulaire principal.", "Erreur", "OK", "Warning"); return
}
if ($owners.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir au moins un proprietaire.", "Champ manquant", "OK", "Warning"); return
}
$applyOpts = @{
structure = $chkApplyStruct.Checked -and $tpl.options.structure
permissions = $chkApplyPerms.Checked -and $tpl.options.permissions
settings = $chkApplySettings.Checked -and $tpl.options.settings
style = $chkApplyStyle.Checked -and $tpl.options.style
}
$isTeam = $radCrTeam.Checked
$btnCreateSite.Enabled = $false
$script:_tpl.TxtCreateLog.Clear()
$bgCreate = {
param($TenantUrl, $ClientId, $Title, $Alias, $IsTeam, $Owners, $Members, $ApplyOpts, $Tpl, $Sync)
function BgLog([string]$m, [string]$c="LightGreen") {
$Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c })
}
function Apply-FolderTree([object[]]$Folders, [string]$ParentSrl) {
foreach ($f in $Folders) {
try {
Add-PnPFolder -Name $f.name -Folder $ParentSrl -ErrorAction SilentlyContinue | Out-Null
BgLog " + $($f.name)" "Cyan"
} catch {}
if ($f.subfolders -and $f.subfolders.Count -gt 0) {
Apply-FolderTree $f.subfolders "$ParentSrl/$($f.name)"
}
}
}
try {
Import-Module PnP.PowerShell -ErrorAction Stop
$adminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
"$($Matches[1])-admin$($Matches[2])"
} else { $TenantUrl }
BgLog "Connexion au tenant admin..." "Yellow"
Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $ClientId
BgLog "Creation du site '$Title' (alias: $Alias)..." "Yellow"
$newUrl = if ($IsTeam) {
New-PnPSite -Type TeamSite -Title $Title -Alias $Alias -Owners $Owners -Wait
} else {
$base = if ($TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $TenantUrl }
New-PnPSite -Type CommunicationSite -Title $Title -Url "$base/sites/$Alias" -Wait
}
$Sync.NewSiteUrl = $newUrl
BgLog "Site cree : $newUrl" "LightGreen"
BgLog "Connexion au nouveau site..." "Yellow"
Connect-PnPOnline -Url $newUrl -Interactive -ClientId $ClientId
$web = Get-PnPWeb
$wSrl = $web.ServerRelativeUrl.TrimEnd('/')
if ($ApplyOpts.settings -and $Tpl.settings -and $Tpl.settings.description) {
BgLog "Application des parametres..." "Yellow"
Set-PnPWeb -Description $Tpl.settings.description
}
if ($ApplyOpts.style -and $Tpl.style -and $Tpl.style.logoUrl) {
BgLog "Application du style (logo)..." "Yellow"
Set-PnPWeb -SiteLogoUrl $Tpl.style.logoUrl
}
if ($ApplyOpts.structure -and $Tpl.structure -and $Tpl.structure.Count -gt 0) {
BgLog "Application de l'arborescence..." "Yellow"
foreach ($lib in $Tpl.structure) {
BgLog " Bibliotheque: $($lib.name)" "Yellow"
$existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
if (-not $existing) {
try {
$tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") {
[Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary
} else {
[Microsoft.SharePoint.Client.ListTemplateType]::GenericList
}
New-PnPList -Title $lib.name -Template $tplType | Out-Null
BgLog " Creee." "Cyan"
} catch { BgLog " Ignoree: $($_.Exception.Message)" "DarkGray" }
} else { BgLog " Deja existante." "DarkGray" }
if ($lib.folders -and $lib.folders.Count -gt 0) {
# Get actual root folder URL from target list (avoids display-name vs URL mismatch)
$targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
if ($targetList) {
$listRf = Get-PnPProperty -ClientObject $targetList -Property RootFolder
$libBase = $listRf.ServerRelativeUrl.TrimEnd('/')
BgLog " Base URL: $libBase" "DarkGray"
Apply-FolderTree $lib.folders $libBase
} else {
BgLog " Liste '$($lib.name)' non trouvee, dossiers ignores." "DarkGray"
}
}
}
}
if ($Members -and $Members.Count -gt 0) {
BgLog "Ajout de $($Members.Count) membre(s)..." "Yellow"
$memberGroup = Get-PnPGroup | Where-Object { $_.Title -like "*Membres*" -or $_.Title -like "*Members*" } | Select-Object -First 1
if ($memberGroup) {
foreach ($m in $Members) {
try {
Add-PnPGroupMember -LoginName $m -Group $memberGroup.Title -ErrorAction SilentlyContinue
BgLog " + $m" "Cyan"
} catch { BgLog " Ignore $m" "DarkGray" }
}
} else { BgLog " Groupe membres non trouve, ajout ignore." "DarkGray" }
}
# Apply folder-level unique permissions
$fpList = $Tpl.folderPermissions
if ($ApplyOpts.permissions -and $fpList -and $fpList.Count -gt 0) {
BgLog "Application des permissions sur les dossiers ($($fpList.Count))..." "Yellow"
# Build map: source library rootSiteRel -> target library server-relative URL
$libMap = @{}
foreach ($lib in $Tpl.structure) {
if (-not $lib.rootSiteRel) { continue }
$tgtLib = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
if ($tgtLib) {
$tgtRf = Get-PnPProperty -ClientObject $tgtLib -Property RootFolder
$libMap[$lib.rootSiteRel] = $tgtRf.ServerRelativeUrl.TrimEnd('/')
}
}
$ctx = Get-PnPContext
foreach ($fp in $fpList) {
# Compute folder SRL on target using the library map
$srcLibSrl = $fp.listSiteRel
$tgtLibBase = $libMap[$srcLibSrl]
if (-not $tgtLibBase) {
BgLog " Bibliotheque non mappee pour '$srcLibSrl', dossier ignore." "DarkGray"
continue
}
# Folder path relative to library root (strip the lib prefix from fp.path)
$folderRelToLib = $fp.path.Substring($srcLibSrl.Length).TrimStart('/')
$folderSrl = "$tgtLibBase/$folderRelToLib"
try {
$folder = $ctx.Web.GetFolderByServerRelativeUrl($folderSrl)
$folderItem = $folder.ListItemAllFields
$ctx.Load($folderItem)
$ctx.ExecuteQuery()
# Break inheritance and clear all inherited permissions
$folderItem.BreakRoleInheritance($false, $false)
$ctx.ExecuteQuery()
# Apply each captured permission entry
foreach ($perm in $fp.perms) {
# Skip system accounts and source-specific SP groups (detected by failed EnsureUser)
try {
$principal = $ctx.Web.EnsureUser($perm.loginName)
$ctx.Load($principal)
$ctx.ExecuteQuery()
foreach ($roleName in $perm.roles) {
try {
$roleDef = $ctx.Web.RoleDefinitions.GetByName($roleName)
$bindings = New-Object Microsoft.SharePoint.Client.RoleDefinitionBindingCollection($ctx)
$bindings.Add($roleDef)
$folderItem.RoleAssignments.Add($principal, $bindings) | Out-Null
$ctx.ExecuteQuery()
BgLog " + $($perm.principal) [$roleName]" "Cyan"
} catch { BgLog " Role '$roleName' ignore pour '$($perm.principal)'" "DarkGray" }
}
} catch {
BgLog " Principal ignore (groupe source ou compte systeme) : '$($perm.principal)'" "DarkGray"
}
}
BgLog " OK: $folderRelToLib" "Cyan"
} catch { BgLog " Dossier ignore '$folderRelToLib': $($_.Exception.Message)" "DarkGray" }
}
}
BgLog "=== Site cree avec succes ! ===" "White"
} catch {
$Sync.Error = $_.Exception.Message
BgLog "Erreur: $($_.Exception.Message)" "Red"
} finally { $Sync.Done = $true }
}
$sync = [hashtable]::Synchronized(@{
Queue = [System.Collections.Generic.Queue[object]]::new()
Done = $false; Error = $null; NewSiteUrl = $null
})
$script:_tpl.CrSync = $sync
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($bgCreate)
[void]$ps.AddArgument($script:_tpl.TenantUrl)
[void]$ps.AddArgument($script:_tpl.ClientId)
[void]$ps.AddArgument($title)
[void]$ps.AddArgument($alias)
[void]$ps.AddArgument($isTeam)
[void]$ps.AddArgument($owners)
[void]$ps.AddArgument($members)
[void]$ps.AddArgument($applyOpts)
[void]$ps.AddArgument($tpl)
[void]$ps.AddArgument($sync)
$script:_tpl.CrRS = $rs
$script:_tpl.CrPS = $ps
$script:_tpl.CrHnd = $ps.BeginInvoke()
$tmr = New-Object System.Windows.Forms.Timer
$tmr.Interval = 200
$script:_tpl.CrTimer = $tmr
$tmr.Add_Tick({
while ($script:_tpl.CrSync.Queue.Count -gt 0) {
$m = $script:_tpl.CrSync.Queue.Dequeue()
_Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color
}
if ($script:_tpl.CrSync.Done) {
$script:_tpl.CrTimer.Stop(); $script:_tpl.CrTimer.Dispose()
while ($script:_tpl.CrSync.Queue.Count -gt 0) {
$m = $script:_tpl.CrSync.Queue.Dequeue()
_Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color
}
try { [void]$script:_tpl.CrPS.EndInvoke($script:_tpl.CrHnd) } catch {}
try { $script:_tpl.CrRS.Close(); $script:_tpl.CrRS.Dispose() } catch {}
$btnCreateSite.Enabled = $true
if ($script:_tpl.CrSync.NewSiteUrl -and -not $script:_tpl.CrSync.Error) {
$url = $script:_tpl.CrSync.NewSiteUrl
$res = [System.Windows.Forms.MessageBox]::Show(
"Site cree avec succes !`n`n$url`n`nOuvrir dans le navigateur ?",
"Succes", "YesNo", "Information")
if ($res -eq "Yes") { Start-Process $url }
}
}
})
$tmr.Start()
})
if ($Owner) { [void]$dlg.ShowDialog($Owner) } else { [void]$dlg.ShowDialog() }
$dlg.Dispose()
}
#endregion
#region ===== HTML Export: Permissions =====
function Merge-PermissionRows([array]$Data) {
# Groups rows that share the same Users + Permissions + GrantedThrough into one merged row.
# Uses [ordered] to preserve insertion order without a separate list.
$map = [ordered]@{}
foreach ($row in $Data) {
$key = "$($row.Users)|$($row.Permissions)|$($row.GrantedThrough)"
if (-not $map.Contains($key)) {
$map[$key] = [PSCustomObject]@{
Locations = @()
Permissions = $row.Permissions
GrantedThrough = $row.GrantedThrough
Type = $row.Type
Users = $row.Users
UserLogins = if ($row.UserLogins) { $row.UserLogins } else { "" }
}
}
$map[$key].Locations += [PSCustomObject]@{
Object = [string]$row.Object
Title = [string]$row.Title
URL = if ($row.URL) { [string]$row.URL } else { "" }
HasUniquePermissions = $row.HasUniquePermissions
}
}
return @($map.Values)
}
function Export-PermissionsToHTML {
param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath)
$generated = Get-Date -Format 'dd/MM/yyyy HH:mm'
$count = $Data.Count
$uniqueCount = ($Data | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true } | Measure-Object).Count
$userCount = ($Data | ForEach-Object { $_.Users -split '; ' } | Where-Object { $_ } | Sort-Object -Unique | Measure-Object).Count
# Build pills HTML for a list of names + emails
function Build-Pills([string[]]$Names, [string[]]$Emails) {
$html = ""
for ($i = 0; $i -lt $Names.Count; $i++) {
$n = EscHtml $Names[$i]
$e = if ($Emails -and $i -lt $Emails.Count) { EscHtml $Emails[$i] } else { "" }
$html += "$n"
}
return $html
}
$mergedRows = Merge-PermissionRows $Data
$script:grpIdx = 0
$rows = ""
foreach ($mrow in $mergedRows) {
$locs = @($mrow.Locations)
# Dominant type badge (use first location's type — entries in a merged group are typically the same type)
$dominantType = $locs[0].Object
$badgeClass = switch -Regex ($dominantType) {
"Site Collection" { "bc"; break }
"^Site$" { "bs"; break }
"Folder" { "bf"; break }
Default { "bl" }
}
# Name / locations cell + Unique Permissions cell
if ($locs.Count -eq 1) {
$loc = $locs[0]
$isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true)
$uqClass = if ($isUnique) { "uq" } else { "inh" }
$uqText = if ($isUnique) { "✓ Unique" } else { "Inherited" }
$nameCell = if ($loc.URL) { "$(EscHtml $loc.Title)" } else { EscHtml $loc.Title }
$uqCell = "$uqText"
} else {
$uqTotal = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count
$locHtml = "
"
foreach ($loc in $locs) {
$isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true)
$uqMark = if ($isUnique) { "
✓" } else { "
~" }
$locBc = switch -Regex ($loc.Object) {
"Site Collection" { "bc"; break }
"^Site$" { "bs"; break }
"Folder" { "bf"; break }
Default { "bl" }
}
$locLink = if ($loc.URL) { "
$(EscHtml $loc.Title)" } else { EscHtml $loc.Title }
$locHtml += "
$(EscHtml $loc.Object) $locLink $uqMark
"
}
$locHtml += "
"
$nameCell = $locHtml
$uqCell = "$($locs.Count) emplacements
($uqTotal uniques)"
}
# Build Users / Members cell
$names = if ($mrow.Users) { @($mrow.Users -split '; ') } else { @() }
$emails = if ($mrow.UserLogins) { @($mrow.UserLogins -split '; ') } else { @() }
$usersCell = ""
if ($names.Count -gt 0) {
$pills = Build-Pills $names $emails
if ($mrow.GrantedThrough -match '^SharePoint Group: (.+)$') {
$grpName = EscHtml $Matches[1]
$gid = "g$($script:grpIdx)"
$script:grpIdx++
$usersCell = ""
} else {
$usersCell = $pills
}
}
$rows += "| $(EscHtml $dominantType) | "
$rows += "$nameCell | "
$rows += "$usersCell | "
$rows += "$(EscHtml $mrow.Permissions) | "
$rows += "$(EscHtml $mrow.GrantedThrough) | "
$rows += "$uqCell |
`n"
}
$html = @"
Permissions - $(EscHtml $SiteTitle)
SharePoint Permissions Report
$uniqueCount
Unique Permission Sets
$userCount
Distinct Users / Groups
| Type | Name | Users / Members | Permission Level | Granted Through | Unique Permissions |
$rows
"@
$html | Out-File -FilePath $OutputPath -Encoding UTF8
}
#endregion
#region ===== HTML Export: Storage =====
function Export-StorageToHTML {
param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath)
$generated = Get-Date -Format 'dd/MM/yyyy HH:mm'
$totalBytes = ($Data | Measure-Object -Property SizeBytes -Sum).Sum
$totalVersionBytes = ($Data | Where-Object { $_.VersionSizeBytes } | Measure-Object -Property VersionSizeBytes -Sum).Sum
if (-not $totalVersionBytes) { $totalVersionBytes = 0 }
$totalFiles = ($Data | Measure-Object -Property ItemCount -Sum).Sum
$libCount = $Data.Count
# Shared toggle-ID counter — must be unique across all levels of nesting
$script:togIdx = 0
# Recursively builds folder rows; each folder with children gets its own toggle
function Build-FolderRows([array]$Folders) {
$html = ""
foreach ($sf in ($Folders | Sort-Object SizeBytes -Descending)) {
$myIdx = $script:togIdx++
$hasSubs = $sf.SubFolders -and $sf.SubFolders.Count -gt 0
$sfSzClass = if ($sf.SizeBytes -ge 10GB) { "sz-lg" } elseif ($sf.SizeBytes -ge 1GB) { "sz-md" } else { "sz-sm" }
$sfLink = if ($sf.URL) { "📁 $(EscHtml $sf.Name)" } else { "📁 $(EscHtml $sf.Name)" }
$togBtn = if ($hasSubs) { "" } else { "" }
$sfVerBytes = if ($sf.VersionSizeBytes) { $sf.VersionSizeBytes } else { 0 }
$sfVerPct = if ($sf.SizeBytes -gt 0 -and $sfVerBytes -gt 0) { [math]::Round($sfVerBytes / $sf.SizeBytes * 100, 0) } else { 0 }
$sfVerClass = if ($sfVerPct -ge 40) { "sz-lg" } elseif ($sfVerPct -ge 15) { "sz-md" } else { "" }
$sfVerTxt = if ($sfVerBytes -gt 0) { "$(Format-Bytes $sfVerBytes) ($sfVerPct%)" } else { "-" }
$html += "$togBtn $sfLink | "
$html += "$($sf.ItemCount) | "
$html += "$(Format-Bytes $sf.SizeBytes) | "
$html += "$sfVerTxt | "
$html += "$(EscHtml $sf.LastModified) |
`n"
if ($hasSubs) {
$html += ""
$html += " | Folder | Files | Size | Versions | Last Modified | "
$html += "$(Build-FolderRows $sf.SubFolders)
|
`n"
}
}
return $html
}
$rows = ""
foreach ($row in ($Data | Sort-Object SizeBytes -Descending)) {
$myLibIdx = $script:togIdx++
$pct = if ($totalBytes -gt 0) { [math]::Round($row.SizeBytes / $totalBytes * 100, 1) } else { 0 }
$szClass = if ($row.SizeBytes -ge 10GB) { "sz-lg" } elseif ($row.SizeBytes -ge 1GB) { "sz-md" } else { "sz-sm" }
$hasFolders = $row.SubFolders -and $row.SubFolders.Count -gt 0
$toggleBtn = if ($hasFolders) { "" } else { "" }
$nameCell = if ($row.LibraryURL) { "$(EscHtml $row.Library)" } else { EscHtml $row.Library }
$verBytes = if ($row.VersionSizeBytes) { $row.VersionSizeBytes } else { 0 }
$verPct = if ($row.SizeBytes -gt 0 -and $verBytes -gt 0) { [math]::Round($verBytes / $row.SizeBytes * 100, 0) } else { 0 }
$verClass = if ($verPct -ge 40) { "sz-lg" } elseif ($verPct -ge 15) { "sz-md" } else { "" }
$verTxt = if ($verBytes -gt 0) { "$(Format-Bytes $verBytes) ($verPct%)" } else { "-" }
$rows += "$toggleBtn $nameCell | "
$rows += "$(EscHtml $row.SiteTitle) | "
$rows += "$($row.ItemCount) | "
$rows += "$(Format-Bytes $row.SizeBytes) | "
$rows += "$verTxt | "
$rows += " | "
$rows += "$(EscHtml $row.LastModified) |
`n"
if ($hasFolders) {
$rows += ""
$rows += " | Folder | Files | Size | Versions | Last Modified | "
$rows += "$(Build-FolderRows $row.SubFolders)
|
`n"
}
}
$html = @"
Storage - $(EscHtml $SiteTitle)
SharePoint Storage Metrics
$(Format-Bytes $totalBytes)
Total Storage Used
$(Format-Bytes $totalVersionBytes)
Version Storage
$libCount
Libraries / Sites Scanned
| Library | Site | Files | Size | Versions | Share of Total | Last Modified |
$rows
"@
$html | Out-File -FilePath $OutputPath -Encoding UTF8
}
#endregion
#region ===== PnP: Permissions =====
Function Get-PnPPermissions([Microsoft.SharePoint.Client.SecurableObject]$Object) {
Switch ($Object.TypedObject.ToString()) {
"Microsoft.SharePoint.Client.Web" {
$ObjectType = "Site"
$ObjectURL = $Object.URL
$ObjectTitle = $Object.Title
}
"Microsoft.SharePoint.Client.ListItem" {
$ObjectType = "Folder"
$Folder = Get-PnPProperty -ClientObject $Object -Property Folder
$ObjectTitle = $Object.Folder.Name
$ObjectURL = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $Object.Folder.ServerRelativeUrl)
}
Default {
$ObjectType = $Object.BaseType
$ObjectTitle = $Object.Title
$RootFolder = Get-PnPProperty -ClientObject $Object -Property RootFolder
$ObjectURL = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $RootFolder.ServerRelativeUrl)
}
}
Get-PnPProperty -ClientObject $Object -Property HasUniqueRoleAssignments, RoleAssignments
$HasUniquePermissions = $Object.HasUniqueRoleAssignments
Foreach ($RoleAssignment in $Object.RoleAssignments) {
Get-PnPProperty -ClientObject $RoleAssignment -Property RoleDefinitionBindings, Member
$PermissionType = $RoleAssignment.Member.PrincipalType
$PermissionLevels = ($RoleAssignment.RoleDefinitionBindings | Select-Object -ExpandProperty Name |
Where-Object { $_ -ne "Limited Access" }) -join "; "
If ($PermissionLevels.Length -eq 0) { Continue }
$entry = [PSCustomObject]@{
Object = $ObjectType
Title = $ObjectTitle
URL = $ObjectURL
HasUniquePermissions = $HasUniquePermissions
Permissions = $PermissionLevels
GrantedThrough = ""
Type = $PermissionType
Users = ""
UserLogins = ""
}
If ($PermissionType -eq "SharePointGroup") {
$grpLogin = $RoleAssignment.Member.LoginName
If ($grpLogin -match '^SharingLinks\.' -or $grpLogin -eq 'Limited Access System Group') { Continue }
$GroupMembers = Get-PnPGroupMember -Identity $grpLogin
If ($GroupMembers.count -eq 0) { Continue }
$filteredMembers = @($GroupMembers | Where-Object { $_.Title -ne "System Account" })
$GroupUsers = ($filteredMembers | Select-Object -ExpandProperty Title) -join "; "
$GroupEmails = ($filteredMembers | Select-Object -ExpandProperty Email) -join "; "
If ($GroupUsers.Length -eq 0) { Continue }
$entry.Users = $GroupUsers
$entry.UserLogins = $GroupEmails
$entry.GrantedThrough = "SharePoint Group: $grpLogin"
}
Else {
$entry.Users = $RoleAssignment.Member.Title
$entry.UserLogins = $RoleAssignment.Member.Email
$entry.GrantedThrough = "Direct Permissions"
}
$script:AllPermissions += $entry
}
}
Function Generate-PnPSitePermissionRpt {
[cmdletbinding()]
Param (
[String] $SiteURL,
[String] $ReportFile,
[switch] $Recursive,
[switch] $ScanFolders,
[switch] $IncludeInheritedPermissions
)
Try {
Write-Log "Connecting to SharePoint... (browser window will open)" "Yellow"
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $script:pnpCiD
$Web = Get-PnPWeb
Write-Log "Getting Site Collection Administrators..." "Yellow"
$SiteAdmins = Get-PnPSiteCollectionAdmin
$script:AllPermissions += [PSCustomObject]@{
Object = "Site Collection"
Title = $Web.Title
URL = $Web.URL
HasUniquePermissions = "TRUE"
Users = ($SiteAdmins | Select-Object -ExpandProperty Title) -join "; "
UserLogins = ($SiteAdmins | Select-Object -ExpandProperty Email) -join "; "
Type = "Site Collection Administrators"
Permissions = "Site Owner"
GrantedThrough = "Direct Permissions"
}
Function Get-PnPFolderPermission([Microsoft.SharePoint.Client.List]$List) {
Write-Log "`t`tScanning folders in: $($List.Title)" "Yellow"
$ListItems = Get-PnPListItem -List $List -PageSize 2000
$Folders = $ListItems | Where-Object {
($_.FileSystemObjectType -eq "Folder") -and
($_.FieldValues.FileLeafRef -ne "Forms") -and
(-Not($_.FieldValues.FileLeafRef.StartsWith("_")))
}
# Apply folder depth filter (999 = maximum / no limit)
If ($script:PermFolderDepth -lt 999) {
$rf = Get-PnPProperty -ClientObject $List -Property RootFolder
$rootSrl = $rf.ServerRelativeUrl.TrimEnd('/')
$Folders = $Folders | Where-Object {
$relPath = $_.FieldValues.FileRef.Substring($rootSrl.Length).TrimStart('/')
($relPath -split '/').Count -le $script:PermFolderDepth
}
}
$i = 0
ForEach ($Folder in $Folders) {
If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $Folder }
Else {
If ((Get-PnPProperty -ClientObject $Folder -Property HasUniqueRoleAssignments) -eq $true) {
Get-PnPPermissions -Object $Folder
}
}
$i++
Write-Progress -PercentComplete ($i / [Math]::Max($Folders.Count,1) * 100) `
-Activity "Folders in '$($List.Title)'" `
-Status "'$($Folder.FieldValues.FileLeafRef)' ($i of $($Folders.Count))" -Id 2 -ParentId 1
}
}
Function Get-PnPListPermission([Microsoft.SharePoint.Client.Web]$Web) {
$Lists = Get-PnPProperty -ClientObject $Web -Property Lists
$ExcludedLists = @(
"Access Requests","App Packages","appdata","appfiles","Apps in Testing","Cache Profiles",
"Composed Looks","Content and Structure Reports","Content type publishing error log",
"Converted Forms","Device Channels","Form Templates","fpdatasources",
"Get started with Apps for Office and SharePoint","List Template Gallery",
"Long Running Operation Status","Maintenance Log Library","Images","site collection images",
"Master Docs","Master Page Gallery","MicroFeed","NintexFormXml","Quick Deploy Items",
"Relationships List","Reusable Content","Reporting Metadata","Reporting Templates",
"Search Config List","Site Assets","Preservation Hold Library","Site Pages",
"Solution Gallery","Style Library","Suggested Content Browser Locations","Theme Gallery",
"TaxonomyHiddenList","User Information List","Web Part Gallery","wfpub","wfsvc",
"Workflow History","Workflow Tasks","Pages"
)
$c = 0
ForEach ($List in $Lists) {
If ($List.Hidden -eq $false -and $ExcludedLists -notcontains $List.Title) {
$c++
Write-Log "`tList ($c/$($Lists.Count)): $($List.Title)" "Cyan"
Write-Progress -PercentComplete ($c / [Math]::Max($Lists.Count,1) * 100) `
-Activity "Scanning lists in $($Web.URL)" `
-Status "'$($List.Title)' ($c of $($Lists.Count))" -Id 1
If ($ScanFolders) { Get-PnPFolderPermission -List $List }
If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $List }
Else {
If ((Get-PnPProperty -ClientObject $List -Property HasUniqueRoleAssignments) -eq $true) {
Get-PnPPermissions -Object $List
}
}
}
}
}
Function Get-PnPWebPermission([Microsoft.SharePoint.Client.Web]$Web) {
Write-Log "Processing web: $($Web.URL)" "Yellow"
Get-PnPPermissions -Object $Web
Write-Log "`tScanning lists and libraries..." "Yellow"
Get-PnPListPermission($Web)
If ($Recursive) {
$Subwebs = Get-PnPProperty -ClientObject $Web -Property Webs
Foreach ($Subweb in $web.Webs) {
If ($IncludeInheritedPermissions) { Get-PnPWebPermission($Subweb) }
Else {
If ((Get-PnPProperty -ClientObject $SubWeb -Property HasUniqueRoleAssignments) -eq $true) {
Get-PnPWebPermission($Subweb)
}
}
}
}
}
Get-PnPWebPermission $Web
# Export based on chosen format
Write-Log "Writing output file..." "Yellow"
If ($script:PermFormat -eq "HTML") {
$outPath = [System.IO.Path]::ChangeExtension($ReportFile, ".html")
Export-PermissionsToHTML -Data $script:AllPermissions -SiteTitle $Web.Title -SiteURL $Web.URL -OutputPath $outPath
$script:PermOutputFile = $outPath
}
Else {
$mergedPerms = Merge-PermissionRows $script:AllPermissions
$mergedPerms | ForEach-Object {
$locs = @($_.Locations)
$uqN = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count
[PSCustomObject]@{
Object = ($locs | Select-Object -ExpandProperty Object -Unique) -join ', '
Title = ($locs | Select-Object -ExpandProperty Title) -join ' | '
URL = ($locs | Select-Object -ExpandProperty URL) -join ' | '
HasUniquePermissions = if ($locs.Count -eq 1) { $locs[0].HasUniquePermissions } else { "$uqN/$($locs.Count) uniques" }
Users = $_.Users
UserLogins = $_.UserLogins
Type = $_.Type
Permissions = $_.Permissions
GrantedThrough = $_.GrantedThrough
}
} | Export-Csv -Path $ReportFile -NoTypeInformation
$script:PermOutputFile = $ReportFile
}
Write-Log "Report generated successfully!" "LightGreen"
}
Catch {
Write-Log "Error: $($_.Exception.Message)" "Red"
throw
}
}
#endregion
#region ===== PnP: Storage Metrics =====
function Get-SiteStorageMetrics {
param([string]$SiteURL, [switch]$IncludeSubsites, [switch]$PerLibrary)
$script:storageResults = @()
# Recursively collects subfolders up to $MaxDepth levels deep
function Collect-FolderStorage([string]$FolderSiteRelUrl, [string]$WebBaseUrl, [int]$CurrentDepth) {
if ($CurrentDepth -ge $script:FolderDepth) { return @() }
$result = @()
try {
$items = Get-PnPFolderItem -FolderSiteRelativeUrl $FolderSiteRelUrl -ItemType Folder
foreach ($fi in $items) {
$fiSrl = "$FolderSiteRelUrl/$($fi.Name)"
try {
$fiSm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $fiSrl
$children = Collect-FolderStorage -FolderSiteRelUrl $fiSrl -WebBaseUrl $WebBaseUrl -CurrentDepth ($CurrentDepth + 1)
$result += [PSCustomObject]@{
Name = $fi.Name
URL = "$($WebBaseUrl.TrimEnd('/'))/$fiSrl"
ItemCount = $fiSm.TotalFileCount
SizeBytes = $fiSm.TotalSize
LastModified = if ($fiSm.LastModified) { $fiSm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
SubFolders = $children
}
} catch {}
}
} catch {}
return $result
}
function Collect-WebStorage([string]$WebUrl) {
# Connect to this specific web (token is cached, no extra browser prompts)
Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD
$webObj = Get-PnPWeb
$wTitle = $webObj.Title
$wUrl = $webObj.Url
# ServerRelativeUrl of the web (e.g. "/sites/MySite") — used to compute site-relative paths
$wSrl = $webObj.ServerRelativeUrl.TrimEnd('/')
if ($PerLibrary) {
$lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden }
foreach ($list in $lists) {
$rf = Get-PnPProperty -ClientObject $list -Property RootFolder
try {
# Convert server-relative (/sites/X/LibName) to site-relative (LibName)
$siteRelUrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
$sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $siteRelUrl
$libUrl = "$($wUrl.TrimEnd('/'))/$siteRelUrl"
# Recursively collect subfolders up to the configured depth
$subFolders = Collect-FolderStorage -FolderSiteRelUrl $siteRelUrl -WebBaseUrl $wUrl -CurrentDepth 0
$script:storageResults += [PSCustomObject]@{
SiteTitle = $wTitle
SiteURL = $wUrl
Library = $list.Title
LibraryURL = $libUrl
ItemCount = $sm.TotalFileCount
SizeBytes = $sm.TotalSize
SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
SubFolders = $subFolders
}
Write-Log "`t $($list.Title): $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files, $($subFolders.Count) folders)" "Cyan"
}
catch { Write-Log "`t Skipped '$($list.Title)': $($_.Exception.Message)" "DarkGray" }
}
}
else {
try {
$sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/"
$script:storageResults += [PSCustomObject]@{
SiteTitle = $wTitle
SiteURL = $wUrl
Library = "(All Libraries)"
LibraryURL = $wUrl
ItemCount = $sm.TotalFileCount
SizeBytes = $sm.TotalSize
SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
}
Write-Log "`t${wTitle}: $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files)" "Cyan"
}
catch { Write-Log "`tSkipped '${wTitle}': $($_.Exception.Message)" "DarkGray" }
}
if ($IncludeSubsites) {
$subWebs = Get-PnPSubWeb
foreach ($sub in $subWebs) {
Write-Log "`tProcessing subsite: $($sub.Title)" "Yellow"
Collect-WebStorage $sub.Url
# Reconnect to parent so subsequent sibling subsites resolve correctly
Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD
}
}
}
Write-Log "Collecting storage metrics for: $SiteURL" "Yellow"
Collect-WebStorage $SiteURL
return $script:storageResults
}
#endregion
#region ===== File Search =====
function Export-SearchResultsToHTML {
param([array]$Results, [string]$KQL, [string]$SiteUrl)
$rows = ""
foreach ($r in $Results) {
$title = EscHtml ($r.Title -replace '^$', '(sans titre)')
$path = EscHtml ($r.Path -replace '^$', '')
$ext = EscHtml ($r.FileExtension -replace '^$', '')
$created = EscHtml ($r.Created -replace '^$', '')
$modif = EscHtml ($r.LastModifiedTime -replace '^$', '')
$author = EscHtml ($r.Author -replace '^$', '')
$modby = EscHtml ($r.ModifiedBy -replace '^$', '')
$sizeB = [long]($r.Size -replace '[^0-9]','0' -replace '^$','0')
$sizeStr = EscHtml (Format-Bytes $sizeB)
$href = EscHtml $r.Path
$rows += "| $title | "
$rows += "$ext | "
$rows += "$created | "
$rows += "$modif | "
$rows += "$author | $modby | "
$rows += "$sizeStr |
`n"
}
$count = $Results.Count
$date = Get-Date -Format "dd/MM/yyyy HH:mm"
$kqlEsc = EscHtml $KQL
$siteEsc = EscHtml $SiteUrl
$html = @"
Recherche de fichiers
Recherche de fichiers SharePoint
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 ===== GUI =====
$form = New-Object System.Windows.Forms.Form
$form.Text = "SharePoint Exporter v6.0"
$form.Size = New-Object System.Drawing.Size(700, 840)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$form.BackColor = [System.Drawing.Color]::WhiteSmoke
# ── Shared: Client ID ──────────────────────────────────────────────────────────
$lbl = { param($t,$x,$y)
$l = New-Object System.Windows.Forms.Label
$l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y)
$l.Size = New-Object System.Drawing.Size(115,22); $l.TextAlign = "MiddleLeft"; $l
}
# ── Profile selector ──────────────────────────────────────────────────────────
$lblProfile = (& $lbl "Profil :" 20 22)
$cboProfile = New-Object System.Windows.Forms.ComboBox
$cboProfile.Location = New-Object System.Drawing.Point(140, 20)
$cboProfile.Size = New-Object System.Drawing.Size(248, 24)
$cboProfile.DropDownStyle = "DropDownList"
$cboProfile.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$btnProfileNew = New-Object System.Windows.Forms.Button
$btnProfileNew.Text = "Creer"
$btnProfileNew.Location = New-Object System.Drawing.Point(396, 19)
$btnProfileNew.Size = New-Object System.Drawing.Size(60, 26)
$btnProfileSave = New-Object System.Windows.Forms.Button
$btnProfileSave.Text = "Sauver"
$btnProfileSave.Location = New-Object System.Drawing.Point(460, 19)
$btnProfileSave.Size = New-Object System.Drawing.Size(60, 26)
$btnProfileRename = New-Object System.Windows.Forms.Button
$btnProfileRename.Text = "Renommer"
$btnProfileRename.Location = New-Object System.Drawing.Point(524, 19)
$btnProfileRename.Size = New-Object System.Drawing.Size(72, 26)
$btnProfileDelete = New-Object System.Windows.Forms.Button
$btnProfileDelete.Text = "Suppr."
$btnProfileDelete.Location = New-Object System.Drawing.Point(600, 19)
$btnProfileDelete.Size = New-Object System.Drawing.Size(62, 26)
$lblTenantUrl = (& $lbl "Tenant URL :" 20 52)
$txtTenantUrl = New-Object System.Windows.Forms.TextBox
$txtTenantUrl.Location = New-Object System.Drawing.Point(140, 52)
$txtTenantUrl.Size = New-Object System.Drawing.Size(400, 22)
$txtTenantUrl.Font = New-Object System.Drawing.Font("Consolas", 9)
$btnBrowseSites = New-Object System.Windows.Forms.Button
$btnBrowseSites.Text = "Voir les sites"
$btnBrowseSites.Location = New-Object System.Drawing.Point(548, 50)
$btnBrowseSites.Size = New-Object System.Drawing.Size(92, 26)
$lblClientId = (& $lbl "Client ID :" 20 84)
$txtClientId = New-Object System.Windows.Forms.TextBox
$txtClientId.Location = New-Object System.Drawing.Point(140, 84)
$txtClientId.Size = New-Object System.Drawing.Size(500, 22)
$txtClientId.Font = New-Object System.Drawing.Font("Consolas", 9)
$lblSiteURL = (& $lbl "Site URL :" 20 116)
$txtSiteURL = New-Object System.Windows.Forms.TextBox
$txtSiteURL.Location = New-Object System.Drawing.Point(140, 116)
$txtSiteURL.Size = New-Object System.Drawing.Size(500, 22)
$lblOutput = (& $lbl "Output Folder :" 20 148)
$txtOutput = New-Object System.Windows.Forms.TextBox
$txtOutput.Location = New-Object System.Drawing.Point(140, 148)
$txtOutput.Size = New-Object System.Drawing.Size(408, 22)
$txtOutput.Text = $PWD.Path
$btnBrowse = New-Object System.Windows.Forms.Button
$btnBrowse.Text = "Browse..."
$btnBrowse.Location = New-Object System.Drawing.Point(558, 146)
$btnBrowse.Size = New-Object System.Drawing.Size(82, 26)
$lblDataDir = (& $lbl "Dossier JSON :" 20 178)
$txtDataDir = New-Object System.Windows.Forms.TextBox
$txtDataDir.Location = New-Object System.Drawing.Point(140, 178)
$txtDataDir.Size = New-Object System.Drawing.Size(408, 22)
$txtDataDir.Font = New-Object System.Drawing.Font("Consolas", 9)
$btnBrowseDataDir = New-Object System.Windows.Forms.Button
$btnBrowseDataDir.Text = "Browse..."
$btnBrowseDataDir.Location = New-Object System.Drawing.Point(558, 176)
$btnBrowseDataDir.Size = New-Object System.Drawing.Size(82, 26)
$sep = New-Object System.Windows.Forms.Panel
$sep.Location = New-Object System.Drawing.Point(20, 212)
$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, 220)
$tabs.Size = New-Object System.Drawing.Size(662, 310)
$tabs.Font = New-Object System.Drawing.Font("Segoe UI", 9)
# helper: GroupBox
function New-Group($text, $x, $y, $w, $h) {
$g = New-Object System.Windows.Forms.GroupBox
$g.Text = $text; $g.Location = New-Object System.Drawing.Point($x,$y)
$g.Size = New-Object System.Drawing.Size($w,$h); $g
}
# helper: CheckBox
function New-Check($text, $x, $y, $w, $checked=$false) {
$c = New-Object System.Windows.Forms.CheckBox
$c.Text = $text; $c.Location = New-Object System.Drawing.Point($x,$y)
$c.Size = New-Object System.Drawing.Size($w,22); $c.Checked = $checked; $c
}
# helper: RadioButton
function New-Radio($text, $x, $y, $w, $checked=$false) {
$r = New-Object System.Windows.Forms.RadioButton
$r.Text = $text; $r.Location = New-Object System.Drawing.Point($x,$y)
$r.Size = New-Object System.Drawing.Size($w,22); $r.Checked = $checked; $r
}
# helper: Action button
function New-ActionBtn($text, $x, $y, $color) {
$b = New-Object System.Windows.Forms.Button
$b.Text = $text; $b.Location = New-Object System.Drawing.Point($x,$y)
$b.Size = New-Object System.Drawing.Size(155,34); $b.BackColor = $color
$b.ForeColor = [System.Drawing.Color]::White; $b.FlatStyle = "Flat"
$b.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Bold); $b
}
# ══ Tab 1: Permissions ════════════════════════════════════════════════════════
$tabPerms = New-Object System.Windows.Forms.TabPage
$tabPerms.Text = " Permissions Report "
$tabPerms.BackColor = [System.Drawing.Color]::WhiteSmoke
$grpPermOpts = New-Group "Scan Options" 10 10 615 96
$chkScanFolders = New-Check "Scan Folders" 15 24 150 $true
$chkRecursive = New-Check "Recursive (subsites)" 175 24 185
# Folder depth controls (only active when Scan Folders is checked)
$lblPermDepth = New-Object System.Windows.Forms.Label
$lblPermDepth.Text = "Folder depth :"
$lblPermDepth.Location = New-Object System.Drawing.Point(15, 50)
$lblPermDepth.Size = New-Object System.Drawing.Size(100, 22)
$lblPermDepth.TextAlign = "MiddleLeft"
$nudPermDepth = New-Object System.Windows.Forms.NumericUpDown
$nudPermDepth.Location = New-Object System.Drawing.Point(118, 50)
$nudPermDepth.Size = New-Object System.Drawing.Size(52, 22)
$nudPermDepth.Minimum = 1
$nudPermDepth.Maximum = 20
$nudPermDepth.Value = 1
$chkPermMaxDepth = New-Object System.Windows.Forms.CheckBox
$chkPermMaxDepth.Text = "Maximum (all levels)"
$chkPermMaxDepth.Location = New-Object System.Drawing.Point(182, 52)
$chkPermMaxDepth.Size = New-Object System.Drawing.Size(180, 20)
$chkInheritedPerms = New-Check "Include Inherited Permissions" 15 74 230
$grpPermOpts.Controls.AddRange(@($chkScanFolders, $chkRecursive, $lblPermDepth, $nudPermDepth, $chkPermMaxDepth, $chkInheritedPerms))
# Disable depth controls when Scan Folders is unchecked
$chkScanFolders.Add_CheckedChanged({
$on = $chkScanFolders.Checked
$lblPermDepth.Enabled = $on
$nudPermDepth.Enabled = $on -and -not $chkPermMaxDepth.Checked
$chkPermMaxDepth.Enabled = $on
})
# When Maximum is checked, grey out the spinner
$chkPermMaxDepth.Add_CheckedChanged({
$nudPermDepth.Enabled = $chkScanFolders.Checked -and -not $chkPermMaxDepth.Checked
})
$grpPermFmt = New-Group "Export Format" 10 114 615 58
$radPermCSV = New-Radio "CSV — raw data, Excel-friendly" 15 24 280 $true
$radPermHTML = New-Radio "HTML — visual report, client-friendly" 305 24 290
$grpPermFmt.Controls.AddRange(@($radPermCSV, $radPermHTML))
$btnGenPerms = New-ActionBtn "Generate Report" 10 184 ([System.Drawing.Color]::SteelBlue)
$btnOpenPerms = New-Object System.Windows.Forms.Button
$btnOpenPerms.Text = "Open Report"
$btnOpenPerms.Location = New-Object System.Drawing.Point(175, 184)
$btnOpenPerms.Size = New-Object System.Drawing.Size(120, 34)
$btnOpenPerms.Enabled = $false
$tabPerms.Controls.AddRange(@($grpPermOpts, $grpPermFmt, $btnGenPerms, $btnOpenPerms))
# ══ Tab 2: Storage Metrics ════════════════════════════════════════════════════
$tabStorage = New-Object System.Windows.Forms.TabPage
$tabStorage.Text = " Storage Metrics "
$tabStorage.BackColor = [System.Drawing.Color]::WhiteSmoke
$grpStorOpts = New-Group "Scan Options" 10 10 615 108
$chkStorPerLib = New-Check "Per-Library Breakdown" 15 24 200 $true
$chkStorSubsites = New-Check "Include Subsites" 230 24 170
# Folder depth controls (only relevant in per-library mode)
$lblDepth = New-Object System.Windows.Forms.Label
$lblDepth.Text = "Folder depth :"
$lblDepth.Location = New-Object System.Drawing.Point(15, 52)
$lblDepth.Size = New-Object System.Drawing.Size(100, 22)
$lblDepth.TextAlign = "MiddleLeft"
$nudDepth = New-Object System.Windows.Forms.NumericUpDown
$nudDepth.Location = New-Object System.Drawing.Point(118, 52)
$nudDepth.Size = New-Object System.Drawing.Size(52, 22)
$nudDepth.Minimum = 1
$nudDepth.Maximum = 20
$nudDepth.Value = 1
$chkMaxDepth = New-Object System.Windows.Forms.CheckBox
$chkMaxDepth.Text = "Maximum (all levels)"
$chkMaxDepth.Location = New-Object System.Drawing.Point(182, 54)
$chkMaxDepth.Size = New-Object System.Drawing.Size(180, 20)
$lblStorNote = New-Object System.Windows.Forms.Label
$lblStorNote.Text = "Note: deeper folder scans on large sites may take several minutes."
$lblStorNote.Location = New-Object System.Drawing.Point(15, 80)
$lblStorNote.Size = New-Object System.Drawing.Size(580, 18)
$lblStorNote.ForeColor = [System.Drawing.Color]::Gray
$lblStorNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
$grpStorOpts.Controls.AddRange(@($chkStorPerLib, $chkStorSubsites, $lblDepth, $nudDepth, $chkMaxDepth, $lblStorNote))
$grpStorFmt = New-Group "Export Format" 10 128 615 58
$radStorCSV = New-Radio "CSV — raw data, Excel-friendly" 15 24 280 $true
$radStorHTML = New-Radio "HTML — visual report, client-friendly" 305 24 290
$grpStorFmt.Controls.AddRange(@($radStorCSV, $radStorHTML))
$msGreen = [System.Drawing.Color]::FromArgb(16,124,16)
$btnGenStorage = New-ActionBtn "Generate Metrics" 10 200 $msGreen
$btnOpenStorage = New-Object System.Windows.Forms.Button
$btnOpenStorage.Text = "Open Report"
$btnOpenStorage.Location = New-Object System.Drawing.Point(175, 200)
$btnOpenStorage.Size = New-Object System.Drawing.Size(120, 34)
$btnOpenStorage.Enabled = $false
# Disable depth controls when Per-Library is unchecked
$chkStorPerLib.Add_CheckedChanged({
$on = $chkStorPerLib.Checked
$lblDepth.Enabled = $on
$nudDepth.Enabled = $on -and -not $chkMaxDepth.Checked
$chkMaxDepth.Enabled = $on
})
# When Maximum is checked, grey out the spinner
$chkMaxDepth.Add_CheckedChanged({
$nudDepth.Enabled = $chkStorPerLib.Checked -and -not $chkMaxDepth.Checked
})
$tabStorage.Controls.AddRange(@($grpStorOpts, $grpStorFmt, $btnGenStorage, $btnOpenStorage))
# ══ Tab 3: Templates ══════════════════════════════════════════════════════
$tabTemplates = New-Object System.Windows.Forms.TabPage
$tabTemplates.Text = " Templates "
$tabTemplates.BackColor = [System.Drawing.Color]::WhiteSmoke
$lblTplDesc = New-Object System.Windows.Forms.Label
$lblTplDesc.Text = "Creez des templates depuis un site existant et appliquez-les pour creer de nouveaux sites."
$lblTplDesc.Location = New-Object System.Drawing.Point(10, 18)
$lblTplDesc.Size = New-Object System.Drawing.Size(580, 20)
$lblTplDesc.ForeColor = [System.Drawing.Color]::DimGray
$lblTplCount = New-Object System.Windows.Forms.Label
$lblTplCount.Name = "lblTplCount"
$lblTplCount.Location = New-Object System.Drawing.Point(10, 44)
$lblTplCount.Size = New-Object System.Drawing.Size(380, 20)
$lblTplCount.ForeColor = [System.Drawing.Color]::DimGray
$lblTplCount.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
$btnOpenTplMgr = New-Object System.Windows.Forms.Button
$btnOpenTplMgr.Text = "Gerer les templates..."
$btnOpenTplMgr.Location = New-Object System.Drawing.Point(10, 72)
$btnOpenTplMgr.Size = New-Object System.Drawing.Size(185, 34)
$btnOpenTplMgr.BackColor = [System.Drawing.Color]::FromArgb(50, 50, 120)
$btnOpenTplMgr.ForeColor = [System.Drawing.Color]::White
$btnOpenTplMgr.FlatStyle = "Flat"
$btnOpenTplMgr.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
$tabTemplates.Controls.AddRange(@($lblTplDesc, $lblTplCount, $btnOpenTplMgr))
# ══ Tab 4: Recherche de fichiers ══════════════════════════════════════════════
$tabSearch = New-Object System.Windows.Forms.TabPage
$tabSearch.Text = " Recherche de fichiers "
$tabSearch.BackColor = [System.Drawing.Color]::WhiteSmoke
# ── GroupBox Filtres ───────────────────────────────────────────────────────────
$grpSearchFilters = New-Group "Filtres de recherche" 10 6 620 170
# Row 1 — Extension & Regex
$lblSrchExt = New-Object System.Windows.Forms.Label
$lblSrchExt.Text = "Extension(s) :"
$lblSrchExt.Location = New-Object System.Drawing.Point(10, 24)
$lblSrchExt.Size = New-Object System.Drawing.Size(88, 22)
$lblSrchExt.TextAlign = "MiddleLeft"
$txtSrchExt = New-Object System.Windows.Forms.TextBox
$txtSrchExt.Location = New-Object System.Drawing.Point(100, 24)
$txtSrchExt.Size = New-Object System.Drawing.Size(120, 22)
$txtSrchExt.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtSrchExt.PlaceholderText = "docx pdf xlsx"
$lblSrchRegex = New-Object System.Windows.Forms.Label
$lblSrchRegex.Text = "Nom / Regex :"
$lblSrchRegex.Location = New-Object System.Drawing.Point(232, 24)
$lblSrchRegex.Size = New-Object System.Drawing.Size(88, 22)
$lblSrchRegex.TextAlign = "MiddleLeft"
$txtSrchRegex = New-Object System.Windows.Forms.TextBox
$txtSrchRegex.Location = New-Object System.Drawing.Point(322, 24)
$txtSrchRegex.Size = New-Object System.Drawing.Size(286, 22)
$txtSrchRegex.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtSrchRegex.PlaceholderText = "Ex: rapport.* ou \.bak$"
# Row 2 — Created dates
$chkSrchCrA = New-Object System.Windows.Forms.CheckBox
$chkSrchCrA.Text = "Cree apres le :"
$chkSrchCrA.Location = New-Object System.Drawing.Point(10, 52)
$chkSrchCrA.Size = New-Object System.Drawing.Size(108, 22)
$dtpSrchCrA = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchCrA.Location = New-Object System.Drawing.Point(120, 52)
$dtpSrchCrA.Size = New-Object System.Drawing.Size(130, 22)
$dtpSrchCrA.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchCrA.Enabled = $false
$chkSrchCrB = New-Object System.Windows.Forms.CheckBox
$chkSrchCrB.Text = "Cree avant le :"
$chkSrchCrB.Location = New-Object System.Drawing.Point(262, 52)
$chkSrchCrB.Size = New-Object System.Drawing.Size(108, 22)
$dtpSrchCrB = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchCrB.Location = New-Object System.Drawing.Point(372, 52)
$dtpSrchCrB.Size = New-Object System.Drawing.Size(130, 22)
$dtpSrchCrB.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchCrB.Enabled = $false
$chkSrchCrA.Add_CheckedChanged({ $dtpSrchCrA.Enabled = $chkSrchCrA.Checked })
$chkSrchCrB.Add_CheckedChanged({ $dtpSrchCrB.Enabled = $chkSrchCrB.Checked })
# Row 3 — Modified dates
$chkSrchModA = New-Object System.Windows.Forms.CheckBox
$chkSrchModA.Text = "Modifie apres :"
$chkSrchModA.Location = New-Object System.Drawing.Point(10, 80)
$chkSrchModA.Size = New-Object System.Drawing.Size(108, 22)
$dtpSrchModA = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchModA.Location = New-Object System.Drawing.Point(120, 80)
$dtpSrchModA.Size = New-Object System.Drawing.Size(130, 22)
$dtpSrchModA.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchModA.Enabled = $false
$chkSrchModB = New-Object System.Windows.Forms.CheckBox
$chkSrchModB.Text = "Modifie avant :"
$chkSrchModB.Location = New-Object System.Drawing.Point(262, 80)
$chkSrchModB.Size = New-Object System.Drawing.Size(108, 22)
$dtpSrchModB = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchModB.Location = New-Object System.Drawing.Point(372, 80)
$dtpSrchModB.Size = New-Object System.Drawing.Size(130, 22)
$dtpSrchModB.Format = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchModB.Enabled = $false
$chkSrchModA.Add_CheckedChanged({ $dtpSrchModA.Enabled = $chkSrchModA.Checked })
$chkSrchModB.Add_CheckedChanged({ $dtpSrchModB.Enabled = $chkSrchModB.Checked })
# Row 4 — Created by / Modified by
$lblSrchCrBy = New-Object System.Windows.Forms.Label
$lblSrchCrBy.Text = "Cree par :"
$lblSrchCrBy.Location = New-Object System.Drawing.Point(10, 108)
$lblSrchCrBy.Size = New-Object System.Drawing.Size(70, 22)
$lblSrchCrBy.TextAlign = "MiddleLeft"
$txtSrchCrBy = New-Object System.Windows.Forms.TextBox
$txtSrchCrBy.Location = New-Object System.Drawing.Point(82, 108)
$txtSrchCrBy.Size = New-Object System.Drawing.Size(168, 22)
$txtSrchCrBy.PlaceholderText = "Prenom Nom ou email"
$lblSrchModBy = New-Object System.Windows.Forms.Label
$lblSrchModBy.Text = "Modifie par :"
$lblSrchModBy.Location = New-Object System.Drawing.Point(262, 108)
$lblSrchModBy.Size = New-Object System.Drawing.Size(82, 22)
$lblSrchModBy.TextAlign = "MiddleLeft"
$txtSrchModBy = New-Object System.Windows.Forms.TextBox
$txtSrchModBy.Location = New-Object System.Drawing.Point(346, 108)
$txtSrchModBy.Size = New-Object System.Drawing.Size(168, 22)
$txtSrchModBy.PlaceholderText = "Prenom Nom ou email"
# Row 5 — Library filter
$lblSrchLib = New-Object System.Windows.Forms.Label
$lblSrchLib.Text = "Bibliotheque :"
$lblSrchLib.Location = New-Object System.Drawing.Point(10, 136)
$lblSrchLib.Size = New-Object System.Drawing.Size(88, 22)
$lblSrchLib.TextAlign = "MiddleLeft"
$txtSrchLib = New-Object System.Windows.Forms.TextBox
$txtSrchLib.Location = New-Object System.Drawing.Point(100, 136)
$txtSrchLib.Size = New-Object System.Drawing.Size(508, 22)
$txtSrchLib.PlaceholderText = "Chemin relatif optionnel ex: Documents partages"
$grpSearchFilters.Controls.AddRange(@(
$lblSrchExt, $txtSrchExt, $lblSrchRegex, $txtSrchRegex,
$chkSrchCrA, $dtpSrchCrA, $chkSrchCrB, $dtpSrchCrB,
$chkSrchModA, $dtpSrchModA, $chkSrchModB, $dtpSrchModB,
$lblSrchCrBy, $txtSrchCrBy, $lblSrchModBy, $txtSrchModBy,
$lblSrchLib, $txtSrchLib
))
# ── GroupBox Format ────────────────────────────────────────────────────────────
$grpSearchFmt = New-Group "Format d'export" 10 180 620 48
$radSrchCSV = New-Radio "CSV (Excel)" 15 22 130 $true
$radSrchHTML = New-Radio "HTML (rapport visuel)" 160 22 180
$lblSrchMax = New-Object System.Windows.Forms.Label
$lblSrchMax.Text = "Max resultats :"
$lblSrchMax.Location = New-Object System.Drawing.Point(360, 22)
$lblSrchMax.Size = New-Object System.Drawing.Size(96, 22)
$lblSrchMax.TextAlign = "MiddleLeft"
$nudSrchMax = New-Object System.Windows.Forms.NumericUpDown
$nudSrchMax.Location = New-Object System.Drawing.Point(458, 22)
$nudSrchMax.Size = New-Object System.Drawing.Size(70, 22)
$nudSrchMax.Minimum = 10
$nudSrchMax.Maximum = 50000
$nudSrchMax.Value = 500
$nudSrchMax.Increment = 100
$grpSearchFmt.Controls.AddRange(@($radSrchCSV, $radSrchHTML, $lblSrchMax, $nudSrchMax))
# ── Buttons ────────────────────────────────────────────────────────────────────
$btnSearch = New-ActionBtn "Lancer la recherche" 10 232 ([System.Drawing.Color]::FromArgb(0, 120, 212))
$btnOpenSearch = New-Object System.Windows.Forms.Button
$btnOpenSearch.Text = "Ouvrir resultats"
$btnOpenSearch.Location = New-Object System.Drawing.Point(175, 232)
$btnOpenSearch.Size = New-Object System.Drawing.Size(130, 34)
$btnOpenSearch.Enabled = $false
$tabSearch.Controls.AddRange(@($grpSearchFilters, $grpSearchFmt, $btnSearch, $btnOpenSearch))
# ══ Tab 5: Doublons ═══════════════════════════════════════════════════════════
$tabDupes = New-Object System.Windows.Forms.TabPage
$tabDupes.Text = " Doublons "
$tabDupes.BackColor = [System.Drawing.Color]::WhiteSmoke
# ── GroupBox: Type de doublons (y=4, h=44 → bottom 48) ──────────────────────
$grpDupType = New-Group "Type de doublons" 10 4 638 44
$radDupFiles = New-Radio "Fichiers en double" 10 16 190 $true
$radDupFolders = New-Radio "Dossiers en double" 210 16 190
$grpDupType.Controls.AddRange(@($radDupFiles, $radDupFolders))
# ── GroupBox: Critères de comparaison (y=52, h=88 → bottom 140) ─────────────
$grpDupCrit = New-Group "Criteres de comparaison" 10 52 638 88
$lblDupNote = New-Object System.Windows.Forms.Label
$lblDupNote.Text = "Le nom est toujours le critere principal. Cochez les criteres supplementaires :"
$lblDupNote.Location = New-Object System.Drawing.Point(10, 15)
$lblDupNote.Size = New-Object System.Drawing.Size(610, 16)
$lblDupNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
$lblDupNote.ForeColor = [System.Drawing.Color]::DimGray
# Row 1 — criteres communs
$chkDupSize = New-Check "Taille identique" 10 34 148 $true
$chkDupCreated = New-Check "Date de creation identique" 164 34 208
$chkDupModified = New-Check "Date de modification identique" 378 34 226
# Row 2 — criteres dossiers uniquement
$chkDupSubCount = New-Check "Nb sous-dossiers identique" 10 60 210
$chkDupFileCount = New-Check "Nb fichiers identique" 226 60 200
$chkDupSubCount.Enabled = $false
$chkDupFileCount.Enabled = $false
$grpDupCrit.Controls.AddRange(@($lblDupNote,
$chkDupSize, $chkDupCreated, $chkDupModified,
$chkDupSubCount, $chkDupFileCount))
# Toggle folder-only criteria based on radio selection
$radDupFiles.Add_CheckedChanged({
$chkDupSubCount.Enabled = -not $radDupFiles.Checked
$chkDupFileCount.Enabled = -not $radDupFiles.Checked
if ($radDupFiles.Checked) { $chkDupSubCount.Checked = $false; $chkDupFileCount.Checked = $false }
})
$radDupFolders.Add_CheckedChanged({
$chkDupSubCount.Enabled = $radDupFolders.Checked
$chkDupFileCount.Enabled = $radDupFolders.Checked
})
# ── GroupBox: Options (y=144, h=44 → bottom 188) ─────────────────────────────
$grpDupOpts = New-Group "Options" 10 144 638 44
$chkDupSubsites = New-Check "Inclure les sous-sites" 10 18 192
$lblDupLib = New-Object System.Windows.Forms.Label
$lblDupLib.Text = "Bibliotheque :"
$lblDupLib.Location = New-Object System.Drawing.Point(210, 18)
$lblDupLib.Size = New-Object System.Drawing.Size(88, 22)
$lblDupLib.TextAlign = "MiddleLeft"
$txtDupLib = New-Object System.Windows.Forms.TextBox
$txtDupLib.Location = New-Object System.Drawing.Point(300, 18)
$txtDupLib.Size = New-Object System.Drawing.Size(326, 22)
$txtDupLib.PlaceholderText = "Toutes (laisser vide)"
$grpDupOpts.Controls.AddRange(@($chkDupSubsites, $lblDupLib, $txtDupLib))
# ── GroupBox: Format (y=192, h=40 → bottom 232) ──────────────────────────────
$grpDupFmt = New-Group "Format d'export" 10 192 638 40
$radDupCSV = New-Radio "CSV (Excel)" 10 16 130 $true
$radDupHTML = New-Radio "HTML (rapport visuel)" 155 16 200
$grpDupFmt.Controls.AddRange(@($radDupCSV, $radDupHTML))
# ── Buttons (y=236 → bottom 270, within 284px inner) ─────────────────────────
$btnScanDupes = New-ActionBtn "Lancer le scan" 10 236 ([System.Drawing.Color]::FromArgb(136, 0, 21))
$btnOpenDupes = New-Object System.Windows.Forms.Button
$btnOpenDupes.Text = "Ouvrir resultats"
$btnOpenDupes.Location = New-Object System.Drawing.Point(175, 236)
$btnOpenDupes.Size = New-Object System.Drawing.Size(130, 34)
$btnOpenDupes.Enabled = $false
$tabDupes.Controls.AddRange(@($grpDupType, $grpDupCrit, $grpDupOpts, $grpDupFmt, $btnScanDupes, $btnOpenDupes))
$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes))
# ── Progress bar ───────────────────────────────────────────────────────────────
$progressBar = New-Object System.Windows.Forms.ProgressBar
$progressBar.Location = New-Object System.Drawing.Point(20, 540)
$progressBar.Size = New-Object System.Drawing.Size(642, 16)
$progressBar.Style = "Marquee"
$progressBar.MarqueeAnimationSpeed = 0
# ── Log ────────────────────────────────────────────────────────────────────────
$lblLog = New-Object System.Windows.Forms.Label
$lblLog.Text = "Log :"
$lblLog.Location = New-Object System.Drawing.Point(20, 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 = @()
$form.Controls.AddRange(@(
$lblProfile, $cboProfile,
$btnProfileNew, $btnProfileSave, $btnProfileRename, $btnProfileDelete,
$lblTenantUrl, $txtTenantUrl, $btnBrowseSites,
$lblClientId, $txtClientId,
$lblSiteURL, $txtSiteURL,
$lblOutput, $txtOutput, $btnBrowse,
$lblDataDir, $txtDataDir, $btnBrowseDataDir,
$sep, $tabs,
$progressBar,
$lblLog, $txtLog
))
#endregion
#region ===== Event Handlers =====
# ── Profile Management ─────────────────────────────────────────────────────────
$cboProfile.Add_SelectedIndexChanged({
$idx = $cboProfile.SelectedIndex
Apply-Profile -idx $idx
})
$btnProfileNew.Add_Click({
$name = Show-InputDialog -Prompt "Nom du profil :" -Title "Nouveau profil" -Default "Nouveau profil" -Owner $form
if ([string]::IsNullOrWhiteSpace($name)) { return }
$newProfile = [PSCustomObject]@{
name = $name
clientId = $txtClientId.Text.Trim()
tenantUrl = $txtTenantUrl.Text.Trim()
}
$list = @($script:Profiles) + $newProfile
Save-Profiles -Profiles $list
Refresh-ProfileList
$idx = $cboProfile.Items.IndexOf($name)
if ($idx -ge 0) { $cboProfile.SelectedIndex = $idx }
})
$btnProfileSave.Add_Click({
$idx = $cboProfile.SelectedIndex
if ($idx -lt 0) {
[System.Windows.Forms.MessageBox]::Show("Selectionnez d'abord un profil ou creez-en un nouveau.", "Aucun profil selectionne", "OK", "Warning")
return
}
$script:Profiles[$idx].clientId = $txtClientId.Text.Trim()
if (-not $script:Profiles[$idx].PSObject.Properties['tenantUrl']) {
$script:Profiles[$idx] | Add-Member -NotePropertyName tenantUrl -NotePropertyValue ""
}
$script:Profiles[$idx].tenantUrl = $txtTenantUrl.Text.Trim()
Save-Profiles -Profiles $script:Profiles
[System.Windows.Forms.MessageBox]::Show("Profil '$($script:Profiles[$idx].name)' sauvegarde.", "Sauvegarde", "OK", "Information")
})
$btnProfileRename.Add_Click({
$idx = $cboProfile.SelectedIndex
if ($idx -lt 0) { return }
$oldName = $script:Profiles[$idx].name
$newName = Show-InputDialog -Prompt "Nouveau nom du profil :" -Title "Renommer le profil" -Default $oldName -Owner $form
if ([string]::IsNullOrWhiteSpace($newName) -or $newName -eq $oldName) { return }
$script:Profiles[$idx].name = $newName
Save-Profiles -Profiles $script:Profiles
Refresh-ProfileList
$idx2 = $cboProfile.Items.IndexOf($newName)
if ($idx2 -ge 0) { $cboProfile.SelectedIndex = $idx2 }
})
$btnProfileDelete.Add_Click({
$idx = $cboProfile.SelectedIndex
if ($idx -lt 0) { return }
$name = $script:Profiles[$idx].name
$res = [System.Windows.Forms.MessageBox]::Show("Supprimer le profil '$name' ?", "Confirmer la suppression", "YesNo", "Warning")
if ($res -ne "Yes") { return }
$list = @($script:Profiles | Where-Object { $_.name -ne $name })
Save-Profiles -Profiles $list
Refresh-ProfileList
})
$btnBrowse.Add_Click({
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.SelectedPath = $txtOutput.Text
if ($dlg.ShowDialog() -eq "OK") { $txtOutput.Text = $dlg.SelectedPath }
})
$btnBrowseDataDir.Add_Click({
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = "Selectionnez le dossier de stockage des fichiers JSON (profils, templates)"
$dlg.SelectedPath = if ($txtDataDir.Text -and (Test-Path $txtDataDir.Text)) {
$txtDataDir.Text
} else {
if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
}
if ($dlg.ShowDialog() -eq "OK") {
$txtDataDir.Text = $dlg.SelectedPath
$script:DataFolder = $dlg.SelectedPath
Save-Settings -DataFolder $dlg.SelectedPath
Refresh-ProfileList
$n = (Load-Templates).Count
$lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer"
}
})
$txtDataDir.Add_Leave({
$newDir = $txtDataDir.Text.Trim()
if ([string]::IsNullOrWhiteSpace($newDir)) { return }
if (-not (Test-Path $newDir)) {
$res = [System.Windows.Forms.MessageBox]::Show(
"Le dossier '$newDir' n'existe pas. Voulez-vous le creer ?",
"Dossier introuvable", "YesNo", "Question")
if ($res -eq "Yes") {
try { New-Item -ItemType Directory -Path $newDir | Out-Null }
catch {
[System.Windows.Forms.MessageBox]::Show(
"Impossible de creer le dossier : $($_.Exception.Message)",
"Erreur", "OK", "Error")
return
}
} else { return }
}
$script:DataFolder = $newDir
Save-Settings -DataFolder $newDir
Refresh-ProfileList
$n = (Load-Templates).Count
$lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer"
})
$btnBrowseSites.Add_Click({
$tenantUrl = $txtTenantUrl.Text.Trim()
$clientId = $txtClientId.Text.Trim()
if ([string]::IsNullOrWhiteSpace($tenantUrl)) {
[System.Windows.Forms.MessageBox]::Show(
"Veuillez renseigner le Tenant URL (ex: https://contoso.sharepoint.com).",
"Tenant URL manquant", "OK", "Warning")
return
}
if ([string]::IsNullOrWhiteSpace($clientId)) {
[System.Windows.Forms.MessageBox]::Show(
"Veuillez renseigner le Client ID.", "Client ID manquant", "OK", "Warning")
return
}
$selected = Show-SitePicker -TenantUrl $tenantUrl -ClientId $clientId -Owner $form
if ($selected -and $selected.Count -gt 0) {
$script:SelectedSites = @($selected)
# Populate Site URL with first selection for compatibility
$txtSiteURL.Text = $selected[0]
$n = $selected.Count
$btnBrowseSites.Text = if ($n -eq 1) { "Voir les sites" } else { "Sites ($n)" }
}
})
# ── Permissions ────────────────────────────────────────────────────────────────
$btnGenPerms.Add_Click({
if (-not (Validate-Inputs)) { return }
# Build site list: picker selection takes priority, else txtSiteURL
$siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
@($script:SelectedSites)
} else {
@($txtSiteURL.Text.Trim())
}
$siteUrls = @($siteUrls | Where-Object { $_ })
$script:pnpCiD = $txtClientId.Text.Trim()
$script:PermFormat = if ($radPermHTML.Checked) { "HTML" } else { "CSV" }
$script:PermFolderDepth = if (-not $chkScanFolders.Checked) { 0 }
elseif ($chkPermMaxDepth.Checked) { 999 }
else { [int]$nudPermDepth.Value }
$btnGenPerms.Enabled = $false
$btnOpenPerms.Enabled = $false
$txtLog.Clear()
$progressBar.MarqueeAnimationSpeed = 30
$depthLabel = if ($script:PermFolderDepth -ge 999) { "Maximum" } elseif ($script:PermFolderDepth -eq 0) { "N/A (folder scan off)" } else { $script:PermFolderDepth }
Write-Log "=== PERMISSIONS REPORT ===" "White"
Write-Log "Sites : $($siteUrls.Count)" "Gray"
Write-Log "Format : $($script:PermFormat)" "Gray"
Write-Log "Folder depth : $depthLabel" "Gray"
Write-Log ("-" * 52) "DarkGray"
$lastFile = $null
foreach ($SiteURL in $siteUrls) {
$SiteName = ($SiteURL -split '/')[-1]; if (!$SiteName) { $SiteName = "root" }
$csvPath = Join-Path $txtOutput.Text "$SiteName-Permissions.csv"
$script:PermOutputFile = $csvPath
$script:AllPermissions = @()
Write-Log ""
Write-Log "--- Site: $SiteURL" "White"
$params = @{ SiteURL = $SiteURL; ReportFile = $csvPath }
if ($chkScanFolders.Checked) { $params.ScanFolders = $true }
if ($chkRecursive.Checked) { $params.Recursive = $true }
if ($chkInheritedPerms.Checked) { $params.IncludeInheritedPermissions = $true }
try {
Generate-PnPSitePermissionRpt @params
Write-Log ("-" * 52) "DarkGray"
Write-Log "Done! $($script:AllPermissions.Count) entries -- Saved: $csvPath" "Cyan"
$lastFile = $csvPath
$btnOpenPerms.Enabled = $true
}
catch { Write-Log "Failed ($SiteURL): $($_.Exception.Message)" "Red" }
}
$script:PermOutputFile = $lastFile
$btnGenPerms.Enabled = $true
$progressBar.MarqueeAnimationSpeed = 0
})
$btnOpenPerms.Add_Click({
if ($script:PermOutputFile -and (Test-Path $script:PermOutputFile)) {
Start-Process $script:PermOutputFile
}
})
# ── Storage ────────────────────────────────────────────────────────────────────
# Background worker scriptblock (shared across all site scans)
$script:_StorBgWork = {
param($SiteURL, $ClientId, $InclSub, $PerLib, $FolderDepth, $Sync)
$script:_bgSync = $Sync
$script:_bgDepth = $FolderDepth
function BgLog([string]$msg, [string]$color = "LightGreen") {
$script:_bgSync.Queue.Enqueue([PSCustomObject]@{ Text = $msg; Color = $color })
}
function Format-Bytes([long]$b) {
if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" }
if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" }
if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" }
return "$b B"
}
function Collect-FolderStorage([string]$SiteRelUrl, [string]$WebBaseUrl, [int]$Depth) {
if ($Depth -ge $script:_bgDepth) { return @() }
$out = [System.Collections.Generic.List[object]]::new()
try {
$items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue
foreach ($fi in $items) {
$childUrl = "$SiteRelUrl/$($fi.Name)"
try {
$sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $childUrl -ErrorAction SilentlyContinue
$sub = Collect-FolderStorage -SiteRelUrl $childUrl -WebBaseUrl $WebBaseUrl -Depth ($Depth + 1)
$out.Add([PSCustomObject]@{
Name = $fi.Name
URL = "$($WebBaseUrl.TrimEnd('/'))/$childUrl"
ItemCount = $sm.TotalFileCount
SizeBytes = $sm.TotalSize
VersionSizeBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
SubFolders = $sub
})
} catch {}
}
} catch {}
return @($out)
}
$script:_bgResults = [System.Collections.Generic.List[object]]::new()
function Collect-WebStorage([string]$WebUrl, [bool]$PerLib, [bool]$InclSub, [string]$ClientId) {
Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId
$web = Get-PnPWeb
$wUrl = $web.Url
$wSrl = $web.ServerRelativeUrl.TrimEnd('/')
BgLog "Site : $($web.Title)" "Yellow"
if ($PerLib) {
$lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden }
BgLog "`t$($lists.Count) bibliotheque(s) trouvee(s)" "Gray"
foreach ($list in $lists) {
$rf = Get-PnPProperty -ClientObject $list -Property RootFolder
try {
$srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
$sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $srl
$libUrl = "$($wUrl.TrimEnd('/'))/$srl"
$subs = Collect-FolderStorage -SiteRelUrl $srl -WebBaseUrl $wUrl -Depth 0
$verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
$script:_bgResults.Add([PSCustomObject]@{
SiteTitle = $web.Title
SiteURL = $wUrl
Library = $list.Title
LibraryURL = $libUrl
ItemCount = $sm.TotalFileCount
SizeBytes = $sm.TotalSize
VersionSizeBytes = $verBytes
SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
VersionSizeMB = [math]::Round($verBytes / 1MB, 1)
SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
SubFolders = $subs
})
BgLog "`t $($list.Title): $(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] ($($sm.TotalFileCount) files)" "Cyan"
} catch { BgLog "`t '$($list.Title)' skipped: $($_.Exception.Message)" "DarkGray" }
}
} else {
try {
$sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/"
$verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
$script:_bgResults.Add([PSCustomObject]@{
SiteTitle = $web.Title
SiteURL = $wUrl
Library = "(All Libraries)"
LibraryURL = $wUrl
ItemCount = $sm.TotalFileCount
SizeBytes = $sm.TotalSize
VersionSizeBytes = $verBytes
SizeMB = [math]::Round($sm.TotalSize / 1MB, 1)
VersionSizeMB = [math]::Round($verBytes / 1MB, 1)
SizeGB = [math]::Round($sm.TotalSize / 1GB, 3)
LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
})
BgLog "`t$(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] -- $($sm.TotalFileCount) files" "Cyan"
} catch { BgLog "`tIgnored: $($_.Exception.Message)" "DarkGray" }
}
if ($InclSub) {
$subwebs = Get-PnPSubWeb
foreach ($sub in $subwebs) {
BgLog "`tSubsite : $($sub.Title)" "Yellow"
Collect-WebStorage -WebUrl $sub.Url -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId
Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId
}
}
}
try {
Import-Module PnP.PowerShell -ErrorAction Stop
Collect-WebStorage -WebUrl $SiteURL -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId
$web = Get-PnPWeb
$Sync.WebTitle = $web.Title
$Sync.WebURL = $web.URL
$Sync.Data = @($script:_bgResults)
} catch {
$Sync.Error = $_.Exception.Message
BgLog "Error : $($_.Exception.Message)" "Red"
} finally {
$Sync.Done = $true
}
}
# Launches scan for next site in queue; called from button click and timer Done block
function Start-NextStorageScan {
if ($script:_StorSiteQueue.Count -eq 0) {
Write-Log "=== All sites processed ===" "Cyan"
$btnGenStorage.Enabled = $true
$progressBar.MarqueeAnimationSpeed = 0
return
}
$nextUrl = $script:_StorSiteQueue.Dequeue()
$siteName = ($nextUrl -split '/')[-1]; if (!$siteName) { $siteName = "root" }
$ext = if ($script:_StorFmt -eq "HTML") { ".html" } else { ".csv" }
$outFile = Join-Path $script:_StorOutFolder "$siteName-Storage$ext"
$script:_StorOut = $outFile
Write-Log ""
Write-Log "--- Site: $nextUrl" "White"
Write-Log " Output: $outFile" "Gray"
Write-Log ("-" * 52) "DarkGray"
$script:_StorSyn = [hashtable]::Synchronized(@{
Queue = [System.Collections.Generic.Queue[object]]::new()
Done = $false
Error = $null
Data = $null
WebTitle = ""
WebURL = ""
})
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($script:_StorBgWork)
[void]$ps.AddArgument($nextUrl)
[void]$ps.AddArgument($script:_StorClientId)
[void]$ps.AddArgument($script:_StorInclSub)
[void]$ps.AddArgument($script:_StorPerLib)
[void]$ps.AddArgument($script:_StorDepth)
[void]$ps.AddArgument($script:_StorSyn)
$script:_StorRS = $rs
$script:_StorPS = $ps
$script:_StorHnd = $ps.BeginInvoke()
$script:_StorTimer = New-Object System.Windows.Forms.Timer
$script:_StorTimer.Interval = 200
$script:_StorTimer.Add_Tick({
while ($script:_StorSyn.Queue.Count -gt 0) {
$m = $script:_StorSyn.Queue.Dequeue()
Write-Log $m.Text $m.Color
}
if ($script:_StorSyn.Done) {
$script:_StorTimer.Stop(); $script:_StorTimer.Dispose()
while ($script:_StorSyn.Queue.Count -gt 0) {
$m = $script:_StorSyn.Queue.Dequeue(); Write-Log $m.Text $m.Color
}
try { [void]$script:_StorPS.EndInvoke($script:_StorHnd) } catch {}
try { $script:_StorRS.Close(); $script:_StorRS.Dispose() } catch {}
if ($script:_StorSyn.Error) {
Write-Log "Failed: $($script:_StorSyn.Error)" "Red"
} elseif ($script:_StorSyn.Data -and $script:_StorSyn.Data.Count -gt 0) {
$data = $script:_StorSyn.Data
Write-Log "Writing output..." "Yellow"
if ($script:_StorFmt -eq "HTML") {
Export-StorageToHTML -Data $data -SiteTitle $script:_StorSyn.WebTitle `
-SiteURL $script:_StorSyn.WebURL -OutputPath $script:_StorOut
} else {
$data | Select-Object SiteTitle,SiteURL,Library,ItemCount,SizeMB,VersionSizeMB,SizeGB,LastModified |
Export-Csv -Path $script:_StorOut -NoTypeInformation
}
Write-Log "Done! $($data.Count) libs -- $(Format-Bytes (($data | Measure-Object -Property SizeBytes -Sum).Sum))" "Cyan"
Write-Log "Saved: $script:_StorOut" "White"
$script:_StorLastOut = $script:_StorOut
$btnOpenStorage.Enabled = $true
} else {
Write-Log "No data -- check permissions or URL." "Orange"
}
# Process next site in queue (if any)
Start-NextStorageScan
}
})
$script:_StorTimer.Start()
}
$btnGenStorage.Add_Click({
if (-not (Validate-Inputs)) { return }
# Build site list: picker selection takes priority, else txtSiteURL
$siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
@($script:SelectedSites)
} else {
@($txtSiteURL.Text.Trim())
}
$siteUrls = @($siteUrls | Where-Object { $_ })
# Store common scan parameters as script vars (read by Start-NextStorageScan)
$script:_StorClientId = $txtClientId.Text.Trim()
$script:pnpCiD = $script:_StorClientId
$script:_StorInclSub = $chkStorSubsites.Checked
$script:_StorPerLib = $chkStorPerLib.Checked
$script:_StorDepth = if (-not $chkStorPerLib.Checked) { 0 } elseif ($chkMaxDepth.Checked) { 999 } else { [int]$nudDepth.Value }
$script:_StorFmt = if ($radStorHTML.Checked) { "HTML" } else { "CSV" }
$script:_StorOutFolder = $txtOutput.Text
$script:_StorLastOut = $null
# Build queue
$script:_StorSiteQueue = [System.Collections.Generic.Queue[string]]::new()
foreach ($u in $siteUrls) { $script:_StorSiteQueue.Enqueue($u) }
$btnGenStorage.Enabled = $false
$btnOpenStorage.Enabled = $false
$txtLog.Clear()
$progressBar.MarqueeAnimationSpeed = 30
$depthLabel = if ($script:_StorDepth -ge 999) { "Maximum" } elseif ($script:_StorDepth -eq 0) { "N/A" } else { $script:_StorDepth }
Write-Log "=== STORAGE METRICS ===" "White"
Write-Log "Sites : $($siteUrls.Count)" "Gray"
Write-Log "Format : $($script:_StorFmt)" "Gray"
Write-Log "Folder depth : $depthLabel" "Gray"
Write-Log ("-" * 52) "DarkGray"
Start-NextStorageScan
})
$btnOpenStorage.Add_Click({
$f = $script:_StorLastOut
if ($f -and (Test-Path $f)) { Start-Process $f }
})
# ── Templates ───────────────────────────────────────────────────────────────
$btnOpenTplMgr.Add_Click({
Show-TemplateManager `
-DefaultSiteUrl $txtSiteURL.Text.Trim() `
-ClientId $txtClientId.Text.Trim() `
-TenantUrl $txtTenantUrl.Text.Trim() `
-Owner $form
# Refresh count after dialog closes
$n = (Load-Templates).Count
$lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer"
})
# ── Recherche de fichiers ───────────────────────────────────────────────────
$btnSearch.Add_Click({
if (-not (Validate-Inputs)) { return }
$siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
$script:SelectedSites[0]
} else { $txtSiteURL.Text.Trim() }
if ([string]::IsNullOrWhiteSpace($siteUrl)) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning")
return
}
# Validate regex before launching
$regexStr = $txtSrchRegex.Text.Trim()
if ($regexStr) {
try { [void][System.Text.RegularExpressions.Regex]::new($regexStr) }
catch {
[System.Windows.Forms.MessageBox]::Show(
"Expression reguliere invalide :`n$($_.Exception.Message)",
"Regex invalide", "OK", "Error")
return
}
}
$filters = @{
Extensions = @($txtSrchExt.Text.Trim() -split '[,\s]+' | Where-Object { $_ } | ForEach-Object { $_.TrimStart('.').ToLower() })
Regex = $regexStr
CreatedAfter = if ($chkSrchCrA.Checked) { $dtpSrchCrA.Value.Date } else { $null }
CreatedBefore = if ($chkSrchCrB.Checked) { $dtpSrchCrB.Value.Date } else { $null }
ModifiedAfter = if ($chkSrchModA.Checked) { $dtpSrchModA.Value.Date } else { $null }
ModifiedBefore= if ($chkSrchModB.Checked) { $dtpSrchModB.Value.Date } else { $null }
CreatedBy = $txtSrchCrBy.Text.Trim()
ModifiedBy = $txtSrchModBy.Text.Trim()
Library = $txtSrchLib.Text.Trim()
MaxResults = [int]$nudSrchMax.Value
Format = if ($radSrchHTML.Checked) { "HTML" } else { "CSV" }
OutFolder = $txtOutput.Text.Trim()
SiteUrl = $siteUrl
ClientId = $txtClientId.Text.Trim()
}
$btnSearch.Enabled = $false
$btnOpenSearch.Enabled = $false
$txtLog.Clear()
$progressBar.MarqueeAnimationSpeed = 30
Write-Log "=== RECHERCHE DE FICHIERS ===" "White"
Write-Log "Site : $siteUrl" "Gray"
if ($filters.Extensions.Count -gt 0) { Write-Log "Extensions : $($filters.Extensions -join ', ')" "Gray" }
if ($filters.Regex) { Write-Log "Regex : $($filters.Regex)" "Gray" }
if ($filters.CreatedAfter) { Write-Log "Cree apres : $($filters.CreatedAfter.ToString('dd/MM/yyyy'))" "Gray" }
if ($filters.CreatedBefore) { Write-Log "Cree avant : $($filters.CreatedBefore.ToString('dd/MM/yyyy'))" "Gray" }
if ($filters.ModifiedAfter) { Write-Log "Modifie apres: $($filters.ModifiedAfter.ToString('dd/MM/yyyy'))" "Gray" }
if ($filters.ModifiedBefore){ Write-Log "Modifie avant: $($filters.ModifiedBefore.ToString('dd/MM/yyyy'))" "Gray" }
if ($filters.CreatedBy) { Write-Log "Cree par : $($filters.CreatedBy)" "Gray" }
if ($filters.ModifiedBy) { Write-Log "Modifie par : $($filters.ModifiedBy)" "Gray" }
if ($filters.Library) { Write-Log "Bibliotheque : $($filters.Library)" "Gray" }
Write-Log "Max resultats: $($filters.MaxResults)" "Gray"
Write-Log ("-" * 52) "DarkGray"
$bgSearch = {
param($Filters, $Sync)
function BgLog([string]$m, [string]$c = "LightGreen") {
$Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
}
try {
Import-Module PnP.PowerShell -ErrorAction Stop
Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId
# Build KQL query
$kqlParts = @("ContentType:Document")
if ($Filters.Extensions -and $Filters.Extensions.Count -gt 0) {
$extParts = $Filters.Extensions | ForEach-Object { "FileExtension:$_" }
$kqlParts += "($($extParts -join ' OR '))"
}
if ($Filters.CreatedAfter) { $kqlParts += "Created>=$($Filters.CreatedAfter.ToString('yyyy-MM-dd'))" }
if ($Filters.CreatedBefore) { $kqlParts += "Created<=$($Filters.CreatedBefore.ToString('yyyy-MM-dd'))" }
if ($Filters.ModifiedAfter) { $kqlParts += "Write>=$($Filters.ModifiedAfter.ToString('yyyy-MM-dd'))" }
if ($Filters.ModifiedBefore) { $kqlParts += "Write<=$($Filters.ModifiedBefore.ToString('yyyy-MM-dd'))" }
if ($Filters.CreatedBy) { $kqlParts += "Author:""$($Filters.CreatedBy)""" }
if ($Filters.ModifiedBy) { $kqlParts += "ModifiedBy:""$($Filters.ModifiedBy)""" }
if ($Filters.Library) {
$libPath = "$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))"
$kqlParts += "Path:""$libPath*"""
}
$kql = $kqlParts -join " AND "
BgLog "Requete KQL : $kql" "Yellow"
$Sync.KQL = $kql
$selectProps = @("Title","Path","Author","LastModifiedTime","FileExtension","Created","ModifiedBy","Size")
$allResults = [System.Collections.Generic.List[object]]::new()
$startRow = 0
$batchSize = 500
do {
$batch = Submit-PnPSearchQuery -Query $kql `
-StartRow $startRow -MaxResults $batchSize `
-SelectProperties $selectProps -TrimDuplicates $false
$hits = @($batch.ResultRows)
foreach ($h in $hits) { $allResults.Add($h) }
BgLog " Lot $([math]::Floor($startRow/$batchSize)+1) : $($hits.Count) resultats (total: $($allResults.Count))" "Cyan"
$startRow += $batchSize
} while ($hits.Count -eq $batchSize -and $allResults.Count -lt $Filters.MaxResults)
# Client-side regex filter on file path/name
if ($Filters.Regex) {
$rx = [System.Text.RegularExpressions.Regex]::new(
$Filters.Regex,
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$allResults = [System.Collections.Generic.List[object]]@(
$allResults | Where-Object { $rx.IsMatch($_.Path) }
)
BgLog " Apres filtre regex : $($allResults.Count) resultats" "Cyan"
}
# Cap at MaxResults
if ($allResults.Count -gt $Filters.MaxResults) {
$allResults = [System.Collections.Generic.List[object]]@(
$allResults | Select-Object -First $Filters.MaxResults
)
}
BgLog "$($allResults.Count) fichier(s) trouves" "LightGreen"
$Sync.Results = @($allResults)
} catch {
$Sync.Error = $_.Exception.Message
BgLog "Erreur : $($_.Exception.Message)" "Red"
} finally {
$Sync.Done = $true
}
}
$sync = [hashtable]::Synchronized(@{
Queue = [System.Collections.Generic.Queue[object]]::new()
Done = $false
Error = $null
Results = $null
KQL = ""
})
$script:_SrchSync = $sync
$script:_SrchFilters = $filters
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($bgSearch)
[void]$ps.AddArgument($filters)
[void]$ps.AddArgument($sync)
$script:_SrchRS = $rs
$script:_SrchPS = $ps
$script:_SrchHnd = $ps.BeginInvoke()
$tmr = New-Object System.Windows.Forms.Timer
$tmr.Interval = 250
$script:_SrchTimer = $tmr
$tmr.Add_Tick({
while ($script:_SrchSync.Queue.Count -gt 0) {
$m = $script:_SrchSync.Queue.Dequeue()
Write-Log $m.Text $m.Color
}
if ($script:_SrchSync.Done) {
$script:_SrchTimer.Stop(); $script:_SrchTimer.Dispose()
while ($script:_SrchSync.Queue.Count -gt 0) {
$m = $script:_SrchSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
}
try { [void]$script:_SrchPS.EndInvoke($script:_SrchHnd) } catch {}
try { $script:_SrchRS.Close(); $script:_SrchRS.Dispose() } catch {}
$btnSearch.Enabled = $true
$progressBar.MarqueeAnimationSpeed = 0
if ($script:_SrchSync.Error) {
Write-Log "Echec : $($script:_SrchSync.Error)" "Red"
return
}
$results = $script:_SrchSync.Results
$kql = $script:_SrchSync.KQL
$f = $script:_SrchFilters
if (-not $results -or $results.Count -eq 0) {
Write-Log "Aucun fichier trouve avec ces criteres." "Orange"
return
}
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$outDir = $f.OutFolder
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
if ($f.Format -eq "HTML") {
$outFile = Join-Path $outDir "FileSearch_$stamp.html"
$html = Export-SearchResultsToHTML -Results $results -KQL $kql -SiteUrl $f.SiteUrl
$html | Set-Content -Path $outFile -Encoding UTF8
} else {
$outFile = Join-Path $outDir "FileSearch_$stamp.csv"
$results | ForEach-Object {
[PSCustomObject]@{
Title = $_.Title
Path = $_.Path
FileExtension = $_.FileExtension
Created = $_.Created
LastModifiedTime= $_.LastModifiedTime
Author = $_.Author
ModifiedBy = $_.ModifiedBy
SizeBytes = $_.Size
}
} | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8
}
Write-Log "Sauvegarde : $outFile" "White"
$script:_SrchLastOut = $outFile
$btnOpenSearch.Enabled = $true
}
})
$tmr.Start()
})
$btnOpenSearch.Add_Click({
$f = $script:_SrchLastOut
if ($f -and (Test-Path $f)) { Start-Process $f }
})
# ── Scan de doublons ────────────────────────────────────────────────────────
$btnScanDupes.Add_Click({
if (-not (Validate-Inputs)) { return }
$siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
$script:SelectedSites[0]
} else { $txtSiteURL.Text.Trim() }
if ([string]::IsNullOrWhiteSpace($siteUrl)) {
[System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning")
return
}
$dupFilters = @{
Mode = if ($radDupFolders.Checked) { "Folders" } else { "Files" }
MatchSize = $chkDupSize.Checked
MatchCreated = $chkDupCreated.Checked
MatchMod = $chkDupModified.Checked
MatchSubDir = $chkDupSubCount.Checked
MatchFiles = $chkDupFileCount.Checked
IncludeSubs = $chkDupSubsites.Checked
Library = $txtDupLib.Text.Trim()
Format = if ($radDupHTML.Checked) { "HTML" } else { "CSV" }
OutFolder = $txtOutput.Text.Trim()
SiteUrl = $siteUrl
ClientId = $txtClientId.Text.Trim()
}
$btnScanDupes.Enabled = $false
$btnOpenDupes.Enabled = $false
$txtLog.Clear()
$progressBar.MarqueeAnimationSpeed = 30
Write-Log "=== SCAN DE DOUBLONS ===" "White"
Write-Log "Mode : $($dupFilters.Mode)" "Gray"
Write-Log "Site : $siteUrl" "Gray"
Write-Log "Criteres : Nom (toujours)$(if($dupFilters.MatchSize){', Taille'})$(if($dupFilters.MatchCreated){', Cree le'})$(if($dupFilters.MatchMod){', Modifie le'})$(if($dupFilters.MatchSubDir){', Nb sous-doss.'})$(if($dupFilters.MatchFiles){', Nb fichiers'})" "Gray"
Write-Log ("-" * 52) "DarkGray"
$bgDupScan = {
param($Filters, $Sync)
function BgLog([string]$m, [string]$c = "LightGreen") {
$Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
}
function MakeKey($name, $item, $f) {
$parts = @($name.ToLower())
if ($f.MatchSize -and $null -ne $item.SizeBytes) { $parts += [string]$item.SizeBytes }
if ($f.MatchCreated -and $null -ne $item.CreatedDay) { $parts += $item.CreatedDay }
if ($f.MatchMod -and $null -ne $item.ModifiedDay){ $parts += $item.ModifiedDay }
if ($f.MatchSubDir -and $null -ne $item.FolderCount){ $parts += [string]$item.FolderCount }
if ($f.MatchFiles -and $null -ne $item.FileCount) { $parts += [string]$item.FileCount }
return $parts -join "|"
}
try {
Import-Module PnP.PowerShell -ErrorAction Stop
Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId
$allItems = [System.Collections.Generic.List[object]]::new()
if ($Filters.Mode -eq "Files") {
# ── Files: use Search API ──────────────────────────────────
$kql = "ContentType:Document"
if ($Filters.Library) {
$kql += " AND Path:""$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))*"""
}
BgLog "Requete KQL : $kql" "Yellow"
$startRow = 0; $batchSize = 500
do {
$batch = Submit-PnPSearchQuery -Query $kql `
-StartRow $startRow -MaxResults $batchSize `
-SelectProperties @("Title","Path","Author","LastModifiedTime","FileExtension","Created","Size") `
-TrimDuplicates $false
$hits = @($batch.ResultRows)
foreach ($h in $hits) {
$fname = [System.IO.Path]::GetFileName($h.Path)
try { $crDay = ([DateTime]$h.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" }
try { $modDay = ([DateTime]$h.LastModifiedTime).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" }
$sizeB = [long]($h.Size -replace '[^0-9]','0' -replace '^$','0')
$allItems.Add([PSCustomObject]@{
Name = $fname
Path = $h.Path
Library = ""
SizeBytes = $sizeB
Created = $h.Created
Modified = $h.LastModifiedTime
CreatedDay = $crDay
ModifiedDay = $modDay
})
}
BgLog " $($hits.Count) fichiers recuperes (total: $($allItems.Count))" "Cyan"
$startRow += $batchSize
} while ($hits.Count -eq $batchSize)
} else {
# ── Folders: use Get-PnPListItem ───────────────────────────
$webUrls = @($Filters.SiteUrl)
if ($Filters.IncludeSubs) {
$subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
if ($subs) { $webUrls += @($subs | Select-Object -ExpandProperty Url) }
}
foreach ($webUrl in $webUrls) {
BgLog "Scan web : $webUrl" "Yellow"
Connect-PnPOnline -Url $webUrl -Interactive -ClientId $Filters.ClientId
$lists = Get-PnPList | Where-Object {
!$_.Hidden -and $_.BaseType -eq "DocumentLibrary" -and
(-not $Filters.Library -or $_.Title -like "*$($Filters.Library)*")
}
foreach ($list in $lists) {
BgLog " Bibliotheque : $($list.Title)" "Cyan"
try {
$folderItems = Get-PnPListItem -List $list -PageSize 2000 -ErrorAction SilentlyContinue |
Where-Object { $_.FileSystemObjectType -eq "Folder" }
foreach ($fi in $folderItems) {
$fv = $fi.FieldValues
$fname = $fv.FileLeafRef
try { $crDay = ([DateTime]$fv.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" }
try { $modDay = ([DateTime]$fv.Modified).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" }
$subCount = [int]($fv.FolderChildCount)
$fileCount = [int]($fv.ItemChildCount) - $subCount
if ($fileCount -lt 0) { $fileCount = 0 }
$allItems.Add([PSCustomObject]@{
Name = $fname
Path = "$($Filters.SiteUrl.TrimEnd('/'))/$($fv.FileRef.TrimStart('/'))"
Library = $list.Title
SizeBytes = $null
Created = $fv.Created
Modified = $fv.Modified
CreatedDay = $crDay
ModifiedDay = $modDay
FolderCount = $subCount
FileCount = $fileCount
})
}
BgLog " $($folderItems.Count) dossier(s)" "Cyan"
} catch { BgLog " Ignore : $($_.Exception.Message)" "DarkGray" }
}
}
}
BgLog "$($allItems.Count) element(s) collecte(s), recherche des doublons..." "Yellow"
# Group by computed key and keep only groups with ≥ 2
$grouped = $allItems | Group-Object { MakeKey $_.Name $_ $Filters } |
Where-Object { $_.Count -ge 2 } |
ForEach-Object {
[PSCustomObject]@{
Key = $_.Name
Name = $_.Group[0].Name
Items = @($_.Group)
}
}
BgLog "$($grouped.Count) groupe(s) de doublons trouve(s)" "LightGreen"
$Sync.Groups = @($grouped)
} catch {
$Sync.Error = $_.Exception.Message
BgLog "Erreur : $($_.Exception.Message)" "Red"
} finally { $Sync.Done = $true }
}
$dupSync = [hashtable]::Synchronized(@{
Queue = [System.Collections.Generic.Queue[object]]::new()
Done = $false; Error = $null; Groups = $null
})
$script:_DupSync = $dupSync
$script:_DupFilters = $dupFilters
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $rs
[void]$ps.AddScript($bgDupScan)
[void]$ps.AddArgument($dupFilters)
[void]$ps.AddArgument($dupSync)
$script:_DupRS = $rs
$script:_DupPS = $ps
$script:_DupHnd = $ps.BeginInvoke()
$tmr = New-Object System.Windows.Forms.Timer
$tmr.Interval = 250
$script:_DupTimer = $tmr
$tmr.Add_Tick({
while ($script:_DupSync.Queue.Count -gt 0) {
$m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
}
if ($script:_DupSync.Done) {
$script:_DupTimer.Stop(); $script:_DupTimer.Dispose()
while ($script:_DupSync.Queue.Count -gt 0) {
$m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
}
try { [void]$script:_DupPS.EndInvoke($script:_DupHnd) } catch {}
try { $script:_DupRS.Close(); $script:_DupRS.Dispose() } catch {}
$btnScanDupes.Enabled = $true
$progressBar.MarqueeAnimationSpeed = 0
if ($script:_DupSync.Error) {
Write-Log "Echec : $($script:_DupSync.Error)" "Red"
return
}
$groups = $script:_DupSync.Groups
if (-not $groups -or $groups.Count -eq 0) {
Write-Log "Aucun doublon detecte avec ces criteres." "Orange"
return
}
$f = $script:_DupFilters
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$outDir = $f.OutFolder
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
if ($f.Format -eq "HTML") {
$outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.html"
$html = Export-DuplicatesToHTML -Groups $groups -Mode $f.Mode -SiteUrl $f.SiteUrl
$html | Set-Content -Path $outFile -Encoding UTF8
} else {
$outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.csv"
$groups | ForEach-Object {
$grp = $_
$grp.Items | ForEach-Object {
[PSCustomObject]@{
DuplicateGroup = $grp.Name
Name = $_.Name
Path = $_.Path
Library = $_.Library
SizeBytes = $_.SizeBytes
Created = $_.Created
Modified = $_.Modified
FolderCount = $_.FolderCount
FileCount = $_.FileCount
}
}
} | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8
}
Write-Log "Sauvegarde : $outFile" "White"
$script:_DupLastOut = $outFile
$btnOpenDupes.Enabled = $true
}
})
$tmr.Start()
})
$btnOpenDupes.Add_Click({
$f = $script:_DupLastOut
if ($f -and (Test-Path $f)) { Start-Process $f }
})
#endregion
# ── 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 }
$txtDataDir.Text = $script:DataFolder
Refresh-ProfileList
$n = (Load-Templates).Count
$lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer"
[System.Windows.Forms.Application]::Run($form)