|
| 1 | +# Generate File version report and trim on specific Teams |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +This script helps you **report on and trim file version history** across multiple Microsoft Teams sites (including private channels) to reclaim SharePoint storage. It is not designed to run from start to end in one go — it is organized into **sequential blocks** that you execute step by step, with an optional batching mechanism for large-scale environments. |
| 6 | + |
| 7 | +### Script blocks (must be run in order) |
| 8 | + |
| 9 | +| Block | Purpose | |
| 10 | +|-------|---------| |
| 11 | +| **Login** | Establishes a PnP connection using your chosen credentials (interactive, app registration, etc.). The same connection object is reused across blocks. | |
| 12 | +| **Report Block** | Iterates over a list of M365 group names. For each group it resolves the main Teams site and all associated private channel sites, collecting owners, document libraries, storage usage, and current auto-expiration policy into a `$result` array. | |
| 13 | +| **Batch variables** *(optional)* | Splits `$result` into fixed-size batches (e.g. `$batch1`, `$batch2`) so you can process a large number of sites incrementally instead of all at once. | |
| 14 | +| **Site Version Report Job** | For every site in `$result` (or a batch), creates a hidden `StorageReport` document library and triggers `New-PnPSiteFileVersionExpirationReportJob` — an async SharePoint job that writes a CSV listing all file versions eligible for expiry. Once completed, the CSV is downloaded locally. A `$reportMode` flag lets you check job status without starting new jobs. | |
| 15 | +| **Sum of Storage Savings** | Reads all downloaded CSVs and sums the bytes for versions whose `AutomaticPolicyExpirationDate` is in the past — giving you the total storage that would be freed before you commit to deletion. | |
| 16 | +| **Site Version Deletion** | For every site, triggers `New-PnPSiteFileVersionBatchDeleteJob -Automatic` to delete expired versions and then sets the site to automatic versioning via `Set-PnPSiteVersionPolicy`. The same `$reportMode` flag is used here as a safety gate: set it to `$false` only when you are ready to delete. | |
| 17 | + |
| 18 | +### Important: user context requirement |
| 19 | + |
| 20 | +`New-PnPSiteFileVersionExpirationReportJob` and `New-PnPSiteFileVersionBatchDeleteJob` **must be triggered in a delegated (user) context** — they do not work with app-only authentication. All other blocks can run with an app registration. A practical split is: |
| 21 | + |
| 22 | +- **App registration** (high tenant privileges): Login, Report Block, Batch variables, Sum block |
| 23 | +- **SPO admin user / service account** (with access to all target sites): Site Version Report Job block, Site Version Deletion block |
| 24 | + |
| 25 | +For simplicity, the script is written to use a single connection throughout — adapt the Login block credentials accordingly for your environment. |
| 26 | + |
| 27 | +# [PnP PowerShell](#tab/pnpps) |
| 28 | + |
| 29 | +```powershell |
| 30 | +#### LOGIN BLOCK |
| 31 | +# set creds to $Creds |
| 32 | +$Creds = @{ |
| 33 | + Interactive = $true |
| 34 | + ClientId = "6e0b566f-bb83-44b0-86fa-013dd60c383d" |
| 35 | +} |
| 36 | +$cn = Connect-PnPOnline @Creds -ReturnConnection |
| 37 | +# -> Describe what permissions are needed |
| 38 | +#### END LOGIN BLOCK |
| 39 | +
|
| 40 | +### START REPORT BLOCK |
| 41 | +$teamsByM365GroupName = @("My Team1","My Team2") |
| 42 | +$result = @() |
| 43 | +
|
| 44 | +foreach ($teamName in $teamsByM365GroupName) { |
| 45 | +
|
| 46 | + Write-Host "Processing $teamName..." -ForegroundColor Cyan |
| 47 | +
|
| 48 | + # 1. Get M365 Group |
| 49 | + $group = Get-PnPMicrosoft365Group -Identity $teamName -IncludeSiteUrl -Connection $cn |
| 50 | +
|
| 51 | + if (!$group) { |
| 52 | + Write-Warning "Group not found: $teamName" |
| 53 | + continue |
| 54 | + } |
| 55 | +
|
| 56 | + # 2. Get Group Owners |
| 57 | + $owners = Get-PnPMicrosoft365GroupOwner -Identity $group.Id -Connection $cn |
| 58 | +
|
| 59 | + # 3. Get Main SharePoint Site (General channel) |
| 60 | + $mainSiteUrl = $group.SiteUrl |
| 61 | +
|
| 62 | + # 4. Get Team + Channels |
| 63 | + $team = Get-PnPTeamsTeam -Identity $group.Id -Connection $cn |
| 64 | + $channels = Get-PnPTeamsChannel -Team $team -Connection $cn |
| 65 | +
|
| 66 | + # Filter private channels |
| 67 | + $privateChannels = $channels | Where-Object {$_.MembershipType -eq "private"} |
| 68 | +
|
| 69 | + $primaryChannel = Get-PnPTeamsPrimaryChannel -Team $team -Connection $cn |
| 70 | +
|
| 71 | + $tenantSite = Get-PnPTenantSite -Url $mainSiteUrl -Connection $cn -Detailed | Select Url, StorageUsageCurrent, EnableAutoExpirationVersionTrim |
| 72 | +
|
| 73 | + $cnSite = Connect-PnPOnline -Url $mainSiteUrl @Creds -ReturnConnection |
| 74 | +
|
| 75 | + $docLibs = Get-PnPList -Connection $cnSite | Where-Object { |
| 76 | + $_.BaseTemplate -eq 101 -and $_.Hidden -eq $false |
| 77 | + } |
| 78 | +
|
| 79 | + $result += [PSCustomObject]@{ |
| 80 | + TeamName = $team.DisplayName |
| 81 | + GroupId = $group.Id |
| 82 | + MainSiteUrl = $mainSiteUrl |
| 83 | + GroupOwners = ($owners.UserPrincipalName -join ";") |
| 84 | + ChannelType = "Default" |
| 85 | + ChannelName = $primaryChannel.DisplayName |
| 86 | + ChannelSite = $mainSiteUrl |
| 87 | + ChannelOwners = ($owners.UserPrincipalName -join ";") |
| 88 | + ChannelDocLibs = ($docLibs.Title -join ";") |
| 89 | + ChannelStorageUsage = $tenantSite.StorageUsageCurrent # in MB -> / 1024 for GB |
| 90 | + ChannelAutoExpiration = $tenantSite.EnableAutoExpirationVersionTrim |
| 91 | + } |
| 92 | +
|
| 93 | + foreach ($channel in $privateChannels) { |
| 94 | +
|
| 95 | + Write-Host "Processing Private Channel $($channel.DisplayName)..." -ForegroundColor Cyan |
| 96 | +
|
| 97 | + # 5. Get Private Channel SPO Site URL |
| 98 | + # Pattern: https://tenant.sharepoint.com/sites/<GroupName>-<ChannelName> |
| 99 | + # Better: resolve via PnP |
| 100 | +
|
| 101 | + $channelSite = Get-PnPTeamsChannelFilesFolder -Team $team -Channel $channel -Connection $cn |
| 102 | +
|
| 103 | + # 6. Get Private Channel Owners |
| 104 | + $channelOwners = Get-PnPTeamsChannelUser -Team $team -Channel $channel -Role Owner -Connection $cn |
| 105 | +
|
| 106 | + $channelSite = $($channelSite.webUrl.split("/")[0..4] -join "/") |
| 107 | +
|
| 108 | + $tenantSite = Get-PnPTenantSite -Url $channelSite -Connection $cn -Detailed | Select Url, StorageUsageCurrent, EnableAutoExpirationVersionTrim |
| 109 | +
|
| 110 | + $cnSite = Connect-PnPOnline -Url $channelSite @Creds -ReturnConnection |
| 111 | +
|
| 112 | + $docLibs = Get-PnPList -Connection $cnSite | Where-Object { |
| 113 | + $_.BaseTemplate -eq 101 -and $_.Hidden -eq $false |
| 114 | + } |
| 115 | +
|
| 116 | + $result += [PSCustomObject]@{ |
| 117 | + TeamName = $team.DisplayName |
| 118 | + GroupId = $group.Id |
| 119 | + MainSiteUrl = $mainSiteUrl |
| 120 | + GroupOwners = ($owners.UserPrincipalName -join ";") |
| 121 | + ChannelType = "Private" |
| 122 | + ChannelName = $channel.DisplayName |
| 123 | + ChannelSite = $channelSite |
| 124 | + ChannelOwners = ($channelOwners.Email -join ";") |
| 125 | + ChannelDocLibs = ($docLibs.Title -join ";") |
| 126 | + ChannelStorageUsage = $tenantSite.StorageUsageCurrent # in MB -> / 1024 for GB |
| 127 | + ChannelAutoExpiration = $tenantSite.EnableAutoExpirationVersionTrim |
| 128 | + } |
| 129 | + } |
| 130 | +} |
| 131 | +
|
| 132 | +$result |
| 133 | +
|
| 134 | +### END REPORT BLOCK |
| 135 | +
|
| 136 | +### IF BATCHES ARE NEEDED |
| 137 | +$batchSize = 20 |
| 138 | +
|
| 139 | +for ($start = 0; $start -lt $result.Count; $start += $batchSize) { |
| 140 | + $batchNumber = [int]($start / $batchSize) + 1 |
| 141 | + $end = [Math]::Min($start + $batchSize - 1, $result.Count - 1) |
| 142 | +
|
| 143 | + Set-Variable -Name "batch$batchNumber" -Value $result[$start..$end] |
| 144 | +} |
| 145 | +
|
| 146 | +### START SITE VERSION REPORT JOB BLOCK |
| 147 | +
|
| 148 | +$reportMode = $true #to check job status only |
| 149 | +#$reportMode = $false #to start new report jobs |
| 150 | +
|
| 151 | +foreach($c in $result){ |
| 152 | +#foreach($c in $batch1){ |
| 153 | +
|
| 154 | + $cnSite = Connect-PnPOnline -Url $c.ChannelSite @Creds -ReturnConnection |
| 155 | +
|
| 156 | + $list = Get-PnPList -Identity "StorageReport" -Connection $cnSite -ErrorAction SilentlyContinue |
| 157 | +
|
| 158 | + if (-not $list) { |
| 159 | + New-PnPList -Title "StorageReport" -Template DocumentLibrary -OnQuickLaunch:$false -Connection $cnSite |
| 160 | + Set-PnPList -Identity "StorageReport" -Hidden:$true -NoCrawl:$true -BreakRoleInheritance:$true -CopyRoleAssignments:$false -Connection $cnSite |
| 161 | + } |
| 162 | +
|
| 163 | + $reportUrl = "$($c.ChannelSite)/StorageReport/SiteVersionReport_$($c.ChannelSite.Split('/')[-1]).csv" |
| 164 | + $status = Get-PnPSiteFileVersionExpirationReportJobStatus -ReportUrl $reportUrl -Connection $cnSite -ErrorAction SilentlyContinue |
| 165 | +
|
| 166 | + if ((-not $status -or $status.Status -in @("no_report_found")) -and $reportMode -eq $false){ |
| 167 | + New-PnPSiteFileVersionExpirationReportJob -ReportUrl $reportUrl -Connection $cnSite |
| 168 | + } |
| 169 | + else { |
| 170 | + Write-Host "Report $reportUrl in Status $($status.Status)" |
| 171 | + } |
| 172 | +
|
| 173 | + if($status.Status -eq "completed"){ |
| 174 | + $tenantBaseUrl = "$([Uri]::new($c.ChannelSite).Scheme)://$([Uri]::new($c.ChannelSite).Host)" |
| 175 | + $siteName = $c.ChannelSite.Split('/')[-1] |
| 176 | + Get-PnPFile -Url "$($c.ChannelSite.Replace($tenantBaseUrl,''))/StorageReport/SiteVersionReport_$siteName.csv" -AsFile -Path "." -Filename "SiteVersionReport_$siteName.csv" -Force -Connection $cnSite |
| 177 | + } |
| 178 | +} |
| 179 | +### END SITE VERSION REPORT JOB BLOCK |
| 180 | +
|
| 181 | +### START GET SUM OF STORAGE SAVINGS BLOCK |
| 182 | +$now = Get-Date |
| 183 | + |
| 184 | +$batchResult = Get-ChildItem -Path "." -Filter *.csv | ForEach-Object { |
| 185 | + |
| 186 | +$sumBytes = Import-Csv $_.FullName | |
| 187 | + Where-Object { |
| 188 | + -not [string]::IsNullOrWhiteSpace($_.AutomaticPolicyExpirationDate) -and |
| 189 | + ([datetime]$_.AutomaticPolicyExpirationDate -lt $now) |
| 190 | + } | |
| 191 | + Measure-Object -Property Size -Sum | |
| 192 | + Select-Object -ExpandProperty Sum |
| 193 | + |
| 194 | + $sumMB = [math]::Round(($sumBytes / 1048576), 2) |
| 195 | + |
| 196 | + [PSCustomObject]@{ |
| 197 | + FileName = $_.Name |
| 198 | + ExpiredSizeMB = $sumMB |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +$batchResult |
| 203 | +($batchResult | Measure-Object -Property ExpiredSizeMB -Sum).Sum |
| 204 | +### END GET SUM OF STORAGE SAVINGS BLOCK |
| 205 | +
|
| 206 | +### START SITE VERSION DELETION |
| 207 | +
|
| 208 | +$reportMode = $true #to get job status only |
| 209 | +#$reportMode = $false #to start the job |
| 210 | +
|
| 211 | +foreach($c in $result){ |
| 212 | +#foreach($c in $batch1){ |
| 213 | +
|
| 214 | + $cnSite = Connect-PnPOnline -Url $c.ChannelSite @Creds -ReturnConnection |
| 215 | +
|
| 216 | + $status = Get-PnPSiteFileVersionBatchDeleteJobStatus -Connection $cnSite -ErrorAction SilentlyContinue |
| 217 | + $status |
| 218 | +
|
| 219 | + if ((-not $status -or $status.Status -eq "NoRequestFound") -and $reportMode -eq $false){ |
| 220 | + New-PnPSiteFileVersionBatchDeleteJob -Automatic -Connection $cnSite |
| 221 | + Set-PnPSiteVersionPolicy -EnableAutoExpirationVersionTrim $true -Connection $cnSite |
| 222 | + } |
| 223 | + else { |
| 224 | + Write-Host "$($c.ChannelSite) - Site File Version Batch Delete Job in Status $($status.Status)" |
| 225 | + } |
| 226 | +} |
| 227 | +
|
| 228 | +### END SITE VERSION DELETION |
| 229 | +``` |
| 230 | +[!INCLUDE [More about PnP PowerShell](../../docfx/includes/MORE-PNPPS.md)] |
| 231 | + |
| 232 | +*** |
| 233 | + |
| 234 | +## Contributors |
| 235 | + |
| 236 | +| Author(s) | |
| 237 | +|-----------| |
| 238 | +| Fabian Hutzli | |
| 239 | + |
| 240 | + |
| 241 | +[!INCLUDE [DISCLAIMER](../../docfx/includes/DISCLAIMER.md)] |
| 242 | +<img src="https://m365-visitor-stats.azurewebsites.net/script-samples/scripts/spo-report-version-expiration-and-trim" aria-hidden="true" /> |
0 commit comments