@@ -80,6 +80,279 @@ Now that the scripts are setup, you just need to run them. All these steps are t
8080
8181## 1. OneDrive Scan
8282
83+ # [ CLI for Microsoft 365] ( #tab/cli-m365-ps )
84+
85+ ``` powershell
86+
87+ [CmdletBinding(SupportsShouldProcess)]
88+ param(
89+ [Parameter(Mandatory = $false, HelpMessage = "SharePoint admin center URL (e.g., https://contoso-admin.sharepoint.com)")]
90+ [ValidatePattern('^https://')]
91+ [string]$TenantAdminUrl,
92+
93+ [Parameter(Mandatory = $false, HelpMessage = "SharePoint site URL to scan (e.g., https://contoso.sharepoint.com/sites/project)")]
94+ [ValidatePattern('^https://')]
95+ [string]$SiteUrl,
96+
97+ [Parameter(Mandatory = $false, HelpMessage = "Title of a specific document library to scan")]
98+ [string]$LibraryName,
99+
100+ [Parameter(Mandatory = $false, HelpMessage = "Server-relative URL of a specific folder to scan")]
101+ [string]$FolderUrl,
102+
103+ [Parameter(Mandatory = $false, HelpMessage = "Number of days to use as age threshold (default: 1460 = 4 years)")]
104+ [int]$DaysOld = 1460,
105+
106+ [Parameter(Mandatory = $false, HelpMessage = "Full path for the CSV report file")]
107+ [string]$OutputPath,
108+
109+ [Parameter(Mandatory = $false, HelpMessage = "Include OneDrive personal sites in scan (requires TenantAdminUrl)")]
110+ [switch]$IncludeOneDrive,
111+
112+ [Parameter(Mandatory = $false, HelpMessage = "Scan subfolders recursively")]
113+ [switch]$Recursive
114+ )
115+
116+ begin {
117+ if (-not $TenantAdminUrl -and -not $SiteUrl) {
118+ throw "You must specify either -TenantAdminUrl or -SiteUrl parameter."
119+ }
120+
121+ if ($TenantAdminUrl -and $SiteUrl) {
122+ throw "Cannot specify both -TenantAdminUrl and -SiteUrl. Choose one scan mode."
123+ }
124+
125+ if ($IncludeOneDrive -and -not $TenantAdminUrl) {
126+ throw "-IncludeOneDrive requires -TenantAdminUrl parameter."
127+ }
128+
129+ if ($LibraryName -and -not $SiteUrl) {
130+ throw "-LibraryName requires -SiteUrl parameter."
131+ }
132+
133+ if ($FolderUrl -and -not $SiteUrl) {
134+ throw "-FolderUrl requires -SiteUrl parameter."
135+ }
136+
137+ if (-not $OutputPath) {
138+ $timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
139+ $OutputPath = Join-Path (Get-Location) "OldFilesReport-$timestamp.csv"
140+ } else {
141+ $parentFolder = Split-Path -Path $OutputPath -Parent
142+ if (-not (Test-Path -Path $parentFolder)) {
143+ throw "Output folder does not exist: $parentFolder"
144+ }
145+ }
146+
147+ $transcriptPath = $OutputPath -replace '\.csv$', '-Transcript.log'
148+ Start-Transcript -Path $transcriptPath
149+
150+ Write-Host "Starting old file report generation..." -ForegroundColor Cyan
151+ Write-Host "Age threshold: $DaysOld days" -ForegroundColor Cyan
152+ Write-Host "Output path: $OutputPath" -ForegroundColor Cyan
153+
154+ Write-Verbose "Ensuring CLI for Microsoft 365 login..."
155+ m365 login --ensure
156+ if ($LASTEXITCODE -ne 0) {
157+ Stop-Transcript
158+ throw "Failed to authenticate with CLI for Microsoft 365."
159+ }
160+
161+ $cutoffDate = (Get-Date).AddDays(-$DaysOld)
162+ $cutoffDateString = $cutoffDate.ToString("yyyy-MM-ddTHH:mm:ssZ")
163+ Write-Verbose "Cutoff date: $cutoffDateString"
164+
165+ $script:ReportCollection = [System.Collections.Generic.List[object]]::new()
166+ $script:Summary = @{
167+ SitesProcessed = 0
168+ LibrariesProcessed = 0
169+ OldFilesFound = 0
170+ Failures = 0
171+ }
172+ }
173+
174+ process {
175+ try {
176+ $sitesToProcess = @()
177+
178+ if ($TenantAdminUrl) {
179+ Write-Host "Retrieving sites from tenant..." -ForegroundColor Cyan
180+ Write-Verbose "Building site list command..."
181+
182+ $siteListArgs = @('spo', 'site', 'list', '--output', 'json')
183+ if ($IncludeOneDrive) {
184+ Write-Verbose "Including OneDrive sites"
185+ $siteListArgs += '--withOneDriveSites'
186+ } else {
187+ Write-Verbose "Filtering for SharePoint sites only"
188+ $siteListArgs += '--filter'
189+ $siteListArgs += "Url -like '/sites/'"
190+ }
191+
192+ $sitesJson = m365 @siteListArgs 2>&1
193+ if ($LASTEXITCODE -ne 0) {
194+ Write-Warning "Failed to retrieve sites. CLI: $sitesJson"
195+ $script:Summary.Failures++
196+ return
197+ }
198+
199+ $sitesToProcess = @($sitesJson | ConvertFrom-Json)
200+ Write-Host "Found $($sitesToProcess.Count) sites to scan" -ForegroundColor Green
201+ } else {
202+ Write-Verbose "Using single site: $SiteUrl"
203+ $sitesToProcess = @(@{ Url = $SiteUrl; Title = "" })
204+ }
205+
206+ $siteCounter = 0
207+ foreach ($site in $sitesToProcess) {
208+ $siteCounter++
209+ $siteUrl = $site.Url
210+ $siteTitle = if ($site.Title) { $site.Title } else { $siteUrl }
211+
212+ Write-Progress -Activity "Processing sites" -Status "Site $siteCounter of $($sitesToProcess.Count): $siteTitle" -PercentComplete (($siteCounter / $sitesToProcess.Count) * 100)
213+ Write-Verbose "Processing site: $siteUrl"
214+
215+ try {
216+ $librariesToProcess = @()
217+
218+ if ($LibraryName) {
219+ Write-Verbose "Using specific library: $LibraryName"
220+ $libraryListJson = m365 spo list list --webUrl $siteUrl --filter "Title eq '$LibraryName' and BaseTemplate eq 101 and Hidden eq false" --properties "Title,RootFolder/ServerRelativeUrl" --output json 2>&1
221+ if ($LASTEXITCODE -ne 0) {
222+ Write-Warning "Failed to retrieve library '$LibraryName' from site '$siteUrl'. CLI: $libraryListJson"
223+ $script:Summary.Failures++
224+ continue
225+ }
226+ $librariesToProcess = @($libraryListJson | ConvertFrom-Json)
227+ } else {
228+ Write-Verbose "Retrieving all document libraries..."
229+ $libraryListJson = m365 spo list list --webUrl $siteUrl --filter "BaseTemplate eq 101 and Hidden eq false" --properties "Title,RootFolder/ServerRelativeUrl" --output json 2>&1
230+ if ($LASTEXITCODE -ne 0) {
231+ Write-Warning "Failed to retrieve libraries from site '$siteUrl'. CLI: $libraryListJson"
232+ $script:Summary.Failures++
233+ continue
234+ }
235+ $librariesToProcess = @($libraryListJson | ConvertFrom-Json)
236+ }
237+
238+ if ($librariesToProcess.Count -eq 0) {
239+ Write-Verbose "No document libraries found in site '$siteUrl'"
240+ continue
241+ }
242+
243+ $script:Summary.SitesProcessed++
244+
245+ foreach ($library in $librariesToProcess) {
246+ $libraryTitle = $library.Title
247+ $folderPath = if ($FolderUrl) { $FolderUrl } else { $library.RootFolder.ServerRelativeUrl }
248+
249+ Write-Verbose "Scanning library: $libraryTitle (Folder: $folderPath)"
250+
251+ try {
252+ $fileListArgs = @(
253+ 'spo', 'file', 'list',
254+ '--webUrl', $siteUrl,
255+ '--folderUrl', $folderPath,
256+ '--fields', 'Name,ServerRelativeUrl,TimeLastModified,TimeCreated,Length,ListItemAllFields/Author,ListItemAllFields/Editor',
257+ '--filter', "TimeLastModified lt datetime'$cutoffDateString'",
258+ '--output', 'json'
259+ )
260+ if ($Recursive) {
261+ $fileListArgs += '--recursive'
262+ }
263+
264+ $filesJson = m365 @fileListArgs 2>&1
265+ if ($LASTEXITCODE -ne 0) {
266+ Write-Warning "Failed to retrieve files from library '$libraryTitle' in site '$siteUrl'. CLI: $filesJson"
267+ $script:Summary.Failures++
268+ continue
269+ }
270+
271+ $files = @($filesJson | ConvertFrom-Json)
272+ Write-Verbose "Found $($files.Count) old files in library '$libraryTitle'"
273+
274+ foreach ($file in $files) {
275+ $lastModified = [DateTime]::Parse($file.TimeLastModified)
276+ $daysOldValue = [Math]::Round((Get-Date).Subtract($lastModified).TotalDays)
277+
278+ $script:ReportCollection.Add([PSCustomObject]@{
279+ SiteUrl = $siteUrl
280+ SiteTitle = $siteTitle
281+ LibraryTitle = $libraryTitle
282+ FileName = $file.Name
283+ FilePath = $file.ServerRelativeUrl
284+ LastModified = $file.TimeLastModified
285+ Created = $file.TimeCreated
286+ SizeBytes = $file.Length
287+ Author = if ($file.ListItemAllFields.Author) { $file.ListItemAllFields.Author.LookupValue } else { "N/A" }
288+ Editor = if ($file.ListItemAllFields.Editor) { $file.ListItemAllFields.Editor.LookupValue } else { "N/A" }
289+ DaysOld = $daysOldValue
290+ })
291+ $script:Summary.OldFilesFound++
292+ }
293+
294+ $script:Summary.LibrariesProcessed++
295+ } catch {
296+ Write-Warning "Error scanning library '$libraryTitle' in site '$siteUrl': $_"
297+ $script:Summary.Failures++
298+ continue
299+ }
300+ }
301+ } catch {
302+ Write-Warning "Error processing site '$siteUrl': $_"
303+ $script:Summary.Failures++
304+ continue
305+ }
306+ }
307+ } catch {
308+ Write-Warning "Unexpected error during processing: $_"
309+ $script:Summary.Failures++
310+ }
311+ }
312+
313+ end {
314+ Write-Progress -Activity "Processing sites" -Completed
315+
316+ if ($script:ReportCollection.Count -gt 0) {
317+ Write-Host "Exporting report to CSV..." -ForegroundColor Cyan
318+ $script:ReportCollection | Sort-Object SiteUrl, LibraryTitle, LastModified | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
319+ Write-Host "Report exported: $OutputPath" -ForegroundColor Green
320+ } else {
321+ Write-Host "No old files found matching criteria." -ForegroundColor Yellow
322+ }
323+
324+ Write-Host "`n===== Summary =====" -ForegroundColor Cyan
325+ Write-Host "Sites processed: $($script:Summary.SitesProcessed)" -ForegroundColor White
326+ Write-Host "Libraries processed: $($script:Summary.LibrariesProcessed)" -ForegroundColor White
327+ Write-Host "Old files found: $($script:Summary.OldFilesFound)" -ForegroundColor White
328+ if ($script:Summary.Failures -gt 0) {
329+ Write-Host "Failures: $($script:Summary.Failures)" -ForegroundColor Red
330+ } else {
331+ Write-Host "Failures: 0" -ForegroundColor Green
332+ }
333+ Write-Host "==================`n" -ForegroundColor Cyan
334+
335+ Stop-Transcript
336+ }
337+
338+ # Scans all SharePoint sites for files older than 3 years
339+ # .\Report-OldFiles.ps1 -TenantAdminUrl "https://contoso-admin.sharepoint.com" -DaysOld 1095
340+
341+ # Scans all SharePoint AND OneDrive sites for files older than 4 years
342+ # .\Report-OldFiles.ps1 -TenantAdminUrl "https://contoso-admin.sharepoint.com" -IncludeOneDrive
343+
344+ # Scans all libraries in a specific site recursively
345+ # .\Report-OldFiles.ps1 -SiteUrl "https://contoso.sharepoint.com/sites/project" -Recursive
346+
347+ # Scans a specific folder in a specific library with verbose output
348+ # .\Report-OldFiles.ps1 -SiteUrl "https://contoso.sharepoint.com/sites/project" -LibraryName "Documents" -FolderUrl "/sites/project/Documents/Archive" -DaysOld 730 -Recursive -Verbose
349+
350+ ```
351+
352+
353+ [ !INCLUDE [ More about CLI for Microsoft 365] ( ../../docfx/includes/MORE-CLIM365.md )]
354+ ***
355+
83356# [ PnP PowerShell] ( #tab/pnpps )
84357``` powershell
85358
@@ -339,6 +612,7 @@ Stop-Transcript
339612| Author(s) |
340613| -----------|
341614| Nick Brattoli|
615+ | Adam Wójcik|
342616
343617
344618[ !INCLUDE [ DISCLAIMER] ( ../../docfx/includes/DISCLAIMER.md )]
0 commit comments