From 0e5f67bfa490e03e1139f0fb50a06682b51ec5b6 Mon Sep 17 00:00:00 2001 From: Kawa Date: Mon, 16 Mar 2026 11:22:01 +0100 Subject: [PATCH] Added 2 new features : - File/folder transfer betrween sites - Bulk site creation --- Sharepoint_ToolBox.ps1 | 1296 +++++++++++++++++++++++++++++++++++++++- TODO.md | 3 +- lang/fr.json | 49 +- 3 files changed, 1345 insertions(+), 3 deletions(-) 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
+
+
+
$totalCount
Total files
+
$okCount
Matched
+
$missingCount
Missing
+
$mismatchCount
Size mismatch
+
$extraCount
Extra at dest
+
+
+
+ + + + + + + + +$rows +
Status File Name Relative Path Source Size Dest Size
+ + + +"@ + 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" }