Compare commits

..

2 Commits

Author SHA1 Message Date
Dev 4dc4022405 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-05-04 12:31:24 +02:00
Dev 3f24fdd01e Merge remote-tracking branch 'kawa/main' 2026-05-04 12:31:13 +02:00
20 changed files with 782 additions and 82 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -28,4 +28,21 @@ public class StorageServiceTests
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L }; var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes); Assert.Equal(2000L, node.VersionSizeBytes);
} }
[Fact]
public void StorageNode_DefaultKind_IsLibrary()
{
var node = new StorageNode();
Assert.Equal(StorageNodeKind.Library, node.Kind);
}
[Fact]
public void StorageScanOptions_DefaultIncludeFlags_AreAllTrue()
{
var opts = new StorageScanOptions();
Assert.True(opts.IncludeHiddenLibraries);
Assert.True(opts.IncludePreservationHold);
Assert.True(opts.IncludeListAttachments);
Assert.True(opts.IncludeRecycleBin);
}
} }
@@ -0,0 +1,141 @@
using System.Reflection;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Verifies that the report filter flags (Show*) project the raw scan output
/// (<c>_allNodes</c>) into <c>Results</c> correctly: hiding a kind drops the
/// matching root nodes plus their entire subtree, preserving DFS ordering.
/// </summary>
public class StorageViewModelFilterTests
{
public StorageViewModelFilterTests() => WeakReferenceMessenger.Default.Reset();
private static StorageViewModel CreateVm()
{
var vm = new StorageViewModel(
new Mock<IStorageService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance);
vm.SetCurrentProfile(new TenantProfile { Name = "T", TenantUrl = "https://t", ClientId = "c" });
return vm;
}
/// <summary>Inject a flat node list straight into the private _allNodes field
/// and trigger a rebuild via toggling a Show flag.</summary>
private static void Seed(StorageViewModel vm, List<StorageNode> flat)
{
var field = typeof(StorageViewModel).GetField("_allNodes",
BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(vm, flat);
// Toggle off+on to force RebuildFilteredResults().
vm.ShowLibraries = false;
vm.ShowLibraries = true;
}
private static List<StorageNode> MakeMixedTree() => new()
{
new() { Name = "Documents", Kind = StorageNodeKind.Library, IndentLevel = 0, TotalSizeBytes = 100 },
new() { Name = "Sub", Kind = StorageNodeKind.Library, IndentLevel = 1, TotalSizeBytes = 50 },
new() { Name = "Preserve", Kind = StorageNodeKind.PreservationHold, IndentLevel = 0, TotalSizeBytes = 200 },
new() { Name = "[Recycle]", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0, TotalSizeBytes = 300 },
new() { Name = "[Attach] L",Kind = StorageNodeKind.ListAttachments, IndentLevel = 0, TotalSizeBytes = 75 },
};
[Fact]
public void AllShowFlagsTrue_AllNodesAppear()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
Assert.Equal(5, vm.Results.Count);
}
[Fact]
public void HideRecycleBin_RemovesOnlyRecycleNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowRecycleBin = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.RecycleBin);
Assert.Equal(4, vm.Results.Count);
}
[Fact]
public void HidePreservationHold_RemovesPreservationNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowPreservationHold = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.PreservationHold);
}
[Fact]
public void HideLibraries_DropsLibraryRootAndItsChildren()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
// "Documents" + "Sub" both gone — Sub's subtree dropped with its parent root.
Assert.DoesNotContain(vm.Results, n => n.Name == "Documents");
Assert.DoesNotContain(vm.Results, n => n.Name == "Sub");
}
[Fact]
public void CombineRecycleBinStages_True_MergesStagesIntoSingleRow()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100, FileStreamSizeBytes = 100, TotalFileCount = 3 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250, FileStreamSizeBytes = 250, TotalFileCount = 7 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = true;
var bins = vm.Results.Where(n => n.Kind == StorageNodeKind.RecycleBin).ToList();
Assert.Single(bins);
Assert.Equal(350, bins[0].TotalSizeBytes);
Assert.Equal(10, bins[0].TotalFileCount);
}
[Fact]
public void CombineRecycleBinStages_False_KeepsSeparateRows()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = false;
Assert.Equal(2, vm.Results.Count(n => n.Kind == StorageNodeKind.RecycleBin));
}
[Fact]
public void HideAll_LeavesEmptyResults()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
vm.ShowHiddenLibraries = false;
vm.ShowPreservationHold = false;
vm.ShowListAttachments = false;
vm.ShowRecycleBin = false;
vm.ShowSubsites = false;
Assert.Empty(vm.Results);
}
}
+1
View File
@@ -16,6 +16,7 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" /> <conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" /> <conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" /> <conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:StorageKindConverter x:Key="StorageKindConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" /> <conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="HorizontalAlignment" Value="Right" />
@@ -12,5 +12,6 @@ public class StorageNode
public long TotalFileCount { get; set; } public long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; } public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; } public int IndentLevel { get; set; }
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
public List<StorageNode> Children { get; set; } = new(); public List<StorageNode> Children { get; set; } = new();
} }
@@ -0,0 +1,15 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classification used to filter storage report output. Every node is captured
/// during a scan; the report user picks which categories appear.
/// </summary>
public enum StorageNodeKind
{
Library,
HiddenLibrary,
PreservationHold,
ListAttachments,
RecycleBin,
Subsite
}
@@ -3,5 +3,9 @@ namespace SharepointToolbox.Core.Models;
public record StorageScanOptions( public record StorageScanOptions(
bool PerLibrary = true, bool PerLibrary = true,
bool IncludeSubsites = false, bool IncludeSubsites = false,
int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels int FolderDepth = 0, // 0 = library root only; >0 = recurse N levels
bool IncludeHiddenLibraries = true,
bool IncludePreservationHold = true,
bool IncludeListAttachments = true,
bool IncludeRecycleBin = true
); );
@@ -306,6 +306,28 @@ Cette action est irréversible.</value>
<data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Type</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Bibliothèque</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Bibliothèque masquée</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Sous-site</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Sources analysées</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Afficher dans le rapport</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Sous-sites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combiner les corbeilles (afficher le total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>Total rapporté par SPO : </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Corbeille : </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data>
@@ -306,6 +306,28 @@ This cannot be undone.</value>
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Kind</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Library</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Hidden Library</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Subsite</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Scan Sources</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Show in Report</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Subsites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combine Recycle Bin Stages (show total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>SPO reported total: </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Recycle Bin: </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
+1
View File
@@ -8,6 +8,7 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs" xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Icon="pack://application:,,,/Resources/SPToolbox-logo-ico.png"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal" TextOptions.TextFormattingMode="Ideal"
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -22,12 +22,13 @@ public class StorageCsvExportService
var sb = new StringBuilder(); var sb = new StringBuilder();
// Header // Header
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}"); sb.AppendLine($"{T["report.col.library"]},{T["stor.col.kind"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", sb.AppendLine(string.Join(",",
Csv(node.Name), Csv(node.Name),
Csv(KindLabel(node.Kind)),
Csv(node.SiteTitle), Csv(node.SiteTitle),
node.TotalFileCount.ToString(), node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes), FormatMb(node.TotalSizeBytes),
@@ -143,4 +144,19 @@ public class StorageCsvExportService
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary> /// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value); private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
} }
@@ -79,6 +79,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -212,6 +213,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -312,6 +314,7 @@ public class StorageHtmlExportService
sb.AppendLine($""" sb.AppendLine($"""
<tr> <tr>
<td>{nameCell}</td> <td>{nameCell}</td>
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
<td>{HtmlEncode(node.SiteTitle)}</td> <td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td> <td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td> <td class="num">{FormatSize(node.TotalSizeBytes)}</td>
@@ -322,7 +325,7 @@ public class StorageHtmlExportService
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -350,6 +353,7 @@ public class StorageHtmlExportService
sb.AppendLine($""" sb.AppendLine($"""
<tr> <tr>
<td>{nameCell}</td> <td>{nameCell}</td>
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
<td>{HtmlEncode(node.SiteTitle)}</td> <td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td> <td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td> <td class="num">{FormatSize(node.TotalSizeBytes)}</td>
@@ -360,7 +364,7 @@ public class StorageHtmlExportService
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -381,4 +385,19 @@ public class StorageHtmlExportService
private static string HtmlEncode(string value) private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty); => System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
} }
+19 -8
View File
@@ -6,8 +6,11 @@ namespace SharepointToolbox.Services;
public interface IStorageService public interface IStorageService
{ {
/// <summary> /// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API. /// Collects storage metrics for a site, capturing every storage source
/// Returns a tree of StorageNode objects with aggregate size data. /// SharePoint reports (visible + hidden libraries, Preservation Hold,
/// list attachments, recycle bin, and optionally subsites). Each
/// <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/> so
/// callers can filter what appears in the report.
/// </summary> /// </summary>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync( Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
@@ -18,9 +21,6 @@ public interface IStorageService
/// <summary> /// <summary>
/// Enumerates files across all non-hidden document libraries in the site /// Enumerates files across all non-hidden document libraries in the site
/// and aggregates storage consumption grouped by file extension. /// and aggregates storage consumption grouped by file extension.
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
/// This is a separate operation from CollectStorageAsync -- it provides
/// file-type breakdown data for chart visualization.
/// </summary> /// </summary>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
@@ -29,13 +29,24 @@ public interface IStorageService
/// <summary> /// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes /// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery. /// by enumerating files per library via CamlQuery. Only re-runs against
/// This works around the StorageMetrics API returning zeros when the /// document-library kinds (Library, HiddenLibrary, PreservationHold).
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// </summary> /// </summary>
Task BackfillZeroNodesAsync( Task BackfillZeroNodesAsync(
ClientContext ctx, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct); CancellationToken ct);
/// <summary>
/// Returns the SharePoint-reported total storage usage for the site
/// (Site.Usage.Storage). This includes everything that counts toward
/// the site quota — recycle bin, version history, hidden libraries,
/// list attachments — and serves as the ground-truth reference total.
/// Returns 0 if the call is denied or the property is unavailable.
/// </summary>
Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
} }
+271 -66
View File
@@ -7,27 +7,39 @@ namespace SharepointToolbox.Services;
/// <summary> /// <summary>
/// CSOM-based storage metrics scanner. /// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern. /// Captures every storage source SharePoint reports for a site:
/// document libraries (visible + hidden), the Preservation Hold Library,
/// list attachments, the recycle bin (1st + 2nd stage), and optionally
/// subsites. Each <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/>
/// so the caller can filter what appears in the report.
/// </summary> /// </summary>
public class StorageService : IStorageService public class StorageService : IStorageService
{ {
/// <summary> // PreservationHoldLibrary base template id.
/// Collects per-library and per-folder storage metrics for a single private const int PreservationHoldTemplate = 851;
/// SharePoint site. Depth and indentation are controlled via
/// <paramref name="options"/>; libraries flagged <c>Hidden</c> are skipped.
/// Traversal is breadth-first and leans on <see cref="SharePointPaginationHelper"/>
/// so libraries above the 5,000-item threshold remain scannable.
/// </summary>
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync( public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
StorageScanOptions options, StorageScanOptions options,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{
var result = new List<StorageNode>();
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
return result;
}
private async Task CollectForWebAsync(
ClientContext ctx,
Web web,
StorageScanOptions options,
List<StorageNode> result,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load web-level metadata in one round-trip ctx.Load(web,
ctx.Load(ctx.Web,
w => w.Title, w => w.Title,
w => w.Url, w => w.Url,
w => w.ServerRelativeUrl, w => w.ServerRelativeUrl,
@@ -35,48 +47,244 @@ public class StorageService : IStorageService
l => l.Title, l => l.Title,
l => l.Hidden, l => l.Hidden,
l => l.BaseType, l => l.BaseType,
l => l.BaseTemplate,
l => l.ItemCount,
l => l.RootFolder.ServerRelativeUrl)); l => l.RootFolder.ServerRelativeUrl));
if (options.IncludeSubsites)
ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/'); string siteTitle = web.Title;
string siteTitle = ctx.Web.Title; var lists = web.Lists.ToList();
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// ── Document libraries (incl. hidden + Preservation Hold) ───────────
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
int idx = 0; int idx = 0;
foreach (var lib in libs) foreach (var lib in docLibs)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
idx++; idx++;
progress.Report(new OperationProgress(idx, libs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})")); StorageNodeKind kind = ClassifyLibrary(lib);
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
progress.Report(new OperationProgress(idx, docLibs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{docLibs.Count})"));
var libNode = await LoadFolderNodeAsync( var libNode = await LoadFolderNodeAsync(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title, ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct); siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0) if (options.FolderDepth > 0)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl, ctx, lib, lib.RootFolder.ServerRelativeUrl,
libNode, 1, options.FolderDepth, libNode, 1, options.FolderDepth,
siteTitle, lib.Title, progress, ct); siteTitle, lib.Title, kind, progress, ct);
} }
result.Add(libNode); result.Add(libNode);
} }
return result; // ── List attachments (non-document-library lists) ───────────────────
if (options.IncludeListAttachments)
{
var nonDocLists = lists
.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0)
.ToList();
int aIdx = 0;
foreach (var list in nonDocLists)
{
ct.ThrowIfCancellationRequested();
aIdx++;
progress.Report(new OperationProgress(aIdx, nonDocLists.Count,
$"Scanning list attachments: {list.Title} ({aIdx}/{nonDocLists.Count})"));
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
if (attachNode != null && attachNode.TotalSizeBytes > 0)
result.Add(attachNode);
}
}
// ── Recycle bin (stage 1 + stage 2) ─────────────────────────────────
if (options.IncludeRecycleBin)
{
progress.Report(OperationProgress.Indeterminate(
$"Scanning recycle bin: {siteTitle}..."));
var rbNodes = await LoadRecycleBinNodesAsync(ctx, siteTitle, progress, ct);
result.AddRange(rbNodes);
}
// ── Subsites (recursive) ────────────────────────────────────────────
if (options.IncludeSubsites)
{
var subwebs = web.Webs.ToList();
foreach (var sub in subwebs)
{
ct.ThrowIfCancellationRequested();
// Build a node header so subsite results are visually grouped.
var subResult = new List<StorageNode>();
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
if (subResult.Count == 0) continue;
var subRoot = new StorageNode
{
Name = sub.Title,
Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
SiteTitle = sub.Title,
Library = string.Empty,
Kind = StorageNodeKind.Subsite,
IndentLevel = 0,
Children = subResult,
TotalSizeBytes = subResult.Sum(n => n.TotalSizeBytes),
FileStreamSizeBytes = subResult.Sum(n => n.FileStreamSizeBytes),
TotalFileCount = subResult.Sum(n => n.TotalFileCount)
};
result.Add(subRoot);
}
}
}
private static StorageNodeKind ClassifyLibrary(List lib)
{
if (lib.BaseTemplate == PreservationHoldTemplate ||
string.Equals(lib.Title, "Preservation Hold Library", StringComparison.OrdinalIgnoreCase))
return StorageNodeKind.PreservationHold;
return lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
}
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(
ClientContext ctx,
List list,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Per-list attachments live in <listRootFolder>/Attachments/<itemId>/<file>.
// The Attachments folder may or may not exist depending on whether any
// item ever had an attachment — guard with try/catch.
string attachmentsUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(attachmentsUrl);
ctx.Load(folder,
f => f.Exists,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0)
return null;
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
? folder.StorageMetrics.LastModified
: folder.TimeLastModified > DateTime.MinValue
? folder.TimeLastModified
: (DateTime?)null;
return new StorageNode
{
Name = $"[Attachments] {list.Title}",
Url = ctx.Url.TrimEnd('/') + attachmentsUrl,
SiteTitle = siteTitle,
Library = list.Title,
Kind = StorageNodeKind.ListAttachments,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
LastModified = lastMod,
IndentLevel = 0,
Children = new List<StorageNode>()
};
}
catch
{
// Attachments folder absent for this list — not an error.
return null;
}
}
private static async Task<List<StorageNode>> LoadRecycleBinNodesAsync(
ClientContext ctx,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var nodes = new List<StorageNode>();
try
{
var bin = ctx.Site.RecycleBin;
ctx.Load(bin, b => b.Include(
i => i.Size,
i => i.ItemState,
i => i.DeletedDate));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
long stage1Size = 0, stage2Size = 0;
int stage1Count = 0, stage2Count = 0;
DateTime? stage1Last = null, stage2Last = null;
foreach (var item in bin)
{
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin)
{
stage2Size += item.Size;
stage2Count++;
if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate;
}
else
{
stage1Size += item.Size;
stage1Count++;
if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate;
}
}
if (stage1Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] First-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage1Size,
FileStreamSizeBytes = stage1Size,
TotalFileCount = stage1Count,
LastModified = stage1Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
if (stage2Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] Second-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage2Size,
FileStreamSizeBytes = stage2Size,
TotalFileCount = stage2Count,
LastModified = stage2Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
}
catch
{
// Insufficient permission to read recycle bin or feature unavailable.
}
return nodes;
} }
/// <summary>
/// Aggregates file counts and total sizes by extension across every
/// non-hidden document library on the site. Extensions are normalised to
/// lowercase; files without an extension roll up into a single bucket.
/// </summary>
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
@@ -84,7 +292,6 @@ public class StorageService : IStorageService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web, ctx.Load(ctx.Web,
w => w.Lists.Include( w => w.Lists.Include(
l => l.Title, l => l.Title,
@@ -97,7 +304,6 @@ public class StorageService : IStorageService
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList(); .ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase); var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0; int libIdx = 0;
@@ -108,9 +314,6 @@ public class StorageService : IStorageService
progress.Report(new OperationProgress(libIdx, libs.Count, progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})")); $"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Paginated CAML without a WHERE clause — WHERE on non-indexed fields
// (FSObjType) throws list-view threshold on libraries > 5,000 items.
// Filter files client-side via FSObjType.
var query = new CamlQuery var query = new CamlQuery
{ {
ViewXml = @"<View Scope='RecursiveAll'> ViewXml = @"<View Scope='RecursiveAll'>
@@ -138,7 +341,7 @@ public class StorageService : IStorageService
foreach (var item in items) foreach (var item in items)
{ {
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders if (item["FSObjType"]?.ToString() != "0") continue;
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty; string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0"; string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0";
@@ -159,7 +362,6 @@ public class StorageService : IStorageService
while (items.ListItemCollectionPosition != null); while (items.ListItemCollectionPosition != null);
} }
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count)) .Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes) .OrderByDescending(m => m.TotalSizeBytes)
@@ -172,13 +374,17 @@ public class StorageService : IStorageService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
// Find root-level library nodes that have any zero-valued nodes in their tree // Only backfill nodes scanned through CSOM document-library StorageMetrics —
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList(); // synthetic categories (recycle bin, list attachments, subsite headers)
// cannot be re-derived from File_x0020_Size.
var libNodes = nodes.Where(n => n.IndentLevel == 0 &&
(n.Kind == StorageNodeKind.Library ||
n.Kind == StorageNodeKind.HiddenLibrary ||
n.Kind == StorageNodeKind.PreservationHold)).ToList();
var needsBackfill = libNodes.Where(lib => var needsBackfill = libNodes.Where(lib =>
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList(); lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
if (needsBackfill.Count == 0) return; if (needsBackfill.Count == 0) return;
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
ctx.Load(ctx.Web, w => w.ServerRelativeUrl, ctx.Load(ctx.Web, w => w.ServerRelativeUrl,
w => w.Lists.Include( w => w.Lists.Include(
l => l.Title, l => l.Hidden, l => l.BaseType, l => l.Title, l => l.Hidden, l => l.BaseType,
@@ -186,7 +392,7 @@ public class StorageService : IStorageService
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libs = ctx.Web.Lists var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .Where(l => l.BaseType == BaseType.DocumentLibrary)
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase); .ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
int idx = 0; int idx = 0;
@@ -195,30 +401,21 @@ public class StorageService : IStorageService
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
idx++; idx++;
if (!libs.TryGetValue(libNode.Name, out var lib)) continue; if (!libs.TryGetValue(libNode.Library, out var lib)) continue;
progress.Report(new OperationProgress(idx, needsBackfill.Count, progress.Report(new OperationProgress(idx, needsBackfill.Count,
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})")); $"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/'); string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase); var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup); BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Capture original TotalSizeBytes before reset — StorageMetrics.TotalSize
// includes version overhead, which cannot be rederived from a file scan
// (File_x0020_Size is the current stream size only).
var originalTotals = new Dictionary<StorageNode, long>(); var originalTotals = new Dictionary<StorageNode, long>();
CaptureTotals(libNode, originalTotals); CaptureTotals(libNode, originalTotals);
// Reset all nodes in this tree to zero before accumulating
ResetNodeCounts(libNode); ResetNodeCounts(libNode);
// Paginated CAML without WHERE (filter folders client-side via FSObjType).
// SMTotalSize = per-file total including all versions (version-aware).
// SMTotalFileStreamSize = current stream only. File_x0020_Size is a fallback
// when SMTotalSize is unavailable (older tenants / custom fields stripped).
var query = new CamlQuery var query = new CamlQuery
{ {
ViewXml = @"<View Scope='RecursiveAll'> ViewXml = @"<View Scope='RecursiveAll'>
@@ -250,24 +447,21 @@ public class StorageService : IStorageService
foreach (var item in items) foreach (var item in items)
{ {
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders if (item["FSObjType"]?.ToString() != "0") continue;
long streamSize = ParseLong(item["File_x0020_Size"]); long streamSize = ParseLong(item["File_x0020_Size"]);
long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize")); long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize"));
long smTotal = ParseLong(SafeGet(item, "SMTotalSize")); long smTotal = ParseLong(SafeGet(item, "SMTotalSize"));
// Prefer SM fields when present; fall back to File_x0020_Size otherwise.
if (smStream > 0) streamSize = smStream; if (smStream > 0) streamSize = smStream;
long totalSize = smTotal > 0 ? smTotal : streamSize; long totalSize = smTotal > 0 ? smTotal : streamSize;
string fileDirRef = item["FileDirRef"]?.ToString() ?? ""; string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
// Always count toward the library root
libNode.TotalSizeBytes += totalSize; libNode.TotalSizeBytes += totalSize;
libNode.FileStreamSizeBytes += streamSize; libNode.FileStreamSizeBytes += streamSize;
libNode.TotalFileCount++; libNode.TotalFileCount++;
// Also count toward the most specific matching subfolder
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup); var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
if (matchedFolder != null && matchedFolder != libNode) if (matchedFolder != null && matchedFolder != libNode)
{ {
@@ -281,9 +475,6 @@ public class StorageService : IStorageService
} }
while (items.ListItemCollectionPosition != null); while (items.ListItemCollectionPosition != null);
// Restore original TotalSizeBytes where it exceeded the recomputed value.
// Preserves StorageMetrics.TotalSize for nodes whose original metrics were
// valid but SMTotalSize was missing on individual files.
foreach (var kv in originalTotals) foreach (var kv in originalTotals)
{ {
if (kv.Value > kv.Key.TotalSizeBytes) if (kv.Value > kv.Key.TotalSizeBytes)
@@ -292,6 +483,23 @@ public class StorageService : IStorageService
} }
} }
public async Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
try
{
ctx.Load(ctx.Site, s => s.Usage);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return ctx.Site.Usage.Storage;
}
catch
{
return 0L;
}
}
private static long ParseLong(object? value) private static long ParseLong(object? value)
{ {
if (value == null) return 0; if (value == null) return 0;
@@ -345,8 +553,6 @@ public class StorageService : IStorageService
private static StorageNode? FindDeepestFolder(string fileDirRef, private static StorageNode? FindDeepestFolder(string fileDirRef,
Dictionary<string, StorageNode> lookup) Dictionary<string, StorageNode> lookup)
{ {
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
// Try exact match, then walk up until we find a match
string path = fileDirRef.TrimEnd('/'); string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path)) while (!string.IsNullOrEmpty(path))
{ {
@@ -359,7 +565,7 @@ public class StorageService : IStorageService
return null; return null;
} }
// -- Private helpers ----------------------------------------------------- // ── Library/folder loading helpers ──────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync( private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx, ClientContext ctx,
@@ -368,6 +574,7 @@ public class StorageService : IStorageService
string siteTitle, string siteTitle,
string library, string library,
int indentLevel, int indentLevel,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -393,6 +600,7 @@ public class StorageService : IStorageService
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle, SiteTitle = siteTitle,
Library = library, Library = library,
Kind = kind,
TotalSizeBytes = folder.StorageMetrics.TotalSize, TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount, TotalFileCount = folder.StorageMetrics.TotalFileCount,
@@ -411,15 +619,13 @@ public class StorageService : IStorageService
int maxDepth, int maxDepth,
string siteTitle, string siteTitle,
string library, string library,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
if (currentDepth > maxDepth) return; if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Enumerate direct child folders via paginated CAML scoped to the parent.
// Folder.Folders lazy loading hits the list-view threshold on libraries
// > 5,000 items; a paged CAML query with no WHERE bypasses it.
var subfolders = new List<(string Name, string ServerRelativeUrl)>(); var subfolders = new List<(string Name, string ServerRelativeUrl)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync( await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
@@ -427,13 +633,12 @@ public class StorageService : IStorageService
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" }, viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
ct: ct)) ct: ct))
{ {
if (item["FSObjType"]?.ToString() != "1") continue; // folders only if (item["FSObjType"]?.ToString() != "1") continue;
string name = item["FileLeafRef"]?.ToString() ?? string.Empty; string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty; string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue; if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
// Skip SharePoint system folders
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) || if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("_", StringComparison.Ordinal)) name.StartsWith("_", StringComparison.Ordinal))
continue; continue;
@@ -447,14 +652,14 @@ public class StorageService : IStorageService
var childNode = await LoadFolderNodeAsync( var childNode = await LoadFolderNodeAsync(
ctx, sub.ServerRelativeUrl, sub.Name, ctx, sub.ServerRelativeUrl, sub.Name,
siteTitle, library, currentDepth, progress, ct); siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth) if (currentDepth < maxDepth)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, list, sub.ServerRelativeUrl, childNode, ctx, list, sub.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth, currentDepth + 1, maxDepth,
siteTitle, library, progress, ct); siteTitle, library, kind, progress, ct);
} }
parentNode.Children.Add(childNode); parentNode.Children.Add(childNode);
@@ -8,6 +8,7 @@
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<StartupObject>SharepointToolbox.App</StartupObject> <StartupObject>SharepointToolbox.App</StartupObject>
<ApplicationIcon>Resources\SPToolbox-logo.ico</ApplicationIcon>
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime --> <!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn> <NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -43,6 +44,8 @@
<EmbeddedResource Include="Resources\bulk_add_members.csv" /> <EmbeddedResource Include="Resources\bulk_add_members.csv" />
<EmbeddedResource Include="Resources\bulk_create_sites.csv" /> <EmbeddedResource Include="Resources\bulk_create_sites.csv" />
<EmbeddedResource Include="Resources\folder_structure.csv" /> <EmbeddedResource Include="Resources\folder_structure.csv" />
<Resource Include="Resources\SPToolbox-logo-ico.png" />
<Resource Include="Resources\SPToolbox-logo.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -41,6 +41,39 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDonutChart = true; private bool _isDonutChart = true;
// ── Scan-time flags (control what is captured during the CSOM scan) ─────
[ObservableProperty] private bool _scanHiddenLibraries = true;
[ObservableProperty] private bool _scanPreservationHold = true;
[ObservableProperty] private bool _scanListAttachments = true;
[ObservableProperty] private bool _scanRecycleBin = true;
// ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
[ObservableProperty] private bool _showLibraries = true;
[ObservableProperty] private bool _showHiddenLibraries = true;
[ObservableProperty] private bool _showPreservationHold = true;
[ObservableProperty] private bool _showListAttachments = true;
[ObservableProperty] private bool _showRecycleBin = true;
[ObservableProperty] private bool _showSubsites = true;
/// <summary>
/// When true, recycle bin stage 1 + stage 2 collapse into a single
/// "[Recycle Bin] Total" row whose size is the sum of both stages.
/// When false, both stages render as separate rows.
/// </summary>
[ObservableProperty] private bool _combineRecycleBinStages = true;
// SPO-reported site total (Site.Usage.Storage). Independent reference
// value the user can compare against the scanned total.
[ObservableProperty] private long _spoReportedTotalSize;
partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();
/// <summary>0 = Single file, 1 = Split by site.</summary> /// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex; [ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary> /// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
@@ -111,6 +144,10 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
} }
// Raw scan output — never filtered. RebuildFilteredResults projects this
// into Results based on the Show* flags.
private List<StorageNode> _allNodes = new();
private ObservableCollection<StorageNode> _results = new(); private ObservableCollection<StorageNode> _results = new();
public ObservableCollection<StorageNode> Results public ObservableCollection<StorageNode> Results
{ {
@@ -136,12 +173,22 @@ public partial class StorageViewModel : FeatureViewModelBase
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary> /// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount); public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
/// <summary>
/// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
/// from the raw scan so it stays visible even when the user hides the
/// recycle-bin row in the report filter.
/// </summary>
public long SummaryRecycleBinSize => _allNodes
.Where(n => n.Kind == StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
public bool HasResults => Results.Count > 0; public bool HasResults => Results.Count > 0;
private void NotifySummaryProperties() private void NotifySummaryProperties()
{ {
OnPropertyChanged(nameof(SummaryTotalSize)); OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize)); OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryRecycleBinSize));
OnPropertyChanged(nameof(SummaryFileCount)); OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults)); OnPropertyChanged(nameof(HasResults));
} }
@@ -220,10 +267,15 @@ public partial class StorageViewModel : FeatureViewModelBase
var options = new StorageScanOptions( var options = new StorageScanOptions(
PerLibrary: PerLibrary, PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites, IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth); FolderDepth: FolderDepth,
IncludeHiddenLibraries: ScanHiddenLibraries,
IncludePreservationHold: ScanPreservationHold,
IncludeListAttachments: ScanListAttachments,
IncludeRecycleBin: ScanRecycleBin);
var allNodes = new List<StorageNode>(); var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>(); var allTypeMetrics = new List<FileTypeMetric>();
long spoReportedTotal = 0;
var autoOwnership = await IsAutoTakeOwnershipEnabled(); var autoOwnership = await IsAutoTakeOwnershipEnabled();
@@ -273,6 +325,8 @@ public partial class StorageViewModel : FeatureViewModelBase
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}...")); progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct); var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics); allTypeMetrics.AddRange(typeMetrics);
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
} }
// Flatten tree for DataGrid display // Flatten tree for DataGrid display
@@ -291,20 +345,113 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
await dispatcher.InvokeAsync(() => await dispatcher.InvokeAsync(() =>
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}); });
} }
else else
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
} }
} }
/// <summary>
/// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
/// Show* flags. Nodes whose root ancestor is excluded by the flags are
/// dropped along with their entire subtree, preserving DFS ordering.
/// </summary>
private void RebuildFilteredResults()
{
if (_allNodes.Count == 0)
{
Results = new ObservableCollection<StorageNode>();
return;
}
var filtered = new List<StorageNode>(_allNodes.Count);
bool includeCurrentSubtree = true;
foreach (var node in _allNodes)
{
if (node.IndentLevel == 0)
includeCurrentSubtree = IsKindShown(node.Kind);
if (includeCurrentSubtree)
filtered.Add(node);
}
if (CombineRecycleBinStages)
filtered = CombineRecycleBins(filtered);
Results = new ObservableCollection<StorageNode>(filtered);
}
/// <summary>
/// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
/// aggregate row inserted at the position of the first recycle-bin node
/// encountered. Preserves SiteTitle grouping when scans cover multiple
/// sites by aggregating per SiteTitle.
/// </summary>
private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
{
var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
var result = new List<StorageNode>(input.Count);
foreach (var node in input)
{
if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
{
string key = node.SiteTitle ?? string.Empty;
if (!byPath.TryGetValue(key, out var agg))
{
agg = new StorageNode
{
Name = "[Recycle Bin] Total",
SiteTitle = node.SiteTitle ?? string.Empty,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
IndentLevel = 0,
Children = new List<StorageNode>()
};
byPath[key] = agg;
result.Add(agg);
}
agg.TotalSizeBytes += node.TotalSizeBytes;
agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
agg.TotalFileCount += node.TotalFileCount;
if (node.LastModified.HasValue &&
(!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
agg.LastModified = node.LastModified;
}
else
{
result.Add(node);
}
}
return result;
}
private bool IsKindShown(StorageNodeKind kind) => kind switch
{
StorageNodeKind.Library => ShowLibraries,
StorageNodeKind.HiddenLibrary => ShowHiddenLibraries,
StorageNodeKind.PreservationHold => ShowPreservationHold,
StorageNodeKind.ListAttachments => ShowListAttachments,
StorageNodeKind.RecycleBin => ShowRecycleBin,
StorageNodeKind.Subsite => ShowSubsites,
_ => true
};
protected override void OnTenantSwitched(TenantProfile profile) protected override void OnTenantSwitched(TenantProfile profile)
{ {
_currentProfile = profile; _currentProfile = profile;
_allNodes = new List<StorageNode>();
SpoReportedTotalSize = 0;
Results = new ObservableCollection<StorageNode>(); Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(); FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile)); OnPropertyChanged(nameof(CurrentProfile));
@@ -96,3 +96,30 @@ public class ListToStringConverter : IValueConverter
=> throw new NotImplementedException(); => throw new NotImplementedException();
} }
/// <summary>
/// Converts a <see cref="SharepointToolbox.Core.Models.StorageNodeKind"/> enum
/// to a localized display string via the translation source.
/// </summary>
[ValueConversion(typeof(SharepointToolbox.Core.Models.StorageNodeKind), typeof(string))]
public class StorageKindConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not SharepointToolbox.Core.Models.StorageNodeKind kind) return string.Empty;
var T = SharepointToolbox.Localization.TranslationSource.Instance;
return kind switch
{
SharepointToolbox.Core.Models.StorageNodeKind.Library => T["stor.kind.library"],
SharepointToolbox.Core.Models.StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
SharepointToolbox.Core.Models.StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
SharepointToolbox.Core.Models.StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
SharepointToolbox.Core.Models.StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
SharepointToolbox.Core.Models.StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
+49 -1
View File
@@ -33,6 +33,43 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<!-- Scan sources group: control what the scan captures -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.sources]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.hidden]}"
IsChecked="{Binding ScanHiddenLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.preservation]}"
IsChecked="{Binding ScanPreservationHold}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.attachments]}"
IsChecked="{Binding ScanListAttachments}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.recyclebin]}"
IsChecked="{Binding ScanRecycleBin}" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Report filter group: control what appears in DataGrid + exports -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.report.filter]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.libraries]}"
IsChecked="{Binding ShowLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.hidden]}"
IsChecked="{Binding ShowHiddenLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.preservation]}"
IsChecked="{Binding ShowPreservationHold}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.attachments]}"
IsChecked="{Binding ShowListAttachments}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.recyclebin]}"
IsChecked="{Binding ShowRecycleBin}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.subsites]}"
IsChecked="{Binding ShowSubsites}" Margin="0,2" />
<Separator Margin="0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.combine.recyclebin]}"
IsChecked="{Binding CombineRecycleBinStages}" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Action buttons --> <!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}" Command="{Binding RunCommand}"
@@ -112,10 +149,18 @@
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.recyclebin_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryRecycleBinSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.spo_reported_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SpoReportedTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -140,6 +185,9 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" /> Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.kind]}"
Binding="{Binding Kind, Converter={StaticResource StorageKindConverter}}"
Width="120" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}" Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" /> Width="70" ElementStyle="{StaticResource RightAlignStyle}" />