Archive 5 phases (36 plans) to milestones/v1.0-phases/. Archive roadmap, requirements, and audit to milestones/. Evolve PROJECT.md with shipped state and validated requirements. Collapse ROADMAP.md to one-line milestone summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
247 lines
9.3 KiB
Markdown
247 lines
9.3 KiB
Markdown
---
|
|
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;
|
|
|
|
/// <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:**
|
|
|
|
```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`
|