Compare commits

...

7 Commits

45 changed files with 2231 additions and 393 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -48,6 +48,54 @@ public class CsvExportServiceTests
Assert.Contains("Object", csv);
}
[Fact]
public void BuildCsv_WithResolvedTarget_IncludesTargetColumns()
{
var entry = new PermissionEntry(
ObjectType: "Site",
Title: "HR",
Url: "https://contoso.sharepoint.com/sites/HR",
HasUniquePermissions: true,
Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
PermissionLevels: "Limited Access: Edit",
GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll",
TargetLabel: "Payroll");
var svc = new CsvExportService();
var csv = svc.BuildCsv(new[] { entry });
Assert.Contains("TargetLabel", csv);
Assert.Contains("TargetUrl", csv);
Assert.Contains("Payroll", csv);
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", csv);
}
[Fact]
public void BuildCsv_WithSharingLink_IncludesLinkType()
{
var entry = new PermissionEntry(
ObjectType: "Folder", Title: "Reports", Url: "https://contoso.sharepoint.com/sites/HR/Docs",
HasUniquePermissions: true,
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PermissionLevels: "Contribute",
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
TargetLabel: "Q4.xlsx",
SharingLinkType: "OrganizationEdit");
var svc = new CsvExportService();
var csv = svc.BuildCsv(new[] { entry });
Assert.Contains("SharingLinkType", csv);
Assert.Contains("OrganizationEdit", csv);
Assert.Contains("Q4.xlsx", csv);
}
[Fact]
public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations()
{
@@ -156,6 +156,146 @@ public class HtmlExportServiceTests
Assert.Contains("getAttribute('data-group-target')", html);
}
// ── System-group target rendering (Limited Access / SharingLinks) ────────
[Fact]
public void BuildHtml_WithResolvedListTarget_RendersClickableLink()
{
var entry = new PermissionEntry(
ObjectType: "Site",
Title: "Contoso HR",
Url: "https://contoso.sharepoint.com/sites/HR",
HasUniquePermissions: true,
Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
PermissionLevels: "Limited Access: Edit",
GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll",
TargetLabel: "Payroll");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry });
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", html);
Assert.Contains(">Payroll</a>", html);
}
[Fact]
public void BuildHtml_WithSharingLinkTarget_RendersLinkTypeBadgeAndTarget()
{
var entry = new PermissionEntry(
ObjectType: "Folder",
Title: "Reports",
Url: "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports",
HasUniquePermissions: true,
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PermissionLevels: "Contribute",
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports/Q4.xlsx",
TargetLabel: "Q4.xlsx",
SharingLinkType: "OrganizationEdit");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry });
// Friendly label, raw code preserved as tooltip
Assert.Contains("Org link", html);
Assert.Contains("Edit", html);
Assert.Contains("title=\"OrganizationEdit\"", html);
Assert.Contains("Q4.xlsx", html);
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports/Q4.xlsx", html);
}
[Theory]
[InlineData("OrganizationView", "Org link", "View")]
[InlineData("AnonymousEdit", "Anyone", "Edit")]
[InlineData("Direct", "Specific people", null)]
[InlineData("Flexible", "Custom link", null)]
public void BuildHtml_FriendlyLinkLabel_ReplacesRawType(string raw, string expectedPart, string? expectedSecond)
{
var entry = new PermissionEntry(
ObjectType: "File", Title: "Doc.docx",
Url: "https://contoso.sharepoint.com/sites/HR/Docs/Doc.docx",
HasUniquePermissions: true,
Users: $"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
UserLogins: $"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PermissionLevels: "Contribute",
GrantedThrough: $"SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Doc.docx",
TargetLabel: "Doc.docx",
SharingLinkType: raw);
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true);
Assert.Contains(expectedPart, html);
if (expectedSecond is not null)
Assert.Contains(expectedSecond, html);
Assert.Contains($"title=\"{raw}\"", html);
}
[Fact]
public void BuildHtml_WithSharingLinkAndHideRaw_OmitsRawGroupString()
{
var entry = new PermissionEntry(
ObjectType: "File", Title: "Q4.xlsx",
Url: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
HasUniquePermissions: true,
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PermissionLevels: "Contribute",
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
TargetLabel: "Q4.xlsx",
SharingLinkType: "OrganizationEdit");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true);
// Raw "SharingLinks.{guid}..." text suppressed when target resolved + flag on
Assert.DoesNotContain("SharingLinks.e686221e", html);
// Target link still rendered
Assert.Contains("Q4.xlsx", html);
Assert.Contains("OrganizationEdit", html);
}
[Fact]
public void BuildHtml_WithSharingLinkButNoTargetAndHideRaw_KeepsRawAsFallback()
{
// Target resolution failed (deleted item) — flag on, but no TargetUrl → keep raw text.
var entry = new PermissionEntry(
ObjectType: "File", Title: "Q4.xlsx",
Url: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
HasUniquePermissions: true,
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PermissionLevels: "Contribute",
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PrincipalType: "SharePointGroup",
SharingLinkType: "OrganizationEdit");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true);
Assert.Contains("SharingLinks.e686221e", html);
}
[Fact]
public void BuildHtml_WithoutTarget_RendersGrantedThroughTextOnly()
{
// Standard SP group with no resolved target — no extra link should appear.
var entry = MakeEntry("Owners", "ownersgroup", principalType: "SharePointGroup");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry });
Assert.DoesNotContain("&rarr;", html);
}
[Fact]
public void BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill()
{
@@ -86,20 +86,15 @@ public class UserAccessCsvExportServiceTests
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry });
// Find the header row and count its quoted comma-separated fields
// Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through"
// That is 7 fields.
// Header: Site, Object Type, Object, URL, Permission Level, Access Type,
// Granted Through, TargetLabel, TargetUrl, SharingLinkType → 10 fields.
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Find a data row (after the blank line separating summary from data)
// Data rows contain the entry content (not the header line itself)
// We want to count fields in the header row:
var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\""));
Assert.NotNull(headerLine);
// Count comma-separated quoted fields: split by "," boundary
var fields = CountCsvFields(headerLine!);
Assert.Equal(7, fields);
Assert.Equal(10, fields);
}
// ── Test 5: WriteSingleFileAsync includes entries for all users ───────────
@@ -190,8 +185,8 @@ public class UserAccessCsvExportServiceTests
await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true);
var content = await File.ReadAllTextAsync(tmpFile);
// Header must contain consolidated columns
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content);
// Header must contain consolidated columns (now includes Target* + SharingLinkType)
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"", content);
// Alice's two entries merged — locations column contains both site titles
Assert.Contains("Contoso", content);
@@ -3,7 +3,8 @@ using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Tests for PERM-03: external user detection and permission-level filtering.
/// Tests for PERM-03: external user detection, permission-level filtering,
/// and SharePoint system-group classification (Limited Access / SharingLinks).
/// Pure static logic — runs immediately without stubs.
/// </summary>
public class PermissionEntryClassificationTests
@@ -13,7 +14,6 @@ public class PermissionEntryClassificationTests
[Fact]
public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue()
{
// B2B guest login names contain the literal "#EXT#" fragment
Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com"));
}
@@ -28,8 +28,6 @@ public class PermissionEntryClassificationTests
[Fact]
public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess()
{
// A principal whose sole permission level is "Limited Access" should produce
// an empty list after filtering — used to decide whether to include the entry.
var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" });
Assert.Empty(result);
}
@@ -41,23 +39,100 @@ public class PermissionEntryClassificationTests
Assert.Equal(new[] { "Contribute" }, result);
}
// ── IsSharingLinksGroup ────────────────────────────────────────────────────
// ── IsBareLimitedAccessSystemGroup ─────────────────────────────────────────
[Fact]
public void IsSharingLinksGroup_WithSharingLinksPrefix_ReturnsTrue()
public void IsBareLimitedAccessSystemGroup_WithExactMatch_ReturnsTrue()
{
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("SharingLinks.abc123.Edit"));
Assert.True(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Limited Access System Group"));
}
[Fact]
public void IsSharingLinksGroup_WithLimitedAccessSystemGroup_ReturnsTrue()
public void IsBareLimitedAccessSystemGroup_WithForWebVariant_ReturnsFalse()
{
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("Limited Access System Group"));
// The "For Web {guid}" variant is enriched, not filtered.
Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup(
"Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"));
}
[Fact]
public void IsSharingLinksGroup_WithNormalGroup_ReturnsFalse()
public void IsBareLimitedAccessSystemGroup_WithNormalGroup_ReturnsFalse()
{
Assert.False(PermissionEntryHelper.IsSharingLinksGroup("Owners"));
Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Owners"));
}
// ── Classify ───────────────────────────────────────────────────────────────
[Fact]
public void Classify_WithNormalGroupTitle_ReturnsNone()
{
var c = PermissionEntryHelper.Classify("Site Owners");
Assert.Equal(SystemGroupKind.None, c.Kind);
}
[Fact]
public void Classify_WithBareLimitedAccess_ReturnsLimitedAccessBare()
{
var c = PermissionEntryHelper.Classify("Limited Access System Group");
Assert.Equal(SystemGroupKind.LimitedAccessBare, c.Kind);
}
[Fact]
public void Classify_WithLimitedAccessForWeb_ExtractsWebId()
{
var c = PermissionEntryHelper.Classify(
"Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80");
Assert.Equal(SystemGroupKind.LimitedAccessWeb, c.Kind);
Assert.Equal(Guid.Parse("e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"), c.WebId);
Assert.Null(c.ListId);
Assert.Null(c.ItemUniqueId);
}
[Fact]
public void Classify_WithLimitedAccessForList_ExtractsListId()
{
var c = PermissionEntryHelper.Classify(
"Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc");
Assert.Equal(SystemGroupKind.LimitedAccessList, c.Kind);
Assert.Equal(Guid.Parse("b20e3b22-2b09-4c99-9ba4-37b42f3a12dc"), c.ListId);
Assert.Null(c.WebId);
Assert.Null(c.ItemUniqueId);
}
[Fact]
public void Classify_WithSharingLink_ExtractsItemAndShareId()
{
var c = PermissionEntryHelper.Classify(
"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6");
Assert.Equal(SystemGroupKind.SharingLink, c.Kind);
Assert.Equal(Guid.Parse("e686221e-d1cb-43c5-8c68-04aa7f90f329"), c.ItemUniqueId);
Assert.Equal(Guid.Parse("64c27910-66ea-421d-b0f0-8f0f72dcfaf6"), c.ShareId);
Assert.Equal("OrganizationEdit", c.LinkType);
}
[Theory]
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.AnonymousView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "AnonymousView")]
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Flexible.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "Flexible")]
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "OrganizationView")]
public void Classify_WithVariousSharingLinkTypes_PreservesLinkType(string title, string expected)
{
var c = PermissionEntryHelper.Classify(title);
Assert.Equal(SystemGroupKind.SharingLink, c.Kind);
Assert.Equal(expected, c.LinkType);
}
[Fact]
public void Classify_WithMalformedSharingLink_ReturnsNone()
{
// Missing share GUID at the end
var c = PermissionEntryHelper.Classify("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Edit");
Assert.Equal(SystemGroupKind.None, c.Kind);
}
[Fact]
public void Classify_WithEmptyString_ReturnsNone()
{
var c = PermissionEntryHelper.Classify("");
Assert.Equal(SystemGroupKind.None, c.Kind);
}
}
@@ -28,4 +28,21 @@ public class StorageServiceTests
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes);
}
[Fact]
public void StorageNode_DefaultKind_IsLibrary()
{
var node = new StorageNode();
Assert.Equal(StorageNodeKind.Library, node.Kind);
}
[Fact]
public void StorageScanOptions_DefaultIncludeFlags_AreAllTrue()
{
var opts = new StorageScanOptions();
Assert.True(opts.IncludeHiddenLibraries);
Assert.True(opts.IncludePreservationHold);
Assert.True(opts.IncludeListAttachments);
Assert.True(opts.IncludeRecycleBin);
}
}
@@ -0,0 +1,141 @@
using System.Reflection;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Verifies that the report filter flags (Show*) project the raw scan output
/// (<c>_allNodes</c>) into <c>Results</c> correctly: hiding a kind drops the
/// matching root nodes plus their entire subtree, preserving DFS ordering.
/// </summary>
public class StorageViewModelFilterTests
{
public StorageViewModelFilterTests() => WeakReferenceMessenger.Default.Reset();
private static StorageViewModel CreateVm()
{
var vm = new StorageViewModel(
new Mock<IStorageService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance);
vm.SetCurrentProfile(new TenantProfile { Name = "T", TenantUrl = "https://t", ClientId = "c" });
return vm;
}
/// <summary>Inject a flat node list straight into the private _allNodes field
/// and trigger a rebuild via toggling a Show flag.</summary>
private static void Seed(StorageViewModel vm, List<StorageNode> flat)
{
var field = typeof(StorageViewModel).GetField("_allNodes",
BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(vm, flat);
// Toggle off+on to force RebuildFilteredResults().
vm.ShowLibraries = false;
vm.ShowLibraries = true;
}
private static List<StorageNode> MakeMixedTree() => new()
{
new() { Name = "Documents", Kind = StorageNodeKind.Library, IndentLevel = 0, TotalSizeBytes = 100 },
new() { Name = "Sub", Kind = StorageNodeKind.Library, IndentLevel = 1, TotalSizeBytes = 50 },
new() { Name = "Preserve", Kind = StorageNodeKind.PreservationHold, IndentLevel = 0, TotalSizeBytes = 200 },
new() { Name = "[Recycle]", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0, TotalSizeBytes = 300 },
new() { Name = "[Attach] L",Kind = StorageNodeKind.ListAttachments, IndentLevel = 0, TotalSizeBytes = 75 },
};
[Fact]
public void AllShowFlagsTrue_AllNodesAppear()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
Assert.Equal(5, vm.Results.Count);
}
[Fact]
public void HideRecycleBin_RemovesOnlyRecycleNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowRecycleBin = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.RecycleBin);
Assert.Equal(4, vm.Results.Count);
}
[Fact]
public void HidePreservationHold_RemovesPreservationNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowPreservationHold = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.PreservationHold);
}
[Fact]
public void HideLibraries_DropsLibraryRootAndItsChildren()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
// "Documents" + "Sub" both gone — Sub's subtree dropped with its parent root.
Assert.DoesNotContain(vm.Results, n => n.Name == "Documents");
Assert.DoesNotContain(vm.Results, n => n.Name == "Sub");
}
[Fact]
public void CombineRecycleBinStages_True_MergesStagesIntoSingleRow()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100, FileStreamSizeBytes = 100, TotalFileCount = 3 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250, FileStreamSizeBytes = 250, TotalFileCount = 7 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = true;
var bins = vm.Results.Where(n => n.Kind == StorageNodeKind.RecycleBin).ToList();
Assert.Single(bins);
Assert.Equal(350, bins[0].TotalSizeBytes);
Assert.Equal(10, bins[0].TotalFileCount);
}
[Fact]
public void CombineRecycleBinStages_False_KeepsSeparateRows()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = false;
Assert.Equal(2, vm.Results.Count(n => n.Kind == StorageNodeKind.RecycleBin));
}
[Fact]
public void HideAll_LeavesEmptyResults()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
vm.ShowHiddenLibraries = false;
vm.ShowPreservationHold = false;
vm.ShowListAttachments = false;
vm.ShowRecycleBin = false;
vm.ShowSubsites = false;
Assert.Empty(vm.Results);
}
}
+1
View File
@@ -16,6 +16,7 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:StorageKindConverter x:Key="StorageKindConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
+1
View File
@@ -149,6 +149,7 @@ public partial class App : Application
services.AddTransient<VersionCleanupView>();
// Phase 2: Permissions
services.AddTransient<ISystemGroupTargetResolver, SystemGroupTargetResolver>();
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>();
services.AddTransient<CsvExportService>();
@@ -50,7 +50,10 @@ public static class PermissionConsolidator
first.GrantedThrough,
first.IsHighPrivilege,
first.IsExternalUser,
locations);
locations,
first.TargetUrl,
first.TargetLabel,
first.SharingLinkType);
})
.OrderBy(c => c.UserLogin)
.ThenBy(c => c.PermissionLevel)
@@ -1,10 +1,56 @@
using System.Text.RegularExpressions;
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Classifies a SharePoint group name into one of the known system-group shapes.
/// </summary>
public enum SystemGroupKind
{
/// <summary>Not a system group — a normal SharePoint group, user, or external user.</summary>
None,
/// <summary>Bare "Limited Access System Group" pseudo-principal (no embedded target).</summary>
LimitedAccessBare,
/// <summary>"Limited Access System Group For Web {webId}".</summary>
LimitedAccessWeb,
/// <summary>"Limited Access System Group For List {listId}".</summary>
LimitedAccessList,
/// <summary>"SharingLinks.{itemUniqueId}.{linkType}.{shareId}".</summary>
SharingLink
}
/// <summary>
/// Result of classifying a SharePoint group name.
/// Carries the embedded GUIDs / link type so a resolver can look up the target.
/// </summary>
public readonly record struct SystemGroupClassification(
SystemGroupKind Kind,
Guid? WebId,
Guid? ListId,
Guid? ItemUniqueId,
string? LinkType,
Guid? ShareId);
/// <summary>
/// Pure static helpers for classifying SharePoint permission entries.
/// </summary>
public static class PermissionEntryHelper
{
// "Limited Access System Group For Web {guid}"
private static readonly Regex LimitedAccessWebRegex = new(
@"^Limited Access System Group For Web\s+(?<id>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// "Limited Access System Group For List {guid}"
private static readonly Regex LimitedAccessListRegex = new(
@"^Limited Access System Group For List\s+(?<id>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// "SharingLinks.{itemUniqueId}.{linkType}.{shareId}"
private static readonly Regex SharingLinkRegex = new(
@"^SharingLinks\.(?<item>[0-9a-fA-F-]{36})\.(?<type>[^.]+)\.(?<share>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Returns true when the login name is a B2B guest (contains #EXT#).
/// </summary>
@@ -21,10 +67,45 @@ public static class PermissionEntryHelper
.ToList();
/// <summary>
/// Returns true when the login name represents an internal sharing-link group
/// or the "Limited Access System Group" pseudo-principal.
/// Returns true when the supplied name/login is the bare "Limited Access System Group"
/// pseudo-principal — the noise entry with no embedded GUID. The
/// per-Web/List and SharingLinks variants are NOT filtered: they are enriched.
/// </summary>
public static bool IsSharingLinksGroup(string loginName) =>
loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase)
|| loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
public static bool IsBareLimitedAccessSystemGroup(string name) =>
name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Classifies a SharePoint group title (the value used in Granted Through). When the
/// title matches a known system pattern, the embedded GUIDs / link type are parsed
/// out so a resolver can look up the actual targeted Web/List/Item.
/// </summary>
public static SystemGroupClassification Classify(string groupTitle)
{
if (string.IsNullOrWhiteSpace(groupTitle))
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
var trimmed = groupTitle.Trim();
if (IsBareLimitedAccessSystemGroup(trimmed))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessBare, null, null, null, null, null);
var mWeb = LimitedAccessWebRegex.Match(trimmed);
if (mWeb.Success && Guid.TryParse(mWeb.Groups["id"].Value, out var webId))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessWeb, webId, null, null, null, null);
var mList = LimitedAccessListRegex.Match(trimmed);
if (mList.Success && Guid.TryParse(mList.Groups["id"].Value, out var listId))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessList, null, listId, null, null, null);
var mShare = SharingLinkRegex.Match(trimmed);
if (mShare.Success
&& Guid.TryParse(mShare.Groups["item"].Value, out var itemId)
&& Guid.TryParse(mShare.Groups["share"].Value, out var shareId))
{
return new SystemGroupClassification(
SystemGroupKind.SharingLink, null, null, itemId, mShare.Groups["type"].Value, shareId);
}
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
}
}
@@ -0,0 +1,58 @@
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Risk tier of a SharePoint sharing link, used to color-code the link-type badge
/// in HTML reports.
/// </summary>
public enum SharingLinkRisk
{
Low, // Read-only, scoped (Org view, Direct, Existing, Review)
Medium, // Org-wide edit
High, // Anonymous (anyone with link, no auth)
Unknown // Unrecognized type — neutral styling
}
/// <summary>
/// Maps the raw <c>linkType</c> segment of a <c>SharingLinks.{itemId}.{type}.{shareId}</c>
/// group name to a human-readable label and a risk tier. The raw codes are SharePoint
/// internals (OrganizationEdit / AnonymousView / Flexible / …); reports show the friendly
/// label and tint the badge by tier.
/// </summary>
public static class SharingLinkLabels
{
/// <summary>
/// Returns the friendly label and risk tier for a sharing-link type code.
/// Unknown codes fall through with the raw value and <see cref="SharingLinkRisk.Unknown"/>.
/// </summary>
public static (string Label, SharingLinkRisk Risk) Describe(string? rawLinkType)
{
if (string.IsNullOrWhiteSpace(rawLinkType))
return (string.Empty, SharingLinkRisk.Unknown);
return rawLinkType.Trim() switch
{
"OrganizationView" => ("Org link · View", SharingLinkRisk.Low),
"OrganizationEdit" => ("Org link · Edit", SharingLinkRisk.Medium),
"AnonymousView" => ("Anyone · View", SharingLinkRisk.High),
"AnonymousEdit" => ("Anyone · Edit", SharingLinkRisk.High),
"Flexible" => ("Custom link", SharingLinkRisk.Medium),
"Direct" => ("Specific people", SharingLinkRisk.Low),
"Existing" => ("Existing access", SharingLinkRisk.Low),
"Review" => ("Review only", SharingLinkRisk.Low),
"Embed" => ("Embedded link", SharingLinkRisk.Medium),
_ => (rawLinkType, SharingLinkRisk.Unknown)
};
}
/// <summary>
/// Returns inline-CSS colors (background, foreground) for a risk tier — matches the
/// risk-card palette used elsewhere in the HTML reports.
/// </summary>
public static (string Background, string Foreground) Colors(SharingLinkRisk risk) => risk switch
{
SharingLinkRisk.Low => ("#D1FAE5", "#065F46"),
SharingLinkRisk.Medium => ("#FEF3C7", "#92400E"),
SharingLinkRisk.High => ("#FEE2E2", "#991B1B"),
_ => ("#F3F4F6", "#374151"),
};
}
@@ -13,7 +13,10 @@ public record ConsolidatedPermissionEntry(
string GrantedThrough,
bool IsHighPrivilege,
bool IsExternalUser,
IReadOnlyList<LocationInfo> Locations
IReadOnlyList<LocationInfo> Locations,
string? TargetUrl = null,
string? TargetLabel = null,
string? SharingLinkType = null
)
{
/// <summary>Convenience count — equals Locations.Count.</summary>
@@ -14,5 +14,10 @@ public record PermissionEntry(
string PermissionLevels, // Semicolon-joined role names (Limited Access already removed)
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType, // "SharePointGroup" | "User" | "External User"
bool WasAutoElevated = false // Set to true when site admin was auto-granted to access this entry
bool WasAutoElevated = false, // Set to true when site admin was auto-granted to access this entry
// Resolved target for Limited Access System Group For Web/List and SharingLinks
// groups — populated when GrantedThrough is one of those system groups, null otherwise.
string? TargetUrl = null,
string? TargetLabel = null,
string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | AnonymousView | etc.
);
@@ -41,6 +41,9 @@ public class SimplifiedPermissionEntry
public string PermissionLevels => Inner.PermissionLevels;
public string GrantedThrough => Inner.GrantedThrough;
public string PrincipalType => Inner.PrincipalType;
public string? TargetUrl => Inner.TargetUrl;
public string? TargetLabel => Inner.TargetLabel;
public string? SharingLinkType => Inner.SharingLinkType;
public SimplifiedPermissionEntry(PermissionEntry entry)
{
@@ -12,5 +12,6 @@ public class StorageNode
public long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; }
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
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(
bool PerLibrary = true,
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
);
@@ -0,0 +1,15 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Resolved target for a SharePoint system group (Limited Access For Web/List or SharingLinks).
/// Carries the human-readable label, the navigable URL, and (for sharing links) the link type
/// so the export layer can render a clickable link in the Granted Through cell.
/// </summary>
public record SystemGroupTarget(
SystemGroupKind Kind,
string Label, // e.g. "MyList" | "Shared Documents/Folder/File.docx" | "Site - HR"
string Url, // Navigable URL of the targeted resource
string? LinkType = null // For SharingLinks: "OrganizationEdit", "AnonymousView", etc.
);
@@ -29,5 +29,8 @@ public record UserAccessEntry(
AccessType AccessType, // Direct | Group | Inherited
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc.
bool IsHighPrivilege, // True for Full Control, Site Collection Administrator
bool IsExternalUser // True if login contains #EXT#
bool IsExternalUser, // True if login contains #EXT#
string? TargetUrl = null, // Resolved URL when GrantedThrough is a Limited Access / SharingLinks system group
string? TargetLabel = null, // Resolved name (list title / file name / web title)
string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | ...
);
@@ -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.rad.csv" xml:space="preserve"><value>CSV</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 -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data>
@@ -560,6 +582,7 @@ Cette action est irréversible.</value>
<!-- Phase 16: Report Consolidation Toggle -->
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data>
<data name="chk.merge.permissions" xml:space="preserve"><value>Fusionner les permissions en double</value></data>
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Masquer les noms bruts (SharingLinks, Limited Access)</value></data>
<!-- Phase 19: App Registration & Removal -->
<data name="profile.register" xml:space="preserve"><value>Enregistrer l'app</value></data>
<data name="profile.remove" xml:space="preserve"><value>Supprimer l'app</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.rad.csv" xml:space="preserve"><value>CSV</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 -->
<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>
@@ -560,6 +582,7 @@ This cannot be undone.</value>
<!-- Phase 16: Report Consolidation Toggle -->
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data>
<data name="chk.merge.permissions" xml:space="preserve"><value>Merge duplicate permissions</value></data>
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Hide raw system group names (SharingLinks, Limited Access)</value></data>
<!-- Phase 19: App Registration & Removal -->
<data name="profile.register" xml:space="preserve"><value>Register App</value></data>
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
+1
View File
@@ -8,6 +8,7 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Icon="pack://application:,,,/Resources/SPToolbox-logo-ico.png"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -14,7 +14,7 @@ public class CsvExportService
private static string BuildHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
@@ -39,7 +39,10 @@ public class CsvExportService
UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
GrantedThrough = g.Key.GrantedThrough
GrantedThrough = g.Key.GrantedThrough,
TargetLabel = g.First().TargetLabel ?? string.Empty,
TargetUrl = g.First().TargetUrl ?? string.Empty,
SharingLinkType = g.First().SharingLinkType ?? string.Empty
});
foreach (var row in merged)
@@ -47,7 +50,8 @@ public class CsvExportService
{
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough)
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough),
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
@@ -68,7 +72,7 @@ public class CsvExportService
private static string BuildSimplifiedHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
@@ -95,7 +99,10 @@ public class CsvExportService
Permissions = g.Key.PermissionLevels,
SimplifiedLabels = g.First().SimplifiedLabels,
RiskLevel = g.First().RiskLevel.ToString(),
GrantedThrough = g.Key.GrantedThrough
GrantedThrough = g.Key.GrantedThrough,
TargetLabel = g.First().TargetLabel ?? string.Empty,
TargetUrl = g.First().TargetUrl ?? string.Empty,
SharingLinkType = g.First().SharingLinkType ?? string.Empty
});
foreach (var row in merged)
@@ -104,7 +111,8 @@ public class CsvExportService
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels),
Csv(row.RiskLevel), Csv(row.GrantedThrough)
Csv(row.RiskLevel), Csv(row.GrantedThrough),
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
@@ -24,4 +24,41 @@ internal static class ExportFileWriter
/// <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)
=> 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);
}
}
@@ -27,7 +27,8 @@ public class HtmlExportService
public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
@@ -57,7 +58,9 @@ public class HtmlExportService
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx);
colSpan: 7, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -66,7 +69,7 @@ public class HtmlExportService
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pills}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
@@ -87,7 +90,8 @@ public class HtmlExportService
public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
@@ -132,7 +136,9 @@ public class HtmlExportService
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
colSpan: 9, grpMemIdx: ref grpMemIdx);
colSpan: 9, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -143,7 +149,7 @@ public class HtmlExportService
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
@@ -161,9 +167,10 @@ public class HtmlExportService
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers);
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
@@ -173,9 +180,10 @@ public class HtmlExportService
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers);
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
@@ -191,11 +199,12 @@ public class HtmlExportService
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers);
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
@@ -203,7 +212,7 @@ public class HtmlExportService
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
.ToList();
var title = TranslationSource.Instance["report.title.permissions"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
@@ -215,11 +224,11 @@ public class HtmlExportService
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
/// <summary>Simplified-entry split variant.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath,
@@ -227,11 +236,12 @@ public class HtmlExportService
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers);
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
@@ -239,7 +249,7 @@ public class HtmlExportService
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
.ToList();
var title = TranslationSource.Instance["report.title.permissions_simplified"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
@@ -251,7 +261,7 @@ public class HtmlExportService
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
@@ -1,4 +1,5 @@
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
@@ -137,7 +138,10 @@ document.addEventListener('click', function(ev) {
string? principalType,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
int colSpan,
ref int grpMemIdx)
ref int grpMemIdx,
string? targetLabel = null,
string? sharingLinkType = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -151,14 +155,40 @@ document.addEventListener('click', function(ev) {
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandable = principalType == "SharePointGroup"
// When the principal is a resolved system group and the user wants the raw
// name hidden, replace the pill's visible text with the link-type badge
// (sharing links) and/or the target label. Falls back to the raw name when
// resolution failed (no targetLabel).
var classification = principalType == "SharePointGroup"
? PermissionEntryHelper.Classify(name)
: new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
bool isResolvedSystemGroup = hideSystemGroupRaw
&& classification.Kind != SystemGroupKind.None
&& classification.Kind != SystemGroupKind.LimitedAccessBare
&& !string.IsNullOrEmpty(targetLabel);
bool hasResolvedMembers = principalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandable && groupMembers != null && groupMembers.TryGetValue(name, out var resolved))
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
{
var grpId = $"grpmem{grpMemIdx}";
pills.Append($"<span class=\"user-pill group-expandable\" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
pills.Append("<span class=\"user-pill group-expandable\"");
if (isResolvedSystemGroup)
pills.Append(" data-system-group=\"1\"");
pills.Append($" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">");
if (isResolvedSystemGroup)
{
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
}
else
{
pills.Append(HtmlEncode(name));
}
pills.Append(" &#9660;</span>");
string memberContent;
if (resolved.Count > 0)
@@ -173,6 +203,14 @@ document.addEventListener('click', function(ev) {
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else if (isResolvedSystemGroup)
{
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
pills.Append("</span>");
}
else
{
var cls = isExt ? "user-pill external-user" : "user-pill";
@@ -183,6 +221,80 @@ document.addEventListener('click', function(ev) {
return (pills.ToString(), subRows.ToString());
}
/// <summary>
/// Renders the Granted Through cell. When the entry carries a resolved system-group
/// target (Limited Access For Web/List or SharingLinks), a clickable link to the
/// targeted resource is appended on a second line. For sharing links the link type
/// (OrganizationEdit / AnonymousView / …) is surfaced alongside the target.
///
/// When <paramref name="hideSystemGroupRaw"/> is true and a target was resolved, the
/// raw "SharePoint Group: SharingLinks.{guid}…" / "Limited Access System Group For
/// Web|List {guid}" prefix is suppressed and only the link-type badge + clickable
/// target are shown — keeps the report readable without losing information.
/// </summary>
internal static string BuildGrantedThroughCell(
string grantedThrough,
string? targetUrl,
string? targetLabel,
string? sharingLinkType,
bool hideSystemGroupRaw = false)
{
var hasTarget = !string.IsNullOrEmpty(targetUrl) && !string.IsNullOrEmpty(targetLabel);
var hasLinkType = !string.IsNullOrEmpty(sharingLinkType);
var suppressRaw = hideSystemGroupRaw && hasTarget;
var sb = new StringBuilder();
if (!suppressRaw)
sb.Append(HtmlEncode(grantedThrough));
if (!hasTarget && !hasLinkType)
return sb.ToString();
if (suppressRaw)
{
// Inline layout — no leading raw text to wrap under.
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("<a href=\"");
sb.Append(HtmlEncode(targetUrl!));
sb.Append("\" target=\"_blank\">");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("</a>");
}
return sb.ToString();
}
sb.Append("<div style=\"margin-top:4px;font-size:.75rem;color:#555\">");
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("&rarr; <a href=\"");
sb.Append(HtmlEncode(targetUrl!));
sb.Append("\" target=\"_blank\">");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("</a>");
}
sb.Append("</div>");
return sb.ToString();
}
/// <summary>
/// Builds the colored badge for a SharePoint sharing-link type. Translates the
/// raw <c>linkType</c> code (e.g. <c>OrganizationEdit</c>) into a human label
/// (e.g. <c>Org link · Edit</c>) and tints by risk tier; raw code surfaces as a
/// <c>title</c> tooltip so operators can still trace it back to the source.
/// </summary>
internal static string BuildSharingLinkBadge(string rawLinkType)
{
var (label, risk) = SharingLinkLabels.Describe(rawLinkType);
var (bg, fg) = SharingLinkLabels.Colors(risk);
return $"<span class=\"badge\" style=\"background:{bg};color:{fg};margin-right:6px\" " +
$"title=\"{HtmlEncode(rawLinkType)}\">{HtmlEncode(label)}</span>";
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
internal static string ObjectTypeCss(string t) => t switch
{
@@ -1,5 +1,6 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.Globalization;
using System.IO;
using System.Text;
@@ -18,33 +19,58 @@ public class StorageCsvExportService
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
return sb.ToString();
}
// Header
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
{
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)
{
sb.AppendLine(string.Join(",",
Csv(node.Name),
Csv(node.SiteTitle),
node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes),
FormatMb(node.VersionSizeBytes),
node.LastModified.HasValue
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
: string.Empty));
AppendCsvField(sb, node.Name).Append(',');
AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
AppendCsvField(sb, node.SiteTitle).Append(',');
sb.Append(node.TotalFileCount).Append(',');
AppendMb(sb, node.TotalSizeBytes).Append(',');
AppendMb(sb, node.VersionSizeBytes).Append(',');
if (node.LastModified.HasValue)
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
sb.AppendLine();
}
return sb.ToString();
}
/// <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)
{
var csv = BuildCsv(nodes);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
// Stream straight to disk: skip the StringBuilder→string copy and the
// 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>
@@ -52,44 +78,68 @@ public class StorageCsvExportService
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
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)
{
sb.AppendLine(string.Join(",",
Csv(node.Name),
Csv(node.SiteTitle),
node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes),
FormatMb(node.VersionSizeBytes),
node.LastModified.HasValue
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
: string.Empty));
AppendCsvField(sb, node.Name).Append(',');
AppendCsvField(sb, node.SiteTitle).Append(',');
sb.Append(node.TotalFileCount).Append(',');
AppendMb(sb, node.TotalSizeBytes).Append(',');
AppendMb(sb, node.VersionSizeBytes).Append(',');
if (node.LastModified.HasValue)
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
sb.AppendLine();
}
// File type breakdown
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($"{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)
{
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
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>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes, fileTypeMetrics);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
WriteCsv(sb, nodes, fileTypeMetrics);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
}
/// <summary>
@@ -138,9 +188,40 @@ public class StorageCsvExportService
// ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatMb(long bytes)
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
private static StringBuilder AppendMb(StringBuilder sb, long bytes)
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
private static StringBuilder AppendCsvField(StringBuilder sb, string 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
{
private int _togIdx;
private string[] _kindLabels = Array.Empty<string>();
private string[] _kindLabelsHtml = Array.Empty<string>();
/// <summary>
/// Builds a self-contained HTML report with one collapsible row per
@@ -21,10 +23,18 @@ public class StorageHtmlExportService
/// breakdown section is desired.
/// </summary>
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;
_togIdx = 0;
var sb = new StringBuilder();
_kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
@@ -60,11 +70,18 @@ public class StorageHtmlExportService
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
// Single-pass root aggregation: replaces 4 separate enumerations
// (.Where().ToList() + 3× .Sum() + a final .Where() during render).
var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
foreach (var n in nodes)
{
if (n.IndentLevel != 0) continue;
rootNodes0.Add(n);
siteTotal0 += n.TotalSizeBytes;
versionTotal0 += n.VersionSizeBytes;
fileTotal0 += n.TotalFileCount;
}
sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
@@ -79,6 +96,7 @@ public class StorageHtmlExportService
<thead>
<tr>
<th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
@@ -89,7 +107,10 @@ public class StorageHtmlExportService
<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);
}
@@ -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("</body></html>");
return sb.ToString();
}
/// <summary>
/// Builds an HTML report including a file-type breakdown chart section.
/// </summary>
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;
_togIdx = 0;
var sb = new StringBuilder();
_kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
@@ -159,11 +186,17 @@ public class StorageHtmlExportService
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
// ── Summary cards (single-pass aggregation) ──
var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
long siteTotal = 0, versionTotal = 0, fileTotal = 0;
foreach (var n in nodes)
{
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=\"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>
<tr>
<th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
@@ -222,7 +256,10 @@ public class StorageHtmlExportService
<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);
}
@@ -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("</body></html>");
return sb.ToString();
}
/// <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)
{
var html = BuildHtml(nodes, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
// Build into StringBuilder, stream chunks straight to disk —
// 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>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(nodes, fileTypeMetrics, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
}
/// <summary>
@@ -305,24 +344,11 @@ public class StorageHtmlExportService
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue
? 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>
""");
AppendRow(sb, node, nameCell);
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>");
foreach (var child in node.Children)
{
@@ -343,24 +369,11 @@ public class StorageHtmlExportService
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue
? 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>
""");
AppendRow(sb, node, nameCell);
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>");
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)
{
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)
=> 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;
}
}
@@ -15,7 +15,7 @@ public class UserAccessCsvExportService
private static string BuildDataHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
@@ -51,7 +51,10 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty)
}));
}
@@ -179,7 +182,7 @@ public class UserAccessCsvExportService
sb.AppendLine();
// Header
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
@@ -187,13 +190,16 @@ public class UserAccessCsvExportService
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
sb.AppendLine(string.Join(",", new[]
{
$"\"{entry.UserDisplayName}\"",
$"\"{entry.UserLogin}\"",
$"\"{entry.PermissionLevel}\"",
$"\"{entry.AccessType}\"",
$"\"{entry.GrantedThrough}\"",
$"\"{locations}\"",
$"\"{entry.LocationCount}\""
Csv(entry.UserDisplayName),
Csv(entry.UserLogin),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty),
Csv(locations),
Csv(entry.LocationCount.ToString())
}));
}
@@ -226,7 +232,10 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty)
}));
}
@@ -250,7 +250,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
@@ -301,7 +301,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
@@ -579,7 +579,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
if (entry.LocationCount == 1)
{
+19 -8
View File
@@ -6,8 +6,11 @@ namespace SharepointToolbox.Services;
public interface IStorageService
{
/// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API.
/// Returns a tree of StorageNode objects with aggregate size data.
/// Collects storage metrics for a site, capturing every storage source
/// 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>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
@@ -18,9 +21,6 @@ public interface IStorageService
/// <summary>
/// Enumerates files across all non-hidden document libraries in the site
/// 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>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx,
@@ -29,13 +29,24 @@ public interface IStorageService
/// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery.
/// This works around the StorageMetrics API returning zeros when the
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// by enumerating files per library via CamlQuery. Only re-runs against
/// document-library kinds (Library, HiddenLibrary, PreservationHold).
/// </summary>
Task BackfillZeroNodesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress,
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);
}
@@ -0,0 +1,23 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Resolves the targeted resource (List, Web, File, Folder) for a SharePoint system
/// group whose name encodes a GUID — Limited Access System Group For Web/List and
/// SharingLinks.{itemId}.{type}.{shareId}. Returns null when the target is missing
/// or unreachable (deleted item, access denied, etc.) — callers fall back to the
/// raw group name.
/// </summary>
public interface ISystemGroupTargetResolver
{
/// <summary>
/// Resolves the target for a classified system group. Cached per (ctx site, guid).
/// </summary>
Task<SystemGroupTarget?> ResolveAsync(
ClientContext ctx,
SystemGroupClassification classification,
CancellationToken ct);
}
@@ -11,6 +11,16 @@ namespace SharepointToolbox.Services;
/// </summary>
public class PermissionsService : IPermissionsService
{
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
public PermissionsService() : this(null) { }
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver)
{
_systemGroupResolver = systemGroupResolver;
}
/// <summary>
/// Detects the SharePoint server error raised when a RoleAssignment member
/// refers to a user that no longer resolves (orphaned Azure AD account).
@@ -336,9 +346,18 @@ public class PermissionsService : IPermissionsService
var member = ra.Member;
var loginName = member.LoginName ?? string.Empty;
var memberTitle = member.Title ?? string.Empty;
// Skip sharing links groups and limited access system groups
if (PermissionEntryHelper.IsSharingLinksGroup(loginName))
// Classify the member name. The bare "Limited Access System Group" is
// pure noise; drop it. The For-Web/For-List and SharingLinks variants
// are kept and enriched below with a resolved target URL.
var classification = PermissionEntryHelper.Classify(memberTitle);
if (classification.Kind == SystemGroupKind.None
&& PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName))
{
continue;
}
if (classification.Kind == SystemGroupKind.LimitedAccessBare)
continue;
// Collect and filter permission levels
@@ -362,19 +381,42 @@ public class PermissionsService : IPermissionsService
// Determine how the permission was granted
string grantedThrough = principalType == "SharePointGroup"
? $"SharePoint Group: {member.Title}"
? $"SharePoint Group: {memberTitle}"
: "Direct Permissions";
// Resolve system-group target (Limited Access For Web/List, SharingLinks)
string? targetUrl = null;
string? targetLabel = null;
string? sharingLinkType = null;
if (_systemGroupResolver is not null && classification.Kind != SystemGroupKind.None)
{
var target = await _systemGroupResolver.ResolveAsync(ctx, classification, ct);
if (target is not null)
{
targetUrl = target.Url;
targetLabel = target.Label;
sharingLinkType = target.LinkType;
}
else if (classification.Kind == SystemGroupKind.SharingLink)
{
// Target lookup failed (deleted item / no access) — still surface link type.
sharingLinkType = classification.LinkType;
}
}
entries.Add(new PermissionEntry(
ObjectType: objectType,
Title: title,
Url: url,
HasUniquePermissions: obj.HasUniqueRoleAssignments,
Users: member.Title ?? string.Empty,
Users: memberTitle,
UserLogins: loginName,
PermissionLevels: permLevels,
GrantedThrough: grantedThrough,
PrincipalType: principalType));
PrincipalType: principalType,
TargetUrl: targetUrl,
TargetLabel: targetLabel,
SharingLinkType: sharingLinkType));
}
return entries;
+502 -198
View File
@@ -7,27 +7,39 @@ namespace SharepointToolbox.Services;
/// <summary>
/// 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>
public class StorageService : IStorageService
{
/// <summary>
/// Collects per-library and per-folder storage metrics for a single
/// 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>
// PreservationHoldLibrary base template id.
private const int PreservationHoldTemplate = 851;
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> progress,
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();
// Load web-level metadata in one round-trip
ctx.Load(ctx.Web,
ctx.Load(web,
w => w.Title,
w => w.Url,
w => w.ServerRelativeUrl,
@@ -35,48 +47,326 @@ public class StorageService : IStorageService
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.BaseTemplate,
l => l.ItemCount,
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);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/');
string siteTitle = ctx.Web.Title;
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
string siteTitle = web.Title;
var lists = web.Lists.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;
foreach (var lib in libs)
foreach (var lib in docLibs)
{
ct.ThrowIfCancellationRequested();
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(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct);
siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0)
{
await CollectSubfoldersAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl,
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);
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>
/// Aggregates file counts and total sizes by extension across every
/// non-hidden document library on the site. Extensions are normalised to
/// lowercase; files without an extension roll up into a single bucket.
/// Normalizes a server-relative path for consistent prefix matching:
/// trims trailing slash, ensures single leading slash. SharePoint
/// inconsistently returns DirName with or without leading slash across
/// API surfaces, so the caller cannot rely on a canonical form.
/// </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(
ClientContext ctx,
IProgress<OperationProgress> progress,
@@ -84,7 +374,6 @@ public class StorageService : IStorageService
{
ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title,
@@ -97,7 +386,6 @@ public class StorageService : IStorageService
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0;
@@ -108,9 +396,10 @@ public class StorageService : IStorageService
progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Paginated CAML without a WHERE clause — WHERE on non-indexed fields
// (FSObjType) throws list-view threshold on libraries > 5,000 items.
// Filter files client-side via FSObjType.
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
// beyond 5000 items breaches the list view threshold. Page lightly,
// then second-pass load File.Length + Versions[*].Size so per-type
// totals include version bytes (matches per-library totals).
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
@@ -118,9 +407,8 @@ public class StorageService : IStorageService
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileLeafRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields>
<RowLimit Paged='TRUE'>5000</RowLimit>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
@@ -132,21 +420,40 @@ public class StorageService : IStorageService
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FSObjType"],
i => i["FileLeafRef"],
i => i["File_x0020_Size"]));
i => i["FileLeafRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string Name)>();
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 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))
fileSize = 0;
if (fileRows.Count > 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))
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
@@ -159,173 +466,173 @@ public class StorageService : IStorageService
while (items.ListItemCollectionPosition != null);
}
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes)
.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,
List lib,
StorageNode libNode,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
progress.Report(OperationProgress.Indeterminate(
$"Counting files: {libNode.Name}..."));
string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl);
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
// beyond the 5000-item view threshold throws "The attempted operation
// is prohibited because it exceeds the list view threshold". Paged
// retrieval without Where is unaffected by the threshold; we filter
// out folders client-side and skip File.Length access for them.
// Smaller page size because each row carries the full Versions collection.
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FSObjType"],
i => i["FileDirRef"]));
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)
{
if (item["FSObjType"]?.ToString() != "0") continue;
var dirRef = item["FileDirRef"]?.ToString() ?? string.Empty;
fileRows.Add((item, dirRef));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
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;
}
while (items.ListItemCollectionPosition != null);
// Post-pass rollup: each folder's totals become own-direct + sum of
// descendants. libNode ends up as total of every file in the tree.
RollupFolderTotals(libNode);
}
/// <summary>
/// Recursively rolls up direct-file totals into ancestor folders so each
/// node's reported size includes everything beneath it. Pre-condition: each
/// node holds only its directly-attributed files (no descendant amounts).
/// </summary>
private static void RollupFolderTotals(StorageNode node)
{
foreach (var child in node.Children)
{
RollupFolderTotals(child);
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)
{
// Find root-level library nodes that have any zero-valued nodes in their tree
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
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
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)
try
{
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);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Capture original TotalSizeBytes before reset — StorageMetrics.TotalSize
// includes version overhead, which cannot be rederived from a file scan
// (File_x0020_Size is the current stream size only).
var originalTotals = new Dictionary<StorageNode, long>();
CaptureTotals(libNode, originalTotals);
// 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
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' />
<FieldRef Name='File_x0020_Size' />
<FieldRef Name='SMTotalSize' />
<FieldRef Name='SMTotalFileStreamSize' />
</ViewFields>
<RowLimit Paged='TRUE'>5000</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FSObjType"],
i => i["FileDirRef"],
i => i["File_x0020_Size"],
i => i["SMTotalSize"],
i => i["SMTotalFileStreamSize"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
long streamSize = ParseLong(item["File_x0020_Size"]);
long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize"));
long smTotal = ParseLong(SafeGet(item, "SMTotalSize"));
// 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++;
}
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
// Restore original TotalSizeBytes where it exceeded the recomputed value.
// Preserves StorageMetrics.TotalSize for nodes whose original metrics were
// valid but SMTotalSize was missing on individual files.
foreach (var kv in originalTotals)
{
if (kv.Value > kv.Key.TotalSizeBytes)
kv.Key.TotalSizeBytes = kv.Value;
}
ctx.Load(ctx.Site, s => s.Usage);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return ctx.Site.Usage.Storage;
}
}
private static long ParseLong(object? value)
{
if (value == null) return 0;
return long.TryParse(value.ToString(), out long n) ? n : 0;
}
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)
catch
{
if (child.TotalFileCount == 0) return true;
if (HasZeroChild(child)) return true;
return 0L;
}
return false;
}
private static void ResetNodeCounts(StorageNode node)
{
node.TotalSizeBytes = 0;
node.TotalSizeBytes = 0;
node.FileStreamSizeBytes = 0;
node.TotalFileCount = 0;
node.TotalFileCount = 0;
foreach (var child in node.Children)
ResetNodeCounts(child);
}
@@ -345,8 +652,6 @@ public class StorageService : IStorageService
private static StorageNode? FindDeepestFolder(string fileDirRef,
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('/');
while (!string.IsNullOrEmpty(path))
{
@@ -359,7 +664,7 @@ public class StorageService : IStorageService
return null;
}
// -- Private helpers -----------------------------------------------------
// ── Library/folder loading helpers ──────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx,
@@ -368,6 +673,7 @@ public class StorageService : IStorageService
string siteTitle,
string library,
int indentLevel,
StorageNodeKind kind,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
@@ -393,6 +699,7 @@ public class StorageService : IStorageService
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle,
Library = library,
Kind = kind,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
@@ -411,15 +718,13 @@ public class StorageService : IStorageService
int maxDepth,
string siteTitle,
string library,
StorageNodeKind kind,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
if (currentDepth > maxDepth) return;
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)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
@@ -427,13 +732,12 @@ public class StorageService : IStorageService
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
ct: ct))
{
if (item["FSObjType"]?.ToString() != "1") continue; // folders only
if (item["FSObjType"]?.ToString() != "1") continue;
string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
// Skip SharePoint system folders
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("_", StringComparison.Ordinal))
continue;
@@ -447,14 +751,14 @@ public class StorageService : IStorageService
var childNode = await LoadFolderNodeAsync(
ctx, sub.ServerRelativeUrl, sub.Name,
siteTitle, library, currentDepth, progress, ct);
siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth)
{
await CollectSubfoldersAsync(
ctx, list, sub.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth,
siteTitle, library, progress, ct);
siteTitle, library, kind, progress, ct);
}
parentNode.Children.Add(childNode);
@@ -0,0 +1,205 @@
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using Serilog;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// CSOM-backed resolver that converts the GUID/identifier embedded in a SharePoint
/// system group name into a clickable target. Results are cached per
/// (site URL, guid) to avoid repeated round-trips when the same Limited Access
/// system group recurs on many objects.
///
/// Never throws — on any failure (not found, access denied, claims error), returns
/// <c>null</c> and the caller renders the raw group name.
/// </summary>
public class SystemGroupTargetResolver : ISystemGroupTargetResolver
{
private readonly Dictionary<string, SystemGroupTarget?> _cache =
new(StringComparer.OrdinalIgnoreCase);
public async Task<SystemGroupTarget?> ResolveAsync(
ClientContext ctx,
SystemGroupClassification classification,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = BuildCacheKey(ctx.Url, classification);
if (key is not null && _cache.TryGetValue(key, out var cached))
return cached;
SystemGroupTarget? result = null;
try
{
result = classification.Kind switch
{
SystemGroupKind.LimitedAccessWeb => await ResolveWebAsync(ctx, classification.WebId!.Value, ct),
SystemGroupKind.LimitedAccessList => await ResolveListAsync(ctx, classification.ListId!.Value, ct),
SystemGroupKind.SharingLink => await ResolveItemAsync(ctx, classification.ItemUniqueId!.Value, classification.LinkType, ct),
_ => null
};
}
catch (Exception ex)
{
Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}",
classification.Kind, ctx.Url, ex.Message);
}
if (key is not null)
_cache[key] = result;
return result;
}
private static string? BuildCacheKey(string siteUrl, SystemGroupClassification c) => c.Kind switch
{
SystemGroupKind.LimitedAccessWeb => $"{siteUrl}|web|{c.WebId}",
SystemGroupKind.LimitedAccessList => $"{siteUrl}|list|{c.ListId}",
SystemGroupKind.SharingLink => $"{siteUrl}|item|{c.ItemUniqueId}",
_ => null
};
private static async Task<SystemGroupTarget?> ResolveWebAsync(
ClientContext ctx, Guid webId, CancellationToken ct)
{
var web = ctx.Site.OpenWebById(webId);
ctx.Load(web, w => w.Title, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return new SystemGroupTarget(SystemGroupKind.LimitedAccessWeb, web.Title, web.Url);
}
private static async Task<SystemGroupTarget?> ResolveListAsync(
ClientContext ctx, Guid listId, CancellationToken ct)
{
var list = ctx.Web.Lists.GetById(listId);
ctx.Load(list, l => l.Title, l => l.DefaultViewUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var url = BuildAbsoluteUrl(ctx.Url, list.DefaultViewUrl);
return new SystemGroupTarget(SystemGroupKind.LimitedAccessList, list.Title, url);
}
private static async Task<SystemGroupTarget?> ResolveItemAsync(
ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
{
// 1. Try as file on current web (most sharing links target files).
try
{
var file = ctx.Web.GetFileById(itemUniqueId);
ctx.Load(file, f => f.Name, f => f.ServerRelativeUrl, f => f.Exists);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
if (file.Exists)
{
var url = BuildAbsoluteUrl(ctx.Url, file.ServerRelativeUrl);
return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType);
}
}
catch (ServerException) { /* fall through */ }
// 2. Try as folder on current web.
try
{
var folder = ctx.Web.GetFolderById(itemUniqueId);
ctx.Load(folder, f => f.Name, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl);
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType);
}
catch (ServerException) { /* fall through */ }
// 3. Search-index fallback — covers items moved to a different subsite or
// deleted recently (the index may lag the deletion by minutes/hours).
// Search is permission-trimmed, so this only returns hits the caller
// can still see; results carry a "(via index)" hint in the label.
return await TryResolveViaSearchAsync(ctx, itemUniqueId, linkType, ct);
}
/// <summary>
/// Queries the SharePoint search index for an item by its UniqueId. Returns
/// a target with the last-indexed path and a label prefix flagging that the
/// hit came from the index (so admins know the live lookup failed).
/// </summary>
private static async Task<SystemGroupTarget?> TryResolveViaSearchAsync(
ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
// KQL: UniqueId managed property. Braces around the GUID are how the
// SP search engine matches the canonical form for content unique ids.
var kq = new KeywordQuery(ctx)
{
QueryText = $"UniqueId:{{{itemUniqueId}}}",
RowLimit = 1,
TrimDuplicates = false
};
kq.SelectProperties.Add("Path");
kq.SelectProperties.Add("Title");
kq.SelectProperties.Add("FileExtension");
var executor = new SearchExecutor(ctx);
var result = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var table = result.Value
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table is null || table.RowCount == 0)
return null;
var row = ToDict(table.ResultRows.First());
var path = row.TryGetValue("Path", out var p) ? p?.ToString() : null;
var title = row.TryGetValue("Title", out var t) ? t?.ToString() : null;
if (string.IsNullOrEmpty(path))
return null;
// Derive a leaf name if Title is empty (folders often have no Title).
var leaf = !string.IsNullOrWhiteSpace(title)
? title!
: Uri.UnescapeDataString(path.TrimEnd('/').Split('/').Last());
return new SystemGroupTarget(
SystemGroupKind.SharingLink,
$"{leaf} (via index)",
path,
linkType);
}
catch (Exception ex)
{
Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}",
itemUniqueId, ctx.Url, ex.Message);
return null;
}
}
/// <summary>
/// CSOM SearchExecutor returns ResultRows as either generic Dictionary or legacy
/// IDictionary depending on CSOM version — normalise to a single shape.
/// </summary>
private static IDictionary<string, object> ToDict(object rawRow)
{
if (rawRow is IDictionary<string, object> generic)
return generic;
var dict = new Dictionary<string, object>();
if (rawRow is System.Collections.IDictionary legacy)
{
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
return dict;
}
private static string BuildAbsoluteUrl(string contextUrl, string? serverRelative)
{
if (string.IsNullOrEmpty(serverRelative))
return contextUrl;
if (Uri.TryCreate(serverRelative, UriKind.Absolute, out _))
return serverRelative;
var uri = new Uri(contextUrl);
return $"{uri.Scheme}://{uri.Host}{serverRelative}";
}
}
@@ -136,7 +136,10 @@ public class UserAccessAuditService : IUserAccessAuditService
AccessType: accessType,
GrantedThrough: entry.GrantedThrough,
IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel),
IsExternalUser: PermissionEntryHelper.IsExternalUser(login));
IsExternalUser: PermissionEntryHelper.IsExternalUser(login),
TargetUrl: entry.TargetUrl,
TargetLabel: entry.TargetLabel,
SharingLinkType: entry.SharingLinkType);
}
}
}
@@ -8,6 +8,7 @@
<UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed>
<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 -->
<NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup>
@@ -43,6 +44,8 @@
<EmbeddedResource Include="Resources\bulk_add_members.csv" />
<EmbeddedResource Include="Resources\bulk_create_sites.csv" />
<EmbeddedResource Include="Resources\folder_structure.csv" />
<Resource Include="Resources\SPToolbox-logo-ico.png" />
<Resource Include="Resources\SPToolbox-logo.ico" />
</ItemGroup>
<ItemGroup>
@@ -51,6 +54,14 @@
<PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
<!-- Override Kiota transitive deps to patch GHSA-7j59-v9qr-6fq9 (NU1903 in <1.20.0) -->
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.83.3" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
@@ -45,6 +45,15 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _mergePermissions;
/// <summary>
/// When true, the HTML report hides the raw "SharePoint Group: SharingLinks.{guid}…"
/// / "Limited Access System Group For Web|List {guid}" text for rows where the
/// system-group target was resolved — only the link-type badge and target link are shown.
/// Rows whose target could not be resolved still display the raw name as a fallback.
/// </summary>
[ObservableProperty]
private bool _hideSystemGroupRaw = true;
[ObservableProperty]
private bool _includeSubsites;
@@ -498,9 +507,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
}
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw);
else
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -41,6 +41,39 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty]
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>
[ObservableProperty] private int _splitModeIndex;
/// <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();
public ObservableCollection<StorageNode> Results
{
@@ -126,15 +163,36 @@ public partial class StorageViewModel : FeatureViewModelBase
}
// ── 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>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
/// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
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>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
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>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
/// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
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;
@@ -142,6 +200,7 @@ public partial class StorageViewModel : FeatureViewModelBase
{
OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryRecycleBinSize));
OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults));
}
@@ -220,10 +279,15 @@ public partial class StorageViewModel : FeatureViewModelBase
var options = new StorageScanOptions(
PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth);
FolderDepth: FolderDepth,
IncludeHiddenLibraries: ScanHiddenLibraries,
IncludePreservationHold: ScanPreservationHold,
IncludeListAttachments: ScanListAttachments,
IncludeRecycleBin: ScanRecycleBin);
var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>();
long spoReportedTotal = 0;
var autoOwnership = await IsAutoTakeOwnershipEnabled();
@@ -273,6 +337,8 @@ public partial class StorageViewModel : FeatureViewModelBase
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics);
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
}
// Flatten tree for DataGrid display
@@ -291,20 +357,113 @@ public partial class StorageViewModel : FeatureViewModelBase
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<StorageNode>(flat);
_allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
});
}
else
{
Results = new ObservableCollection<StorageNode>(flat);
_allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
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)
{
_currentProfile = profile;
_allNodes = new List<StorageNode>();
SpoReportedTotalSize = 0;
Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile));
@@ -96,3 +96,30 @@ public class ListToStringConverter : IValueConverter
=> 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();
}
@@ -91,7 +91,9 @@
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.merge.permissions]}"
IsChecked="{Binding MergePermissions}" />
IsChecked="{Binding MergePermissions}" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.hide.system.group.raw]}"
IsChecked="{Binding HideSystemGroupRaw}" />
</StackPanel>
</GroupBox>
+49 -1
View File
@@ -33,6 +33,43 @@
</StackPanel>
</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 -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
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 SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</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 SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</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>
</Border>
@@ -140,6 +185,9 @@
</DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
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]}"
Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" />