Compare commits
5 Commits
55e5cfc506
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ecc7b329d4 | |||
| f56e8813e5 | |||
| 461c7d5bb4 | |||
| 4dc4022405 | |||
| 3f24fdd01e |
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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étail :</value></data>
|
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de dé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>
|
||||||
|
|||||||
@@ -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 |
@@ -24,4 +24,41 @@ internal static class ExportFileWriter
|
|||||||
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
||||||
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
||||||
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 with
|
||||||
|
/// BOM, chunk by chunk. Avoids the full-document <c>ToString()</c> copy
|
||||||
|
/// and the separate UTF-8 byte buffer that <see cref="File.WriteAllTextAsync(string, string, Encoding, CancellationToken)"/>
|
||||||
|
/// would otherwise allocate — meaningful for large CSV exports.
|
||||||
|
/// </summary>
|
||||||
|
public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
|
||||||
|
=> WriteChunksAsync(filePath, builder, Utf8WithBom, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 without
|
||||||
|
/// BOM. Same rationale as <see cref="WriteCsvChunksAsync"/> — for large
|
||||||
|
/// HTML reports it halves peak memory by skipping the intermediate string.
|
||||||
|
/// </summary>
|
||||||
|
public static Task WriteHtmlChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
|
||||||
|
=> WriteChunksAsync(filePath, builder, Utf8NoBom, ct);
|
||||||
|
|
||||||
|
private static async Task WriteChunksAsync(string filePath, StringBuilder builder, Encoding encoding, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// FileOptions.Asynchronous lets StreamWriter use true async I/O.
|
||||||
|
await using var fs = new FileStream(
|
||||||
|
filePath,
|
||||||
|
FileMode.Create,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 64 * 1024,
|
||||||
|
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
await using var sw = new StreamWriter(fs, encoding, bufferSize: 64 * 1024);
|
||||||
|
|
||||||
|
foreach (var chunk in builder.GetChunks())
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
await sw.WriteAsync(chunk, ct);
|
||||||
|
}
|
||||||
|
await sw.FlushAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Localization;
|
using SharepointToolbox.Localization;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -18,33 +19,58 @@ public class StorageCsvExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||||||
|
WriteCsv(sb, nodes);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
|
||||||
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"]}");
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
// Hoist resource lookups out of the row loop: ResourceManager.GetString
|
||||||
|
// is a culture-aware dictionary probe — caching once per export saves
|
||||||
|
// O(rows × columns) lookups on large tenants.
|
||||||
|
string colLibrary = T["report.col.library"];
|
||||||
|
string colKind = T["stor.col.kind"];
|
||||||
|
string colSite = T["report.col.site"];
|
||||||
|
string colFiles = T["report.stat.files"];
|
||||||
|
string colTotalMb = T["report.col.total_size_mb"];
|
||||||
|
string colVerMb = T["report.col.version_size_mb"];
|
||||||
|
string colLastMod = T["report.col.last_modified"];
|
||||||
|
|
||||||
|
sb.Append(colLibrary).Append(',')
|
||||||
|
.Append(colKind).Append(',')
|
||||||
|
.Append(colSite).Append(',')
|
||||||
|
.Append(colFiles).Append(',')
|
||||||
|
.Append(colTotalMb).Append(',')
|
||||||
|
.Append(colVerMb).Append(',')
|
||||||
|
.AppendLine(colLastMod);
|
||||||
|
|
||||||
|
var kindLabels = BuildKindLabelCache();
|
||||||
|
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
sb.AppendLine(string.Join(",",
|
AppendCsvField(sb, node.Name).Append(',');
|
||||||
Csv(node.Name),
|
AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
|
||||||
Csv(node.SiteTitle),
|
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||||||
node.TotalFileCount.ToString(),
|
sb.Append(node.TotalFileCount).Append(',');
|
||||||
FormatMb(node.TotalSizeBytes),
|
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||||||
FormatMb(node.VersionSizeBytes),
|
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||||||
node.LastModified.HasValue
|
if (node.LastModified.HasValue)
|
||||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||||
: string.Empty));
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(nodes);
|
// Stream straight to disk: skip the StringBuilder→string copy and the
|
||||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
// separate UTF-8 buffer that File.WriteAllTextAsync materializes.
|
||||||
|
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||||||
|
WriteCsv(sb, nodes);
|
||||||
|
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -52,44 +78,68 @@ public class StorageCsvExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||||||
var sb = new StringBuilder();
|
WriteCsv(sb, nodes, fileTypeMetrics);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||||
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
string colLibrary = T["report.col.library"];
|
||||||
|
string colSite = T["report.col.site"];
|
||||||
|
string colFiles = T["report.stat.files"];
|
||||||
|
string colTotalMb = T["report.col.total_size_mb"];
|
||||||
|
string colVerMb = T["report.col.version_size_mb"];
|
||||||
|
string colLastMod = T["report.col.last_modified"];
|
||||||
|
|
||||||
|
sb.Append(colLibrary).Append(',')
|
||||||
|
.Append(colSite).Append(',')
|
||||||
|
.Append(colFiles).Append(',')
|
||||||
|
.Append(colTotalMb).Append(',')
|
||||||
|
.Append(colVerMb).Append(',')
|
||||||
|
.AppendLine(colLastMod);
|
||||||
|
|
||||||
// Library details
|
|
||||||
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"]}");
|
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
sb.AppendLine(string.Join(",",
|
AppendCsvField(sb, node.Name).Append(',');
|
||||||
Csv(node.Name),
|
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||||||
Csv(node.SiteTitle),
|
sb.Append(node.TotalFileCount).Append(',');
|
||||||
node.TotalFileCount.ToString(),
|
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||||||
FormatMb(node.TotalSizeBytes),
|
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||||||
FormatMb(node.VersionSizeBytes),
|
if (node.LastModified.HasValue)
|
||||||
node.LastModified.HasValue
|
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
sb.AppendLine();
|
||||||
: string.Empty));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File type breakdown
|
|
||||||
if (fileTypeMetrics.Count > 0)
|
if (fileTypeMetrics.Count > 0)
|
||||||
{
|
{
|
||||||
|
string colFileType = T["report.col.file_type"];
|
||||||
|
string colSizeMb = T["report.col.size_mb"];
|
||||||
|
string colFileCnt = T["report.col.file_count"];
|
||||||
|
string noExtLabel = T["report.text.no_extension"];
|
||||||
|
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
|
sb.Append(colFileType).Append(',')
|
||||||
|
.Append(colSizeMb).Append(',')
|
||||||
|
.AppendLine(colFileCnt);
|
||||||
|
|
||||||
foreach (var m in fileTypeMetrics)
|
foreach (var m in fileTypeMetrics)
|
||||||
{
|
{
|
||||||
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
|
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
|
||||||
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
AppendCsvField(sb, label).Append(',');
|
||||||
|
AppendMb(sb, m.TotalSizeBytes).Append(',');
|
||||||
|
sb.Append(m.FileCount).AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
|
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(nodes, fileTypeMetrics);
|
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
WriteCsv(sb, nodes, fileTypeMetrics);
|
||||||
|
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -138,9 +188,40 @@ public class StorageCsvExportService
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string FormatMb(long bytes)
|
private static StringBuilder AppendMb(StringBuilder sb, long bytes)
|
||||||
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
|
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
|
private static StringBuilder AppendCsvField(StringBuilder sb, string value)
|
||||||
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
=> sb.Append(CsvSanitizer.EscapeMinimal(value));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||||||
|
/// once per export, indexed by the enum's int value. Avoids a
|
||||||
|
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] BuildKindLabelCache()
|
||||||
|
{
|
||||||
|
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||||||
|
int max = 0;
|
||||||
|
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||||||
|
var cache = new string[max + 1];
|
||||||
|
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||||||
|
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
public class StorageHtmlExportService
|
public class StorageHtmlExportService
|
||||||
{
|
{
|
||||||
private int _togIdx;
|
private int _togIdx;
|
||||||
|
private string[] _kindLabels = Array.Empty<string>();
|
||||||
|
private string[] _kindLabelsHtml = Array.Empty<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a self-contained HTML report with one collapsible row per
|
/// Builds a self-contained HTML report with one collapsible row per
|
||||||
@@ -21,10 +23,18 @@ public class StorageHtmlExportService
|
|||||||
/// breakdown section is desired.
|
/// breakdown section is desired.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(3072 + nodes.Count * 340);
|
||||||
|
BuildHtmlCore(sb, nodes, branding);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, ReportBranding? branding)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
_togIdx = 0;
|
_togIdx = 0;
|
||||||
var sb = new StringBuilder();
|
_kindLabels = BuildKindLabelCache();
|
||||||
|
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
|
||||||
|
|
||||||
sb.AppendLine("<!DOCTYPE html>");
|
sb.AppendLine("<!DOCTYPE html>");
|
||||||
sb.AppendLine("<html lang=\"en\">");
|
sb.AppendLine("<html lang=\"en\">");
|
||||||
@@ -60,11 +70,18 @@ public class StorageHtmlExportService
|
|||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||||
|
|
||||||
// Summary cards
|
// Single-pass root aggregation: replaces 4 separate enumerations
|
||||||
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
// (.Where().ToList() + 3× .Sum() + a final .Where() during render).
|
||||||
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
|
var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
|
||||||
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
|
long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
|
||||||
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
|
foreach (var n in nodes)
|
||||||
|
{
|
||||||
|
if (n.IndentLevel != 0) continue;
|
||||||
|
rootNodes0.Add(n);
|
||||||
|
siteTotal0 += n.TotalSizeBytes;
|
||||||
|
versionTotal0 += n.VersionSizeBytes;
|
||||||
|
fileTotal0 += n.TotalFileCount;
|
||||||
|
}
|
||||||
|
|
||||||
sb.AppendLine($"""
|
sb.AppendLine($"""
|
||||||
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
||||||
@@ -79,6 +96,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>
|
||||||
@@ -89,7 +107,10 @@ public class StorageHtmlExportService
|
|||||||
<tbody>
|
<tbody>
|
||||||
""");
|
""");
|
||||||
|
|
||||||
foreach (var node in nodes)
|
// Render only the pre-materialized root list — recursing into
|
||||||
|
// Children handles descendants. Iterating the flat list would render
|
||||||
|
// every descendant a second time as a top-level row.
|
||||||
|
foreach (var node in rootNodes0)
|
||||||
{
|
{
|
||||||
RenderNode(sb, node);
|
RenderNode(sb, node);
|
||||||
}
|
}
|
||||||
@@ -101,18 +122,24 @@ public class StorageHtmlExportService
|
|||||||
|
|
||||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||||
sb.AppendLine("</body></html>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds an HTML report including a file-type breakdown chart section.
|
/// Builds an HTML report including a file-type breakdown chart section.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
|
||||||
|
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
_togIdx = 0;
|
_togIdx = 0;
|
||||||
var sb = new StringBuilder();
|
_kindLabels = BuildKindLabelCache();
|
||||||
|
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
|
||||||
|
|
||||||
sb.AppendLine("<!DOCTYPE html>");
|
sb.AppendLine("<!DOCTYPE html>");
|
||||||
sb.AppendLine("<html lang=\"en\">");
|
sb.AppendLine("<html lang=\"en\">");
|
||||||
@@ -159,11 +186,17 @@ public class StorageHtmlExportService
|
|||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||||
|
|
||||||
// ── Summary cards ──
|
// ── Summary cards (single-pass aggregation) ──
|
||||||
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
|
||||||
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
|
long siteTotal = 0, versionTotal = 0, fileTotal = 0;
|
||||||
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
|
foreach (var n in nodes)
|
||||||
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
{
|
||||||
|
if (n.IndentLevel != 0) continue;
|
||||||
|
rootNodes.Add(n);
|
||||||
|
siteTotal += n.TotalSizeBytes;
|
||||||
|
versionTotal += n.VersionSizeBytes;
|
||||||
|
fileTotal += n.TotalFileCount;
|
||||||
|
}
|
||||||
|
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
sb.AppendLine("<div class=\"stats\">");
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
|
||||||
@@ -212,6 +245,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>
|
||||||
@@ -222,7 +256,10 @@ public class StorageHtmlExportService
|
|||||||
<tbody>
|
<tbody>
|
||||||
""");
|
""");
|
||||||
|
|
||||||
foreach (var node in nodes)
|
// Render only the pre-materialized root list — recursing into
|
||||||
|
// Children handles descendants. Iterating the flat list would render
|
||||||
|
// every descendant a second time as a top-level row.
|
||||||
|
foreach (var node in rootNodes)
|
||||||
{
|
{
|
||||||
RenderNode(sb, node);
|
RenderNode(sb, node);
|
||||||
}
|
}
|
||||||
@@ -234,22 +271,24 @@ public class StorageHtmlExportService
|
|||||||
|
|
||||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||||
sb.AppendLine("</body></html>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(nodes, branding);
|
// Build into StringBuilder, stream chunks straight to disk —
|
||||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
// skips a full-document char-array copy from sb.ToString().
|
||||||
|
var sb = new StringBuilder(3072 + nodes.Count * 340);
|
||||||
|
BuildHtmlCore(sb, nodes, branding);
|
||||||
|
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
|
||||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
|
||||||
|
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -305,24 +344,11 @@ public class StorageHtmlExportService
|
|||||||
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}"
|
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}"
|
||||||
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
|
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
|
||||||
|
|
||||||
string lastMod = node.LastModified.HasValue
|
AppendRow(sb, node, nameCell);
|
||||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
sb.AppendLine($"""
|
|
||||||
<tr>
|
|
||||||
<td>{nameCell}</td>
|
|
||||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
|
||||||
<td class="num">{node.TotalFileCount:N0}</td>
|
|
||||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
|
||||||
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
|
|
||||||
<td>{lastMod}</td>
|
|
||||||
</tr>
|
|
||||||
""");
|
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -343,24 +369,11 @@ public class StorageHtmlExportService
|
|||||||
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}</span>"
|
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}</span>"
|
||||||
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
|
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
|
||||||
|
|
||||||
string lastMod = node.LastModified.HasValue
|
AppendRow(sb, node, nameCell);
|
||||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
sb.AppendLine($"""
|
|
||||||
<tr>
|
|
||||||
<td>{nameCell}</td>
|
|
||||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
|
||||||
<td class="num">{node.TotalFileCount:N0}</td>
|
|
||||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
|
||||||
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
|
|
||||||
<td>{lastMod}</td>
|
|
||||||
</tr>
|
|
||||||
""");
|
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -371,6 +384,35 @@ public class StorageHtmlExportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends one data row given the pre-rendered name cell. Hot path:
|
||||||
|
/// pulls localized kind labels from <see cref="_kindLabelsHtml"/> instead
|
||||||
|
/// of going through <c>ResourceManager.GetString</c> + <c>HtmlEncode</c>
|
||||||
|
/// per row.
|
||||||
|
/// </summary>
|
||||||
|
private void AppendRow(StringBuilder sb, StorageNode node, string nameCell)
|
||||||
|
{
|
||||||
|
int kindIdx = (int)node.Kind;
|
||||||
|
string kindLabel = (uint)kindIdx < (uint)_kindLabelsHtml.Length
|
||||||
|
? _kindLabelsHtml[kindIdx]
|
||||||
|
: HtmlEncode(node.Kind.ToString());
|
||||||
|
string lastMod = node.LastModified.HasValue
|
||||||
|
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<tr>
|
||||||
|
<td>{nameCell}</td>
|
||||||
|
<td>{kindLabel}</td>
|
||||||
|
<td>{HtmlEncode(node.SiteTitle)}</td>
|
||||||
|
<td class="num">{node.TotalFileCount:N0}</td>
|
||||||
|
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
||||||
|
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
|
||||||
|
<td>{lastMod}</td>
|
||||||
|
</tr>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
private static string FormatSize(long bytes)
|
||||||
{
|
{
|
||||||
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||||
@@ -381,4 +423,43 @@ 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||||||
|
/// once per export. Cached array index lookup avoids
|
||||||
|
/// <c>ResourceManager.GetString</c> per row in hot rendering loops.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] BuildKindLabelCache()
|
||||||
|
{
|
||||||
|
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||||||
|
int max = 0;
|
||||||
|
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||||||
|
var cache = new string[max + 1];
|
||||||
|
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||||||
|
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HTML-encodes each entry of <paramref name="raw"/> once.</summary>
|
||||||
|
private static string[] BuildHtmlEncodedCache(string[] raw)
|
||||||
|
{
|
||||||
|
var encoded = new string[raw.Length];
|
||||||
|
for (int i = 0; i < raw.Length; i++) encoded[i] = HtmlEncode(raw[i]);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,326 @@ 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) ───────────
|
||||||
|
// Track each library's RootFolder server-relative URL so bin items can
|
||||||
|
// be attributed back to their source library (matches storman.aspx,
|
||||||
|
// which folds bin contents into the owning library's Total Size).
|
||||||
|
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
|
||||||
|
var libsByRoot = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSOM Folder.StorageMetrics is unreliable across the board for
|
||||||
|
// larger libraries — sometimes returns the storman value, sometimes
|
||||||
|
// returns a fraction of it, sometimes zero. Subfolder StorageMetrics
|
||||||
|
// are equally inconsistent. The only CSOM path that matches storman
|
||||||
|
// is per-file File.Length + File.Versions[*].Size enumeration, so
|
||||||
|
// run it unconditionally, replacing the CSOM totals.
|
||||||
|
ResetNodeCounts(libNode);
|
||||||
|
await BackfillLibFromFilesAsync(ctx, lib, libNode, progress, ct);
|
||||||
|
|
||||||
result.Add(libNode);
|
result.Add(libNode);
|
||||||
|
libsByRoot[NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl)] = 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, perDir) = await LoadRecycleBinNodesAsync(ctx, web, siteTitle, progress, ct);
|
||||||
|
|
||||||
|
// Attribute bin items to owning library (longest-prefix match on DirName)
|
||||||
|
// so library Total Size matches storman.aspx, which counts an item's
|
||||||
|
// bytes against its source library even after deletion.
|
||||||
|
if (perDir.Count > 0 && libsByRoot.Count > 0)
|
||||||
|
{
|
||||||
|
var libRootsByLength = libsByRoot
|
||||||
|
.OrderByDescending(kv => kv.Key.Length)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var kv in perDir)
|
||||||
|
{
|
||||||
|
string dirNorm = NormalizeServerRelative(kv.Key);
|
||||||
|
foreach (var lib in libRootsByLength)
|
||||||
|
{
|
||||||
|
if (dirNorm.Equals(lib.Key, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
dirNorm.StartsWith(lib.Key + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lib.Value.TotalSizeBytes += kv.Value.Size;
|
||||||
|
lib.Value.TotalFileCount += kv.Value.Count;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Bin contents already rolled up into each library's TotalSizeBytes
|
||||||
|
// (storman behavior); summing root RecycleBin children too would
|
||||||
|
// double-count. Filter them out here.
|
||||||
|
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.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalSizeBytes),
|
||||||
|
FileStreamSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.FileStreamSizeBytes),
|
||||||
|
TotalFileCount = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).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> Nodes, Dictionary<string, (long Size, int Count)> PerDir)> LoadRecycleBinNodesAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
Web web,
|
||||||
|
string siteTitle,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var nodes = new List<StorageNode>();
|
||||||
|
var perDir = new Dictionary<string, (long Size, int Count)>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Web-scoped: ctx.Site.RecycleBin would return the entire site-collection
|
||||||
|
// bin and inflate totals by (1 + N_subsites) when IncludeSubsites is on.
|
||||||
|
var bin = web.RecycleBin;
|
||||||
|
ctx.Load(bin, b => b.Include(
|
||||||
|
i => i.Size,
|
||||||
|
i => i.ItemState,
|
||||||
|
i => i.DeletedDate,
|
||||||
|
i => i.DirName));
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
// RecycleBinItem.DirName is web-relative on SharePoint Online
|
||||||
|
// (e.g. "Documents/SubFolder" without leading slash or web URL).
|
||||||
|
// Prepend the web's ServerRelativeUrl so the result matches
|
||||||
|
// List.RootFolder.ServerRelativeUrl form used by libsByRoot.
|
||||||
|
string webSrl = NormalizeServerRelative(web.ServerRelativeUrl);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
string raw = item.DirName ?? string.Empty;
|
||||||
|
string dirSrl;
|
||||||
|
if (raw.StartsWith('/'))
|
||||||
|
dirSrl = NormalizeServerRelative(raw);
|
||||||
|
else if (string.IsNullOrEmpty(raw))
|
||||||
|
dirSrl = webSrl;
|
||||||
|
else
|
||||||
|
dirSrl = NormalizeServerRelative(webSrl + "/" + raw);
|
||||||
|
|
||||||
|
if (perDir.TryGetValue(dirSrl, out var tally))
|
||||||
|
perDir[dirSrl] = (tally.Size + item.Size, tally.Count + 1);
|
||||||
|
else
|
||||||
|
perDir[dirSrl] = (item.Size, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, perDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Aggregates file counts and total sizes by extension across every
|
/// Normalizes a server-relative path for consistent prefix matching:
|
||||||
/// non-hidden document library on the site. Extensions are normalised to
|
/// trims trailing slash, ensures single leading slash. SharePoint
|
||||||
/// lowercase; files without an extension roll up into a single bucket.
|
/// inconsistently returns DirName with or without leading slash across
|
||||||
|
/// API surfaces, so the caller cannot rely on a canonical form.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
private static string NormalizeServerRelative(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return string.Empty;
|
||||||
|
string trimmed = path.Trim().TrimEnd('/');
|
||||||
|
if (trimmed.Length == 0) return string.Empty;
|
||||||
|
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
|
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
|
||||||
ClientContext ctx,
|
ClientContext ctx,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
@@ -84,7 +374,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 +386,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 +396,10 @@ 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
|
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
|
||||||
// (FSObjType) throws list-view threshold on libraries > 5,000 items.
|
// beyond 5000 items breaches the list view threshold. Page lightly,
|
||||||
// Filter files client-side via FSObjType.
|
// then second-pass load File.Length + Versions[*].Size so per-type
|
||||||
|
// totals include version bytes (matches per-library totals).
|
||||||
var query = new CamlQuery
|
var query = new CamlQuery
|
||||||
{
|
{
|
||||||
ViewXml = @"<View Scope='RecursiveAll'>
|
ViewXml = @"<View Scope='RecursiveAll'>
|
||||||
@@ -118,9 +407,8 @@ public class StorageService : IStorageService
|
|||||||
<ViewFields>
|
<ViewFields>
|
||||||
<FieldRef Name='FSObjType' />
|
<FieldRef Name='FSObjType' />
|
||||||
<FieldRef Name='FileLeafRef' />
|
<FieldRef Name='FileLeafRef' />
|
||||||
<FieldRef Name='File_x0020_Size' />
|
|
||||||
</ViewFields>
|
</ViewFields>
|
||||||
<RowLimit Paged='TRUE'>5000</RowLimit>
|
<RowLimit Paged='TRUE'>500</RowLimit>
|
||||||
</View>"
|
</View>"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,21 +420,40 @@ public class StorageService : IStorageService
|
|||||||
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
||||||
ic => ic.Include(
|
ic => ic.Include(
|
||||||
i => i["FSObjType"],
|
i => i["FSObjType"],
|
||||||
i => i["FileLeafRef"],
|
i => i["FileLeafRef"]));
|
||||||
i => i["File_x0020_Size"]));
|
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
var fileRows = new List<(ListItem Item, string Name)>();
|
||||||
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";
|
fileRows.Add((item, fileName));
|
||||||
|
ctx.Load(item.File, f => f.Length);
|
||||||
|
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
|
||||||
|
}
|
||||||
|
|
||||||
if (!long.TryParse(sizeStr, out long fileSize))
|
if (fileRows.Count > 0)
|
||||||
fileSize = 0;
|
{
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
}
|
||||||
|
|
||||||
string ext = Path.GetExtension(fileName).ToLowerInvariant();
|
foreach (var row in fileRows)
|
||||||
|
{
|
||||||
|
long current;
|
||||||
|
try { current = row.Item.File.Length; }
|
||||||
|
catch { continue; }
|
||||||
|
|
||||||
|
long versions = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var v in row.Item.File.Versions)
|
||||||
|
versions += v.Size;
|
||||||
|
}
|
||||||
|
catch { /* no version history */ }
|
||||||
|
|
||||||
|
long fileSize = current + versions;
|
||||||
|
string ext = Path.GetExtension(row.Name).ToLowerInvariant();
|
||||||
|
|
||||||
if (extensionMap.TryGetValue(ext, out var existing))
|
if (extensionMap.TryGetValue(ext, out var existing))
|
||||||
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
|
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
|
||||||
@@ -159,66 +466,39 @@ 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)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task BackfillZeroNodesAsync(
|
/// <summary>
|
||||||
|
/// Per-library backfill executed inline by CollectForWebAsync when CSOM's
|
||||||
|
/// Folder.StorageMetrics returns zero counts. Enumerates every file via
|
||||||
|
/// CamlQuery and explicitly loads File.Length + File.Versions.Size so
|
||||||
|
/// version bytes are summed accurately — matches what storman.aspx reports.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task BackfillLibFromFilesAsync(
|
||||||
ClientContext ctx,
|
ClientContext ctx,
|
||||||
IReadOnlyList<StorageNode> nodes,
|
List lib,
|
||||||
|
StorageNode libNode,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Find root-level library nodes that have any zero-valued nodes in their tree
|
progress.Report(OperationProgress.Indeterminate(
|
||||||
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
$"Counting files: {libNode.Name}..."));
|
||||||
var needsBackfill = libNodes.Where(lib =>
|
|
||||||
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
|
|
||||||
if (needsBackfill.Count == 0) return;
|
|
||||||
|
|
||||||
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
|
string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl);
|
||||||
ctx.Load(ctx.Web, 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);
|
|
||||||
|
|
||||||
var libs = ctx.Web.Lists
|
|
||||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
|
||||||
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
int idx = 0;
|
|
||||||
foreach (var libNode in needsBackfill)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
idx++;
|
|
||||||
|
|
||||||
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
|
|
||||||
|
|
||||||
progress.Report(new OperationProgress(idx, needsBackfill.Count,
|
|
||||||
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
|
|
||||||
|
|
||||||
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
|
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
|
||||||
// includes version overhead, which cannot be rederived from a file scan
|
// beyond the 5000-item view threshold throws "The attempted operation
|
||||||
// (File_x0020_Size is the current stream size only).
|
// is prohibited because it exceeds the list view threshold". Paged
|
||||||
var originalTotals = new Dictionary<StorageNode, long>();
|
// retrieval without Where is unaffected by the threshold; we filter
|
||||||
CaptureTotals(libNode, originalTotals);
|
// out folders client-side and skip File.Length access for them.
|
||||||
|
// Smaller page size because each row carries the full Versions collection.
|
||||||
// Reset all nodes in this tree to zero before accumulating
|
|
||||||
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'>
|
||||||
@@ -226,11 +506,8 @@ public class StorageService : IStorageService
|
|||||||
<ViewFields>
|
<ViewFields>
|
||||||
<FieldRef Name='FSObjType' />
|
<FieldRef Name='FSObjType' />
|
||||||
<FieldRef Name='FileDirRef' />
|
<FieldRef Name='FileDirRef' />
|
||||||
<FieldRef Name='File_x0020_Size' />
|
|
||||||
<FieldRef Name='SMTotalSize' />
|
|
||||||
<FieldRef Name='SMTotalFileStreamSize' />
|
|
||||||
</ViewFields>
|
</ViewFields>
|
||||||
<RowLimit Paged='TRUE'>5000</RowLimit>
|
<RowLimit Paged='TRUE'>500</RowLimit>
|
||||||
</View>"
|
</View>"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,83 +519,113 @@ public class StorageService : IStorageService
|
|||||||
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
||||||
ic => ic.Include(
|
ic => ic.Include(
|
||||||
i => i["FSObjType"],
|
i => i["FSObjType"],
|
||||||
i => i["FileDirRef"],
|
i => i["FileDirRef"]));
|
||||||
i => i["File_x0020_Size"],
|
|
||||||
i => i["SMTotalSize"],
|
|
||||||
i => i["SMTotalFileStreamSize"]));
|
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
// Second pass: queue File.Length + File.Versions[*].Size only for
|
||||||
|
// file rows. Including these in the page 1 query throws a
|
||||||
|
// ServerObjectNullReferenceException on folder rows (item.File is
|
||||||
|
// null for folders). Filtering FSObjType client-side here keeps
|
||||||
|
// per-page round-trips at two regardless of file count.
|
||||||
|
var fileRows = new List<(ListItem Item, string DirRef)>();
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
|
if (item["FSObjType"]?.ToString() != "0") continue;
|
||||||
|
var dirRef = item["FileDirRef"]?.ToString() ?? string.Empty;
|
||||||
long streamSize = ParseLong(item["File_x0020_Size"]);
|
fileRows.Add((item, dirRef));
|
||||||
long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize"));
|
ctx.Load(item.File, f => f.Length);
|
||||||
long smTotal = ParseLong(SafeGet(item, "SMTotalSize"));
|
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
|
||||||
|
|
||||||
// Prefer SM fields when present; fall back to File_x0020_Size otherwise.
|
|
||||||
if (smStream > 0) streamSize = smStream;
|
|
||||||
long totalSize = smTotal > 0 ? smTotal : streamSize;
|
|
||||||
|
|
||||||
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
|
|
||||||
|
|
||||||
// Always count toward the library root
|
|
||||||
libNode.TotalSizeBytes += totalSize;
|
|
||||||
libNode.FileStreamSizeBytes += streamSize;
|
|
||||||
libNode.TotalFileCount++;
|
|
||||||
|
|
||||||
// Also count toward the most specific matching subfolder
|
|
||||||
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
|
|
||||||
if (matchedFolder != null && matchedFolder != libNode)
|
|
||||||
{
|
|
||||||
matchedFolder.TotalSizeBytes += totalSize;
|
|
||||||
matchedFolder.FileStreamSizeBytes += streamSize;
|
|
||||||
matchedFolder.TotalFileCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fileRows.Count > 0)
|
||||||
|
{
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in fileRows)
|
||||||
|
{
|
||||||
|
long current;
|
||||||
|
try { current = row.Item.File.Length; }
|
||||||
|
catch { continue; }
|
||||||
|
|
||||||
|
long versions = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var v in row.Item.File.Versions)
|
||||||
|
versions += v.Size;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Versioning disabled / no version history — leave at 0.
|
||||||
|
}
|
||||||
|
|
||||||
|
long totalSize = current + versions;
|
||||||
|
|
||||||
|
// Attribute each file to its deepest matching folder only.
|
||||||
|
// Parent rollup happens once after all pages are processed,
|
||||||
|
// adding direct + descendants — matches storman's per-folder
|
||||||
|
// total. Fall back to libNode for files at lib root or in
|
||||||
|
// folders excluded from the tree (Forms, _-prefixed system
|
||||||
|
// folders, depth-limited subfolders).
|
||||||
|
var target = FindDeepestFolder(row.DirRef, folderLookup) ?? libNode;
|
||||||
|
target.TotalSizeBytes += totalSize;
|
||||||
|
target.FileStreamSizeBytes += current;
|
||||||
|
target.TotalFileCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||||
}
|
}
|
||||||
while (items.ListItemCollectionPosition != null);
|
while (items.ListItemCollectionPosition != null);
|
||||||
|
|
||||||
// Restore original TotalSizeBytes where it exceeded the recomputed value.
|
// Post-pass rollup: each folder's totals become own-direct + sum of
|
||||||
// Preserves StorageMetrics.TotalSize for nodes whose original metrics were
|
// descendants. libNode ends up as total of every file in the tree.
|
||||||
// valid but SMTotalSize was missing on individual files.
|
RollupFolderTotals(libNode);
|
||||||
foreach (var kv in originalTotals)
|
|
||||||
{
|
|
||||||
if (kv.Value > kv.Key.TotalSizeBytes)
|
|
||||||
kv.Key.TotalSizeBytes = kv.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long ParseLong(object? value)
|
/// <summary>
|
||||||
{
|
/// Recursively rolls up direct-file totals into ancestor folders so each
|
||||||
if (value == null) return 0;
|
/// node's reported size includes everything beneath it. Pre-condition: each
|
||||||
return long.TryParse(value.ToString(), out long n) ? n : 0;
|
/// node holds only its directly-attributed files (no descendant amounts).
|
||||||
}
|
/// </summary>
|
||||||
|
private static void RollupFolderTotals(StorageNode node)
|
||||||
private static object? SafeGet(ListItem item, string fieldName)
|
|
||||||
{
|
|
||||||
try { return item[fieldName]; }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CaptureTotals(StorageNode node, Dictionary<StorageNode, long> map)
|
|
||||||
{
|
|
||||||
map[node] = node.TotalSizeBytes;
|
|
||||||
foreach (var child in node.Children)
|
|
||||||
CaptureTotals(child, map);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool HasZeroChild(StorageNode node)
|
|
||||||
{
|
{
|
||||||
foreach (var child in node.Children)
|
foreach (var child in node.Children)
|
||||||
{
|
{
|
||||||
if (child.TotalFileCount == 0) return true;
|
RollupFolderTotals(child);
|
||||||
if (HasZeroChild(child)) return true;
|
node.TotalSizeBytes += child.TotalSizeBytes;
|
||||||
|
node.FileStreamSizeBytes += child.FileStreamSizeBytes;
|
||||||
|
node.TotalFileCount += child.TotalFileCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op retained for interface compatibility. Backfill now runs inline
|
||||||
|
/// inside <see cref="CollectStorageAsync"/> via BackfillLibFromFilesAsync,
|
||||||
|
/// which has access to the CSOM library reference and runs before bin
|
||||||
|
/// distribution so the count==0 trigger is not polluted by bin items.
|
||||||
|
/// </summary>
|
||||||
|
public Task BackfillZeroNodesAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
IReadOnlyList<StorageNode> nodes,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ResetNodeCounts(StorageNode node)
|
private static void ResetNodeCounts(StorageNode node)
|
||||||
@@ -345,8 +652,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 +664,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 +673,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 +699,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 +718,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 +732,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 +751,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
|
||||||
{
|
{
|
||||||
@@ -126,15 +163,36 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Summary properties (computed from root-level library nodes) ─────────
|
// ── Summary properties (computed from root-level library nodes) ─────────
|
||||||
|
//
|
||||||
|
// Recycle-bin contents are rolled into each library's TotalSizeBytes by the
|
||||||
|
// StorageService (matches storman.aspx). Including the synthetic root-level
|
||||||
|
// RecycleBin nodes here would double-count those bytes — filter them out.
|
||||||
|
// SummaryRecycleBinSize below still reads from _allNodes so the bin metric
|
||||||
|
// remains visible to the user.
|
||||||
|
|
||||||
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary>
|
/// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
|
||||||
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
|
public long SummaryTotalSize => Results
|
||||||
|
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
|
||||||
|
.Sum(n => n.TotalSizeBytes);
|
||||||
|
|
||||||
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary>
|
/// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
|
||||||
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
|
public long SummaryVersionSize => Results
|
||||||
|
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
|
||||||
|
.Sum(n => n.VersionSizeBytes);
|
||||||
|
|
||||||
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
|
/// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
|
||||||
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
|
public long SummaryFileCount => Results
|
||||||
|
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
|
||||||
|
.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;
|
||||||
|
|
||||||
@@ -142,6 +200,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
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 +279,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 +337,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 +357,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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}" />
|
||||||
|
|||||||
Reference in New Issue
Block a user