Skip to content

Commit 203e032

Browse files
authored
Merge pull request #962 from fabianhutzli:report-trim-file-versions
Add SPO file version report and trim script for Teams sites
2 parents e4ec6c5 + 9aefea5 commit 203e032

4 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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" />
328 KB
Loading
58.7 KB
Loading
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
[
2+
{
3+
"name": "spo-report-version-expiration-and-trim",
4+
"source": "pnp",
5+
"title": "Generate File version report and trim on specific Teams",
6+
"shortDescription": "Reports on file version history across Microsoft Teams sites and private channels, calculates storage savings, and trims expired versions using SharePoint's automatic versioning policy.",
7+
"url": "https://pnp.github.io/script-samples/spo-report-version-expiration-and-trim/README.html",
8+
"longDescription": [
9+
""
10+
],
11+
"creationDateTime": "2026-06-24",
12+
"updateDateTime": "2026-06-24",
13+
"products": [
14+
"SharePoint"
15+
],
16+
"metadata": [
17+
{
18+
"key": "PNP-POWERSHELL",
19+
"value": "3.2.0"
20+
}
21+
],
22+
"categories": [
23+
"Data",
24+
"Configure",
25+
"Report"
26+
],
27+
"tags": [
28+
"Get-PnPMicrosoft365Group",
29+
"Get-PnPMicrosoft365GroupOwner",
30+
"Get-PnPTeamsTeam",
31+
"Get-PnPTeamsChannel",
32+
"Get-PnPTeamsPrimaryChannel",
33+
"Get-PnPTeamsChannelFilesFolder",
34+
"Get-PnPTeamsChannelUser",
35+
"Get-PnPTenantSite",
36+
"Get-PnPList",
37+
"New-PnPList",
38+
"Set-PnPList",
39+
"Get-PnPFile",
40+
"Get-PnPSiteFileVersionExpirationReportJobStatus",
41+
"New-PnPSiteFileVersionExpirationReportJob",
42+
"Get-PnPSiteFileVersionBatchDeleteJobStatus",
43+
"New-PnPSiteFileVersionBatchDeleteJob",
44+
"Set-PnPSiteVersionPolicy"
45+
],
46+
"thumbnails": [
47+
{
48+
"type": "image",
49+
"order": 100,
50+
"url": "https://raw.githubusercontent.com/pnp/script-samples/main/scripts/spo-report-version-expiration-and-trim/assets/preview.png",
51+
"alt": "Preview of the sample Generate File version report and trim on specific Teams"
52+
}
53+
],
54+
"authors": [
55+
{
56+
"gitHubAccount": "fabianhutzli",
57+
"company": "",
58+
"pictureUrl": "https://github.com/fabianhutzli.png",
59+
"name": "Fabian Hutzli"
60+
}
61+
],
62+
"references": [
63+
null
64+
]
65+
}
66+
]

0 commit comments

Comments
 (0)