diff --git a/Sharepoint_ToolBox.ps1 b/Sharepoint_ToolBox.ps1
index 8d81b2f..b6da9ee 100644
--- a/Sharepoint_ToolBox.ps1
+++ b/Sharepoint_ToolBox.ps1
@@ -2380,6 +2380,328 @@ function filterGroups(){
#endregion
+#region ===== Transfer =====
+
+function Export-TransferVerifyToHTML {
+ param([array]$Results, [string]$SrcSite, [string]$SrcLib, [string]$DstSite, [string]$DstLib)
+
+ $okCount = @($Results | Where-Object { $_.Status -eq "OK" }).Count
+ $missingCount = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count
+ $mismatchCount = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
+ $extraCount = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count
+ $totalCount = $Results.Count
+ $date = Get-Date -Format "dd/MM/yyyy HH:mm"
+
+ $rows = ""
+ foreach ($r in $Results) {
+ $statusClass = switch ($r.Status) {
+ "OK" { "status-ok" }
+ "MISSING" { "status-missing" }
+ "SIZE_MISMATCH" { "status-mismatch" }
+ "EXTRA" { "status-extra" }
+ default { "" }
+ }
+ $statusLabel = switch ($r.Status) {
+ "OK" { "OK" }
+ "MISSING" { "MISSING" }
+ "SIZE_MISMATCH" { "SIZE MISMATCH" }
+ "EXTRA" { "EXTRA" }
+ default { $r.Status }
+ }
+ $name = EscHtml $r.Name
+ $relPath = EscHtml $r.RelativePath
+ $srcSize = if ($null -ne $r.SourceSize) { EscHtml (Format-Bytes $r.SourceSize) } else { "-" }
+ $dstSize = if ($null -ne $r.DestSize) { EscHtml (Format-Bytes $r.DestSize) } else { "-" }
+ $srcRaw = if ($null -ne $r.SourceSize) { $r.SourceSize } else { 0 }
+ $dstRaw = if ($null -ne $r.DestSize) { $r.DestSize } else { 0 }
+ $rows += "
"
+ $rows += "| $statusLabel | "
+ $rows += "$name | "
+ $rows += "$relPath | "
+ $rows += "$srcSize | "
+ $rows += "$dstSize |
`n"
+ }
+
+ $srcEsc = EscHtml "$SrcSite/$SrcLib"
+ $dstEsc = EscHtml "$DstSite/$DstLib"
+
+$html = @"
+
+
+Transfer Verification Report
+
+
+
+
Transfer Verification Report
+
Source: $srcEsc → Destination: $dstEsc — $date
+
+
+
+
+
+
$mismatchCount
Size mismatch
+
+
+
+
+
+
+| Status |
+File Name |
+Relative Path |
+Source Size |
+Dest Size |
+
+$rows
+
+
+
+
+"@
+ return $html
+}
+
+#endregion
+
+#region ===== Bulk Site Creation =====
+
+function Show-BulkSiteDialog {
+ param(
+ [System.Windows.Forms.Form]$Owner = $null,
+ [hashtable]$Existing = $null # pass to edit an existing entry
+ )
+
+ $templates = @(Load-Templates)
+
+ $dlg = New-Object System.Windows.Forms.Form
+ $dlg.Text = if ($Existing) { T "bulk.dlg.title.edit" } else { T "bulk.dlg.title" }
+ $dlg.Size = New-Object System.Drawing.Size(520, 400)
+ $dlg.StartPosition = "CenterParent"
+ $dlg.FormBorderStyle = "FixedDialog"
+ $dlg.MaximizeBox = $false
+ $dlg.MinimizeBox = $false
+ if ($Owner) { $dlg.Owner = $Owner }
+
+ $y = 14
+
+ # Site name
+ $lblName = New-Object System.Windows.Forms.Label
+ $lblName.Text = T "bulk.lbl.name"
+ $lblName.Location = New-Object System.Drawing.Point(14, $y)
+ $lblName.Size = New-Object System.Drawing.Size(480, 18)
+ $y += 20
+ $txtName = New-Object System.Windows.Forms.TextBox
+ $txtName.Location = New-Object System.Drawing.Point(14, $y)
+ $txtName.Size = New-Object System.Drawing.Size(476, 22)
+ $y += 30
+
+ # URL alias
+ $lblAlias = New-Object System.Windows.Forms.Label
+ $lblAlias.Text = T "bulk.lbl.alias"
+ $lblAlias.Location = New-Object System.Drawing.Point(14, $y)
+ $lblAlias.Size = New-Object System.Drawing.Size(480, 18)
+ $y += 20
+ $txtAlias = New-Object System.Windows.Forms.TextBox
+ $txtAlias.Location = New-Object System.Drawing.Point(14, $y)
+ $txtAlias.Size = New-Object System.Drawing.Size(476, 22)
+ $y += 30
+
+ # Auto-generate alias from name
+ $txtName.Add_TextChanged({
+ if (-not $txtAlias.Tag) {
+ $raw = $txtName.Text.Trim().ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', ''
+ $txtAlias.Text = $raw
+ }
+ })
+ $txtAlias.Add_TextChanged({ if ($txtAlias.Focused) { $txtAlias.Tag = "manual" } })
+
+ # Site type
+ $lblType = New-Object System.Windows.Forms.Label
+ $lblType.Text = T "bulk.lbl.type"
+ $lblType.Location = New-Object System.Drawing.Point(14, $y)
+ $lblType.Size = New-Object System.Drawing.Size(150, 18)
+ $radTeam = New-Object System.Windows.Forms.RadioButton
+ $radTeam.Text = T "bulk.rad.team"
+ $radTeam.Location = New-Object System.Drawing.Point(170, ($y - 2))
+ $radTeam.Size = New-Object System.Drawing.Size(140, 22)
+ $radTeam.Checked = $true
+ $radComm = New-Object System.Windows.Forms.RadioButton
+ $radComm.Text = T "bulk.rad.comm"
+ $radComm.Location = New-Object System.Drawing.Point(320, ($y - 2))
+ $radComm.Size = New-Object System.Drawing.Size(170, 22)
+ $y += 28
+
+ # Template
+ $lblTpl = New-Object System.Windows.Forms.Label
+ $lblTpl.Text = T "bulk.lbl.template"
+ $lblTpl.Location = New-Object System.Drawing.Point(14, $y)
+ $lblTpl.Size = New-Object System.Drawing.Size(150, 18)
+ $cboTpl = New-Object System.Windows.Forms.ComboBox
+ $cboTpl.DropDownStyle = "DropDownList"
+ $cboTpl.Location = New-Object System.Drawing.Point(170, ($y - 2))
+ $cboTpl.Size = New-Object System.Drawing.Size(320, 22)
+ $cboTpl.Items.Add((T "bulk.none")) | Out-Null
+ foreach ($t in $templates) { $cboTpl.Items.Add($t.name) | Out-Null }
+ $cboTpl.SelectedIndex = 0
+ $y += 30
+
+ # Owners
+ $lblOwners = New-Object System.Windows.Forms.Label
+ $lblOwners.Text = T "bulk.lbl.owners"
+ $lblOwners.Location = New-Object System.Drawing.Point(14, $y)
+ $lblOwners.Size = New-Object System.Drawing.Size(480, 18)
+ $y += 20
+ $txtOwners = New-Object System.Windows.Forms.TextBox
+ $txtOwners.Location = New-Object System.Drawing.Point(14, $y)
+ $txtOwners.Size = New-Object System.Drawing.Size(476, 22)
+ $txtOwners.PlaceholderText = T "bulk.ph.owners"
+ $y += 30
+
+ # Members
+ $lblMembers = New-Object System.Windows.Forms.Label
+ $lblMembers.Text = T "bulk.lbl.members"
+ $lblMembers.Location = New-Object System.Drawing.Point(14, $y)
+ $lblMembers.Size = New-Object System.Drawing.Size(380, 18)
+
+ $btnCsvMembers = New-Object System.Windows.Forms.Button
+ $btnCsvMembers.Text = T "bulk.btn.csv.members"
+ $btnCsvMembers.Location = New-Object System.Drawing.Point(394, ($y - 4))
+ $btnCsvMembers.Size = New-Object System.Drawing.Size(96, 24)
+ $btnCsvMembers.Add_Click({
+ $ofd = New-Object System.Windows.Forms.OpenFileDialog
+ $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
+ if ($ofd.ShowDialog($dlg) -eq "OK") {
+ $rows = Import-Csv $ofd.FileName
+ $emails = $rows | ForEach-Object {
+ $r = $_
+ $v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email }
+ elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn }
+ elseif ($r.UserPrincipalName) { $r.UserPrincipalName }
+ else { $r.userprincipalname }
+ $v
+ } | Where-Object { $_ } | Select-Object -Unique
+ if ($emails.Count -gt 0) {
+ $existing = $txtMembers.Text.Trim()
+ if ($existing) { $txtMembers.Text = "$existing, $($emails -join ', ')" }
+ else { $txtMembers.Text = $emails -join ", " }
+ }
+ }
+ })
+
+ $y += 20
+ $txtMembers = New-Object System.Windows.Forms.TextBox
+ $txtMembers.Location = New-Object System.Drawing.Point(14, $y)
+ $txtMembers.Size = New-Object System.Drawing.Size(476, 22)
+ $txtMembers.PlaceholderText = T "bulk.ph.members"
+ $y += 36
+
+ # OK / Cancel
+ $btnOk = New-Object System.Windows.Forms.Button
+ $btnOk.Text = "OK"
+ $btnOk.Location = New-Object System.Drawing.Point(310, $y)
+ $btnOk.Size = New-Object System.Drawing.Size(85, 30)
+ $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
+ $dlg.AcceptButton = $btnOk
+
+ $btnCancel = New-Object System.Windows.Forms.Button
+ $btnCancel.Text = "Cancel"
+ $btnCancel.Location = New-Object System.Drawing.Point(405, $y)
+ $btnCancel.Size = New-Object System.Drawing.Size(85, 30)
+ $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
+ $dlg.CancelButton = $btnCancel
+
+ # Pre-fill if editing
+ if ($Existing) {
+ $txtName.Text = $Existing.Name
+ $txtAlias.Tag = "manual"
+ $txtAlias.Text = $Existing.Alias
+ if ($Existing.Type -eq "Communication") { $radComm.Checked = $true }
+ if ($Existing.Template) {
+ $idx = $cboTpl.Items.IndexOf($Existing.Template)
+ if ($idx -ge 0) { $cboTpl.SelectedIndex = $idx }
+ }
+ $txtOwners.Text = $Existing.Owners
+ $txtMembers.Text = $Existing.Members
+ }
+
+ $dlg.Controls.AddRange(@($lblName, $txtName, $lblAlias, $txtAlias,
+ $lblType, $radTeam, $radComm, $lblTpl, $cboTpl,
+ $lblOwners, $txtOwners, $lblMembers, $btnCsvMembers, $txtMembers,
+ $btnOk, $btnCancel))
+
+ $result = $dlg.ShowDialog($Owner)
+ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
+ $name = $txtName.Text.Trim()
+ $alias = $txtAlias.Text.Trim()
+ if (-not $name -or -not $alias) { return $null }
+ return @{
+ Name = $name
+ Alias = $alias
+ Type = if ($radTeam.Checked) { "Team" } else { "Communication" }
+ Template = if ($cboTpl.SelectedIndex -gt 0) { $cboTpl.SelectedItem } else { "" }
+ Owners = $txtOwners.Text.Trim()
+ Members = $txtMembers.Text.Trim()
+ }
+ }
+ return $null
+}
+
+#endregion
+
#region ===== Internationalization =====
$script:LangDefault = @{
@@ -2461,6 +2783,51 @@ $script:LangDefault = @{
"ph.modified.by" = "First Last or email"
"ph.library" = "Optional relative path e.g. Shared Documents"
"ph.dup.lib" = "All (leave empty)"
+ "tab.transfer" = " Transfer "
+ "grp.xfer.source" = "Source"
+ "grp.xfer.dest" = "Destination"
+ "lbl.xfer.site" = "Site URL:"
+ "lbl.xfer.library" = "Library / Folder:"
+ "grp.xfer.options" = "Options"
+ "chk.xfer.recursive" = "Include subfolders (recursive)"
+ "chk.xfer.overwrite" = "Overwrite existing files"
+ "btn.xfer.start" = "Start Transfer"
+ "btn.xfer.verify" = "Verify"
+ "btn.xfer.open" = "Open Report"
+ "ph.xfer.site" = "https://tenant.sharepoint.com/sites/xxx"
+ "ph.xfer.library" = "Shared Documents/subfolder"
+ "xfer.note" = "Only the current version of each file is transferred (no version history)."
+ "tab.bulk" = " Bulk Create "
+ "grp.bulk.list" = "Sites to create"
+ "btn.bulk.add" = "Add Site..."
+ "btn.bulk.csv" = "Import CSV..."
+ "btn.bulk.remove" = "Remove"
+ "btn.bulk.clear" = "Clear All"
+ "btn.bulk.create" = "Create All Sites"
+ "bulk.col.name" = "Site Name"
+ "bulk.col.alias" = "URL Alias"
+ "bulk.col.type" = "Type"
+ "bulk.col.template" = "Template"
+ "bulk.col.owners" = "Owners"
+ "bulk.col.members" = "Members"
+ "bulk.dlg.title" = "Add Site"
+ "bulk.dlg.title.edit" = "Edit Site"
+ "bulk.lbl.name" = "Site name:"
+ "bulk.lbl.alias" = "URL alias (after /sites/):"
+ "bulk.lbl.type" = "Site type:"
+ "bulk.rad.team" = "Team Site"
+ "bulk.rad.comm" = "Communication Site"
+ "bulk.lbl.template" = "Template:"
+ "bulk.lbl.owners" = "Owners (comma-separated):"
+ "bulk.lbl.members" = "Members (comma-separated):"
+ "bulk.btn.csv.members" = "Import CSV..."
+ "bulk.none" = "(None)"
+ "bulk.ph.owners" = "admin@domain.com, user2@domain.com"
+ "bulk.ph.members" = "user@domain.com, ..."
+ "bulk.status.pending" = "Pending"
+ "bulk.status.creating" = "Creating..."
+ "bulk.status.ok" = "OK"
+ "bulk.status.error" = "Error"
}
$script:Lang = $null # null = use LangDefault
@@ -3067,7 +3434,153 @@ $btnOpenDupes.Enabled = $false
$tabDupes.Controls.AddRange(@($grpDupType, $grpDupCrit, $grpDupOpts, $grpDupFmt, $btnScanDupes, $btnOpenDupes))
-$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes))
+# ══════════════════════════════════════════════════════════════════════════════
+# Tab 6 – Transfer
+# ══════════════════════════════════════════════════════════════════════════════
+$tabTransfer = New-Object System.Windows.Forms.TabPage
+$tabTransfer.Text = T "tab.transfer"
+
+# ── GroupBox: Source (y=4, h=74) ─────────────────────────────────────────────
+$grpXferSrc = New-Group (T "grp.xfer.source") 10 4 620 74
+
+$lblXferSrcSite = New-Object System.Windows.Forms.Label
+$lblXferSrcSite.Text = T "lbl.xfer.site"
+$lblXferSrcSite.Location = New-Object System.Drawing.Point(10, 20)
+$lblXferSrcSite.Size = New-Object System.Drawing.Size(145, 22)
+$lblXferSrcSite.TextAlign = "MiddleLeft"
+
+$txtXferSrcSite = New-Object System.Windows.Forms.TextBox
+$txtXferSrcSite.Location = New-Object System.Drawing.Point(160, 20)
+$txtXferSrcSite.Size = New-Object System.Drawing.Size(448, 22)
+$txtXferSrcSite.PlaceholderText = T "ph.xfer.site"
+
+$lblXferSrcLib = New-Object System.Windows.Forms.Label
+$lblXferSrcLib.Text = T "lbl.xfer.library"
+$lblXferSrcLib.Location = New-Object System.Drawing.Point(10, 46)
+$lblXferSrcLib.Size = New-Object System.Drawing.Size(145, 22)
+$lblXferSrcLib.TextAlign = "MiddleLeft"
+
+$txtXferSrcLib = New-Object System.Windows.Forms.TextBox
+$txtXferSrcLib.Location = New-Object System.Drawing.Point(160, 46)
+$txtXferSrcLib.Size = New-Object System.Drawing.Size(448, 22)
+$txtXferSrcLib.PlaceholderText = T "ph.xfer.library"
+
+$grpXferSrc.Controls.AddRange(@($lblXferSrcSite, $txtXferSrcSite, $lblXferSrcLib, $txtXferSrcLib))
+
+# ── GroupBox: Destination (y=82, h=74) ───────────────────────────────────────
+$grpXferDst = New-Group (T "grp.xfer.dest") 10 82 620 74
+
+$lblXferDstSite = New-Object System.Windows.Forms.Label
+$lblXferDstSite.Text = T "lbl.xfer.site"
+$lblXferDstSite.Location = New-Object System.Drawing.Point(10, 20)
+$lblXferDstSite.Size = New-Object System.Drawing.Size(145, 22)
+$lblXferDstSite.TextAlign = "MiddleLeft"
+
+$txtXferDstSite = New-Object System.Windows.Forms.TextBox
+$txtXferDstSite.Location = New-Object System.Drawing.Point(160, 20)
+$txtXferDstSite.Size = New-Object System.Drawing.Size(448, 22)
+$txtXferDstSite.PlaceholderText = T "ph.xfer.site"
+
+$lblXferDstLib = New-Object System.Windows.Forms.Label
+$lblXferDstLib.Text = T "lbl.xfer.library"
+$lblXferDstLib.Location = New-Object System.Drawing.Point(10, 46)
+$lblXferDstLib.Size = New-Object System.Drawing.Size(145, 22)
+$lblXferDstLib.TextAlign = "MiddleLeft"
+
+$txtXferDstLib = New-Object System.Windows.Forms.TextBox
+$txtXferDstLib.Location = New-Object System.Drawing.Point(160, 46)
+$txtXferDstLib.Size = New-Object System.Drawing.Size(448, 22)
+$txtXferDstLib.PlaceholderText = T "ph.xfer.library"
+
+$grpXferDst.Controls.AddRange(@($lblXferDstSite, $txtXferDstSite, $lblXferDstLib, $txtXferDstLib))
+
+# ── GroupBox: Options (y=160, h=58) ──────────────────────────────────────────
+$grpXferOpts = New-Group (T "grp.xfer.options") 10 160 620 58
+
+$chkXferRecursive = New-Check (T "chk.xfer.recursive") 10 18 260 $true
+$chkXferOverwrite = New-Check (T "chk.xfer.overwrite") 280 18 230
+
+$lblXferNote = New-Object System.Windows.Forms.Label
+$lblXferNote.Text = T "xfer.note"
+$lblXferNote.Location = New-Object System.Drawing.Point(10, 40)
+$lblXferNote.Size = New-Object System.Drawing.Size(600, 16)
+$lblXferNote.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
+$lblXferNote.ForeColor = [System.Drawing.Color]::DimGray
+
+$grpXferOpts.Controls.AddRange(@($chkXferRecursive, $chkXferOverwrite, $lblXferNote))
+
+# ── Buttons (y=224) ──────────────────────────────────────────────────────────
+$btnXferStart = New-ActionBtn (T "btn.xfer.start") 10 224 ([System.Drawing.Color]::FromArgb(0, 120, 60))
+
+$btnXferVerify = New-Object System.Windows.Forms.Button
+$btnXferVerify.Text = T "btn.xfer.verify"
+$btnXferVerify.Location = New-Object System.Drawing.Point(175, 224)
+$btnXferVerify.Size = New-Object System.Drawing.Size(130, 34)
+$btnXferVerify.BackColor = [System.Drawing.Color]::FromArgb(0, 120, 212)
+$btnXferVerify.ForeColor = [System.Drawing.Color]::White
+$btnXferVerify.FlatStyle = "Flat"
+$btnXferVerify.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
+
+$btnXferOpen = New-Object System.Windows.Forms.Button
+$btnXferOpen.Text = T "btn.xfer.open"
+$btnXferOpen.Location = New-Object System.Drawing.Point(315, 224)
+$btnXferOpen.Size = New-Object System.Drawing.Size(130, 34)
+$btnXferOpen.Enabled = $false
+
+$tabTransfer.Controls.AddRange(@($grpXferSrc, $grpXferDst, $grpXferOpts, $btnXferStart, $btnXferVerify, $btnXferOpen))
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Tab 7 – Bulk Create
+# ══════════════════════════════════════════════════════════════════════════════
+$tabBulk = New-Object System.Windows.Forms.TabPage
+$tabBulk.Text = T "tab.bulk"
+
+# ── GroupBox: Site list ──────────────────────────────────────────────────────
+$grpBulkList = New-Group (T "grp.bulk.list") 10 4 620 230
+
+$lvBulk = New-Object System.Windows.Forms.ListView
+$lvBulk.Location = New-Object System.Drawing.Point(10, 18)
+$lvBulk.Size = New-Object System.Drawing.Size(598, 168)
+$lvBulk.View = "Details"
+$lvBulk.FullRowSelect = $true
+$lvBulk.GridLines = $true
+$lvBulk.Font = New-Object System.Drawing.Font("Segoe UI", 8.5)
+$lvBulk.Columns.Add((T "bulk.col.name"), 120) | Out-Null
+$lvBulk.Columns.Add((T "bulk.col.alias"), 90) | Out-Null
+$lvBulk.Columns.Add((T "bulk.col.type"), 60) | Out-Null
+$lvBulk.Columns.Add((T "bulk.col.template"), 90) | Out-Null
+$lvBulk.Columns.Add((T "bulk.col.owners"), 110) | Out-Null
+$lvBulk.Columns.Add((T "bulk.col.members"), 110) | Out-Null
+
+$btnBulkAdd = New-Object System.Windows.Forms.Button
+$btnBulkAdd.Text = T "btn.bulk.add"
+$btnBulkAdd.Location = New-Object System.Drawing.Point(10, 192)
+$btnBulkAdd.Size = New-Object System.Drawing.Size(120, 28)
+
+$btnBulkCsv = New-Object System.Windows.Forms.Button
+$btnBulkCsv.Text = T "btn.bulk.csv"
+$btnBulkCsv.Location = New-Object System.Drawing.Point(138, 192)
+$btnBulkCsv.Size = New-Object System.Drawing.Size(120, 28)
+
+$btnBulkRemove = New-Object System.Windows.Forms.Button
+$btnBulkRemove.Text = T "btn.bulk.remove"
+$btnBulkRemove.Location = New-Object System.Drawing.Point(266, 192)
+$btnBulkRemove.Size = New-Object System.Drawing.Size(90, 28)
+
+$btnBulkClear = New-Object System.Windows.Forms.Button
+$btnBulkClear.Text = T "btn.bulk.clear"
+$btnBulkClear.Location = New-Object System.Drawing.Point(364, 192)
+$btnBulkClear.Size = New-Object System.Drawing.Size(90, 28)
+
+$grpBulkList.Controls.AddRange(@($lvBulk, $btnBulkAdd, $btnBulkCsv, $btnBulkRemove, $btnBulkClear))
+
+# ── Create button ────────────────────────────────────────────────────────────
+$btnBulkCreate = New-ActionBtn (T "btn.bulk.create") 10 240 ([System.Drawing.Color]::FromArgb(0, 120, 60))
+$btnBulkCreate.Size = New-Object System.Drawing.Size(200, 34)
+
+$tabBulk.Controls.AddRange(@($grpBulkList, $btnBulkCreate))
+
+$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes, $tabTransfer, $tabBulk))
# ── Progress bar ───────────────────────────────────────────────────────────────
$progressBar = New-Object System.Windows.Forms.ProgressBar
@@ -3232,12 +3745,37 @@ $_reg = {
& $_reg $script:i18nMap $btnScanDupes "btn.run.scan"
& $_reg $script:i18nMap $btnOpenDupes "btn.open.results"
+# Transfer tab controls
+& $_reg $script:i18nMap $grpXferSrc "grp.xfer.source"
+& $_reg $script:i18nMap $lblXferSrcSite "lbl.xfer.site"
+& $_reg $script:i18nMap $lblXferSrcLib "lbl.xfer.library"
+& $_reg $script:i18nMap $grpXferDst "grp.xfer.dest"
+& $_reg $script:i18nMap $lblXferDstSite "lbl.xfer.site"
+& $_reg $script:i18nMap $lblXferDstLib "lbl.xfer.library"
+& $_reg $script:i18nMap $grpXferOpts "grp.xfer.options"
+& $_reg $script:i18nMap $chkXferRecursive "chk.xfer.recursive"
+& $_reg $script:i18nMap $chkXferOverwrite "chk.xfer.overwrite"
+& $_reg $script:i18nMap $lblXferNote "xfer.note"
+& $_reg $script:i18nMap $btnXferStart "btn.xfer.start"
+& $_reg $script:i18nMap $btnXferVerify "btn.xfer.verify"
+& $_reg $script:i18nMap $btnXferOpen "btn.xfer.open"
+
+# Bulk Create tab controls
+& $_reg $script:i18nMap $grpBulkList "grp.bulk.list"
+& $_reg $script:i18nMap $btnBulkAdd "btn.bulk.add"
+& $_reg $script:i18nMap $btnBulkCsv "btn.bulk.csv"
+& $_reg $script:i18nMap $btnBulkRemove "btn.bulk.remove"
+& $_reg $script:i18nMap $btnBulkClear "btn.bulk.clear"
+& $_reg $script:i18nMap $btnBulkCreate "btn.bulk.create"
+
# Tab pages
& $_reg $script:i18nTabs $tabPerms "tab.perms"
& $_reg $script:i18nTabs $tabStorage "tab.storage"
& $_reg $script:i18nTabs $tabTemplates "tab.templates"
& $_reg $script:i18nTabs $tabSearch "tab.search"
& $_reg $script:i18nTabs $tabDupes "tab.dupes"
+& $_reg $script:i18nTabs $tabTransfer "tab.transfer"
+& $_reg $script:i18nTabs $tabBulk "tab.bulk"
# Menu items
& $_reg $script:i18nMenus $menuSettings "menu.settings"
@@ -3252,6 +3790,10 @@ $script:i18nPlaceholders = [System.Collections.Generic.Dictionary[string,object]
& $_reg $script:i18nPlaceholders $txtSrchModBy "ph.modified.by"
& $_reg $script:i18nPlaceholders $txtSrchLib "ph.library"
& $_reg $script:i18nPlaceholders $txtDupLib "ph.dup.lib"
+& $_reg $script:i18nPlaceholders $txtXferSrcSite "ph.xfer.site"
+& $_reg $script:i18nPlaceholders $txtXferSrcLib "ph.xfer.library"
+& $_reg $script:i18nPlaceholders $txtXferDstSite "ph.xfer.site"
+& $_reg $script:i18nPlaceholders $txtXferDstLib "ph.xfer.library"
#endregion
@@ -4217,6 +4759,758 @@ $btnOpenDupes.Add_Click({
if ($f -and (Test-Path $f)) { Start-Process $f }
})
+# ── Transfer ──────────────────────────────────────────────────────────────────
+
+$btnXferStart.Add_Click({
+ $clientId = $txtClientId.Text.Trim()
+ $srcSite = $txtXferSrcSite.Text.Trim()
+ $dstSite = $txtXferDstSite.Text.Trim()
+ $srcLib = $txtXferSrcLib.Text.Trim()
+ $dstLib = $txtXferDstLib.Text.Trim()
+
+ if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }
+ if (-not $srcSite) { Write-Log "URL du site source requis." "Red"; return }
+ if (-not $dstSite) { Write-Log "URL du site destination requis." "Red"; return }
+ if (-not $srcLib) { Write-Log "Bibliotheque source requise." "Red"; return }
+ if (-not $dstLib) { $dstLib = $srcLib }
+ if ($srcSite -eq $dstSite -and $srcLib -eq $dstLib) {
+ Write-Log "Source et destination identiques." "Red"; return
+ }
+
+ $params = @{
+ ClientId = $clientId
+ SrcSite = $srcSite
+ DstSite = $dstSite
+ SrcLib = $srcLib
+ DstLib = $dstLib
+ Recursive = $chkXferRecursive.Checked
+ Overwrite = $chkXferOverwrite.Checked
+ }
+
+ $btnXferStart.Enabled = $false
+ $btnXferVerify.Enabled = $false
+ $btnXferOpen.Enabled = $false
+ $txtLog.Clear()
+ Start-ProgressAnim
+ Write-Log "=== TRANSFER ===" "White"
+ Write-Log "Source : $srcSite / $srcLib" "Gray"
+ Write-Log "Destination : $dstSite / $dstLib" "Gray"
+ Write-Log "Recursive : $($params.Recursive) Overwrite: $($params.Overwrite)" "Gray"
+ Write-Log ("-" * 52) "DarkGray"
+
+ $bgTransfer = {
+ param($Params, $Sync)
+ function BgLog([string]$m, [string]$c = "LightGreen") {
+ $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
+ }
+
+ function Get-AllSPFiles([string]$BasePath, [string]$Rel = "") {
+ $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue)
+ foreach ($f in $files) {
+ if ($f.ServerRelativeUrl -match '/_vti_history/') { continue }
+ [PSCustomObject]@{
+ Name = $f.Name
+ ServerRelativeUrl = $f.ServerRelativeUrl
+ Length = $f.Length
+ RelativePath = "$Rel$($f.Name)"
+ RelativeFolder = $Rel
+ }
+ }
+ if ($Params.Recursive) {
+ $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue)
+ foreach ($d in $folders) {
+ if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue }
+ Get-AllSPFiles "$BasePath/$($d.Name)" "$Rel$($d.Name)/"
+ }
+ }
+ }
+
+ try {
+ Import-Module PnP.PowerShell -ErrorAction Stop
+
+ # ── Enumerate source ──
+ BgLog "Connexion au site source..." "Cyan"
+ Connect-PnPOnline -Url $Params.SrcSite -Interactive -ClientId $Params.ClientId
+ BgLog "Enumeration des fichiers source ($($Params.SrcLib))..." "Cyan"
+ $srcFiles = @(Get-AllSPFiles $Params.SrcLib)
+ BgLog " $($srcFiles.Count) fichier(s) trouves" "LightGreen"
+
+ if ($srcFiles.Count -eq 0) {
+ BgLog "Aucun fichier a transferer." "Orange"
+ $Sync.TransferCount = 0
+ return
+ }
+
+ # ── Download to temp ──
+ $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_Transfer_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
+ New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
+ BgLog "Dossier temporaire : $tempRoot" "DarkGray"
+
+ $idx = 0
+ foreach ($f in $srcFiles) {
+ $idx++
+ $localDir = Join-Path $tempRoot $f.RelativeFolder
+ if (-not (Test-Path $localDir)) {
+ New-Item -ItemType Directory -Path $localDir -Force | Out-Null
+ }
+ Get-PnPFile -Url $f.ServerRelativeUrl -Path $localDir -FileName $f.Name -AsFile -Force
+ BgLog " [$idx/$($srcFiles.Count)] Downloaded: $($f.RelativePath) ($('{0:N0}' -f $f.Length) bytes)" "LightGreen"
+ }
+ BgLog "Download termine." "White"
+
+ # ── Upload to destination ──
+ BgLog "Connexion au site destination..." "Cyan"
+ Connect-PnPOnline -Url $Params.DstSite -Interactive -ClientId $Params.ClientId
+
+ $idx = 0
+ foreach ($f in $srcFiles) {
+ $idx++
+ $dstFolder = if ($f.RelativeFolder) {
+ "$($Params.DstLib.TrimEnd('/'))/$($f.RelativeFolder.TrimEnd('/'))"
+ } else { $Params.DstLib }
+ Resolve-PnPFolder -SiteRelativePath $dstFolder -ErrorAction SilentlyContinue | Out-Null
+ $localFile = Join-Path (Join-Path $tempRoot $f.RelativeFolder) $f.Name
+ Add-PnPFile -Path $localFile -Folder $dstFolder -ErrorAction Stop | Out-Null
+ BgLog " [$idx/$($srcFiles.Count)] Uploaded: $($f.RelativePath)" "LightGreen"
+ }
+ BgLog "Upload termine." "White"
+ $Sync.TransferCount = $srcFiles.Count
+ } catch {
+ $Sync.Error = $_.Exception.Message
+ BgLog "Erreur : $($_.Exception.Message)" "Red"
+ } finally {
+ # Cleanup temp
+ if ($tempRoot -and (Test-Path $tempRoot)) {
+ Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
+ BgLog "Fichiers temporaires nettoyes." "DarkGray"
+ }
+ $Sync.Done = $true
+ }
+ }
+
+ $sync = [hashtable]::Synchronized(@{
+ Queue = [System.Collections.Generic.Queue[object]]::new()
+ Done = $false
+ Error = $null
+ TransferCount = 0
+ })
+ $script:_XferSync = $sync
+ $script:_XferParams = $params
+
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
+ $ps = [System.Management.Automation.PowerShell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript($bgTransfer)
+ [void]$ps.AddArgument($params)
+ [void]$ps.AddArgument($sync)
+ $script:_XferRS = $rs
+ $script:_XferPS = $ps
+ $script:_XferHnd = $ps.BeginInvoke()
+
+ $tmr = New-Object System.Windows.Forms.Timer
+ $tmr.Interval = 250
+ $script:_XferTimer = $tmr
+ $tmr.Add_Tick({
+ while ($script:_XferSync.Queue.Count -gt 0) {
+ $m = $script:_XferSync.Queue.Dequeue()
+ Write-Log $m.Text $m.Color
+ }
+ if ($script:_XferSync.Done) {
+ $script:_XferTimer.Stop(); $script:_XferTimer.Dispose()
+ while ($script:_XferSync.Queue.Count -gt 0) {
+ $m = $script:_XferSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
+ }
+ try { [void]$script:_XferPS.EndInvoke($script:_XferHnd) } catch {}
+ try { $script:_XferRS.Close(); $script:_XferRS.Dispose() } catch {}
+ $btnXferStart.Enabled = $true
+ $btnXferVerify.Enabled = $true
+ Stop-ProgressAnim
+
+ if ($script:_XferSync.Error) {
+ Write-Log "Echec du transfert : $($script:_XferSync.Error)" "Red"
+ return
+ }
+
+ $cnt = $script:_XferSync.TransferCount
+ Write-Log "=== TRANSFERT TERMINE : $cnt fichier(s) ===" "White"
+ }
+ })
+ $tmr.Start()
+})
+
+# ── Verify ────────────────────────────────────────────────────────────────────
+
+$btnXferVerify.Add_Click({
+ $clientId = $txtClientId.Text.Trim()
+ $srcSite = $txtXferSrcSite.Text.Trim()
+ $dstSite = $txtXferDstSite.Text.Trim()
+ $srcLib = $txtXferSrcLib.Text.Trim()
+ $dstLib = $txtXferDstLib.Text.Trim()
+ $outDir = $txtOutput.Text.Trim()
+
+ if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }
+ if (-not $srcSite) { Write-Log "URL du site source requis." "Red"; return }
+ if (-not $dstSite) { Write-Log "URL du site destination requis." "Red"; return }
+ if (-not $srcLib) { Write-Log "Bibliotheque source requise." "Red"; return }
+ if (-not $dstLib) { $dstLib = $srcLib }
+ if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
+
+ $params = @{
+ ClientId = $clientId
+ SrcSite = $srcSite
+ DstSite = $dstSite
+ SrcLib = $srcLib
+ DstLib = $dstLib
+ Recursive = $chkXferRecursive.Checked
+ OutFolder = $outDir
+ }
+
+ $btnXferStart.Enabled = $false
+ $btnXferVerify.Enabled = $false
+ $btnXferOpen.Enabled = $false
+ $txtLog.Clear()
+ Start-ProgressAnim
+ Write-Log "=== VERIFICATION ===" "White"
+ Write-Log "Source : $srcSite / $srcLib" "Gray"
+ Write-Log "Destination : $dstSite / $dstLib" "Gray"
+ Write-Log ("-" * 52) "DarkGray"
+
+ $bgVerify = {
+ param($Params, $Sync)
+ function BgLog([string]$m, [string]$c = "LightGreen") {
+ $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
+ }
+
+ function Get-AllSPFiles([string]$BasePath, [string]$Rel = "") {
+ $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue)
+ foreach ($f in $files) {
+ if ($f.ServerRelativeUrl -match '/_vti_history/') { continue }
+ [PSCustomObject]@{
+ Name = $f.Name
+ Length = $f.Length
+ RelativePath = "$Rel$($f.Name)"
+ }
+ }
+ if ($Params.Recursive) {
+ $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue)
+ foreach ($d in $folders) {
+ if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue }
+ Get-AllSPFiles "$BasePath/$($d.Name)" "$Rel$($d.Name)/"
+ }
+ }
+ }
+
+ try {
+ Import-Module PnP.PowerShell -ErrorAction Stop
+
+ # Enumerate source
+ BgLog "Connexion au site source..." "Cyan"
+ Connect-PnPOnline -Url $Params.SrcSite -Interactive -ClientId $Params.ClientId
+ BgLog "Enumeration des fichiers source..." "Cyan"
+ $srcFiles = @(Get-AllSPFiles $Params.SrcLib)
+ BgLog " $($srcFiles.Count) fichier(s) source" "LightGreen"
+
+ $srcMap = @{}
+ foreach ($f in $srcFiles) { $srcMap[$f.RelativePath] = $f }
+
+ # Enumerate destination
+ BgLog "Connexion au site destination..." "Cyan"
+ Connect-PnPOnline -Url $Params.DstSite -Interactive -ClientId $Params.ClientId
+ BgLog "Enumeration des fichiers destination..." "Cyan"
+ $dstFiles = @(Get-AllSPFiles $Params.DstLib)
+ BgLog " $($dstFiles.Count) fichier(s) destination" "LightGreen"
+
+ $dstMap = @{}
+ foreach ($f in $dstFiles) { $dstMap[$f.RelativePath] = $f }
+
+ # Compare
+ BgLog "Comparaison en cours..." "Cyan"
+ $results = [System.Collections.Generic.List[object]]::new()
+
+ foreach ($key in $srcMap.Keys) {
+ $src = $srcMap[$key]
+ if ($dstMap.ContainsKey($key)) {
+ $dst = $dstMap[$key]
+ if ([long]$src.Length -eq [long]$dst.Length) {
+ $results.Add([PSCustomObject]@{
+ Name = $src.Name
+ RelativePath = $key
+ Status = "OK"
+ SourceSize = [long]$src.Length
+ DestSize = [long]$dst.Length
+ })
+ } else {
+ $results.Add([PSCustomObject]@{
+ Name = $src.Name
+ RelativePath = $key
+ Status = "SIZE_MISMATCH"
+ SourceSize = [long]$src.Length
+ DestSize = [long]$dst.Length
+ })
+ }
+ } else {
+ $results.Add([PSCustomObject]@{
+ Name = $src.Name
+ RelativePath = $key
+ Status = "MISSING"
+ SourceSize = [long]$src.Length
+ DestSize = $null
+ })
+ }
+ }
+
+ foreach ($key in $dstMap.Keys) {
+ if (-not $srcMap.ContainsKey($key)) {
+ $dst = $dstMap[$key]
+ $results.Add([PSCustomObject]@{
+ Name = $dst.Name
+ RelativePath = $key
+ Status = "EXTRA"
+ SourceSize = $null
+ DestSize = [long]$dst.Length
+ })
+ }
+ }
+
+ $okN = @($results | Where-Object { $_.Status -eq "OK" }).Count
+ $missN = @($results | Where-Object { $_.Status -eq "MISSING" }).Count
+ $mmN = @($results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
+ $exN = @($results | Where-Object { $_.Status -eq "EXTRA" }).Count
+ BgLog "Resultats : $okN OK, $missN manquant(s), $mmN taille(s) differente(s), $exN extra" "White"
+
+ $Sync.VerifyResults = @($results)
+ } catch {
+ $Sync.Error = $_.Exception.Message
+ BgLog "Erreur : $($_.Exception.Message)" "Red"
+ } finally {
+ $Sync.Done = $true
+ }
+ }
+
+ $sync = [hashtable]::Synchronized(@{
+ Queue = [System.Collections.Generic.Queue[object]]::new()
+ Done = $false
+ Error = $null
+ VerifyResults = $null
+ })
+ $script:_VerSync = $sync
+ $script:_VerParams = $params
+
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
+ $ps = [System.Management.Automation.PowerShell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript($bgVerify)
+ [void]$ps.AddArgument($params)
+ [void]$ps.AddArgument($sync)
+ $script:_VerRS = $rs
+ $script:_VerPS = $ps
+ $script:_VerHnd = $ps.BeginInvoke()
+
+ $tmr = New-Object System.Windows.Forms.Timer
+ $tmr.Interval = 250
+ $script:_VerTimer = $tmr
+ $tmr.Add_Tick({
+ while ($script:_VerSync.Queue.Count -gt 0) {
+ $m = $script:_VerSync.Queue.Dequeue()
+ Write-Log $m.Text $m.Color
+ }
+ if ($script:_VerSync.Done) {
+ $script:_VerTimer.Stop(); $script:_VerTimer.Dispose()
+ while ($script:_VerSync.Queue.Count -gt 0) {
+ $m = $script:_VerSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
+ }
+ try { [void]$script:_VerPS.EndInvoke($script:_VerHnd) } catch {}
+ try { $script:_VerRS.Close(); $script:_VerRS.Dispose() } catch {}
+ $btnXferStart.Enabled = $true
+ $btnXferVerify.Enabled = $true
+ Stop-ProgressAnim
+
+ if ($script:_VerSync.Error) {
+ Write-Log "Echec de la verification : $($script:_VerSync.Error)" "Red"
+ return
+ }
+
+ $results = $script:_VerSync.VerifyResults
+ if (-not $results -or $results.Count -eq 0) {
+ Write-Log "Aucun fichier a comparer." "Orange"
+ return
+ }
+
+ $p = $script:_VerParams
+ $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
+ $outDir = $p.OutFolder
+ if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
+
+ $outFile = Join-Path $outDir "TransferVerify_$stamp.html"
+ $html = Export-TransferVerifyToHTML -Results $results `
+ -SrcSite $p.SrcSite -SrcLib $p.SrcLib `
+ -DstSite $p.DstSite -DstLib $p.DstLib
+ $html | Set-Content -Path $outFile -Encoding UTF8
+
+ Write-Log "Rapport : $outFile" "White"
+ $script:_XferLastReport = $outFile
+ $btnXferOpen.Enabled = $true
+ }
+ })
+ $tmr.Start()
+})
+
+# ── Open Transfer Report ──────────────────────────────────────────────────────
+
+$btnXferOpen.Add_Click({
+ $f = $script:_XferLastReport
+ if ($f -and (Test-Path $f)) { Start-Process $f }
+})
+
+# ── Bulk Create ───────────────────────────────────────────────────────────────
+
+# Helper: add a site entry hashtable to the ListView
+function Add-BulkListItem([hashtable]$entry) {
+ $lvi = New-Object System.Windows.Forms.ListViewItem($entry.Name)
+ $lvi.SubItems.Add($entry.Alias) | Out-Null
+ $lvi.SubItems.Add($entry.Type) | Out-Null
+ $lvi.SubItems.Add($entry.Template)| Out-Null
+ $lvi.SubItems.Add($entry.Owners) | Out-Null
+ $lvi.SubItems.Add($entry.Members) | Out-Null
+ $lvi.Tag = $entry
+ $lvBulk.Items.Add($lvi) | Out-Null
+}
+
+# Double-click to edit
+$lvBulk.Add_DoubleClick({
+ if ($lvBulk.SelectedItems.Count -eq 0) { return }
+ $sel = $lvBulk.SelectedItems[0]
+ $edited = Show-BulkSiteDialog -Owner $form -Existing $sel.Tag
+ if ($edited) {
+ $sel.Text = $edited.Name
+ $sel.SubItems[1].Text = $edited.Alias
+ $sel.SubItems[2].Text = $edited.Type
+ $sel.SubItems[3].Text = $edited.Template
+ $sel.SubItems[4].Text = $edited.Owners
+ $sel.SubItems[5].Text = $edited.Members
+ $sel.Tag = $edited
+ }
+})
+
+$btnBulkAdd.Add_Click({
+ $entry = Show-BulkSiteDialog -Owner $form
+ if ($entry) { Add-BulkListItem $entry }
+})
+
+$btnBulkCsv.Add_Click({
+ $ofd = New-Object System.Windows.Forms.OpenFileDialog
+ $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
+ if ($ofd.ShowDialog($form) -ne "OK") { return }
+ $rows = Import-Csv $ofd.FileName
+ $count = 0
+ foreach ($r in $rows) {
+ # Accepted column names (case-insensitive via PSObject)
+ $name = if ($r.Name) { $r.Name } elseif ($r.name) { $r.name }
+ elseif ($r.Title) { $r.Title } elseif ($r.title) { $r.title } else { "" }
+ $alias = if ($r.Alias) { $r.Alias } elseif ($r.alias) { $r.alias }
+ elseif ($r.URL) { $r.URL } elseif ($r.url) { $r.url } else { "" }
+ $type = if ($r.Type) { $r.Type } elseif ($r.type) { $r.type } else { "Team" }
+ $tpl = if ($r.Template) { $r.Template } elseif ($r.template) { $r.template } else { "" }
+ $own = if ($r.Owners) { $r.Owners } elseif ($r.owners) { $r.owners }
+ elseif ($r.Owner) { $r.Owner } elseif ($r.owner) { $r.owner } else { "" }
+ $mem = if ($r.Members) { $r.Members } elseif ($r.members) { $r.members } else { "" }
+
+ if (-not $name -or -not $alias) { continue }
+ # Normalize type
+ if ($type -match '^[Cc]omm') { $type = "Communication" } else { $type = "Team" }
+ Add-BulkListItem @{
+ Name = $name.Trim()
+ Alias = $alias.Trim()
+ Type = $type
+ Template = $tpl.Trim()
+ Owners = $own.Trim()
+ Members = $mem.Trim()
+ }
+ $count++
+ }
+ Write-Log "$count site(s) imported from CSV." "LightGreen"
+})
+
+$btnBulkRemove.Add_Click({
+ while ($lvBulk.SelectedItems.Count -gt 0) {
+ $lvBulk.Items.Remove($lvBulk.SelectedItems[0])
+ }
+})
+
+$btnBulkClear.Add_Click({
+ $lvBulk.Items.Clear()
+})
+
+$btnBulkCreate.Add_Click({
+ $clientId = $txtClientId.Text.Trim()
+ $tenantUrl = $txtTenantUrl.Text.Trim()
+
+ if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }
+ if (-not $tenantUrl) { Write-Log "Tenant URL requis." "Red"; return }
+ if ($lvBulk.Items.Count -eq 0) { Write-Log "Aucun site dans la liste." "Red"; return }
+
+ # Collect entries
+ $entries = @()
+ foreach ($lvi in $lvBulk.Items) { $entries += $lvi.Tag }
+
+ # Load all templates once
+ $allTemplates = @{}
+ foreach ($t in (Load-Templates)) { $allTemplates[$t.name] = $t }
+
+ $params = @{
+ ClientId = $clientId
+ TenantUrl = $tenantUrl
+ Entries = $entries
+ Templates = $allTemplates
+ }
+
+ $btnBulkCreate.Enabled = $false
+ $btnBulkAdd.Enabled = $false
+ $btnBulkCsv.Enabled = $false
+ $btnBulkRemove.Enabled = $false
+ $btnBulkClear.Enabled = $false
+ $txtLog.Clear()
+ Start-ProgressAnim
+ Write-Log "=== BULK SITE CREATION ===" "White"
+ Write-Log "Sites to create: $($entries.Count)" "Gray"
+ Write-Log ("-" * 52) "DarkGray"
+
+ $bgBulk = {
+ param($Params, $Sync)
+ function BgLog([string]$m, [string]$c = "LightGreen") {
+ $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
+ }
+
+ # Folder-tree helper (same as template manager)
+ function Apply-FolderTreeBg([array]$folders, [string]$parentUrl) {
+ foreach ($fd in $folders) {
+ try {
+ Add-PnPFolder -Name $fd.name -Folder $parentUrl -ErrorAction SilentlyContinue | Out-Null
+ } catch {}
+ if ($fd.subfolders -and $fd.subfolders.Count -gt 0) {
+ Apply-FolderTreeBg $fd.subfolders "$parentUrl/$($fd.name)"
+ }
+ }
+ }
+
+ try {
+ Import-Module PnP.PowerShell -ErrorAction Stop
+
+ $adminUrl = if ($Params.TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
+ "$($Matches[1])-admin$($Matches[2])"
+ } else { $Params.TenantUrl }
+ $base = if ($Params.TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $Params.TenantUrl }
+
+ BgLog "Connexion au tenant admin..." "Yellow"
+ Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId
+
+ $total = $Params.Entries.Count
+ $idx = 0
+
+ foreach ($entry in $Params.Entries) {
+ $idx++
+ $name = $entry.Name
+ $alias = $entry.Alias
+ $isTeam = $entry.Type -ne "Communication"
+ $owners = @($entry.Owners -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+ $members = @($entry.Members -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+ $tplName = $entry.Template
+
+ BgLog "[$idx/$total] Creating '$name' (alias: $alias, type: $($entry.Type))..." "White"
+
+ # Update status
+ $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Creating..." })
+
+ try {
+ # Create the site
+ Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId
+ $newUrl = if ($isTeam) {
+ if ($owners.Count -gt 0) {
+ New-PnPSite -Type TeamSite -Title $name -Alias $alias -Owners $owners -Wait
+ } else {
+ New-PnPSite -Type TeamSite -Title $name -Alias $alias -Wait
+ }
+ } else {
+ New-PnPSite -Type CommunicationSite -Title $name -Url "$base/sites/$alias" -Wait
+ }
+ BgLog " Site cree : $newUrl" "LightGreen"
+
+ # Connect to the new site for template + members
+ Connect-PnPOnline -Url $newUrl -Interactive -ClientId $Params.ClientId
+
+ # Apply template if specified
+ if ($tplName -and $Params.Templates.ContainsKey($tplName)) {
+ $tpl = $Params.Templates[$tplName]
+ BgLog " Applying template '$tplName'..." "Yellow"
+
+ # Settings
+ if ($tpl.options.settings -and $tpl.settings -and $tpl.settings.description) {
+ Set-PnPWeb -Description $tpl.settings.description
+ }
+ # Style
+ if ($tpl.options.style -and $tpl.style -and $tpl.style.logoUrl) {
+ Set-PnPWeb -SiteLogoUrl $tpl.style.logoUrl
+ }
+ # Structure
+ if ($tpl.options.structure -and $tpl.structure -and $tpl.structure.Count -gt 0) {
+ foreach ($lib in $tpl.structure) {
+ $existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
+ if (-not $existing) {
+ try {
+ $tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") {
+ [Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary
+ } else {
+ [Microsoft.SharePoint.Client.ListTemplateType]::GenericList
+ }
+ New-PnPList -Title $lib.name -Template $tplType | Out-Null
+ } catch {}
+ }
+ if ($lib.folders -and $lib.folders.Count -gt 0) {
+ $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
+ if ($targetList) {
+ $listRf = Get-PnPProperty -ClientObject $targetList -Property RootFolder
+ $libBase = $listRf.ServerRelativeUrl.TrimEnd('/')
+ Apply-FolderTreeBg $lib.folders $libBase
+ }
+ }
+ }
+ BgLog " Structure applied." "Cyan"
+ }
+ }
+
+ # Add members
+ if ($members.Count -gt 0) {
+ $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
+ } catch {}
+ }
+ BgLog " $($members.Count) member(s) added." "Cyan"
+ }
+ }
+
+ # Add owners for Communication sites (TeamSite owners set at creation)
+ if (-not $isTeam -and $owners.Count -gt 0) {
+ $ownerGroup = Get-PnPGroup | Where-Object { $_.Title -like "*Propri*" -or $_.Title -like "*Owner*" } | Select-Object -First 1
+ if ($ownerGroup) {
+ foreach ($o in $owners) {
+ try {
+ Add-PnPGroupMember -LoginName $o -Group $ownerGroup.Title -ErrorAction SilentlyContinue
+ } catch {}
+ }
+ }
+ }
+
+ $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "OK" })
+ $Sync.CreatedSites.Add([PSCustomObject]@{
+ Name = $name
+ Alias = $alias
+ Type = $entry.Type
+ URL = $newUrl
+ })
+ $Sync.OkCount++
+
+ } catch {
+ $errMsg = $_.Exception.Message
+ BgLog " ERREUR : $errMsg" "Red"
+ $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: $errMsg" })
+ $Sync.ErrCount++
+ }
+ }
+
+ BgLog "Termine : $($Sync.OkCount) OK, $($Sync.ErrCount) erreur(s)" "White"
+ } catch {
+ $Sync.Error = $_.Exception.Message
+ BgLog "Erreur globale : $($_.Exception.Message)" "Red"
+ } finally {
+ $Sync.Done = $true
+ }
+ }
+
+ $sync = [hashtable]::Synchronized(@{
+ Queue = [System.Collections.Generic.Queue[object]]::new()
+ Done = $false
+ Error = $null
+ OkCount = 0
+ ErrCount = 0
+ CreatedSites = [System.Collections.Generic.List[object]]::new()
+ })
+ $script:_BulkSync = $sync
+
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
+ $ps = [System.Management.Automation.PowerShell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript($bgBulk)
+ [void]$ps.AddArgument($params)
+ [void]$ps.AddArgument($sync)
+ $script:_BulkRS = $rs
+ $script:_BulkPS = $ps
+ $script:_BulkHnd = $ps.BeginInvoke()
+
+ $tmr = New-Object System.Windows.Forms.Timer
+ $tmr.Interval = 250
+ $script:_BulkTimer = $tmr
+ $tmr.Add_Tick({
+ while ($script:_BulkSync.Queue.Count -gt 0) {
+ $m = $script:_BulkSync.Queue.Dequeue()
+ # Status update messages update the ListView item
+ if ($m.Text -eq "##STATUS##") {
+ $i = $m.Index
+ if ($i -lt $lvBulk.Items.Count) {
+ $lvi = $lvBulk.Items[$i]
+ # Use the first column text to show status via ForeColor
+ if ($m.Value -eq "OK") {
+ $lvi.ForeColor = [System.Drawing.Color]::Green
+ } elseif ($m.Value -like "Error*") {
+ $lvi.ForeColor = [System.Drawing.Color]::Red
+ } else {
+ $lvi.ForeColor = [System.Drawing.Color]::DarkOrange
+ }
+ }
+ } else {
+ Write-Log $m.Text $m.Color
+ }
+ }
+ if ($script:_BulkSync.Done) {
+ $script:_BulkTimer.Stop(); $script:_BulkTimer.Dispose()
+ while ($script:_BulkSync.Queue.Count -gt 0) {
+ $m = $script:_BulkSync.Queue.Dequeue()
+ if ($m.Text -ne "##STATUS##") { Write-Log $m.Text $m.Color }
+ }
+ try { [void]$script:_BulkPS.EndInvoke($script:_BulkHnd) } catch {}
+ try { $script:_BulkRS.Close(); $script:_BulkRS.Dispose() } catch {}
+ $btnBulkCreate.Enabled = $true
+ $btnBulkAdd.Enabled = $true
+ $btnBulkCsv.Enabled = $true
+ $btnBulkRemove.Enabled = $true
+ $btnBulkClear.Enabled = $true
+ Stop-ProgressAnim
+
+ if ($script:_BulkSync.Error) {
+ Write-Log "Echec : $($script:_BulkSync.Error)" "Red"
+ return
+ }
+ # Export CSV report of created sites
+ if ($script:_BulkSync.CreatedSites.Count -gt 0) {
+ $outDir = $txtOutput.Text.Trim()
+ if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
+ if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
+ $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
+ $csvFile = Join-Path $outDir "BulkCreate_$stamp.csv"
+ $script:_BulkSync.CreatedSites | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
+ Write-Log "Rapport CSV : $csvFile" "White"
+ }
+ Write-Log "=== BULK CREATE COMPLETE: $($script:_BulkSync.OkCount) OK, $($script:_BulkSync.ErrCount) error(s) ===" "White"
+ }
+ })
+ $tmr.Start()
+})
+
#endregion
# ── Initialisation : chargement des settings ───────────────────────────────
diff --git a/TODO.md b/TODO.md
index 0e50f81..9196862 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,4 +1,5 @@
# Features à ajouter :
- Sauvegarde du contexte d'authentification en plus des profils
- Possibilité de demander la liste de site auquels un user precis a acces
-- Copie de site à site
\ No newline at end of file
+- Copie de site à site
+- Barre de recherche dans les fichiers HTML exportés
\ No newline at end of file
diff --git a/lang/fr.json b/lang/fr.json
index 115b293..734147c 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -90,5 +90,52 @@
"ph.created.by": "Prénom Nom ou email",
"ph.modified.by": "Prénom Nom ou email",
"ph.library": "Chemin relatif optionnel ex : Documents partagés",
- "ph.dup.lib": "Toutes (laisser vide)"
+ "ph.dup.lib": "Toutes (laisser vide)",
+
+ "tab.transfer": " Transfert ",
+ "grp.xfer.source": "Source",
+ "grp.xfer.dest": "Destination",
+ "lbl.xfer.site": "URL du site :",
+ "lbl.xfer.library": "Bibliothèque / Dossier :",
+ "grp.xfer.options": "Options",
+ "chk.xfer.recursive": "Inclure les sous-dossiers (récursif)",
+ "chk.xfer.overwrite": "Écraser les fichiers existants",
+ "btn.xfer.start": "Lancer le transfert",
+ "btn.xfer.verify": "Vérifier",
+ "btn.xfer.open": "Ouvrir le rapport",
+ "ph.xfer.site": "https://tenant.sharepoint.com/sites/xxx",
+ "ph.xfer.library": "Documents partagés/sous-dossier",
+ "xfer.note": "Seule la version actuelle de chaque fichier est transférée (pas d'historique de versions).",
+
+ "tab.bulk": " Création en masse ",
+ "grp.bulk.list": "Sites à créer",
+ "btn.bulk.add": "Ajouter un site...",
+ "btn.bulk.csv": "Importer CSV...",
+ "btn.bulk.remove": "Supprimer",
+ "btn.bulk.clear": "Tout effacer",
+ "btn.bulk.create": "Créer tous les sites",
+ "bulk.col.name": "Nom du site",
+ "bulk.col.alias": "Alias URL",
+ "bulk.col.type": "Type",
+ "bulk.col.template": "Template",
+ "bulk.col.owners": "Propriétaires",
+ "bulk.col.members": "Membres",
+ "bulk.dlg.title": "Ajouter un site",
+ "bulk.dlg.title.edit": "Modifier un site",
+ "bulk.lbl.name": "Nom du site :",
+ "bulk.lbl.alias": "Alias URL (après /sites/) :",
+ "bulk.lbl.type": "Type de site :",
+ "bulk.rad.team": "Site d'équipe",
+ "bulk.rad.comm": "Site de communication",
+ "bulk.lbl.template": "Template :",
+ "bulk.lbl.owners": "Propriétaires (séparés par virgule) :",
+ "bulk.lbl.members": "Membres (séparés par virgule) :",
+ "bulk.btn.csv.members": "Importer CSV...",
+ "bulk.none": "(Aucun)",
+ "bulk.ph.owners": "admin@domaine.com, user2@domaine.com",
+ "bulk.ph.members": "user@domaine.com, ...",
+ "bulk.status.pending": "En attente",
+ "bulk.status.creating": "Création...",
+ "bulk.status.ok": "OK",
+ "bulk.status.error": "Erreur"
}