--- phase: 03 plan: 02 title: StorageService — CSOM StorageMetrics Scan Engine status: pending wave: 1 depends_on: - 03-01 files_modified: - SharepointToolbox/Services/StorageService.cs autonomous: true requirements: - STOR-01 - STOR-02 - STOR-03 must_haves: truths: - "StorageService implements IStorageService and is registered in DI (added in Plan 03-07)" - "CollectStorageAsync returns one StorageNode per document library at IndentLevel=0, with correct TotalSizeBytes, FileStreamSizeBytes, VersionSizeBytes, TotalFileCount, and LastModified" - "With FolderDepth>0, child StorageNodes are recursively populated and appear at IndentLevel=1+" - "VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative)" - "All CSOM round-trips use ExecuteQueryRetryHelper.ExecuteQueryRetryAsync — no direct ctx.ExecuteQueryAsync calls" - "System/hidden lists are skipped (Hidden=true or BaseType != DocumentLibrary)" - "ct.ThrowIfCancellationRequested() is called at the top of every recursive step" artifacts: - path: "SharepointToolbox/Services/StorageService.cs" provides: "CSOM scan engine — IStorageService implementation" exports: ["StorageService"] key_links: - from: "StorageService.cs" to: "ExecuteQueryRetryHelper.ExecuteQueryRetryAsync" via: "every CSOM load" pattern: "ExecuteQueryRetryHelper\\.ExecuteQueryRetryAsync" - from: "StorageService.cs" to: "folder.StorageMetrics" via: "ctx.Load include expression" pattern: "StorageMetrics" --- # Plan 03-02: StorageService — CSOM StorageMetrics Scan Engine ## Goal Implement `StorageService` — the C# port of the PowerShell `Get-PnPFolderStorageMetric` / `Collect-FolderStorage` pattern. It loads `Folder.StorageMetrics` for each document library on a site (and optionally recurses into subfolders up to a configurable depth), returning a flat list of `StorageNode` objects that the ViewModel will display in a `DataGrid`. ## Context Plan 03-01 created `StorageNode`, `StorageScanOptions`, and `IStorageService`. This plan creates the only concrete implementation. The service receives an already-authenticated `ClientContext` from the ViewModel (obtained via `ISessionManager.GetOrCreateContextAsync`) — it never calls SessionManager itself. Critical loading pattern: `ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.Name, f => f.ServerRelativeUrl)` — if `StorageMetrics` is not in the Load expression, `folder.StorageMetrics.TotalSize` throws `PropertyOrFieldNotInitializedException`. The `VersionSizeBytes` derived property is already on `StorageNode` (`TotalSizeBytes - FileStreamSizeBytes`). StorageService only needs to populate `TotalSizeBytes` and `FileStreamSizeBytes`. ## Tasks ### Task 1: Implement StorageService **File:** `SharepointToolbox/Services/StorageService.cs` **Action:** Create **Why:** Implements STOR-01, STOR-02, STOR-03. Single file, single concern — no helper changes needed. ```csharp using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; /// /// CSOM-based storage metrics scanner. /// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern. /// public class StorageService : IStorageService { public async Task> CollectStorageAsync( ClientContext ctx, StorageScanOptions options, IProgress progress, CancellationToken ct) { ct.ThrowIfCancellationRequested(); // Load web-level metadata in one round-trip ctx.Load(ctx.Web, w => w.Title, w => w.Url, w => w.ServerRelativeUrl, w => w.Lists.Include( l => l.Title, l => l.Hidden, l => l.BaseType, l => l.RootFolder.ServerRelativeUrl)); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/'); string siteTitle = ctx.Web.Title; var result = new List(); var libs = ctx.Web.Lists .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .ToList(); int idx = 0; foreach (var lib in libs) { ct.ThrowIfCancellationRequested(); idx++; progress.Report(new OperationProgress(idx, libs.Count, $"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})")); var libNode = await LoadFolderNodeAsync( ctx, lib.RootFolder.ServerRelativeUrl, lib.Title, siteTitle, lib.Title, 0, progress, ct); if (options.FolderDepth > 0) { await CollectSubfoldersAsync( ctx, lib.RootFolder.ServerRelativeUrl, libNode, 1, options.FolderDepth, siteTitle, lib.Title, progress, ct); } result.Add(libNode); } return result; } // ── Private helpers ────────────────────────────────────────────────────── private static async Task LoadFolderNodeAsync( ClientContext ctx, string serverRelativeUrl, string name, string siteTitle, string library, int indentLevel, IProgress progress, CancellationToken ct) { ct.ThrowIfCancellationRequested(); Folder folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl); ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl, f => f.Name); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : folder.TimeLastModified > DateTime.MinValue ? folder.TimeLastModified : (DateTime?)null; return new StorageNode { Name = name, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, SiteTitle = siteTitle, Library = library, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = lastMod, IndentLevel = indentLevel, Children = new List() }; } private static async Task CollectSubfoldersAsync( ClientContext ctx, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, IProgress progress, CancellationToken ct) { if (currentDepth > maxDepth) return; ct.ThrowIfCancellationRequested(); // Load direct child folders of this folder Folder parentFolder = ctx.Web.GetFolderByServerRelativeUrl(parentServerRelativeUrl); ctx.Load(parentFolder, f => f.Folders.Include( sf => sf.Name, sf => sf.ServerRelativeUrl)); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); foreach (Folder subFolder in parentFolder.Folders) { ct.ThrowIfCancellationRequested(); // Skip SharePoint system folders if (subFolder.Name.Equals("Forms", StringComparison.OrdinalIgnoreCase) || subFolder.Name.StartsWith("_", StringComparison.Ordinal)) continue; var childNode = await LoadFolderNodeAsync( ctx, subFolder.ServerRelativeUrl, subFolder.Name, siteTitle, library, currentDepth, progress, ct); if (currentDepth < maxDepth) { await CollectSubfoldersAsync( ctx, subFolder.ServerRelativeUrl, childNode, currentDepth + 1, maxDepth, siteTitle, library, progress, ct); } parentNode.Children.Add(childNode); } } } ``` **Verification:** ```bash dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageServiceTests" -x ``` Expected: 0 build errors; 2 pure-logic tests pass (VersionSizeBytes), 2 CSOM stubs skip ## Verification ```bash dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx ``` Expected: 0 errors. `StorageService` implements `IStorageService` (grep: `class StorageService : IStorageService`). `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` is called for every folder load (grep verifiable). ## Commit Message feat(03-02): implement StorageService CSOM StorageMetrics scan engine ## Output After completion, create `.planning/phases/03-storage/03-02-SUMMARY.md`