- 3 tasks completed, 9 files created/modified - Visual checkpoint pending: all three Phase 3 tabs wired and ready for UI verification
9.3 KiB
phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | title | status | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03 | 02 | StorageService — CSOM StorageMetrics Scan Engine | pending | 1 |
|
|
true |
|
|
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.
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern.
/// </summary>
public class StorageService : IStorageService
{
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> 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<StorageNode>();
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<StorageNode> LoadFolderNodeAsync(
ClientContext ctx,
string serverRelativeUrl,
string name,
string siteTitle,
string library,
int indentLevel,
IProgress<OperationProgress> 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<StorageNode>()
};
}
private static async Task CollectSubfoldersAsync(
ClientContext ctx,
string parentServerRelativeUrl,
StorageNode parentNode,
int currentDepth,
int maxDepth,
string siteTitle,
string library,
IProgress<OperationProgress> 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:
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
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