@@ -2854,6 +2854,32 @@ $script:LangDefault = @{
" bulk.status.creating " = " Creating... "
" bulk.status.ok " = " OK "
" bulk.status.error " = " Error "
" tab.structure " = " Structure "
" grp.struct.csv " = " CSV Import "
" lbl.struct.desc " = " Import a CSV to create a folder tree. Each column represents a depth level. "
" btn.struct.csv " = " Load CSV... "
" grp.struct.preview " = " Preview "
" grp.struct.target " = " Target "
" lbl.struct.library " = " Target library: "
" ph.struct.library " = " Shared Documents "
" btn.struct.create " = " Create Structure "
" btn.struct.clear " = " Clear "
" struct.col.path " = " Full Path "
" struct.col.depth " = " Depth "
" tab.versions " = " Versions "
" grp.ver.keep " = " Versions to Keep "
" lbl.ver.count " = " Number of versions to keep: "
" chk.ver.date " = " Also filter by date "
" rad.ver.before " = " Keep versions before: "
" rad.ver.after " = " Keep versions after: "
" grp.ver.scope " = " Scope "
" lbl.ver.library " = " Library / Folder: "
" ph.ver.library " = " Shared Documents "
" chk.ver.recursive " = " Include subfolders (recursive) "
" chk.ver.subsites " = " Include subsites "
" chk.ver.dryrun " = " Dry run (preview only, no deletion) "
" btn.ver.run " = " Clean Versions "
" btn.ver.open " = " Open Report "
}
$script:Lang = $null # null = use LangDefault
@@ -3606,7 +3632,137 @@ $btnBulkCreate.Size = New-Object System.Drawing.Size(200, 34)
$tabBulk . Controls . AddRange ( @ ( $grpBulkList , $btnBulkCreate ) )
$tabs . TabPages . AddRange ( @ ( $tabPerms , $tabStorage , $tabTemplates , $tabSearch , $tabDupes , $tabTransfer , $tabBulk ) )
# ══════════════════════════════════════════════════════════════════════════════
# Tab 8 – Structure (folder tree from CSV)
# ══════════════════════════════════════════════════════════════════════════════
$tabStruct = New-Object System . Windows . Forms . TabPage
$tabStruct . Text = T " tab.structure "
# ── CSV import + target (single row) ───────────────────────────────────────
$grpStructCsv = New-Group ( T " grp.struct.csv " ) 10 4 620 52
$lblStructDesc = New-Object System . Windows . Forms . Label
$lblStructDesc . Text = T " lbl.struct.desc "
$lblStructDesc . Location = New-Object System . Drawing . Point ( 10 , 20 )
$lblStructDesc . Size = New-Object System . Drawing . Size ( 460 , 20 )
$btnStructCsv = New-Object System . Windows . Forms . Button
$btnStructCsv . Text = T " btn.struct.csv "
$btnStructCsv . Location = New-Object System . Drawing . Point ( 490 , 18 )
$btnStructCsv . Size = New-Object System . Drawing . Size ( 118 , 26 )
$grpStructCsv . Controls . AddRange ( @ ( $lblStructDesc , $btnStructCsv ) )
# ── Preview ────────────────────────────────────────────────────────────────
$grpStructPreview = New-Group ( T " grp.struct.preview " ) 10 58 620 148
$tvStruct = New-Object System . Windows . Forms . TreeView
$tvStruct . Location = New-Object System . Drawing . Point ( 10 , 18 )
$tvStruct . Size = New-Object System . Drawing . Size ( 598 , 120 )
$tvStruct . Font = New-Object System . Drawing . Font ( " Segoe UI " , 9 )
$tvStruct . ShowLines = $true
$tvStruct . ShowPlusMinus = $true
$grpStructPreview . Controls . Add ( $tvStruct )
# ── Target + Buttons (single row) ─────────────────────────────────────────
$lblStructLib = New-Object System . Windows . Forms . Label
$lblStructLib . Text = T " lbl.struct.library "
$lblStructLib . Location = New-Object System . Drawing . Point ( 12 , 214 )
$lblStructLib . Size = New-Object System . Drawing . Size ( 110 , 20 )
$txtStructLib = New-Object System . Windows . Forms . TextBox
$txtStructLib . Location = New-Object System . Drawing . Point ( 124 , 212 )
$txtStructLib . Size = New-Object System . Drawing . Size ( 200 , 22 )
$txtStructLib . PlaceholderText = T " ph.struct.library "
$btnStructCreate = New-ActionBtn ( T " btn.struct.create " ) 340 208 ( [ System.Drawing.Color ] :: FromArgb ( 0 , 120 , 212 ) )
$btnStructCreate . Size = New-Object System . Drawing . Size ( 180 , 30 )
$btnStructClear = New-Object System . Windows . Forms . Button
$btnStructClear . Text = T " btn.struct.clear "
$btnStructClear . Location = New-Object System . Drawing . Point ( 528 , 208 )
$btnStructClear . Size = New-Object System . Drawing . Size ( 90 , 30 )
$tabStruct . Controls . AddRange ( @ ( $grpStructCsv , $grpStructPreview , $lblStructLib , $txtStructLib , $btnStructCreate , $btnStructClear ) )
# ══════════════════════════════════════════════════════════════════════════════
# Tab 9 – Version Cleanup
# ══════════════════════════════════════════════════════════════════════════════
$tabVersions = New-Object System . Windows . Forms . TabPage
$tabVersions . Text = T " tab.versions "
$tabVersions . BackColor = [ System.Drawing.Color ] :: WhiteSmoke
# ── Versions to keep ─────────────────────────────────────────────────────────
$grpVerKeep = New-Group ( T " grp.ver.keep " ) 10 4 620 110
$lblVerCount = New-Object System . Windows . Forms . Label
$lblVerCount . Text = T " lbl.ver.count "
$lblVerCount . Location = New-Object System . Drawing . Point ( 10 , 22 )
$lblVerCount . Size = New-Object System . Drawing . Size ( 220 , 20 )
$nudVerCount = New-Object System . Windows . Forms . NumericUpDown
$nudVerCount . Location = New-Object System . Drawing . Point ( 235 , 20 )
$nudVerCount . Size = New-Object System . Drawing . Size ( 70 , 22 )
$nudVerCount . Minimum = 0
$nudVerCount . Maximum = 500
$nudVerCount . Value = 5
$chkVerDate = New-Check ( T " chk.ver.date " ) 10 50 250 $false
$radVerBefore = New-Radio ( T " rad.ver.before " ) 30 74 200 $true
$radVerBefore . Enabled = $false
$radVerAfter = New-Radio ( T " rad.ver.after " ) 30 96 200 $false
$radVerAfter . Enabled = $false
$dtpVer = New-Object System . Windows . Forms . DateTimePicker
$dtpVer . Location = New-Object System . Drawing . Point ( 235 , 74 )
$dtpVer . Size = New-Object System . Drawing . Size ( 150 , 22 )
$dtpVer . Format = [ System.Windows.Forms.DateTimePickerFormat ] :: Short
$dtpVer . Enabled = $false
$chkVerDate . Add_CheckedChanged ( {
$on = $chkVerDate . Checked
$radVerBefore . Enabled = $on
$radVerAfter . Enabled = $on
$dtpVer . Enabled = $on
} )
$grpVerKeep . Controls . AddRange ( @ ( $lblVerCount , $nudVerCount , $chkVerDate , $radVerBefore , $radVerAfter , $dtpVer ) )
# ── Scope ─────────────────────────────────────────────────────────────────────
$grpVerScope = New-Group ( T " grp.ver.scope " ) 10 118 620 76
$lblVerLib = New-Object System . Windows . Forms . Label
$lblVerLib . Text = T " lbl.ver.library "
$lblVerLib . Location = New-Object System . Drawing . Point ( 10 , 22 )
$lblVerLib . Size = New-Object System . Drawing . Size ( 150 , 20 )
$txtVerLib = New-Object System . Windows . Forms . TextBox
$txtVerLib . Location = New-Object System . Drawing . Point ( 164 , 20 )
$txtVerLib . Size = New-Object System . Drawing . Size ( 230 , 22 )
$txtVerLib . PlaceholderText = T " ph.ver.library "
$chkVerRecursive = New-Check ( T " chk.ver.recursive " ) 10 48 260 $true
$chkVerSubsites = New-Check ( T " chk.ver.subsites " ) 280 48 200 $false
$grpVerScope . Controls . AddRange ( @ ( $lblVerLib , $txtVerLib , $chkVerRecursive , $chkVerSubsites ) )
# ── Options + Buttons ─────────────────────────────────────────────────────────
$chkVerDryRun = New-Check ( T " chk.ver.dryrun " ) 12 200 350 $true
$btnVerRun = New-ActionBtn ( T " btn.ver.run " ) 10 228 ( [ System.Drawing.Color ] :: FromArgb ( 180 , 60 , 20 ) )
$btnVerRun . Size = New-Object System . Drawing . Size ( 180 , 30 )
$btnVerOpen = New-Object System . Windows . Forms . Button
$btnVerOpen . Text = T " btn.ver.open "
$btnVerOpen . Location = New-Object System . Drawing . Point ( 200 , 228 )
$btnVerOpen . Size = New-Object System . Drawing . Size ( 130 , 30 )
$btnVerOpen . Enabled = $false
$tabVersions . Controls . AddRange ( @ ( $grpVerKeep , $grpVerScope , $chkVerDryRun , $btnVerRun , $btnVerOpen ) )
$tabs . TabPages . AddRange ( @ ( $tabPerms , $tabStorage , $tabTemplates , $tabSearch , $tabDupes , $tabTransfer , $tabBulk , $tabStruct , $tabVersions ) )
# ── Progress bar ───────────────────────────────────────────────────────────────
$progressBar = New-Object System . Windows . Forms . ProgressBar
@@ -3793,6 +3949,24 @@ $_reg = {
& $_reg $script:i18nMap $btnBulkRemove " btn.bulk.remove "
& $_reg $script:i18nMap $btnBulkClear " btn.bulk.clear "
& $_reg $script:i18nMap $btnBulkCreate " btn.bulk.create "
& $_reg $script:i18nMap $lblStructDesc " lbl.struct.desc "
& $_reg $script:i18nMap $btnStructCsv " btn.struct.csv "
& $_reg $script:i18nMap $lblStructLib " lbl.struct.library "
& $_reg $script:i18nMap $btnStructCreate " btn.struct.create "
& $_reg $script:i18nMap $btnStructClear " btn.struct.clear "
# Version Cleanup tab
& $_reg $script:i18nMap $lblVerCount " lbl.ver.count "
& $_reg $script:i18nMap $chkVerDate " chk.ver.date "
& $_reg $script:i18nMap $radVerBefore " rad.ver.before "
& $_reg $script:i18nMap $radVerAfter " rad.ver.after "
& $_reg $script:i18nMap $lblVerLib " lbl.ver.library "
& $_reg $script:i18nMap $chkVerRecursive " chk.ver.recursive "
& $_reg $script:i18nMap $chkVerSubsites " chk.ver.subsites "
& $_reg $script:i18nMap $chkVerDryRun " chk.ver.dryrun "
& $_reg $script:i18nMap $btnVerRun " btn.ver.run "
& $_reg $script:i18nMap $btnVerOpen " btn.ver.open "
& $_reg $script:i18nMap $grpVerKeep " grp.ver.keep "
& $_reg $script:i18nMap $grpVerScope " grp.ver.scope "
# Tab pages
& $_reg $script:i18nTabs $tabPerms " tab.perms "
@@ -3802,6 +3976,8 @@ $_reg = {
& $_reg $script:i18nTabs $tabDupes " tab.dupes "
& $_reg $script:i18nTabs $tabTransfer " tab.transfer "
& $_reg $script:i18nTabs $tabBulk " tab.bulk "
& $_reg $script:i18nTabs $tabStruct " tab.structure "
& $_reg $script:i18nTabs $tabVersions " tab.versions "
# Menu items
& $_reg $script:i18nMenus $menuSettings " menu.settings "
@@ -3816,10 +3992,12 @@ $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 $txtStructLib " ph.struct.library "
& $_reg $script:i18nPlaceholders $txtXferSrcSite " ph.xfer.site "
& $_reg $script:i18nPlaceholders $txtXferSrcLib " ph.xfer.library "
& $_reg $script:i18nPlaceholders $txtXferDstSite " ph.xfer.site "
& $_reg $script:i18nPlaceholders $txtXferDstLib " ph.xfer.library "
& $_reg $script:i18nPlaceholders $txtVerLib " ph.ver.library "
#endregion
@@ -5229,30 +5407,41 @@ $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
# Try semicolon first (handles commas inside fields), fall back to comma
$content = Get-Content $ofd . FileName -Raw
if ( $content -match ';' ) {
$rows = Import-Csv $ofd . FileName -Delimiter ';'
} else {
$rows = Import-Csv $ofd . FileName
}
$count = 0
foreach ( $r in $rows ) {
# Accepte d column nam es (case-insensitive via PSObject )
$name = if ( $r . Name ) { $r . Name } elseif ( $r . name ) { $r . name }
elseif ( $r . Title ) { $r . Title } elseif ( $r . titl e) { $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 { " " }
# Rea d columns via PSObject properti es (case-insensitive)
$props = @ { }
foreach ( $p in $r . PSObject . Properties ) { $props [ $p . Name . ToLower ( ) ] = " $ ($p . Valu e) " . Trim ( ) }
if ( -not $name -or -not $alias ) { continue }
$name = if ( $props [ 'name' ] ) { $props [ 'name' ] } elseif ( $props [ 'title' ] ) { $props [ 'title' ] } else { " " }
$alias = if ( $props [ 'alias' ] ) { $props [ 'alias' ] } elseif ( $props [ 'url' ] ) { $props [ 'url' ] } else { " " }
$type = if ( $props [ 'type' ] ) { $props [ 'type' ] } else { " Team " }
$tpl = if ( $props [ 'template' ] ) { $props [ 'template' ] } else { " " }
$own = if ( $props [ 'owners' ] ) { $props [ 'owners' ] } elseif ( $props [ 'owner' ] ) { $props [ 'owner' ] } else { " " }
$mem = if ( $props [ 'members' ] ) { $props [ 'members' ] } else { " " }
# Name is required; skip empty rows
if ( -not $name ) { continue }
# Auto-generate alias from name if not provided
if ( -not $alias ) {
$alias = $name . ToLower ( ) -replace '[^a-z0-9\-]' , '-' -replace '-+' , '-' -replace '^-|-$' , ''
}
# Normalize type
if ( $type -match '^[Cc]omm' ) { $type = " Communication " } else { $type = " Team " }
Add-BulkListItem @ {
Name = $name . Trim ( )
Alias = $alias . Trim ( )
Name = $name
Alias = $alias
Type = $type
Template = $tpl . Trim ( )
Owners = $own . Trim ( )
Members = $mem . Trim ( )
Template = $tpl
Owners = $own
Members = $mem
}
$count + +
}
@@ -5340,32 +5529,83 @@ $btnBulkCreate.Add_Click({
$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 { $_ } )
$ownerRaw = " $ ($entry . Owners)"
$memberRaw = " $ ($entry . Members)"
$owners = [ string[] ] @ ( $ownerRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' } )
$members = [ string[] ] @ ( $memberRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' } )
$tplName = $entry . Template
BgLog " [ $idx / $total ] Creating ' $name ' (alias: $alias , type: $( $entry . Type ) )... " " White "
BgLog " DEBUG owners raw=' $ownerRaw ' parsed=[ $( $owners -join '|' ) ] count= $( $owners . Count ) " " Gray "
BgLog " DEBUG members raw=' $memberRaw ' parsed=[ $( $members -join '|' ) ] count= $( $members . Count ) " " Gray "
# TeamSite requires at least one owner
if ( $isTeam -and $owners . Count -eq 0 ) {
BgLog " ERREUR : TeamSite requires at least one owner — skipping ' $name ' " " Red "
$Sync . Queue . Enqueue ( @ { Text = " ##STATUS## " ; Index = ( $idx - 1 ) ; Value = " Error: no owner " } )
$Sync . ErrCount + +
continue
}
# Update status
$Sync . Queue . Enqueue ( @ { Text = " ##STATUS## " ; Index = ( $idx - 1 ) ; Value = " Creating... " } )
try {
# Create the site
# Create the site WITHOUT owners/members (PnP bug: odata.bind empty array)
# Current user becomes default owner; we add owners/members after creation
Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params . ClientId
$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
}
if ( $isTeam ) {
BgLog " Creating TeamSite ' $alias ' (owners/members added after)... " " DarkGray "
$newUrl = New-PnPSite -Type TeamSite -Title $name -Alias $alias -Wait
} else {
New-PnPSite -Type CommunicationSite -Title $name -Url " $base /sites/ $alias " -Wait
BgLog " Creating CommunicationSite ' $alias '... " " DarkGray "
$newUrl = New-PnPSite -Type CommunicationSite -Title $name -Url " $base /sites/ $alias " -Wait
}
BgLog " Site cree : $newUrl " " LightGreen "
# Connect to the new site for template + members
# Connect to the new site for owners/members/template
Connect-PnPOnline -Url $newUrl -Interactive -ClientId $Params . ClientId
# Assign owners & members post-creation
if ( $isTeam ) {
$groupId = $null
try { $groupId = ( Get-PnPSite -Includes GroupId ) . GroupId . Guid } catch { }
if ( $groupId ) {
foreach ( $o in $owners ) {
try {
Add-PnPMicrosoft365GroupOwner -Identity $groupId -Users $o -ErrorAction Stop
BgLog " Owner added: $o " " Cyan "
} catch { BgLog " Warn owner ' $o ': $( $_ . Exception . Message ) " " DarkYellow " }
}
foreach ( $m in $members ) {
try {
Add-PnPMicrosoft365GroupMember -Identity $groupId -Users $m -ErrorAction Stop
BgLog " Member added: $m " " Cyan "
} catch { BgLog " Warn member ' $m ': $( $_ . Exception . Message ) " " DarkYellow " }
}
} else {
BgLog " Could not get M365 GroupId — owners/members not assigned " " DarkYellow "
}
} else {
# CommunicationSite — classic SharePoint groups
if ( $owners . Count -gt 0 ) {
$ownerGrp = Get-PnPGroup | Where-Object { $_ . Title -like " *Propri* " -or $_ . Title -like " *Owner* " } | Select-Object -First 1
if ( $ownerGrp ) {
foreach ( $o in $owners ) {
try { Add-PnPGroupMember -LoginName $o -Group $ownerGrp . Title -ErrorAction SilentlyContinue } catch { }
}
}
}
if ( $members . Count -gt 0 ) {
$memberGrp = Get-PnPGroup | Where-Object { $_ . Title -like " *Membre* " -or $_ . Title -like " *Member* " } | Select-Object -First 1
if ( $memberGrp ) {
foreach ( $m in $members ) {
try { Add-PnPGroupMember -LoginName $m -Group $memberGrp . Title -ErrorAction SilentlyContinue } catch { }
}
}
}
}
# Apply template if specified
if ( $tplName -and $Params . Templates . ContainsKey ( $tplName ) ) {
$tpl = $Params . Templates [ $tplName ]
@@ -5406,30 +5646,7 @@ $btnBulkCreate.Add_Click({
}
}
# 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 ] @ {
@@ -5539,6 +5756,318 @@ $btnBulkCreate.Add_Click({
#endregion
#region ===== Structure (folder tree from CSV) =====
# Store the parsed folder paths
$script:_StructPaths = @ ( )
function Build-StructTree([string]$csvPath ) {
# Auto-detect delimiter
$raw = Get-Content $csvPath -Raw
$delim = if ( $raw -match ';' ) { ';' } else { ',' }
$rows = Import-Csv $csvPath -Delimiter $delim
$paths = [ System.Collections.Generic.List[string] ] :: new ( )
foreach ( $r in $rows ) {
$cols = @ ( $r . PSObject . Properties | ForEach-Object { " $( $_ . Value ) " . Trim ( ) } )
# Build path from non-empty columns
$parts = @ ( $cols | Where-Object { $_ -ne '' } )
if ( $parts . Count -gt 0 ) {
# Add all intermediate paths to ensure parents exist
for ( $i = 1 ; $i -le $parts . Count ; $i + + ) {
$p = ( $parts [ 0 . . ( $i - 1 ) ] -join '/' )
if ( -not $paths . Contains ( $p ) ) { $paths . Add ( $p ) }
}
}
}
$script:_StructPaths = @ ( $paths | Sort-Object )
return $script:_StructPaths
}
function Populate-StructTreeView([string[]]$paths ) {
$tvStruct . Nodes . Clear ( )
$nodeMap = @ { }
foreach ( $p in $paths ) {
$parts = $p -split '/'
$parentKey = if ( $parts . Count -gt 1 ) { ( $parts [ 0 . . ( $parts . Count - 2 ) ] -join '/' ) } else { '' }
$name = $parts [ -1 ]
$node = New-Object System . Windows . Forms . TreeNode ( $name )
$node . Tag = $p
if ( $parentKey -and $nodeMap . ContainsKey ( $parentKey ) ) {
$nodeMap [ $parentKey ] . Nodes . Add ( $node ) | Out-Null
} else {
$tvStruct . Nodes . Add ( $node ) | Out-Null
}
$nodeMap [ $p ] = $node
}
$tvStruct . ExpandAll ( )
}
$btnStructCsv . Add_Click ( {
$ofd = New-Object System . Windows . Forms . OpenFileDialog
$ofd . Filter = " CSV (*.csv)|*.csv|All (*.*)|*.* "
if ( $ofd . ShowDialog ( $form ) -ne " OK " ) { return }
try {
$paths = Build-StructTree $ofd . FileName
Populate-StructTreeView $paths
Write-Log " $( $paths . Count ) folder(s) loaded from CSV. " " LightGreen "
} catch {
Write-Log " CSV error: $( $_ . Exception . Message ) " " Red "
}
} )
$btnStructClear . Add_Click ( {
$tvStruct . Nodes . Clear ( )
$script:_StructPaths = @ ( )
Write-Log " Structure cleared. " " Gray "
} )
$btnStructCreate . Add_Click ( {
$siteUrl = $txtSiteUrl . Text . Trim ( )
$clientId = $txtClientId . Text . Trim ( )
$library = $txtStructLib . Text . Trim ( )
if ( -not $siteUrl ) { Write-Log " Site URL required. " " Red " ; return }
if ( -not $clientId ) { Write-Log " Client ID required. " " Red " ; return }
if ( -not $library ) { Write-Log " Target library required. " " Red " ; return }
if ( $script:_StructPaths . Count -eq 0 ) { Write-Log " No structure loaded. Load a CSV first. " " Red " ; return }
$btnStructCreate . Enabled = $false
$btnStructCsv . Enabled = $false
Start-ProgressAnim
Write-Log " === CREATING FOLDER STRUCTURE === " " White "
Write-Log " Target: $siteUrl / $library " " Gray "
Write-Log " Folders to create: $( $script:_StructPaths . Count ) " " Gray "
Write-Log ( " - " * 52 ) " DarkGray "
try {
Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
# Get the library root
$list = Get-PnPList -Identity $library -ErrorAction Stop
$rf = Get-PnPProperty -ClientObject $list -Property RootFolder
$base = $rf . ServerRelativeUrl . TrimEnd ( '/' )
$ok = 0
$err = 0
$total = $script:_StructPaths . Count
foreach ( $p in $script:_StructPaths ) {
$folderPath = " $base / $p "
try {
# Resolve-PnPFolder creates the full path recursively
Resolve-PnPFolder -SiteRelativePath " $library / $p " -ErrorAction Stop | Out-Null
$ok + +
Write-Log " OK: $p " " LightGreen "
} catch {
$err + +
Write-Log " FAIL: $p — $( $_ . Exception . Message ) " " Red "
}
}
Write-Log " === STRUCTURE COMPLETE: $ok OK, $err error(s) === " " White "
} catch {
Write-Log " Error: $( $_ . Exception . Message ) " " Red "
} finally {
$btnStructCreate . Enabled = $true
$btnStructCsv . Enabled = $true
Stop-ProgressAnim
}
} )
# ── Version Cleanup handlers ─────────────────────────────────────────────────
$script:_VerReport = $null
$btnVerOpen . Add_Click ( {
if ( $script:_VerReport -and ( Test-Path $script:_VerReport ) ) {
Start-Process $script:_VerReport
}
} )
$btnVerRun . Add_Click ( {
# --- Gather all selected site URLs ---
$siteUrls = @ ( )
if ( $script:_CachedSites -and $script:_CachedSites . Count -gt 0 ) {
foreach ( $s in $script:_CachedSites ) {
if ( $s . Checked ) { $siteUrls + = $s . Url }
}
}
if ( $siteUrls . Count -eq 0 ) {
$single = $txtSiteUrl . Text . Trim ( )
if ( $single ) { $siteUrls = @ ( $single ) }
}
if ( $siteUrls . Count -eq 0 ) { Write-Log " Site URL required. " " Red " ; return }
$clientId = $txtClientId . Text . Trim ( )
if ( -not $clientId ) { Write-Log " Client ID required. " " Red " ; return }
$keepCount = [ int ] $nudVerCount . Value
$useDate = $chkVerDate . Checked
$dateBefore = $radVerBefore . Checked # true = keep before, false = keep after
$cutoffDate = $dtpVer . Value
$library = $txtVerLib . Text . Trim ( )
$recursive = $chkVerRecursive . Checked
$subsites = $chkVerSubsites . Checked
$dryRun = $chkVerDryRun . Checked
$btnVerRun . Enabled = $false
Start-ProgressAnim
$modeLabel = if ( $dryRun ) { " DRY RUN " } else { " LIVE " }
Write-Log " === VERSION CLEANUP ( $modeLabel ) === " " White "
Write-Log " Keep: $keepCount version(s) " " Gray "
if ( $useDate ) {
$dir = if ( $dateBefore ) { " before " } else { " after " }
Write-Log " Date filter: keep versions $dir $( $cutoffDate . ToString ( 'yyyy-MM-dd' ) ) " " Gray "
}
Write-Log ( " - " * 52 ) " DarkGray "
$report = [ System.Collections.Generic.List[object] ] :: new ( )
$totalDeleted = 0
$totalKept = 0
$totalErrors = 0
try {
foreach ( $siteUrl in $siteUrls ) {
Write-Log " Connecting to $siteUrl ... " " Gray "
Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
# Collect site URLs to process (main + subsites)
$sitesToProcess = @ ( $siteUrl )
if ( $subsites ) {
try {
$subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
foreach ( $sw in $subs ) { $sitesToProcess + = $sw . Url }
} catch { }
}
foreach ( $currentSite in $sitesToProcess ) {
if ( $currentSite -ne $siteUrl ) {
try { Connect-PnPOnline -Url $currentSite -Interactive -ClientId $clientId } catch {
Write-Log " Cannot connect to subsite $currentSite — skipped " " DarkOrange "
continue
}
}
Write-Log " Processing site: $currentSite " " White "
# Get target lists
$lists = @ ( )
if ( $library ) {
try { $lists = @ ( Get-PnPList -Identity $library -ErrorAction Stop ) } catch {
Write-Log " Library ' $library ' not found — skipped " " DarkOrange "
continue
}
} else {
$lists = Get-PnPList | Where-Object { $_ . BaseTemplate -eq 101 -and $_ . Hidden -eq $false }
}
foreach ( $list in $lists ) {
Write-Log " Library: $( $list . Title ) " " Gray "
try {
$camlQuery = " <View Scope='RecursiveAll'><Query></Query><RowLimit>5000</RowLimit></View> "
if ( -not $recursive ) {
$camlQuery = " <View><Query></Query><RowLimit>5000</RowLimit></View> "
}
$items = Get-PnPListItem -List $list . Title -Query $camlQuery -ErrorAction Stop |
Where-Object { $_ . FileSystemObjectType -eq " File " }
} catch {
Write-Log " Error listing files: $( $_ . Exception . Message ) " " Red "
$totalErrors + +
continue
}
foreach ( $item in $items ) {
try {
$file = $item . FieldValues [ " FileRef " ]
$versions = Get-PnPFileVersion -Url $file -ErrorAction Stop
if ( $versions . Count -le $keepCount ) { continue }
# Sort versions oldest first (by VersionLabel numeric)
$sorted = $versions | Sort-Object { [ double ] $_ . VersionLabel }
# Determine which versions to delete
$toDelete = @ ( )
foreach ( $v in $sorted ) {
# Always keep the last $keepCount versions
$idx = [ array ] :: IndexOf ( $sorted , $v )
$remaining = $sorted . Count - $idx
if ( $remaining -le $keepCount ) { break }
# Apply date filter if enabled
if ( $useDate ) {
$vDate = [ datetime ] $v . Created
if ( $dateBefore ) {
# Keep versions before cutoff → delete versions ON or AFTER cutoff
if ( $vDate -lt $cutoffDate ) { continue }
} else {
# Keep versions after cutoff → delete versions BEFORE cutoff
if ( $vDate -ge $cutoffDate ) { continue }
}
}
$toDelete + = $v
}
if ( $toDelete . Count -eq 0 ) { continue }
$fileName = Split-Path $file -Leaf
foreach ( $v in $toDelete ) {
if ( $dryRun ) {
Write-Log " [DRY] Would delete v $( $v . VersionLabel ) of $fileName ( $( $v . Created ) ) " " DarkOrange "
} else {
try {
Remove-PnPFileVersion -Url $file -Identity $v . Id -Force -ErrorAction Stop
Write-Log " Deleted v $( $v . VersionLabel ) of $fileName " " LightGreen "
} catch {
Write-Log " Error deleting v $( $v . VersionLabel ) of $fileName — $( $_ . Exception . Message ) " " Red "
$totalErrors + +
}
}
$totalDeleted + +
}
$kept = $sorted . Count - $toDelete . Count
$totalKept + = $kept
$report . Add ( [ PSCustomObject ] @ {
Site = $currentSite
Library = $list . Title
File = $file
TotalVer = $sorted . Count
Deleted = $toDelete . Count
Kept = $kept
} )
} catch {
$totalErrors + +
}
}
}
}
}
# Export CSV report
if ( $report . Count -gt 0 ) {
$outDir = $txtOutput . Text . Trim ( )
if ( -not $outDir ) { $outDir = if ( $PSScriptRoot ) { $PSScriptRoot } else { $PWD . Path } }
if ( -not ( Test-Path $outDir ) ) { New-Item -ItemType Directory -Path $outDir | Out-Null }
$stamp = Get-Date -Format " yyyyMMdd_HHmmss "
$prefix = if ( $dryRun ) { " VersionCleanup_DryRun " } else { " VersionCleanup " }
$csvFile = Join-Path $outDir " ${prefix} _ $stamp .csv "
$report | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
$script:_VerReport = $csvFile
$btnVerOpen . Enabled = $true
Write-Log " Report: $csvFile " " White "
}
Write-Log " === VERSION CLEANUP COMPLETE: $totalDeleted deleted, $totalKept kept, $totalErrors error(s) === " " White "
} catch {
Write-Log " Error: $( $_ . Exception . Message ) " " Red "
} finally {
$btnVerRun . Enabled = $true
Stop-ProgressAnim
}
} )
#endregion
# ── Initialisation : chargement des settings ───────────────────────────────
$_settings = Load-Settings
$script:DataFolder = if ( $_settings . dataFolder -and ( Test-Path $_settings . dataFolder ) ) {