From b5df0641b0db35895e766b52927c2b5e7338dc85 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 15:26:16 +0200 Subject: [PATCH] feat(03-02): implement StorageService CSOM StorageMetrics scan engine - Add StorageService implementing IStorageService - Load Folder.StorageMetrics, TimeLastModified, Name, ServerRelativeUrl in one CSOM round-trip per folder - CollectStorageAsync returns one StorageNode per document library at IndentLevel=0 - With FolderDepth>0, CollectSubfoldersAsync recurses into child folders - All CSOM calls use ExecuteQueryRetryHelper.ExecuteQueryRetryAsync (3 call sites) - System/hidden lists skipped (Hidden=true or BaseType != DocumentLibrary) - Forms/ and _-prefixed system folders skipped during subfolder recursion - ct.ThrowIfCancellationRequested() called at top of every recursive step --- SharepointToolbox/Services/StorageService.cs | 156 +++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 SharepointToolbox/Services/StorageService.cs diff --git a/SharepointToolbox/Services/StorageService.cs b/SharepointToolbox/Services/StorageService.cs new file mode 100644 index 0000000..402d6c9 --- /dev/null +++ b/SharepointToolbox/Services/StorageService.cs @@ -0,0 +1,156 @@ +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); + } + } +}