Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/03-storage/03-02-PLAN.md
Dev 724fdc550d chore: complete v1.0 milestone
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>
2026-04-07 09:19:03 +02:00

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
03-01
SharepointToolbox/Services/StorageService.cs
true
STOR-01
STOR-02
STOR-03
truths artifacts key_links
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
path provides exports
SharepointToolbox/Services/StorageService.cs CSOM scan engine — IStorageService implementation
StorageService
from to via pattern
StorageService.cs ExecuteQueryRetryHelper.ExecuteQueryRetryAsync every CSOM load ExecuteQueryRetryHelper.ExecuteQueryRetryAsync
from to via pattern
StorageService.cs folder.StorageMetrics ctx.Load include expression 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.

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