Compare commits

...

10 Commits

64 changed files with 3176 additions and 507 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -48,6 +48,54 @@ public class CsvExportServiceTests
Assert.Contains("Object", csv); 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] [Fact]
public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations() public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations()
{ {
@@ -156,6 +156,146 @@ public class HtmlExportServiceTests
Assert.Contains("getAttribute('data-group-target')", html); 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] [Fact]
public void BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill() public void BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill()
{ {
@@ -86,20 +86,15 @@ public class UserAccessCsvExportServiceTests
var svc = new UserAccessCsvExportService(); var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry }); var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry });
// Find the header row and count its quoted comma-separated fields // Header: Site, Object Type, Object, URL, Permission Level, Access Type,
// Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through" // Granted Through, TargetLabel, TargetUrl, SharingLinkType → 10 fields.
// That is 7 fields.
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); 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\"")); var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\""));
Assert.NotNull(headerLine); Assert.NotNull(headerLine);
// Count comma-separated quoted fields: split by "," boundary
var fields = CountCsvFields(headerLine!); var fields = CountCsvFields(headerLine!);
Assert.Equal(7, fields); Assert.Equal(10, fields);
} }
// ── Test 5: WriteSingleFileAsync includes entries for all users ─────────── // ── Test 5: WriteSingleFileAsync includes entries for all users ───────────
@@ -190,8 +185,8 @@ public class UserAccessCsvExportServiceTests
await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true); await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true);
var content = await File.ReadAllTextAsync(tmpFile); var content = await File.ReadAllTextAsync(tmpFile);
// Header must contain consolidated columns // Header must contain consolidated columns (now includes Target* + SharingLinkType)
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content); 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 // Alice's two entries merged — locations column contains both site titles
Assert.Contains("Contoso", content); Assert.Contains("Contoso", content);
@@ -3,7 +3,8 @@ using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Tests.Services; namespace SharepointToolbox.Tests.Services;
/// <summary> /// <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. /// Pure static logic — runs immediately without stubs.
/// </summary> /// </summary>
public class PermissionEntryClassificationTests public class PermissionEntryClassificationTests
@@ -13,7 +14,6 @@ public class PermissionEntryClassificationTests
[Fact] [Fact]
public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue() 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")); Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com"));
} }
@@ -28,8 +28,6 @@ public class PermissionEntryClassificationTests
[Fact] [Fact]
public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess() 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" }); var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" });
Assert.Empty(result); Assert.Empty(result);
} }
@@ -41,23 +39,100 @@ public class PermissionEntryClassificationTests
Assert.Equal(new[] { "Contribute" }, result); Assert.Equal(new[] { "Contribute" }, result);
} }
// ── IsSharingLinksGroup ──────────────────────────────────────────────────── // ── IsBareLimitedAccessSystemGroup ─────────────────────────────────────────
[Fact] [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] [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] [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 }; var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes); Assert.Equal(2000L, node.VersionSizeBytes);
} }
[Fact]
public void StorageNode_DefaultKind_IsLibrary()
{
var node = new StorageNode();
Assert.Equal(StorageNodeKind.Library, node.Kind);
}
[Fact]
public void StorageScanOptions_DefaultIncludeFlags_AreAllTrue()
{
var opts = new StorageScanOptions();
Assert.True(opts.IncludeHiddenLibraries);
Assert.True(opts.IncludePreservationHold);
Assert.True(opts.IncludeListAttachments);
Assert.True(opts.IncludeRecycleBin);
}
} }
@@ -0,0 +1,141 @@
using System.Reflection;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Verifies that the report filter flags (Show*) project the raw scan output
/// (<c>_allNodes</c>) into <c>Results</c> correctly: hiding a kind drops the
/// matching root nodes plus their entire subtree, preserving DFS ordering.
/// </summary>
public class StorageViewModelFilterTests
{
public StorageViewModelFilterTests() => WeakReferenceMessenger.Default.Reset();
private static StorageViewModel CreateVm()
{
var vm = new StorageViewModel(
new Mock<IStorageService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance);
vm.SetCurrentProfile(new TenantProfile { Name = "T", TenantUrl = "https://t", ClientId = "c" });
return vm;
}
/// <summary>Inject a flat node list straight into the private _allNodes field
/// and trigger a rebuild via toggling a Show flag.</summary>
private static void Seed(StorageViewModel vm, List<StorageNode> flat)
{
var field = typeof(StorageViewModel).GetField("_allNodes",
BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(vm, flat);
// Toggle off+on to force RebuildFilteredResults().
vm.ShowLibraries = false;
vm.ShowLibraries = true;
}
private static List<StorageNode> MakeMixedTree() => new()
{
new() { Name = "Documents", Kind = StorageNodeKind.Library, IndentLevel = 0, TotalSizeBytes = 100 },
new() { Name = "Sub", Kind = StorageNodeKind.Library, IndentLevel = 1, TotalSizeBytes = 50 },
new() { Name = "Preserve", Kind = StorageNodeKind.PreservationHold, IndentLevel = 0, TotalSizeBytes = 200 },
new() { Name = "[Recycle]", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0, TotalSizeBytes = 300 },
new() { Name = "[Attach] L",Kind = StorageNodeKind.ListAttachments, IndentLevel = 0, TotalSizeBytes = 75 },
};
[Fact]
public void AllShowFlagsTrue_AllNodesAppear()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
Assert.Equal(5, vm.Results.Count);
}
[Fact]
public void HideRecycleBin_RemovesOnlyRecycleNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowRecycleBin = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.RecycleBin);
Assert.Equal(4, vm.Results.Count);
}
[Fact]
public void HidePreservationHold_RemovesPreservationNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowPreservationHold = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.PreservationHold);
}
[Fact]
public void HideLibraries_DropsLibraryRootAndItsChildren()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
// "Documents" + "Sub" both gone — Sub's subtree dropped with its parent root.
Assert.DoesNotContain(vm.Results, n => n.Name == "Documents");
Assert.DoesNotContain(vm.Results, n => n.Name == "Sub");
}
[Fact]
public void CombineRecycleBinStages_True_MergesStagesIntoSingleRow()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100, FileStreamSizeBytes = 100, TotalFileCount = 3 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250, FileStreamSizeBytes = 250, TotalFileCount = 7 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = true;
var bins = vm.Results.Where(n => n.Kind == StorageNodeKind.RecycleBin).ToList();
Assert.Single(bins);
Assert.Equal(350, bins[0].TotalSizeBytes);
Assert.Equal(10, bins[0].TotalFileCount);
}
[Fact]
public void CombineRecycleBinStages_False_KeepsSeparateRows()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = false;
Assert.Equal(2, vm.Results.Count(n => n.Kind == StorageNodeKind.RecycleBin));
}
[Fact]
public void HideAll_LeavesEmptyResults()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
vm.ShowHiddenLibraries = false;
vm.ShowPreservationHold = false;
vm.ShowListAttachments = false;
vm.ShowRecycleBin = false;
vm.ShowSubsites = false;
Assert.Empty(vm.Results);
}
}
+1
View File
@@ -16,6 +16,7 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" /> <conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" /> <conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" /> <conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:StorageKindConverter x:Key="StorageKindConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" /> <conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="HorizontalAlignment" Value="Right" />
+1
View File
@@ -149,6 +149,7 @@ public partial class App : Application
services.AddTransient<VersionCleanupView>(); services.AddTransient<VersionCleanupView>();
// Phase 2: Permissions // Phase 2: Permissions
services.AddTransient<ISystemGroupTargetResolver, SystemGroupTargetResolver>();
services.AddTransient<IPermissionsService, PermissionsService>(); services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>(); services.AddTransient<ISiteListService, SiteListService>();
services.AddTransient<CsvExportService>(); services.AddTransient<CsvExportService>();
@@ -50,7 +50,10 @@ public static class PermissionConsolidator
first.GrantedThrough, first.GrantedThrough,
first.IsHighPrivilege, first.IsHighPrivilege,
first.IsExternalUser, first.IsExternalUser,
locations); locations,
first.TargetUrl,
first.TargetLabel,
first.SharingLinkType);
}) })
.OrderBy(c => c.UserLogin) .OrderBy(c => c.UserLogin)
.ThenBy(c => c.PermissionLevel) .ThenBy(c => c.PermissionLevel)
@@ -1,10 +1,56 @@
using System.Text.RegularExpressions;
namespace SharepointToolbox.Core.Helpers; 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> /// <summary>
/// Pure static helpers for classifying SharePoint permission entries. /// Pure static helpers for classifying SharePoint permission entries.
/// </summary> /// </summary>
public static class PermissionEntryHelper 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> /// <summary>
/// Returns true when the login name is a B2B guest (contains #EXT#). /// Returns true when the login name is a B2B guest (contains #EXT#).
/// </summary> /// </summary>
@@ -21,10 +67,45 @@ public static class PermissionEntryHelper
.ToList(); .ToList();
/// <summary> /// <summary>
/// Returns true when the login name represents an internal sharing-link group /// Returns true when the supplied name/login is the bare "Limited Access System Group"
/// or the "Limited Access System Group" pseudo-principal. /// pseudo-principal — the noise entry with no embedded GUID. The
/// per-Web/List and SharingLinks variants are NOT filtered: they are enriched.
/// </summary> /// </summary>
public static bool IsSharingLinksGroup(string loginName) => public static bool IsBareLimitedAccessSystemGroup(string name) =>
loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase) name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
|| loginName.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, string GrantedThrough,
bool IsHighPrivilege, bool IsHighPrivilege,
bool IsExternalUser, bool IsExternalUser,
IReadOnlyList<LocationInfo> Locations IReadOnlyList<LocationInfo> Locations,
string? TargetUrl = null,
string? TargetLabel = null,
string? SharingLinkType = null
) )
{ {
/// <summary>Convenience count — equals Locations.Count.</summary> /// <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 PermissionLevels, // Semicolon-joined role names (Limited Access already removed)
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>" string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType, // "SharePointGroup" | "User" | "External User" 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 PermissionLevels => Inner.PermissionLevels;
public string GrantedThrough => Inner.GrantedThrough; public string GrantedThrough => Inner.GrantedThrough;
public string PrincipalType => Inner.PrincipalType; public string PrincipalType => Inner.PrincipalType;
public string? TargetUrl => Inner.TargetUrl;
public string? TargetLabel => Inner.TargetLabel;
public string? SharingLinkType => Inner.SharingLinkType;
public SimplifiedPermissionEntry(PermissionEntry entry) public SimplifiedPermissionEntry(PermissionEntry entry)
{ {
@@ -12,5 +12,6 @@ public class StorageNode
public long TotalFileCount { get; set; } public long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; } public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; } public int IndentLevel { get; set; }
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
public List<StorageNode> Children { get; set; } = new(); public List<StorageNode> Children { get; set; } = new();
} }
@@ -0,0 +1,15 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classification used to filter storage report output. Every node is captured
/// during a scan; the report user picks which categories appear.
/// </summary>
public enum StorageNodeKind
{
Library,
HiddenLibrary,
PreservationHold,
ListAttachments,
RecycleBin,
Subsite
}
@@ -3,5 +3,9 @@ namespace SharepointToolbox.Core.Models;
public record StorageScanOptions( public record StorageScanOptions(
bool PerLibrary = true, bool PerLibrary = true,
bool IncludeSubsites = false, bool IncludeSubsites = false,
int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels int FolderDepth = 0, // 0 = library root only; >0 = recurse N levels
bool IncludeHiddenLibraries = true,
bool IncludePreservationHold = true,
bool IncludeListAttachments = true,
bool IncludeRecycleBin = true
); );
@@ -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 AccessType AccessType, // Direct | Group | Inherited
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc. string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc.
bool IsHighPrivilege, // True for Full Control, Site Collection Administrator 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.col.share" xml:space="preserve"><value>Part du total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Type</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Bibliothèque</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Bibliothèque masquée</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Sous-site</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Sources analysées</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Afficher dans le rapport</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Sous-sites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combiner les corbeilles (afficher le total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>Total rapporté par SPO : </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Corbeille : </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data>
@@ -560,6 +582,9 @@ Cette action est irréversible.</value>
<!-- Phase 16: Report Consolidation Toggle --> <!-- Phase 16: Report Consolidation Toggle -->
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data> <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.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>
<data name="chk.exclude.sharing.links" xml:space="preserve"><value>Exclure les liens de partage</value></data>
<data name="chk.exclude.system.groups" xml:space="preserve"><value>Exclure les groupes système (Limited Access)</value></data>
<!-- Phase 19: App Registration & Removal --> <!-- Phase 19: App Registration & Removal -->
<data name="profile.register" xml:space="preserve"><value>Enregistrer l'app</value></data> <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> <data name="profile.remove" xml:space="preserve"><value>Supprimer l'app</value></data>
@@ -664,6 +689,7 @@ Cette action est irréversible.</value>
<data name="report.text.generated" xml:space="preserve"><value>G&#233;n&#233;r&#233;</value></data> <data name="report.text.generated" xml:space="preserve"><value>G&#233;n&#233;r&#233;</value></data>
<data name="report.text.generated_colon" xml:space="preserve"><value>G&#233;n&#233;r&#233; :</value></data> <data name="report.text.generated_colon" xml:space="preserve"><value>G&#233;n&#233;r&#233; :</value></data>
<data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data> <data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data>
<data name="report.text.empty_group" xml:space="preserve"><value>Groupe vide</value></data>
<data name="report.text.link" xml:space="preserve"><value>Lien</value></data> <data name="report.text.link" xml:space="preserve"><value>Lien</value></data>
<data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data> <data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data>
<data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data> <data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data>
@@ -761,4 +787,84 @@ Cette action est irréversible.</value>
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data> <data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data> <data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data> <data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
<data name="report.text.entries_unit" xml:space="preserve"><value>entr&#233;es</value></data>
<!-- Textes d'aide / boutons info -->
<data name="help.perm.simplified.title" xml:space="preserve"><value>Mode simplifié</value></data>
<data name="help.perm.simplified.body" xml:space="preserve"><value>Regroupe les permissions brutes SharePoint en libellés lisibles (Propriétaire, Éditeur, Contributeur, Lecteur, Lecture seule) et colore les lignes par niveau de risque. Utile pour un aperçu rapide de la sécurité sans jargon technique.</value></data>
<data name="help.perm.merge.title" xml:space="preserve"><value>Fusionner les permissions</value></data>
<data name="help.perm.merge.body" xml:space="preserve"><value>Lorsqu'activé, les entrées de permission multiples pour le même utilisateur ou groupe sont regroupées en une seule ligne dans l'export, réduisant la taille du rapport. Désactivez pour voir chaque permission individuellement.</value></data>
<data name="help.perm.hidesys.title" xml:space="preserve"><value>Masquer les groupes système</value></data>
<data name="help.perm.hidesys.body" xml:space="preserve"><value>Supprime les groupes système créés automatiquement par SharePoint (ex. « Excel Services Viewers », groupes « SharingLinks.* »). Ces groupes sont gérés en interne par SharePoint et ne sont généralement pas pertinents pour les audits d'accès.</value></data>
<data name="help.perm.excl.sharing.title" xml:space="preserve"><value>Exclure les liens de partage</value></data>
<data name="help.perm.excl.sharing.body" xml:space="preserve"><value>Supprime les entrées de lien de partage des résultats et des exports (ex. « Tout le monde avec le lien », liens à l'échelle de l'organisation). Utile pour ne conserver que les permissions directes des utilisateurs et groupes.</value></data>
<data name="help.perm.excl.system.title" xml:space="preserve"><value>Exclure les groupes système (Limited Access)</value></data>
<data name="help.perm.excl.system.body" xml:space="preserve"><value>Supprime les entrées « Limited Access System Group For Web/List » des résultats et des exports. SharePoint crée ces groupes automatiquement lorsqu'un utilisateur a accès à un élément spécifique ; ils sont rarement pertinents pour les audits d'accès.</value></data>
<data name="help.perm.inherited.title" xml:space="preserve"><value>Inclure les permissions héritées</value></data>
<data name="help.perm.inherited.body" xml:space="preserve"><value>Par défaut, seuls les objets avec des permissions uniques (rompues) sont affichés. Activez pour inclure les objets qui héritent les permissions d'un parent et obtenir une vue complète des accès.</value></data>
<data name="help.perm.splitmode.title" xml:space="preserve"><value>Mode de fractionnement de l'export</value></data>
<data name="help.perm.splitmode.body" xml:space="preserve"><value>Fichier unique : tous les résultats dans un seul fichier CSV ou HTML.
Fractionner par site : crée un fichier séparé pour chaque collection de sites. Utile pour les grandes tenances multi-sites.</value></data>
<data name="help.search.title" xml:space="preserve"><value>Recherche de fichiers KQL</value></data>
<data name="help.search.body" xml:space="preserve"><value>Recherche des fichiers dans vos sites SharePoint via KQL (Keyword Query Language). Le champ mot-clé est optionnel — laissez-le vide pour retourner tous les fichiers correspondant aux filtres actifs. Combinez les filtres de date, auteur et bibliothèque pour affiner les résultats.</value></data>
<data name="help.search.regex.title" xml:space="preserve"><value>Filtre regex sur le nom de fichier</value></data>
<data name="help.search.regex.body" xml:space="preserve"><value>Filtre les résultats côté client avec une expression régulière .NET appliquée aux noms de fichiers. Exemple : \.pdf$ correspond uniquement aux PDF. Laissez vide pour ignorer ce filtre. L'expression est insensible à la casse.</value></data>
<data name="help.versions.policy.title" xml:space="preserve"><value>Politique de nettoyage des versions</value></data>
<data name="help.versions.policy.body" xml:space="preserve"><value>Supprime définitivement les anciennes versions de documents des bibliothèques SharePoint. Seules les N versions les plus récentes sont conservées — les versions plus anciennes sont supprimées de façon permanente et ne peuvent pas être récupérées. Effectuez d'abord une analyse pour prévisualiser les suppressions.</value></data>
<data name="help.versions.keepfirst.title" xml:space="preserve"><value>Conserver la première version</value></data>
<data name="help.versions.keepfirst.body" xml:space="preserve"><value>Conserve toujours la version 1.0 (originale) de chaque document, indépendamment du paramètre « Conserver les N dernières ». Utile pour maintenir une trace de l'état initial du document.</value></data>
<data name="help.versions.confirm.title" xml:space="preserve"><value>Confirmer avant suppression</value></data>
<data name="help.versions.confirm.body" xml:space="preserve"><value>Lorsqu'activé, une boîte de dialogue de confirmation apparaît pour chaque fichier avant la suppression des versions. Décochez pour un traitement en lot sans intervention.</value></data>
<data name="help.dup.criteria.title" xml:space="preserve"><value>Critères de détection des doublons</value></data>
<data name="help.dup.criteria.body" xml:space="preserve"><value>Deux éléments sont identifiés comme doublons quand leurs noms correspondent ET que tous les critères supplémentaires cochés correspondent également. Plus de critères cochés = moins de groupes, mais plus précis. Nom uniquement : trouve les fichiers avec le même nom, quel que soit leur contenu.</value></data>
<data name="help.transfer.incsource.title" xml:space="preserve"><value>Inclure le dossier source</value></data>
<data name="help.transfer.incsource.body" xml:space="preserve"><value>Lorsqu'activé, le dossier source lui-même est recréé à la destination (ex. transférer « Rapports » crée un dossier « Rapports/ » à la cible). Lorsque désactivé, seul le contenu du dossier est transféré — utile pour fusionner du contenu dans un dossier existant.</value></data>
<data name="help.transfer.copycontent.title" xml:space="preserve"><value>Copier uniquement le contenu</value></data>
<data name="help.transfer.copycontent.body" xml:space="preserve"><value>Lorsqu'activé, seuls les fichiers et sous-dossiers à l'intérieur du dossier sélectionné sont transférés — le dossier lui-même n'est pas recréé à la destination.</value></data>
<data name="help.transfer.conflict.title" xml:space="preserve"><value>Politique de conflit de fichiers</value></data>
<data name="help.transfer.conflict.body" xml:space="preserve"><value>Définit ce qui se passe quand un fichier du même nom existe déjà à la destination :
• Ignorer — laisser le fichier destination inchangé.
• Écraser — remplacer le fichier destination par le fichier source.
• Renommer — conserver les deux en ajoutant un suffixe numérique au fichier transféré.</value></data>
<data name="help.bulkmembers.title" xml:space="preserve"><value>Ajout de membres en masse — Format CSV</value></data>
<data name="help.bulkmembers.body" xml:space="preserve"><value>Le fichier CSV doit contenir ces colonnes (en-têtes obligatoires, ordre libre) :
• GroupName — le nom exact du groupe SharePoint
• Email — l'adresse e-mail de l'utilisateur
• Role — Member, Owner ou Visitor
Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli.</value></data>
<data name="help.bulksites.title" xml:space="preserve"><value>Création de sites en masse — Format CSV</value></data>
<data name="help.bulksites.body" xml:space="preserve"><value>Le fichier CSV doit contenir ces colonnes :
• Name — le nom d'affichage du nouveau site
• Alias — alias d'URL (sans espaces ; fait partie de l'URL du site)
• Type — TeamSite ou CommunicationSite
• Owners — liste d'adresses e-mail des propriétaires séparées par des virgules
Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli.</value></data>
<data name="help.folderstruct.title" xml:space="preserve"><value>Créer une structure de dossiers — Format CSV</value></data>
<data name="help.folderstruct.body" xml:space="preserve"><value>Crée une hiérarchie de dossiers dans une bibliothèque SharePoint à partir d'un fichier CSV. Chaque ligne définit un chemin avec jusqu'à 4 niveaux (Level1Level4). Laissez les colonnes des niveaux inférieurs vides pour des chemins plus courts.
Exemple : Contrats | 2024 | T1 | (vide)
Crée : Bibliothèque / Contrats / 2024 / T1</value></data>
<data name="help.templates.capture.title" xml:space="preserve"><value>Capturer un modèle de site</value></data>
<data name="help.templates.capture.body" xml:space="preserve"><value>Enregistre la structure du site sélectionné (bibliothèques, dossiers, permissions, paramètres et logo) comme modèle réutilisable stocké localement. Le site source n'est pas modifié.
Sélectionnez les éléments à capturer avec les cases à cocher ci-dessus.</value></data>
<data name="help.templates.apply.title" xml:space="preserve"><value>Appliquer le modèle à un nouveau site</value></data>
<data name="help.templates.apply.body" xml:space="preserve"><value>Crée un nouveau site SharePoint et reproduit la structure du modèle sélectionné — bibliothèques, dossiers, permissions, paramètres et logo. Le modèle source et le site d'origine ne sont pas affectés.
Fournissez un nom d'affichage et un alias d'URL avant de cliquer sur Appliquer.</value></data>
<data name="help.audit.mode.title" xml:space="preserve"><value>Mode Recherche vs Mode Navigation</value></data>
<data name="help.audit.mode.body" xml:space="preserve"><value>Mode Recherche : tapez un nom ou e-mail pour trouver un utilisateur via Azure AD. Les résultats apparaissent dans une liste — cliquez pour sélectionner.
Mode Navigation : charge tous les utilisateurs du répertoire de la tenant. Utilisez le filtre pour trouver un utilisateur, puis double-cliquez pour l'ajouter à l'audit.</value></data>
<data name="help.audit.vs.perms.title" xml:space="preserve"><value>Audit d'accès vs Audit des permissions</value></data>
<data name="help.audit.vs.perms.body" xml:space="preserve"><value>L'onglet Permissions analyse les objets (bibliothèques, dossiers, éléments) pour montrer qui y a accès.
Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et il trouve chaque objet auquel ils peuvent accéder — y compris via des groupes SharePoint ou Active Directory.</value></data>
<data name="help.storage.hidden.title" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="help.storage.hidden.body" xml:space="preserve"><value>Analyse les bibliothèques SharePoint cachées dans la navigation normale du site (ex. Site Assets, Style Library, Form Templates). Elles peuvent consommer beaucoup d'espace et sont souvent oubliées dans les audits de routine.</value></data>
<data name="help.storage.preservation.title" xml:space="preserve"><value>Bibliothèque de conservation</value></data>
<data name="help.storage.preservation.body" xml:space="preserve"><value>Bibliothèque SharePoint cachée qui stocke les versions de documents modifiés ou supprimés pendant qu'une politique de rétention Microsoft Purview / Microsoft 365 Compliance est active. Elle peut croître considérablement sans être visible pour les utilisateurs du site.</value></data>
</root> </root>
+106
View File
@@ -306,6 +306,28 @@ This cannot be undone.</value>
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Kind</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Library</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Hidden Library</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Subsite</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Scan Sources</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Show in Report</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Subsites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combine Recycle Bin Stages (show total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>SPO reported total: </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Recycle Bin: </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
@@ -560,6 +582,9 @@ This cannot be undone.</value>
<!-- Phase 16: Report Consolidation Toggle --> <!-- Phase 16: Report Consolidation Toggle -->
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data> <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.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>
<data name="chk.exclude.sharing.links" xml:space="preserve"><value>Exclude sharing links</value></data>
<data name="chk.exclude.system.groups" xml:space="preserve"><value>Exclude system groups (Limited Access)</value></data>
<!-- Phase 19: App Registration & Removal --> <!-- Phase 19: App Registration & Removal -->
<data name="profile.register" xml:space="preserve"><value>Register App</value></data> <data name="profile.register" xml:space="preserve"><value>Register App</value></data>
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data> <data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
@@ -664,6 +689,7 @@ This cannot be undone.</value>
<data name="report.text.generated" xml:space="preserve"><value>Generated</value></data> <data name="report.text.generated" xml:space="preserve"><value>Generated</value></data>
<data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data> <data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data>
<data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data> <data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data>
<data name="report.text.empty_group" xml:space="preserve"><value>Empty group</value></data>
<data name="report.text.link" xml:space="preserve"><value>Link</value></data> <data name="report.text.link" xml:space="preserve"><value>Link</value></data>
<data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data> <data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data>
<data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data> <data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data>
@@ -761,4 +787,84 @@ This cannot be undone.</value>
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data> <data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data> <data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data> <data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
<data name="report.text.entries_unit" xml:space="preserve"><value>entries</value></data>
<!-- Help / Info button strings -->
<data name="help.perm.simplified.title" xml:space="preserve"><value>Simplified Permissions Mode</value></data>
<data name="help.perm.simplified.body" xml:space="preserve"><value>Groups raw SharePoint permissions into readable labels (Owner, Editor, Contributor, Reader, View-Only) and color-codes rows by risk level. Useful for a quick security overview without permission-level jargon.</value></data>
<data name="help.perm.merge.title" xml:space="preserve"><value>Merge Permissions</value></data>
<data name="help.perm.merge.body" xml:space="preserve"><value>When enabled, multiple permission entries for the same user or group are consolidated into a single row in the export, reducing report size. Disable to see every individual permission assignment separately.</value></data>
<data name="help.perm.hidesys.title" xml:space="preserve"><value>Hide System Groups</value></data>
<data name="help.perm.hidesys.body" xml:space="preserve"><value>Removes automatically-created SharePoint system groups from results (e.g. "Excel Services Viewers", "SharingLinks.*" groups). These groups are managed internally by SharePoint and are typically not relevant for user access audits.</value></data>
<data name="help.perm.excl.sharing.title" xml:space="preserve"><value>Exclude Sharing Links</value></data>
<data name="help.perm.excl.sharing.body" xml:space="preserve"><value>Removes sharing link entries from results and exports (e.g. "Anyone with the link", organisation-wide links). Useful when you only care about direct user and group permissions.</value></data>
<data name="help.perm.excl.system.title" xml:space="preserve"><value>Exclude System Groups (Limited Access)</value></data>
<data name="help.perm.excl.system.body" xml:space="preserve"><value>Removes "Limited Access System Group For Web/List" entries from results and exports. SharePoint creates these automatically when a user has item-level access; they are rarely relevant for user access audits.</value></data>
<data name="help.perm.inherited.title" xml:space="preserve"><value>Include Inherited Permissions</value></data>
<data name="help.perm.inherited.body" xml:space="preserve"><value>By default only objects with unique (broken) permissions are reported. Enable this to also include objects that inherit permissions from a parent, giving a complete picture of who can access every item.</value></data>
<data name="help.perm.splitmode.title" xml:space="preserve"><value>Export Split Mode</value></data>
<data name="help.perm.splitmode.body" xml:space="preserve"><value>Single File: all results are saved in one CSV or HTML file.
Split by Site: creates a separate file for each site collection. Useful when auditing large multi-site tenants to keep individual files manageable.</value></data>
<data name="help.search.title" xml:space="preserve"><value>KQL File Search</value></data>
<data name="help.search.body" xml:space="preserve"><value>Searches files across your SharePoint sites using KQL (Keyword Query Language). The keyword field is optional — leave it empty to return all files matching only the active filters. Combine date range, author, and library filters to narrow results.</value></data>
<data name="help.search.regex.title" xml:space="preserve"><value>Filename Regex Filter</value></data>
<data name="help.search.regex.body" xml:space="preserve"><value>Post-filters results client-side using a .NET regular expression matched against file names. Example: \.pdf$ matches only PDF files. Leave blank to skip this filter. The expression is case-insensitive.</value></data>
<data name="help.versions.policy.title" xml:space="preserve"><value>Version Cleanup Policy</value></data>
<data name="help.versions.policy.body" xml:space="preserve"><value>Permanently deletes old document versions from SharePoint libraries. Only the N most recent versions are kept — older ones are removed permanently and cannot be recovered. Run a preview scan first to see what will be deleted.</value></data>
<data name="help.versions.keepfirst.title" xml:space="preserve"><value>Keep First Version</value></data>
<data name="help.versions.keepfirst.body" xml:space="preserve"><value>Always preserves version 1.0 (the original) of each document, regardless of the "Keep Last N" setting. Useful to maintain an audit trail of a document's initial state.</value></data>
<data name="help.versions.confirm.title" xml:space="preserve"><value>Confirm Before Delete</value></data>
<data name="help.versions.confirm.body" xml:space="preserve"><value>When enabled, a confirmation dialog appears for each file before its versions are deleted. Uncheck for unattended batch processing.</value></data>
<data name="help.dup.criteria.title" xml:space="preserve"><value>Duplicate Matching Criteria</value></data>
<data name="help.dup.criteria.body" xml:space="preserve"><value>Two items are flagged as duplicates when their names match AND all checked additional criteria also match. More criteria checked = fewer groups, but more precise matches. Using name only finds files with the same filename anywhere in the site, regardless of content.</value></data>
<data name="help.transfer.incsource.title" xml:space="preserve"><value>Include Source Folder</value></data>
<data name="help.transfer.incsource.body" xml:space="preserve"><value>When enabled, the source folder itself is recreated at the destination (e.g. transferring "Reports" creates a "Reports/" folder at the target). When disabled, only the contents inside the folder are transferred — useful when merging into an existing destination folder.</value></data>
<data name="help.transfer.copycontent.title" xml:space="preserve"><value>Copy Folder Contents Only</value></data>
<data name="help.transfer.copycontent.body" xml:space="preserve"><value>When enabled, only the files and subfolders inside the selected folder are transferred — the selected folder itself is not recreated at the destination.</value></data>
<data name="help.transfer.conflict.title" xml:space="preserve"><value>File Conflict Policy</value></data>
<data name="help.transfer.conflict.body" xml:space="preserve"><value>Defines what happens when a file with the same name already exists at the destination:
• Skip — leave the existing destination file unchanged.
• Overwrite — replace the destination file with the source file.
• Rename — keep both by appending a number suffix to the transferred file's name.</value></data>
<data name="help.bulkmembers.title" xml:space="preserve"><value>Bulk Add Members — CSV Format</value></data>
<data name="help.bulkmembers.body" xml:space="preserve"><value>The CSV file must contain these columns (headers required, order is flexible):
• GroupName — the exact SharePoint group name
• Email — the user's email address
• Role — Member, Owner, or Visitor
Click "Load Example" to open a pre-filled sample file.</value></data>
<data name="help.bulksites.title" xml:space="preserve"><value>Bulk Create Sites — CSV Format</value></data>
<data name="help.bulksites.body" xml:space="preserve"><value>The CSV file must contain these columns:
• Name — the display name for the new site
• Alias — URL alias (no spaces; becomes part of the site URL)
• Type — TeamSite or CommunicationSite
• Owners — comma-separated list of owner email addresses
Click "Load Example" to open a pre-filled sample file.</value></data>
<data name="help.folderstruct.title" xml:space="preserve"><value>Create Folder Structure — CSV Format</value></data>
<data name="help.folderstruct.body" xml:space="preserve"><value>Creates a folder hierarchy inside a SharePoint library from a CSV file. Each row defines one folder path using up to 4 levels (Level1Level4). Leave deeper level columns empty for shallower paths.
Example row: Contracts | 2024 | Q1 | (empty)
Creates: Library / Contracts / 2024 / Q1</value></data>
<data name="help.templates.capture.title" xml:space="preserve"><value>Capture Site Template</value></data>
<data name="help.templates.capture.body" xml:space="preserve"><value>Saves the currently selected site's structure (libraries, folder hierarchy, permissions, settings, and logo) as a reusable template stored locally on your machine. The source site is not modified in any way.
Select which elements to include using the checkboxes above.</value></data>
<data name="help.templates.apply.title" xml:space="preserve"><value>Apply Template to New Site</value></data>
<data name="help.templates.apply.body" xml:space="preserve"><value>Creates a brand-new SharePoint site and reproduces the structure captured in the selected template — including libraries, folders, permissions, settings, and logo. The source template and original site are not affected.
Provide a display name and URL alias for the new site before clicking Apply.</value></data>
<data name="help.audit.mode.title" xml:space="preserve"><value>Search vs Browse Mode</value></data>
<data name="help.audit.mode.body" xml:space="preserve"><value>Search Mode: type a name or email to find a specific user via Azure AD. Matching users appear in a list — click to select them for the audit.
Browse Mode: loads all users in your tenant directory. Use the filter box to narrow the list, then double-click a row to add the user to the audit.</value></data>
<data name="help.audit.vs.perms.title" xml:space="preserve"><value>User Access Audit vs Permissions Audit</value></data>
<data name="help.audit.vs.perms.body" xml:space="preserve"><value>The Permissions tab scans objects (libraries, folders, items) and shows who has access to each one.
This tab does the reverse: you select one or more users and it finds every object they can access — including access granted via SharePoint groups or Active Directory groups.</value></data>
<data name="help.storage.hidden.title" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="help.storage.hidden.body" xml:space="preserve"><value>Scans SharePoint libraries hidden from the site's normal navigation (e.g. Site Assets, Style Library, Form Templates). These can consume significant storage and are often overlooked in routine audits.</value></data>
<data name="help.storage.preservation.title" xml:space="preserve"><value>Preservation Hold Library</value></data>
<data name="help.storage.preservation.body" xml:space="preserve"><value>A hidden SharePoint library that stores versions of documents modified or deleted while a Microsoft Purview / Microsoft 365 Compliance retention policy is active. It can grow very large over time without being visible to normal site users.</value></data>
</root> </root>
+1
View File
@@ -8,6 +8,7 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs" xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Icon="pack://application:,,,/Resources/SPToolbox-logo-ico.png"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal" TextOptions.TextFormattingMode="Ideal"
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -64,6 +64,7 @@ public class BulkMemberService : IBulkMemberService
return; return;
} }
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}", Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}",
@@ -83,7 +84,7 @@ public class BulkMemberService : IBulkMemberService
{ {
// Resolve user by email // Resolve user by email
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct); var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
if (user == null) if (user?.Id == null)
throw new InvalidOperationException($"User not found: {email}"); throw new InvalidOperationException($"User not found: {email}");
var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}"; var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}";
@@ -138,13 +139,16 @@ public class BulkMemberService : IBulkMemberService
} }
} }
} }
catch { /* not a group-connected site */ } catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Debug("Group lookup not available for {SiteUrl}: {Error}", siteUrl, ex.Message); }
} }
return null; return null;
} }
catch catch (OperationCanceledException) { throw; }
catch (Exception ex)
{ {
Log.Debug("Could not resolve M365 group ID for {SiteUrl}: {Error}", siteUrl, ex.Message);
return null; return null;
} }
} }
@@ -54,6 +54,9 @@ public class BulkSiteService : IBulkSiteService
var owners = ParseEmails(row.Owners); var owners = ParseEmails(row.Owners);
var members = ParseEmails(row.Members); var members = ParseEmails(row.Members);
if (owners.Count == 0)
throw new InvalidOperationException($"Team site '{row.Name}' requires at least one owner.");
var creationInfo = new TeamSiteCollectionCreationInformation var creationInfo = new TeamSiteCollectionCreationInformation
{ {
DisplayName = row.Name, DisplayName = row.Name,
@@ -88,6 +91,7 @@ public class BulkSiteService : IBulkSiteService
membersGroup.Users.AddUser(user); membersGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Failed to add member {Email} to {Site}: {Error}", Log.Warning("Failed to add member {Email} to {Site}: {Error}",
@@ -142,6 +146,7 @@ public class BulkSiteService : IBulkSiteService
ownersGroup.Users.AddUser(user); ownersGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Failed to add owner {Email} to {Site}: {Error}", Log.Warning("Failed to add owner {Email} to {Site}: {Error}",
@@ -162,6 +167,7 @@ public class BulkSiteService : IBulkSiteService
membersGroup.Users.AddUser(user); membersGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Failed to add member {Email} to {Site}: {Error}", Log.Warning("Failed to add member {Email} to {Site}: {Error}",
@@ -14,7 +14,7 @@ public class CsvExportService
private static string BuildHeader() private static string BuildHeader()
{ {
var T = TranslationSource.Instance; 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> /// <summary>
@@ -39,7 +39,10 @@ public class CsvExportService
UserLogins = g.First().UserLogins, UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType, PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels, 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) foreach (var row in merged)
@@ -47,7 +50,8 @@ public class CsvExportService
{ {
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url), Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins), 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(); return sb.ToString();
@@ -68,7 +72,7 @@ public class CsvExportService
private static string BuildSimplifiedHeader() private static string BuildSimplifiedHeader()
{ {
var T = TranslationSource.Instance; 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> /// <summary>
@@ -95,7 +99,10 @@ public class CsvExportService
Permissions = g.Key.PermissionLevels, Permissions = g.Key.PermissionLevels,
SimplifiedLabels = g.First().SimplifiedLabels, SimplifiedLabels = g.First().SimplifiedLabels,
RiskLevel = g.First().RiskLevel.ToString(), 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) foreach (var row in merged)
@@ -104,7 +111,8 @@ public class CsvExportService
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url), Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins), Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels), 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(); 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> /// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct) public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct); => File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
/// <summary>
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 with
/// BOM, chunk by chunk. Avoids the full-document <c>ToString()</c> copy
/// and the separate UTF-8 byte buffer that <see cref="File.WriteAllTextAsync(string, string, Encoding, CancellationToken)"/>
/// would otherwise allocate — meaningful for large CSV exports.
/// </summary>
public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
=> WriteChunksAsync(filePath, builder, Utf8WithBom, ct);
/// <summary>
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 without
/// BOM. Same rationale as <see cref="WriteCsvChunksAsync"/> — for large
/// HTML reports it halves peak memory by skipping the intermediate string.
/// </summary>
public static Task WriteHtmlChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
=> WriteChunksAsync(filePath, builder, Utf8NoBom, ct);
private static async Task WriteChunksAsync(string filePath, StringBuilder builder, Encoding encoding, CancellationToken ct)
{
// FileOptions.Asynchronous lets StreamWriter use true async I/O.
await using var fs = new FileStream(
filePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 64 * 1024,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var sw = new StreamWriter(fs, encoding, bufferSize: 64 * 1024);
foreach (var chunk in builder.GetChunks())
{
ct.ThrowIfCancellationRequested();
await sw.WriteAsync(chunk, ct);
}
await sw.FlushAsync(ct);
}
} }
@@ -27,7 +27,8 @@ public class HtmlExportService
public string BuildHtml( public string BuildHtml(
IReadOnlyList<PermissionEntry> entries, IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats( var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
@@ -57,7 +58,9 @@ public class HtmlExportService
var (pills, subRows) = BuildUserPillsCell( var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers, 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("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); 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><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pills}</td>"); sb.AppendLine($" <td>{pills}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</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>"); sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows); if (subRows.Length > 0) sb.Append(subRows);
} }
@@ -87,7 +90,8 @@ public class HtmlExportService
public string BuildHtml( public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries, IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries); var summaries = PermissionSummaryBuilder.Build(entries);
@@ -118,34 +122,46 @@ public class HtmlExportService
AppendFilterInput(sb); AppendFilterInput(sb);
AppendTableOpen(sb); AppendTableOpen(sb);
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>"); sb.AppendLine($" <th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>"); sb.AppendLine("<tbody>");
int grpMemIdx = 0; int grpMemIdx = 0;
foreach (var entry in entries) int sectionIdx = 0;
var groups = entries.GroupBy(e => (e.ObjectType, e.Title, e.Url)).ToList();
foreach (var group in groups)
{ {
var typeCss = ObjectTypeCss(entry.ObjectType); var sectionId = $"sec{sectionIdx++}";
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; var first = group.First();
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; var typeCss = ObjectTypeCss(group.Key.ObjectType);
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel); var uniqueCss = first.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = first.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var count = group.Count();
var (pills, subRows) = BuildUserPillsCell( sb.AppendLine($"<tr class=\"section-header collapsed\" data-section=\"{sectionId}\">");
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers, sb.AppendLine($" <td colspan=\"5\"><span class=\"chevron\">&#9660;</span><span class=\"{typeCss}\">{HtmlEncode(group.Key.ObjectType)}</span> <strong>{HtmlEncode(group.Key.Title)}</strong> <a href=\"{HtmlEncode(group.Key.Url)}\" target=\"_blank\">&#8599;</a> <span class=\"{uniqueCss}\">{uniqueLbl}</span><span class=\"entry-badge\">{count} {T["report.text.entries_unit"]}</span></td>");
colSpan: 9, grpMemIdx: ref grpMemIdx);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
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.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("</tr>"); sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
foreach (var entry in group)
{
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
colSpan: 5, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw,
sectionId: sectionId);
sb.AppendLine($"<tr data-section-member=\"{sectionId}\" style=\"display:none\">");
sb.AppendLine($" <td>{pills}</td>");
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>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
} }
AppendTableClose(sb); AppendTableClose(sb);
@@ -161,9 +177,10 @@ public class HtmlExportService
string filePath, string filePath,
CancellationToken ct, CancellationToken ct,
ReportBranding? branding = null, 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); await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
} }
@@ -173,9 +190,10 @@ public class HtmlExportService
string filePath, string filePath,
CancellationToken ct, CancellationToken ct,
ReportBranding? branding = null, 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); await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
} }
@@ -191,11 +209,12 @@ public class HtmlExportService
HtmlSplitLayout layout, HtmlSplitLayout layout,
CancellationToken ct, CancellationToken ct,
ReportBranding? branding = null, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{ {
if (splitMode != ReportSplitMode.BySite) if (splitMode != ReportSplitMode.BySite)
{ {
await WriteAsync(entries, basePath, ct, branding, groupMembers); await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return; return;
} }
@@ -203,7 +222,7 @@ public class HtmlExportService
if (layout == HtmlSplitLayout.SingleTabbed) if (layout == HtmlSplitLayout.SingleTabbed)
{ {
var parts = partitions 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(); .ToList();
var title = TranslationSource.Instance["report.title.permissions"]; var title = TranslationSource.Instance["report.title.permissions"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title); var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
@@ -215,11 +234,11 @@ public class HtmlExportService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label); 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( public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries, IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath, string basePath,
@@ -227,11 +246,12 @@ public class HtmlExportService
HtmlSplitLayout layout, HtmlSplitLayout layout,
CancellationToken ct, CancellationToken ct,
ReportBranding? branding = null, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{ {
if (splitMode != ReportSplitMode.BySite) if (splitMode != ReportSplitMode.BySite)
{ {
await WriteAsync(entries, basePath, ct, branding, groupMembers); await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return; return;
} }
@@ -239,7 +259,7 @@ public class HtmlExportService
if (layout == HtmlSplitLayout.SingleTabbed) if (layout == HtmlSplitLayout.SingleTabbed)
{ {
var parts = partitions 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(); .ToList();
var title = TranslationSource.Instance["report.title.permissions_simplified"]; var title = TranslationSource.Instance["report.title.permissions_simplified"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title); var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
@@ -251,7 +271,7 @@ public class HtmlExportService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label); 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 System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
@@ -51,17 +52,62 @@ a:hover { text-decoration: underline; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; } .risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; } .risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; } .risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.section-header td { background: #edf2f7; font-weight: 600; cursor: pointer; padding: 8px 14px; border-bottom: 2px solid #cbd5e0; user-select: none; }
.section-header:hover td { background: #e2e8f0; }
.section-header .chevron { margin-right: 8px; display: inline-block; transition: transform 0.15s; }
.section-header.collapsed .chevron { transform: rotate(-90deg); }
.entry-badge { display: inline-block; background: #e2e8f0; color: #4a5568; border-radius: 10px; padding: 1px 8px; font-size: .75rem; font-weight: 600; margin-left: 8px; }
"; ";
internal const string InlineJs = @"function filterTable() { internal const string InlineJs = @"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase(); var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr'); var sections = document.querySelectorAll('#permTable tbody tr.section-header');
rows.forEach(function(row) { if (sections.length === 0) {
if (row.hasAttribute('data-group')) return; document.querySelectorAll('#permTable tbody tr').forEach(function(row) {
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none'; if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
return;
}
if (!input) {
sections.forEach(function(hdr) {
hdr.style.display = '';
var sid = hdr.getAttribute('data-section');
var collapsed = hdr.classList.contains('collapsed');
document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])').forEach(function(r) {
r.style.display = collapsed ? 'none' : '';
});
});
return;
}
sections.forEach(function(hdr) {
var sid = hdr.getAttribute('data-section');
var members = document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])');
var anyMatch = false;
members.forEach(function(r) {
var match = r.textContent.toLowerCase().indexOf(input) > -1;
r.style.display = match ? '' : 'none';
if (match) anyMatch = true;
});
if (!anyMatch && hdr.textContent.toLowerCase().indexOf(input) > -1) {
anyMatch = true;
members.forEach(function(r) { r.style.display = ''; });
}
hdr.style.display = anyMatch ? '' : 'none';
}); });
} }
document.addEventListener('click', function(ev) { document.addEventListener('click', function(ev) {
var hdr = ev.target.closest('.section-header');
if (hdr) {
var sid = hdr.getAttribute('data-section');
hdr.classList.toggle('collapsed');
var collapsed = hdr.classList.contains('collapsed');
document.querySelectorAll('[data-section-member=' + sid + ']').forEach(function(r) {
if (r.hasAttribute('data-group')) { r.style.display = 'none'; return; }
r.style.display = collapsed ? 'none' : '';
});
return;
}
var trigger = ev.target.closest('.group-expandable'); var trigger = ev.target.closest('.group-expandable');
if (!trigger) return; if (!trigger) return;
var id = trigger.getAttribute('data-group-target'); var id = trigger.getAttribute('data-group-target');
@@ -137,7 +183,11 @@ document.addEventListener('click', function(ev) {
string? principalType, string? principalType,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers, IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
int colSpan, int colSpan,
ref int grpMemIdx) ref int grpMemIdx,
string? targetLabel = null,
string? sharingLinkType = null,
bool hideSystemGroupRaw = false,
string? sectionId = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -151,27 +201,74 @@ document.addEventListener('click', function(ev) {
var name = i < names.Length ? names[i].Trim() : login; var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); 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 != null
&& groupMembers.TryGetValue(name, out _); && 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}"; if (resolved.Count == 0)
pills.Append($"<span class=\"user-pill group-expandable\" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolved.Count > 0)
{ {
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;"); // Members unavailable — render plain pill, skip expandable sub-row.
memberContent = string.Join(" &bull; ", parts); var cls2 = isResolvedSystemGroup ? "user-pill\" data-system-group=\"1" : "user-pill";
pills.Append($"<span class=\"{cls2}\" title=\"{HtmlEncode(T["report.text.empty_group"])}\" 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("</span>");
} }
else else
{ {
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>"; var grpId = $"grpmem{grpMemIdx}";
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>");
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
var memberContent = string.Join(" &bull; ", parts);
var sectionAttr = sectionId != null ? $" data-section-member=\"{HtmlEncode(sectionId)}\"" : "";
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\"{sectionAttr} style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
} }
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 else
{ {
@@ -183,6 +280,80 @@ document.addEventListener('click', function(ev) {
return (pills.ToString(), subRows.ToString()); 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> /// <summary>Returns the CSS class for the object-type badge.</summary>
internal static string ObjectTypeCss(string t) => t switch internal static string ObjectTypeCss(string t) => t switch
{ {
@@ -1,5 +1,6 @@
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
@@ -18,33 +19,58 @@ public class StorageCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes) public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{ {
var T = TranslationSource.Instance; // Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
var sb = new StringBuilder(); var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
return sb.ToString();
}
// Header private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}"); {
var T = TranslationSource.Instance;
// Hoist resource lookups out of the row loop: ResourceManager.GetString
// is a culture-aware dictionary probe — caching once per export saves
// O(rows × columns) lookups on large tenants.
string colLibrary = T["report.col.library"];
string colKind = T["stor.col.kind"];
string colSite = T["report.col.site"];
string colFiles = T["report.stat.files"];
string colTotalMb = T["report.col.total_size_mb"];
string colVerMb = T["report.col.version_size_mb"];
string colLastMod = T["report.col.last_modified"];
sb.Append(colLibrary).Append(',')
.Append(colKind).Append(',')
.Append(colSite).Append(',')
.Append(colFiles).Append(',')
.Append(colTotalMb).Append(',')
.Append(colVerMb).Append(',')
.AppendLine(colLastMod);
var kindLabels = BuildKindLabelCache();
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", AppendCsvField(sb, node.Name).Append(',');
Csv(node.Name), AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
Csv(node.SiteTitle), AppendCsvField(sb, node.SiteTitle).Append(',');
node.TotalFileCount.ToString(), sb.Append(node.TotalFileCount).Append(',');
FormatMb(node.TotalSizeBytes), AppendMb(sb, node.TotalSizeBytes).Append(',');
FormatMb(node.VersionSizeBytes), AppendMb(sb, node.VersionSizeBytes).Append(',');
node.LastModified.HasValue if (node.LastModified.HasValue)
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd")) AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
: string.Empty)); sb.AppendLine();
} }
return sb.ToString();
} }
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary> /// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(nodes); // Stream straight to disk: skip the StringBuilder→string copy and the
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); // separate UTF-8 buffer that File.WriteAllTextAsync materializes.
var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
} }
/// <summary> /// <summary>
@@ -52,44 +78,68 @@ public class StorageCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics) public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{ {
var T = TranslationSource.Instance; var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
var sb = new StringBuilder(); WriteCsv(sb, nodes, fileTypeMetrics);
return sb.ToString();
}
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var T = TranslationSource.Instance;
string colLibrary = T["report.col.library"];
string colSite = T["report.col.site"];
string colFiles = T["report.stat.files"];
string colTotalMb = T["report.col.total_size_mb"];
string colVerMb = T["report.col.version_size_mb"];
string colLastMod = T["report.col.last_modified"];
sb.Append(colLibrary).Append(',')
.Append(colSite).Append(',')
.Append(colFiles).Append(',')
.Append(colTotalMb).Append(',')
.Append(colVerMb).Append(',')
.AppendLine(colLastMod);
// Library details
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", AppendCsvField(sb, node.Name).Append(',');
Csv(node.Name), AppendCsvField(sb, node.SiteTitle).Append(',');
Csv(node.SiteTitle), sb.Append(node.TotalFileCount).Append(',');
node.TotalFileCount.ToString(), AppendMb(sb, node.TotalSizeBytes).Append(',');
FormatMb(node.TotalSizeBytes), AppendMb(sb, node.VersionSizeBytes).Append(',');
FormatMb(node.VersionSizeBytes), if (node.LastModified.HasValue)
node.LastModified.HasValue AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd")) sb.AppendLine();
: string.Empty));
} }
// File type breakdown
if (fileTypeMetrics.Count > 0) if (fileTypeMetrics.Count > 0)
{ {
string colFileType = T["report.col.file_type"];
string colSizeMb = T["report.col.size_mb"];
string colFileCnt = T["report.col.file_count"];
string noExtLabel = T["report.text.no_extension"];
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}"); sb.Append(colFileType).Append(',')
.Append(colSizeMb).Append(',')
.AppendLine(colFileCnt);
foreach (var m in fileTypeMetrics) foreach (var m in fileTypeMetrics)
{ {
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension; string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString())); AppendCsvField(sb, label).Append(',');
AppendMb(sb, m.TotalSizeBytes).Append(',');
sb.Append(m.FileCount).AppendLine();
} }
} }
return sb.ToString();
} }
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary> /// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(nodes, fileTypeMetrics); var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); WriteCsv(sb, nodes, fileTypeMetrics);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
} }
/// <summary> /// <summary>
@@ -138,9 +188,40 @@ public class StorageCsvExportService
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatMb(long bytes) private static StringBuilder AppendMb(StringBuilder sb, long bytes)
=> (bytes / (1024.0 * 1024.0)).ToString("F2"); => sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary> private static StringBuilder AppendCsvField(StringBuilder sb, string value)
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value); => sb.Append(CsvSanitizer.EscapeMinimal(value));
/// <summary>
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
/// once per export, indexed by the enum's int value. Avoids a
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
/// </summary>
private static string[] BuildKindLabelCache()
{
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
int max = 0;
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
var cache = new string[max + 1];
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
foreach (var v in values) cache[(int)v] = KindLabel(v);
return cache;
}
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
} }
@@ -13,6 +13,8 @@ namespace SharepointToolbox.Services.Export;
public class StorageHtmlExportService public class StorageHtmlExportService
{ {
private int _togIdx; private int _togIdx;
private string[] _kindLabels = Array.Empty<string>();
private string[] _kindLabelsHtml = Array.Empty<string>();
/// <summary> /// <summary>
/// Builds a self-contained HTML report with one collapsible row per /// Builds a self-contained HTML report with one collapsible row per
@@ -21,10 +23,18 @@ public class StorageHtmlExportService
/// breakdown section is desired. /// breakdown section is desired.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
{
var sb = new StringBuilder(3072 + nodes.Count * 340);
BuildHtmlCore(sb, nodes, branding);
return sb.ToString();
}
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, ReportBranding? branding)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
_togIdx = 0; _togIdx = 0;
var sb = new StringBuilder(); _kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>"); sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">"); sb.AppendLine("<html lang=\"en\">");
@@ -60,11 +70,18 @@ public class StorageHtmlExportService
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>"); sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// Summary cards // Single-pass root aggregation: replaces 4 separate enumerations
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList(); // (.Where().ToList() + 3× .Sum() + a final .Where() during render).
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes); var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes); long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount); foreach (var n in nodes)
{
if (n.IndentLevel != 0) continue;
rootNodes0.Add(n);
siteTotal0 += n.TotalSizeBytes;
versionTotal0 += n.VersionSizeBytes;
fileTotal0 += n.TotalFileCount;
}
sb.AppendLine($""" sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap"> <div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
@@ -79,6 +96,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -89,7 +107,10 @@ public class StorageHtmlExportService
<tbody> <tbody>
"""); """);
foreach (var node in nodes) // Render only the pre-materialized root list — recursing into
// Children handles descendants. Iterating the flat list would render
// every descendant a second time as a top-level row.
foreach (var node in rootNodes0)
{ {
RenderNode(sb, node); RenderNode(sb, node);
} }
@@ -101,18 +122,24 @@ public class StorageHtmlExportService
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
return sb.ToString();
} }
/// <summary> /// <summary>
/// Builds an HTML report including a file-type breakdown chart section. /// Builds an HTML report including a file-type breakdown chart section.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
{
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
return sb.ToString();
}
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
_togIdx = 0; _togIdx = 0;
var sb = new StringBuilder(); _kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>"); sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">"); sb.AppendLine("<html lang=\"en\">");
@@ -159,11 +186,17 @@ public class StorageHtmlExportService
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>"); sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── Summary cards ── // ── Summary cards (single-pass aggregation) ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList(); var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes); long siteTotal = 0, versionTotal = 0, fileTotal = 0;
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes); foreach (var n in nodes)
long fileTotal = rootNodes.Sum(n => n.TotalFileCount); {
if (n.IndentLevel != 0) continue;
rootNodes.Add(n);
siteTotal += n.TotalSizeBytes;
versionTotal += n.VersionSizeBytes;
fileTotal += n.TotalFileCount;
}
sb.AppendLine("<div class=\"stats\">"); sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
@@ -212,6 +245,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -222,7 +256,10 @@ public class StorageHtmlExportService
<tbody> <tbody>
"""); """);
foreach (var node in nodes) // Render only the pre-materialized root list — recursing into
// Children handles descendants. Iterating the flat list would render
// every descendant a second time as a top-level row.
foreach (var node in rootNodes)
{ {
RenderNode(sb, node); RenderNode(sb, node);
} }
@@ -234,22 +271,24 @@ public class StorageHtmlExportService
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
return sb.ToString();
} }
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary> /// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(nodes, branding); // Build into StringBuilder, stream chunks straight to disk —
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); // skips a full-document char-array copy from sb.ToString().
var sb = new StringBuilder(3072 + nodes.Count * 340);
BuildHtmlCore(sb, nodes, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
} }
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary> /// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(nodes, fileTypeMetrics, branding); var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
} }
/// <summary> /// <summary>
@@ -305,24 +344,11 @@ public class StorageHtmlExportService
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}" ? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>"; : $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue AppendRow(sb, node, nameCell);
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -343,24 +369,11 @@ public class StorageHtmlExportService
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>" ? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>"; : $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue AppendRow(sb, node, nameCell);
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -371,6 +384,35 @@ public class StorageHtmlExportService
} }
} }
/// <summary>
/// Appends one data row given the pre-rendered name cell. Hot path:
/// pulls localized kind labels from <see cref="_kindLabelsHtml"/> instead
/// of going through <c>ResourceManager.GetString</c> + <c>HtmlEncode</c>
/// per row.
/// </summary>
private void AppendRow(StringBuilder sb, StorageNode node, string nameCell)
{
int kindIdx = (int)node.Kind;
string kindLabel = (uint)kindIdx < (uint)_kindLabelsHtml.Length
? _kindLabelsHtml[kindIdx]
: HtmlEncode(node.Kind.ToString());
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{kindLabel}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
}
private static string FormatSize(long bytes) private static string FormatSize(long bytes)
{ {
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB"; if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
@@ -381,4 +423,43 @@ public class StorageHtmlExportService
private static string HtmlEncode(string value) private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty); => System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
/// <summary>
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
/// once per export. Cached array index lookup avoids
/// <c>ResourceManager.GetString</c> per row in hot rendering loops.
/// </summary>
private static string[] BuildKindLabelCache()
{
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
int max = 0;
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
var cache = new string[max + 1];
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
foreach (var v in values) cache[(int)v] = KindLabel(v);
return cache;
}
/// <summary>HTML-encodes each entry of <paramref name="raw"/> once.</summary>
private static string[] BuildHtmlEncodedCache(string[] raw)
{
var encoded = new string[raw.Length];
for (int i = 0; i < raw.Length; i++) encoded[i] = HtmlEncode(raw[i]);
return encoded;
}
} }
@@ -15,7 +15,7 @@ public class UserAccessCsvExportService
private static string BuildDataHeader() private static string BuildDataHeader()
{ {
var T = TranslationSource.Instance; 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> /// <summary>
@@ -51,7 +51,10 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl), Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel), Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()), 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(); sb.AppendLine();
// Header // 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 // Data rows
foreach (var entry in consolidated) foreach (var entry in consolidated)
@@ -187,13 +190,16 @@ public class UserAccessCsvExportService
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle)); var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
sb.AppendLine(string.Join(",", new[] sb.AppendLine(string.Join(",", new[]
{ {
$"\"{entry.UserDisplayName}\"", Csv(entry.UserDisplayName),
$"\"{entry.UserLogin}\"", Csv(entry.UserLogin),
$"\"{entry.PermissionLevel}\"", Csv(entry.PermissionLevel),
$"\"{entry.AccessType}\"", Csv(entry.AccessType.ToString()),
$"\"{entry.GrantedThrough}\"", Csv(entry.GrantedThrough),
$"\"{locations}\"", Csv(entry.TargetLabel ?? string.Empty),
$"\"{entry.LocationCount}\"" 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.ObjectUrl),
Csv(entry.PermissionLevel), Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()), 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>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>"); sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</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>"); sb.AppendLine("</tr>");
} }
} }
@@ -301,7 +301,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td>{objectCell}</td>"); sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>"); sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</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>"); 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.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>"); sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</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) if (entry.LocationCount == 1)
{ {
+249 -12
View File
@@ -7,14 +7,19 @@ using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services; namespace SharepointToolbox.Services;
/// <summary> /// <summary>
/// Orchestrates server-side file copy/move between two SharePoint libraries /// Orchestrates file copy/move between two SharePoint libraries (same or
/// (same or different tenants). Uses <see cref="MoveCopyUtil"/> for the /// different tenants). Hybrid strategy: server-side <see cref="MoveCopyUtil"/>
/// transfer itself so bytes never round-trip through the local machine. /// first (zero local bandwidth), then transparent fallback to stream copy
/// Folder creation and enumeration are done via CSOM; all ambient retries /// (<c>OpenBinaryDirect</c>/<c>SaveBinaryDirect</c>) on a list-view-threshold
/// flow through <see cref="ExecuteQueryRetryHelper"/>. /// failure so transfers still succeed against libraries above the 5,000-item
/// cap. Folder enumeration uses paged CAML; folder creation is cached per job
/// to avoid re-checking the same path for every file.
/// </summary> /// </summary>
public class FileTransferService : IFileTransferService public class FileTransferService : IFileTransferService
{ {
private const int ListViewThresholdItemCount = 5000;
private const int LargeLibraryPageSize = 500;
/// <summary> /// <summary>
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files /// Runs the configured <see cref="TransferJob"/>. Enumerates source files
/// (unless the job is folder-only), pre-creates destination folders, then /// (unless the job is folder-only), pre-creates destination folders, then
@@ -30,12 +35,30 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
// 1. Enumerate files from source (unless contents are suppressed). // 1. Pre-flight: discover library item counts so we can pick a page size
// for source enumeration and warn early that the server-side copy path
// may trip the list-view threshold. The stream fallback in
// TransferSingleFileAsync handles the LVT case transparently, but the
// counts help size-tune enumeration up front.
var srcItemCount = await TryGetListItemCountAsync(sourceCtx, job.SourceLibrary, progress, ct);
var dstItemCount = await TryGetListItemCountAsync(destCtx, job.DestinationLibrary, progress, ct);
Log.Information(
"Transfer pre-flight: source={SrcLib} ({SrcCount} items), dest={DstLib} ({DstCount} items)",
job.SourceLibrary, srcItemCount, job.DestinationLibrary, dstItemCount);
if (srcItemCount > ListViewThresholdItemCount || dstItemCount > ListViewThresholdItemCount)
{
progress.Report(OperationProgress.Indeterminate(
$"Large library detected (source: {srcItemCount}, dest: {dstItemCount}). " +
"Using paged enumeration and stream-copy fallback when needed."));
}
// 2. Enumerate files from source (unless contents are suppressed).
IReadOnlyList<string> files; IReadOnlyList<string> files;
if (job.CopyFolderContents) if (job.CopyFolderContents)
{ {
progress.Report(new OperationProgress(0, 0, "Enumerating source files...")); progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
files = await EnumerateFilesAsync(sourceCtx, job, progress, ct); files = await EnumerateFilesAsync(sourceCtx, job, srcItemCount, progress, ct);
} }
else else
{ {
@@ -51,7 +74,7 @@ public class FileTransferService : IFileTransferService
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>()); return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
} }
// 2. Build source and destination base paths. Resolve library roots via // 3. Build source and destination base paths. Resolve library roots via
// CSOM — constructing from title breaks for localized libraries whose // CSOM — constructing from title breaks for localized libraries whose
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"), // URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
// causing "Access denied" when CSOM tries to touch a non-existent path. // causing "Access denied" when CSOM tries to touch a non-existent path.
@@ -60,6 +83,11 @@ public class FileTransferService : IFileTransferService
var dstBasePath = await ResolveLibraryPathAsync( var dstBasePath = await ResolveLibraryPathAsync(
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct); destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
// Per-job cache of destination folders we've already ensured. Without
// this, EnsureFolderAsync re-checks .Exists for every file in the same
// folder — thousands of round-trips on a flat directory transfer.
var ensuredFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// When IncludeSourceFolder is set, recreate the source folder name under // When IncludeSourceFolder is set, recreate the source folder name under
// destination so dest/srcFolderName/... mirrors the source tree. When // destination so dest/srcFolderName/... mirrors the source tree. When
// no SourceFolderPath is set, fall back to the source library name. // no SourceFolderPath is set, fall back to the source library name.
@@ -74,11 +102,11 @@ public class FileTransferService : IFileTransferService
if (!string.IsNullOrEmpty(srcFolderName)) if (!string.IsNullOrEmpty(srcFolderName))
{ {
dstBasePath = $"{dstBasePath}/{srcFolderName}"; dstBasePath = $"{dstBasePath}/{srcFolderName}";
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct); await EnsureFolderCachedAsync(destCtx, dstBasePath, ensuredFolders, progress, ct);
} }
} }
// 3. Transfer each file using BulkOperationRunner // 4. Transfer each file using BulkOperationRunner
return await BulkOperationRunner.RunAsync( return await BulkOperationRunner.RunAsync(
files, files,
async (fileRelUrl, idx, token) => async (fileRelUrl, idx, token) =>
@@ -88,13 +116,13 @@ public class FileTransferService : IFileTransferService
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase)) if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/'); relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/');
// Ensure destination folder exists // Ensure destination folder exists (cached)
var destFolderRelative = dstBasePath; var destFolderRelative = dstBasePath;
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/'); var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(fileFolder)) if (!string.IsNullOrEmpty(fileFolder))
{ {
destFolderRelative = $"{dstBasePath}/{fileFolder}"; destFolderRelative = $"{dstBasePath}/{fileFolder}";
await EnsureFolderAsync(destCtx, destFolderRelative, progress, token); await EnsureFolderCachedAsync(destCtx, destFolderRelative, ensuredFolders, progress, token);
} }
var fileName = Path.GetFileName(relativePart); var fileName = Path.GetFileName(relativePart);
@@ -116,6 +144,32 @@ public class FileTransferService : IFileTransferService
TransferJob job, TransferJob job,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{
// Hybrid path: try the server-side MoveCopyUtil first (bytes never
// leave SharePoint). If the destination (or source) library trips the
// list-view threshold, fall back to a stream copy via HTTP-direct APIs
// that bypass list internals.
try
{
await ServerSideTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct);
}
catch (ServerException ex) when (IsListViewThresholdException(ex))
{
Log.Warning(
"Server-side transfer hit list-view threshold for {File} — falling back to stream copy.",
srcFileUrl);
await StreamTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct);
}
}
private async Task ServerSideTransferAsync(
ClientContext sourceCtx,
ClientContext destCtx,
string srcFileUrl,
string dstFileUrl,
TransferJob job,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host), // MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
// not server-relative paths. Passing "/sites/..." silently fails or // not server-relative paths. Passing "/sites/..." silently fails or
@@ -153,9 +207,154 @@ public class FileTransferService : IFileTransferService
} }
} }
/// <summary>
/// Path-based stream copy fallback. Reads the source via
/// <see cref="Microsoft.SharePoint.Client.File.OpenBinaryStream"/> and writes
/// to the destination via <c>Folder.Files.Add(FileCreationInformation)</c>.
/// Both target a specific folder by path rather than querying list items,
/// so they succeed against libraries that exceed the list-view threshold.
/// Bytes do round-trip through the local machine — this is strictly the
/// fallback when server-side copy is unavailable.
/// </summary>
private async Task StreamTransferAsync(
ClientContext sourceCtx,
ClientContext destCtx,
string srcFileUrl,
string dstFileUrl,
TransferJob job,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Resolve the destination file name for conflict handling. Returns null
// when policy=Skip and the file already exists.
var effectiveDestUrl = await ResolveDestinationOnConflictAsync(destCtx, dstFileUrl, job, progress, ct);
if (effectiveDestUrl == null)
{
Log.Warning("Skipped (already exists, stream fallback): {File}", srcFileUrl);
return;
}
// Rename policy guarantees a free path via ResolveDestinationOnConflictAsync,
// so overwrite is only needed for the explicit Overwrite policy.
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
ct.ThrowIfCancellationRequested();
// 1. Download the source bytes into memory. OpenBinaryStream is a
// ClientResult<Stream> — usable only after ExecuteQuery.
var srcFile = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
var streamResult = srcFile.OpenBinaryStream();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
using var buffer = new MemoryStream();
await streamResult.Value.CopyToAsync(buffer, 81920, ct);
buffer.Position = 0;
// 2. Upload to the destination folder. Files.Add with ContentStream
// streams the payload in one request and does not touch list-view
// metadata, so it bypasses LVT.
var slash = effectiveDestUrl.LastIndexOf('/');
var destFolderUrl = effectiveDestUrl.Substring(0, slash);
var destFileName = effectiveDestUrl.Substring(slash + 1);
var destFolder = destCtx.Web.GetFolderByServerRelativeUrl(destFolderUrl);
var creation = new FileCreationInformation
{
Url = destFileName,
Overwrite = overwrite,
ContentStream = buffer,
};
destFolder.Files.Add(creation);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(destCtx, progress, ct);
if (job.Mode == TransferMode.Move)
{
// Stream copy cannot atomically move; delete the source after a
// successful upload to honour Move semantics.
var srcDelete = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
srcDelete.DeleteObject();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
}
}
/// <summary>
/// Honours <see cref="TransferJob.ConflictPolicy"/> when the destination
/// path already exists. Returns the URL to write to, or <c>null</c> when
/// the file should be skipped. For <see cref="ConflictPolicy.Rename"/>,
/// probes <c>name (1).ext</c>, <c>name (2).ext</c>, ... until a free slot
/// is found.
/// </summary>
private static async Task<string?> ResolveDestinationOnConflictAsync(
ClientContext destCtx,
string dstFileUrl,
TransferJob job,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
if (job.ConflictPolicy == ConflictPolicy.Overwrite)
return dstFileUrl;
bool exists = await FileExistsAsync(destCtx, dstFileUrl, progress, ct);
if (!exists) return dstFileUrl;
if (job.ConflictPolicy == ConflictPolicy.Skip)
return null;
// Rename: keep both. Append " (n)" before the extension.
var dir = dstFileUrl.Substring(0, dstFileUrl.LastIndexOf('/'));
var leaf = dstFileUrl.Substring(dstFileUrl.LastIndexOf('/') + 1);
var stem = Path.GetFileNameWithoutExtension(leaf);
var ext = Path.GetExtension(leaf);
for (int n = 1; n <= 999; n++)
{
var candidate = $"{dir}/{stem} ({n}){ext}";
if (!await FileExistsAsync(destCtx, candidate, progress, ct))
return candidate;
}
// Extremely unlikely; surface as failure rather than silent overwrite.
throw new InvalidOperationException(
$"Could not find an unused destination filename for {dstFileUrl} after 999 attempts.");
}
private static async Task<bool> FileExistsAsync(
ClientContext ctx,
string fileServerRelativeUrl,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
try
{
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
ctx.Load(file, f => f.Exists);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return file.Exists;
}
catch
{
return false;
}
}
/// <summary>
/// Detects SharePoint's list-view-threshold ServerException across locales.
/// English: "exceeds the list view threshold". French: "depasse le seuil
/// d'affichage de liste". German: "Listenansichtsschwellenwert".
/// </summary>
internal static bool IsListViewThresholdException(Exception ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("list view threshold", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("seuil d'affichage", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("seuil d", StringComparison.OrdinalIgnoreCase) && msg.Contains("liste", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Listenansichtsschwellenwert", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("umbral de vista de lista", StringComparison.OrdinalIgnoreCase);
}
private async Task<IReadOnlyList<string>> EnumerateFilesAsync( private async Task<IReadOnlyList<string>> EnumerateFilesAsync(
ClientContext ctx, ClientContext ctx,
TransferJob job, TransferJob job,
int sourceItemCount,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -226,6 +425,44 @@ public class FileTransferService : IFileTransferService
return false; return false;
} }
private async Task<int> TryGetListItemCountAsync(
ClientContext ctx,
string libraryTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
try
{
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
ctx.Load(list, l => l.ItemCount);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return list.ItemCount;
}
catch (Exception ex)
{
// Non-fatal: pre-flight count is purely informational. Treat as
// unknown (-1) so the rest of the pipeline still runs.
Log.Warning("Failed to read ItemCount for {Library}: {Error}", libraryTitle, ex.Message);
return -1;
}
}
/// <summary>
/// EnsureFolderAsync wrapper that records successful checks in a per-job
/// set so the same destination folder isn't re-validated for every file.
/// </summary>
private async Task EnsureFolderCachedAsync(
ClientContext ctx,
string folderServerRelativeUrl,
HashSet<string> cache,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var normalized = folderServerRelativeUrl.TrimEnd('/');
if (!cache.Add(normalized)) return;
await EnsureFolderAsync(ctx, normalized, progress, ct);
}
private async Task EnsureFolderAsync( private async Task EnsureFolderAsync(
ClientContext ctx, ClientContext ctx,
string folderServerRelativeUrl, string folderServerRelativeUrl,
+19 -8
View File
@@ -6,8 +6,11 @@ namespace SharepointToolbox.Services;
public interface IStorageService public interface IStorageService
{ {
/// <summary> /// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API. /// Collects storage metrics for a site, capturing every storage source
/// Returns a tree of StorageNode objects with aggregate size data. /// SharePoint reports (visible + hidden libraries, Preservation Hold,
/// list attachments, recycle bin, and optionally subsites). Each
/// <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/> so
/// callers can filter what appears in the report.
/// </summary> /// </summary>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync( Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
@@ -18,9 +21,6 @@ public interface IStorageService
/// <summary> /// <summary>
/// Enumerates files across all non-hidden document libraries in the site /// Enumerates files across all non-hidden document libraries in the site
/// and aggregates storage consumption grouped by file extension. /// and aggregates storage consumption grouped by file extension.
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
/// This is a separate operation from CollectStorageAsync -- it provides
/// file-type breakdown data for chart visualization.
/// </summary> /// </summary>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
@@ -29,13 +29,24 @@ public interface IStorageService
/// <summary> /// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes /// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery. /// by enumerating files per library via CamlQuery. Only re-runs against
/// This works around the StorageMetrics API returning zeros when the /// document-library kinds (Library, HiddenLibrary, PreservationHold).
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// </summary> /// </summary>
Task BackfillZeroNodesAsync( Task BackfillZeroNodesAsync(
ClientContext ctx, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct); CancellationToken ct);
/// <summary>
/// Returns the SharePoint-reported total storage usage for the site
/// (Site.Usage.Storage). This includes everything that counts toward
/// the site quota — recycle bin, version history, hidden libraries,
/// list attachments — and serves as the ground-truth reference total.
/// Returns 0 if the call is denied or the property is unavailable.
/// </summary>
Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
} }
@@ -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> /// </summary>
public class PermissionsService : IPermissionsService public class PermissionsService : IPermissionsService
{ {
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
public PermissionsService() : this(null) { }
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver)
{
_systemGroupResolver = systemGroupResolver;
}
/// <summary> /// <summary>
/// Detects the SharePoint server error raised when a RoleAssignment member /// Detects the SharePoint server error raised when a RoleAssignment member
/// refers to a user that no longer resolves (orphaned Azure AD account). /// 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 member = ra.Member;
var loginName = member.LoginName ?? string.Empty; var loginName = member.LoginName ?? string.Empty;
var memberTitle = member.Title ?? string.Empty;
// Skip sharing links groups and limited access system groups // Classify the member name. The bare "Limited Access System Group" is
if (PermissionEntryHelper.IsSharingLinksGroup(loginName)) // 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; continue;
// Collect and filter permission levels // Collect and filter permission levels
@@ -362,19 +381,42 @@ public class PermissionsService : IPermissionsService
// Determine how the permission was granted // Determine how the permission was granted
string grantedThrough = principalType == "SharePointGroup" string grantedThrough = principalType == "SharePointGroup"
? $"SharePoint Group: {member.Title}" ? $"SharePoint Group: {memberTitle}"
: "Direct Permissions"; : "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( entries.Add(new PermissionEntry(
ObjectType: objectType, ObjectType: objectType,
Title: title, Title: title,
Url: url, Url: url,
HasUniquePermissions: obj.HasUniqueRoleAssignments, HasUniquePermissions: obj.HasUniqueRoleAssignments,
Users: member.Title ?? string.Empty, Users: memberTitle,
UserLogins: loginName, UserLogins: loginName,
PermissionLevels: permLevels, PermissionLevels: permLevels,
GrantedThrough: grantedThrough, GrantedThrough: grantedThrough,
PrincipalType: principalType)); PrincipalType: principalType,
TargetUrl: targetUrl,
TargetLabel: targetLabel,
SharingLinkType: sharingLinkType));
} }
return entries; return entries;
@@ -23,7 +23,7 @@ namespace SharepointToolbox.Services;
/// </summary> /// </summary>
public class SharePointGroupResolver : ISharePointGroupResolver public class SharePointGroupResolver : ISharePointGroupResolver
{ {
private readonly AppGraphClientFactory? _graphClientFactory; private readonly AppGraphClientFactory _graphClientFactory;
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory) public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
{ {
@@ -57,6 +57,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
foreach (var g in ctx.Web.SiteGroups) foreach (var g in ctx.Web.SiteGroups)
groupTitles.Add(g.Title); groupTitles.Add(g.Title);
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message); Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
@@ -92,7 +93,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
if (IsAadGroup(user.LoginName)) if (IsAadGroup(user.LoginName))
{ {
// Lazy-create graph client on first AAD group encountered // Lazy-create graph client on first AAD group encountered
graphClient ??= await _graphClientFactory!.CreateClientAsync(clientId, ct); graphClient ??= await _graphClientFactory.CreateClientAsync(clientId, ct);
var aadId = ExtractAadGroupId(user.LoginName); var aadId = ExtractAadGroupId(user.LoginName);
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct); var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
@@ -110,6 +111,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase) .DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message); Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
@@ -182,6 +184,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
await pageIterator.IterateAsync(ct); await pageIterator.IterateAsync(ct);
return members; return members;
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message); Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
+502 -198
View File
@@ -7,27 +7,39 @@ namespace SharepointToolbox.Services;
/// <summary> /// <summary>
/// CSOM-based storage metrics scanner. /// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern. /// Captures every storage source SharePoint reports for a site:
/// document libraries (visible + hidden), the Preservation Hold Library,
/// list attachments, the recycle bin (1st + 2nd stage), and optionally
/// subsites. Each <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/>
/// so the caller can filter what appears in the report.
/// </summary> /// </summary>
public class StorageService : IStorageService public class StorageService : IStorageService
{ {
/// <summary> // PreservationHoldLibrary base template id.
/// Collects per-library and per-folder storage metrics for a single private const int PreservationHoldTemplate = 851;
/// SharePoint site. Depth and indentation are controlled via
/// <paramref name="options"/>; libraries flagged <c>Hidden</c> are skipped.
/// Traversal is breadth-first and leans on <see cref="SharePointPaginationHelper"/>
/// so libraries above the 5,000-item threshold remain scannable.
/// </summary>
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync( public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
StorageScanOptions options, StorageScanOptions options,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{
var result = new List<StorageNode>();
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
return result;
}
private async Task CollectForWebAsync(
ClientContext ctx,
Web web,
StorageScanOptions options,
List<StorageNode> result,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load web-level metadata in one round-trip ctx.Load(web,
ctx.Load(ctx.Web,
w => w.Title, w => w.Title,
w => w.Url, w => w.Url,
w => w.ServerRelativeUrl, w => w.ServerRelativeUrl,
@@ -35,48 +47,326 @@ public class StorageService : IStorageService
l => l.Title, l => l.Title,
l => l.Hidden, l => l.Hidden,
l => l.BaseType, l => l.BaseType,
l => l.BaseTemplate,
l => l.ItemCount,
l => l.RootFolder.ServerRelativeUrl)); l => l.RootFolder.ServerRelativeUrl));
if (options.IncludeSubsites)
ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/'); string siteTitle = web.Title;
string siteTitle = ctx.Web.Title; var lists = web.Lists.ToList();
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// ── Document libraries (incl. hidden + Preservation Hold) ───────────
// Track each library's RootFolder server-relative URL so bin items can
// be attributed back to their source library (matches storman.aspx,
// which folds bin contents into the owning library's Total Size).
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
var libsByRoot = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
int idx = 0; int idx = 0;
foreach (var lib in libs) foreach (var lib in docLibs)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
idx++; idx++;
progress.Report(new OperationProgress(idx, libs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})")); StorageNodeKind kind = ClassifyLibrary(lib);
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
progress.Report(new OperationProgress(idx, docLibs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{docLibs.Count})"));
var libNode = await LoadFolderNodeAsync( var libNode = await LoadFolderNodeAsync(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title, ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct); siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0) if (options.FolderDepth > 0)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl, ctx, lib, lib.RootFolder.ServerRelativeUrl,
libNode, 1, options.FolderDepth, libNode, 1, options.FolderDepth,
siteTitle, lib.Title, progress, ct); siteTitle, lib.Title, kind, progress, ct);
} }
// CSOM Folder.StorageMetrics is unreliable across the board for
// larger libraries — sometimes returns the storman value, sometimes
// returns a fraction of it, sometimes zero. Subfolder StorageMetrics
// are equally inconsistent. The only CSOM path that matches storman
// is per-file File.Length + File.Versions[*].Size enumeration, so
// run it unconditionally, replacing the CSOM totals.
ResetNodeCounts(libNode);
await BackfillLibFromFilesAsync(ctx, lib, libNode, progress, ct);
result.Add(libNode); result.Add(libNode);
libsByRoot[NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl)] = libNode;
} }
return result; // ── List attachments (non-document-library lists) ───────────────────
if (options.IncludeListAttachments)
{
var nonDocLists = lists
.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0)
.ToList();
int aIdx = 0;
foreach (var list in nonDocLists)
{
ct.ThrowIfCancellationRequested();
aIdx++;
progress.Report(new OperationProgress(aIdx, nonDocLists.Count,
$"Scanning list attachments: {list.Title} ({aIdx}/{nonDocLists.Count})"));
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
if (attachNode != null && attachNode.TotalSizeBytes > 0)
result.Add(attachNode);
}
}
// ── Recycle bin (stage 1 + stage 2) ─────────────────────────────────
if (options.IncludeRecycleBin)
{
progress.Report(OperationProgress.Indeterminate(
$"Scanning recycle bin: {siteTitle}..."));
var (rbNodes, perDir) = await LoadRecycleBinNodesAsync(ctx, web, siteTitle, progress, ct);
// Attribute bin items to owning library (longest-prefix match on DirName)
// so library Total Size matches storman.aspx, which counts an item's
// bytes against its source library even after deletion.
if (perDir.Count > 0 && libsByRoot.Count > 0)
{
var libRootsByLength = libsByRoot
.OrderByDescending(kv => kv.Key.Length)
.ToList();
foreach (var kv in perDir)
{
string dirNorm = NormalizeServerRelative(kv.Key);
foreach (var lib in libRootsByLength)
{
if (dirNorm.Equals(lib.Key, StringComparison.OrdinalIgnoreCase) ||
dirNorm.StartsWith(lib.Key + "/", StringComparison.OrdinalIgnoreCase))
{
lib.Value.TotalSizeBytes += kv.Value.Size;
lib.Value.TotalFileCount += kv.Value.Count;
break;
}
}
}
}
result.AddRange(rbNodes);
}
// ── Subsites (recursive) ────────────────────────────────────────────
if (options.IncludeSubsites)
{
var subwebs = web.Webs.ToList();
foreach (var sub in subwebs)
{
ct.ThrowIfCancellationRequested();
// Build a node header so subsite results are visually grouped.
var subResult = new List<StorageNode>();
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
if (subResult.Count == 0) continue;
// Bin contents already rolled up into each library's TotalSizeBytes
// (storman behavior); summing root RecycleBin children too would
// double-count. Filter them out here.
var subRoot = new StorageNode
{
Name = sub.Title,
Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
SiteTitle = sub.Title,
Library = string.Empty,
Kind = StorageNodeKind.Subsite,
IndentLevel = 0,
Children = subResult,
TotalSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalSizeBytes),
FileStreamSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.FileStreamSizeBytes),
TotalFileCount = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalFileCount)
};
result.Add(subRoot);
}
}
}
private static StorageNodeKind ClassifyLibrary(List lib)
{
if (lib.BaseTemplate == PreservationHoldTemplate ||
string.Equals(lib.Title, "Preservation Hold Library", StringComparison.OrdinalIgnoreCase))
return StorageNodeKind.PreservationHold;
return lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
}
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(
ClientContext ctx,
List list,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Per-list attachments live in <listRootFolder>/Attachments/<itemId>/<file>.
// The Attachments folder may or may not exist depending on whether any
// item ever had an attachment — guard with try/catch.
string attachmentsUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(attachmentsUrl);
ctx.Load(folder,
f => f.Exists,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0)
return null;
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
? folder.StorageMetrics.LastModified
: folder.TimeLastModified > DateTime.MinValue
? folder.TimeLastModified
: (DateTime?)null;
return new StorageNode
{
Name = $"[Attachments] {list.Title}",
Url = ctx.Url.TrimEnd('/') + attachmentsUrl,
SiteTitle = siteTitle,
Library = list.Title,
Kind = StorageNodeKind.ListAttachments,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
LastModified = lastMod,
IndentLevel = 0,
Children = new List<StorageNode>()
};
}
catch
{
// Attachments folder absent for this list — not an error.
return null;
}
}
private static async Task<(List<StorageNode> Nodes, Dictionary<string, (long Size, int Count)> PerDir)> LoadRecycleBinNodesAsync(
ClientContext ctx,
Web web,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var nodes = new List<StorageNode>();
var perDir = new Dictionary<string, (long Size, int Count)>(StringComparer.OrdinalIgnoreCase);
try
{
// Web-scoped: ctx.Site.RecycleBin would return the entire site-collection
// bin and inflate totals by (1 + N_subsites) when IncludeSubsites is on.
var bin = web.RecycleBin;
ctx.Load(bin, b => b.Include(
i => i.Size,
i => i.ItemState,
i => i.DeletedDate,
i => i.DirName));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// RecycleBinItem.DirName is web-relative on SharePoint Online
// (e.g. "Documents/SubFolder" without leading slash or web URL).
// Prepend the web's ServerRelativeUrl so the result matches
// List.RootFolder.ServerRelativeUrl form used by libsByRoot.
string webSrl = NormalizeServerRelative(web.ServerRelativeUrl);
long stage1Size = 0, stage2Size = 0;
int stage1Count = 0, stage2Count = 0;
DateTime? stage1Last = null, stage2Last = null;
foreach (var item in bin)
{
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin)
{
stage2Size += item.Size;
stage2Count++;
if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate;
}
else
{
stage1Size += item.Size;
stage1Count++;
if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate;
}
string raw = item.DirName ?? string.Empty;
string dirSrl;
if (raw.StartsWith('/'))
dirSrl = NormalizeServerRelative(raw);
else if (string.IsNullOrEmpty(raw))
dirSrl = webSrl;
else
dirSrl = NormalizeServerRelative(webSrl + "/" + raw);
if (perDir.TryGetValue(dirSrl, out var tally))
perDir[dirSrl] = (tally.Size + item.Size, tally.Count + 1);
else
perDir[dirSrl] = (item.Size, 1);
}
if (stage1Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] First-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage1Size,
FileStreamSizeBytes = stage1Size,
TotalFileCount = stage1Count,
LastModified = stage1Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
if (stage2Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] Second-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage2Size,
FileStreamSizeBytes = stage2Size,
TotalFileCount = stage2Count,
LastModified = stage2Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
}
catch
{
// Insufficient permission to read recycle bin or feature unavailable.
}
return (nodes, perDir);
} }
/// <summary> /// <summary>
/// Aggregates file counts and total sizes by extension across every /// Normalizes a server-relative path for consistent prefix matching:
/// non-hidden document library on the site. Extensions are normalised to /// trims trailing slash, ensures single leading slash. SharePoint
/// lowercase; files without an extension roll up into a single bucket. /// inconsistently returns DirName with or without leading slash across
/// API surfaces, so the caller cannot rely on a canonical form.
/// </summary> /// </summary>
private static string NormalizeServerRelative(string? path)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
string trimmed = path.Trim().TrimEnd('/');
if (trimmed.Length == 0) return string.Empty;
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
}
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
@@ -84,7 +374,6 @@ public class StorageService : IStorageService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web, ctx.Load(ctx.Web,
w => w.Lists.Include( w => w.Lists.Include(
l => l.Title, l => l.Title,
@@ -97,7 +386,6 @@ public class StorageService : IStorageService
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList(); .ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase); var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0; int libIdx = 0;
@@ -108,9 +396,10 @@ public class StorageService : IStorageService
progress.Report(new OperationProgress(libIdx, libs.Count, progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})")); $"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Paginated CAML without a WHERE clause — WHERE on non-indexed fields // No <Where> clause: filtering on FSObjType (non-indexed) on a list
// (FSObjType) throws list-view threshold on libraries > 5,000 items. // beyond 5000 items breaches the list view threshold. Page lightly,
// Filter files client-side via FSObjType. // then second-pass load File.Length + Versions[*].Size so per-type
// totals include version bytes (matches per-library totals).
var query = new CamlQuery var query = new CamlQuery
{ {
ViewXml = @"<View Scope='RecursiveAll'> ViewXml = @"<View Scope='RecursiveAll'>
@@ -118,9 +407,8 @@ public class StorageService : IStorageService
<ViewFields> <ViewFields>
<FieldRef Name='FSObjType' /> <FieldRef Name='FSObjType' />
<FieldRef Name='FileLeafRef' /> <FieldRef Name='FileLeafRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields> </ViewFields>
<RowLimit Paged='TRUE'>5000</RowLimit> <RowLimit Paged='TRUE'>500</RowLimit>
</View>" </View>"
}; };
@@ -132,21 +420,40 @@ public class StorageService : IStorageService
ctx.Load(items, ic => ic.ListItemCollectionPosition, ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include( ic => ic.Include(
i => i["FSObjType"], i => i["FSObjType"],
i => i["FileLeafRef"], i => i["FileLeafRef"]));
i => i["File_x0020_Size"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string Name)>();
foreach (var item in items) foreach (var item in items)
{ {
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders if (item["FSObjType"]?.ToString() != "0") continue;
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty; string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0"; fileRows.Add((item, fileName));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (!long.TryParse(sizeStr, out long fileSize)) if (fileRows.Count > 0)
fileSize = 0; {
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
string ext = Path.GetExtension(fileName).ToLowerInvariant(); foreach (var row in fileRows)
{
long current;
try { current = row.Item.File.Length; }
catch { continue; }
long versions = 0;
try
{
foreach (var v in row.Item.File.Versions)
versions += v.Size;
}
catch { /* no version history */ }
long fileSize = current + versions;
string ext = Path.GetExtension(row.Name).ToLowerInvariant();
if (extensionMap.TryGetValue(ext, out var existing)) if (extensionMap.TryGetValue(ext, out var existing))
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1); extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
@@ -159,173 +466,173 @@ public class StorageService : IStorageService
while (items.ListItemCollectionPosition != null); while (items.ListItemCollectionPosition != null);
} }
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count)) .Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes) .OrderByDescending(m => m.TotalSizeBytes)
.ToList(); .ToList();
} }
public async Task BackfillZeroNodesAsync( /// <summary>
/// Per-library backfill executed inline by CollectForWebAsync when CSOM's
/// Folder.StorageMetrics returns zero counts. Enumerates every file via
/// CamlQuery and explicitly loads File.Length + File.Versions.Size so
/// version bytes are summed accurately — matches what storman.aspx reports.
/// </summary>
private static async Task BackfillLibFromFilesAsync(
ClientContext ctx,
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, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) 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 try
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)
{ {
ct.ThrowIfCancellationRequested(); ctx.Load(ctx.Site, s => s.Usage);
idx++; await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return ctx.Site.Usage.Storage;
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;
}
} }
} catch
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)
{ {
if (child.TotalFileCount == 0) return true; return 0L;
if (HasZeroChild(child)) return true;
} }
return false;
} }
private static void ResetNodeCounts(StorageNode node) private static void ResetNodeCounts(StorageNode node)
{ {
node.TotalSizeBytes = 0; node.TotalSizeBytes = 0;
node.FileStreamSizeBytes = 0; node.FileStreamSizeBytes = 0;
node.TotalFileCount = 0; node.TotalFileCount = 0;
foreach (var child in node.Children) foreach (var child in node.Children)
ResetNodeCounts(child); ResetNodeCounts(child);
} }
@@ -345,8 +652,6 @@ public class StorageService : IStorageService
private static StorageNode? FindDeepestFolder(string fileDirRef, private static StorageNode? FindDeepestFolder(string fileDirRef,
Dictionary<string, StorageNode> lookup) Dictionary<string, StorageNode> lookup)
{ {
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
// Try exact match, then walk up until we find a match
string path = fileDirRef.TrimEnd('/'); string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path)) while (!string.IsNullOrEmpty(path))
{ {
@@ -359,7 +664,7 @@ public class StorageService : IStorageService
return null; return null;
} }
// -- Private helpers ----------------------------------------------------- // ── Library/folder loading helpers ──────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync( private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx, ClientContext ctx,
@@ -368,6 +673,7 @@ public class StorageService : IStorageService
string siteTitle, string siteTitle,
string library, string library,
int indentLevel, int indentLevel,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -393,6 +699,7 @@ public class StorageService : IStorageService
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle, SiteTitle = siteTitle,
Library = library, Library = library,
Kind = kind,
TotalSizeBytes = folder.StorageMetrics.TotalSize, TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount, TotalFileCount = folder.StorageMetrics.TotalFileCount,
@@ -411,15 +718,13 @@ public class StorageService : IStorageService
int maxDepth, int maxDepth,
string siteTitle, string siteTitle,
string library, string library,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
if (currentDepth > maxDepth) return; if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Enumerate direct child folders via paginated CAML scoped to the parent.
// Folder.Folders lazy loading hits the list-view threshold on libraries
// > 5,000 items; a paged CAML query with no WHERE bypasses it.
var subfolders = new List<(string Name, string ServerRelativeUrl)>(); var subfolders = new List<(string Name, string ServerRelativeUrl)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync( await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
@@ -427,13 +732,12 @@ public class StorageService : IStorageService
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" }, viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
ct: ct)) ct: ct))
{ {
if (item["FSObjType"]?.ToString() != "1") continue; // folders only if (item["FSObjType"]?.ToString() != "1") continue;
string name = item["FileLeafRef"]?.ToString() ?? string.Empty; string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty; string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue; if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
// Skip SharePoint system folders
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) || if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("_", StringComparison.Ordinal)) name.StartsWith("_", StringComparison.Ordinal))
continue; continue;
@@ -447,14 +751,14 @@ public class StorageService : IStorageService
var childNode = await LoadFolderNodeAsync( var childNode = await LoadFolderNodeAsync(
ctx, sub.ServerRelativeUrl, sub.Name, ctx, sub.ServerRelativeUrl, sub.Name,
siteTitle, library, currentDepth, progress, ct); siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth) if (currentDepth < maxDepth)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, list, sub.ServerRelativeUrl, childNode, ctx, list, sub.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth, currentDepth + 1, maxDepth,
siteTitle, library, progress, ct); siteTitle, library, kind, progress, ct);
} }
parentNode.Children.Add(childNode); parentNode.Children.Add(childNode);
@@ -0,0 +1,207 @@
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 (OperationCanceledException) { throw; }
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 ex) { Log.Debug("File by ID not found for {Id} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); }
// 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 ex) { Log.Debug("Folder by ID not found for {Id} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); }
// 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 (OperationCanceledException) { throw; }
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, AccessType: accessType,
GrantedThrough: entry.GrantedThrough, GrantedThrough: entry.GrantedThrough,
IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel), IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel),
IsExternalUser: PermissionEntryHelper.IsExternalUser(login)); IsExternalUser: PermissionEntryHelper.IsExternalUser(login),
TargetUrl: entry.TargetUrl,
TargetLabel: entry.TargetLabel,
SharingLinkType: entry.SharingLinkType);
} }
} }
} }
@@ -117,6 +117,7 @@ public class VersionCleanupService : IVersionCleanupService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
int before = 0;
try try
{ {
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl); var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
@@ -131,7 +132,7 @@ public class VersionCleanupService : IVersionCleanupService
// file.Versions contains only HISTORICAL versions; the current published // file.Versions contains only HISTORICAL versions; the current published
// version lives on `file` itself and is never deletable here. // version lives on `file` itself and is never deletable here.
var versions = file.Versions.ToList(); var versions = file.Versions.ToList();
int before = versions.Count; before = versions.Count;
if (before == 0) return null; if (before == 0) return null;
// Sort by Created ascending so [0] is the oldest historical version. // Sort by Created ascending so [0] is the oldest historical version.
@@ -173,6 +174,7 @@ public class VersionCleanupService : IVersionCleanupService
BytesFreed = bytesFreed, BytesFreed = bytesFreed,
}; };
} }
catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl); _logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
@@ -182,6 +184,7 @@ public class VersionCleanupService : IVersionCleanupService
Library = libraryTitle, Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl, FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl), FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
VersionsBefore = before,
Error = ex.Message, Error = ex.Message,
}; };
} }
@@ -8,6 +8,7 @@
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<StartupObject>SharepointToolbox.App</StartupObject> <StartupObject>SharepointToolbox.App</StartupObject>
<ApplicationIcon>Resources\SPToolbox-logo.ico</ApplicationIcon>
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime --> <!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn> <NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -43,6 +44,8 @@
<EmbeddedResource Include="Resources\bulk_add_members.csv" /> <EmbeddedResource Include="Resources\bulk_add_members.csv" />
<EmbeddedResource Include="Resources\bulk_create_sites.csv" /> <EmbeddedResource Include="Resources\bulk_create_sites.csv" />
<EmbeddedResource Include="Resources\folder_structure.csv" /> <EmbeddedResource Include="Resources\folder_structure.csv" />
<Resource Include="Resources\SPToolbox-logo-ico.png" />
<Resource Include="Resources\SPToolbox-logo.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -51,6 +54,14 @@
<PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" /> <PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.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" Version="4.83.3" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" /> <PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" /> <PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
@@ -17,6 +17,8 @@
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" /> <SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" />
<SolidColorBrush x:Key="DangerBrush" Color="#F87171" /> <SolidColorBrush x:Key="DangerBrush" Color="#F87171" />
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399" /> <SolidColorBrush x:Key="SuccessBrush" Color="#34D399" />
<SolidColorBrush x:Key="ErrorRowBgBrush" Color="#2D0C0C" />
<SolidColorBrush x:Key="ErrorRowFgBrush" Color="#F87171" />
<!-- Forced-dark text for elements painted with hardcoded light pastel backgrounds (risk tiles, colored rows). --> <!-- Forced-dark text for elements painted with hardcoded light pastel backgrounds (risk tiles, colored rows). -->
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" /> <SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" /> <SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" />
@@ -17,6 +17,8 @@
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" /> <SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" /> <SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
<SolidColorBrush x:Key="SuccessBrush" Color="#047857" /> <SolidColorBrush x:Key="SuccessBrush" Color="#047857" />
<SolidColorBrush x:Key="ErrorRowBgBrush" Color="#FDE0E0" />
<SolidColorBrush x:Key="ErrorRowFgBrush" Color="#B00020" />
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" /> <SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#DBE7FF" /> <SolidColorBrush x:Key="SelectionBrush" Color="#DBE7FF" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" /> <SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" />
+49
View File
@@ -1357,6 +1357,55 @@
<Setter Property="Height" Value="6" /> <Setter Property="Height" Value="6" />
</Style> </Style>
<!-- ====================================================== -->
<!-- InfoButton — small circular "?" help button -->
<!-- ====================================================== -->
<Style x:Key="InfoButtonStyle" TargetType="{x:Type Button}">
<Setter Property="Width" Value="18" />
<Setter Property="Height" Value="18" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource TextMutedBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Grid>
<Ellipse x:Name="Circle"
Fill="{TemplateBinding Background}"
Stroke="{TemplateBinding BorderBrush}"
StrokeThickness="{TemplateBinding BorderThickness}" />
<TextBlock x:Name="Label"
Text="?"
Foreground="{TemplateBinding Foreground}"
FontSize="{TemplateBinding FontSize}"
FontWeight="{TemplateBinding FontWeight}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
SnapsToDevicePixels="True" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Circle" Property="Fill" Value="{DynamicResource AccentSoftBrush}" />
<Setter TargetName="Circle" Property="Stroke" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Label" Property="Foreground" Value="{DynamicResource AccentBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Circle" Property="Fill" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Label" Property="Foreground" Value="{DynamicResource AccentForegroundBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ====================================================== --> <!-- ====================================================== -->
<!-- ToolTip --> <!-- ToolTip -->
<!-- ====================================================== --> <!-- ====================================================== -->
@@ -45,6 +45,23 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _mergePermissions; 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;
/// <summary>When true, sharing link entries (SharingLinkType != null) are removed from results and exports.</summary>
[ObservableProperty]
private bool _excludeSharingLinks;
/// <summary>When true, "Limited Access System Group For Web/List" entries are removed from results and exports.</summary>
[ObservableProperty]
private bool _excludeSystemGroups;
[ObservableProperty] [ObservableProperty]
private bool _includeSubsites; private bool _includeSubsites;
@@ -93,6 +110,17 @@ public partial class PermissionsViewModel : FeatureViewModelBase
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single; private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles; private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
/// <summary>
/// Results after applying ExcludeSharingLinks / ExcludeSystemGroups filters.
/// Rebuilt when Results changes or filter flags change.
/// </summary>
private IReadOnlyList<PermissionEntry> _filteredResults = Array.Empty<PermissionEntry>();
public IReadOnlyList<PermissionEntry> FilteredResults
{
get => _filteredResults;
private set => SetProperty(ref _filteredResults, value);
}
/// <summary> /// <summary>
/// Simplified wrappers computed from Results. Rebuilt when Results changes. /// Simplified wrappers computed from Results. Rebuilt when Results changes.
/// </summary> /// </summary>
@@ -115,16 +143,37 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// <summary> /// <summary>
/// The collection the DataGrid actually binds to. Returns: /// The collection the DataGrid actually binds to. Returns:
/// - Results (raw) when simplified mode is OFF /// - FilteredResults (raw) when simplified mode is OFF
/// - SimplifiedResults when simplified mode is ON and detail view is ON /// - SimplifiedResults when simplified mode is ON and detail view is ON
/// - (View handles summary display separately via Summaries property) /// - (View handles summary display separately via Summaries property)
/// </summary> /// </summary>
public object ActiveItemsSource => IsSimplifiedMode public object ActiveItemsSource => IsSimplifiedMode
? (object)SimplifiedResults ? (object)SimplifiedResults
: Results; : FilteredResults;
partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth)); partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth));
partial void OnExcludeSharingLinksChanged(bool value) => RefreshAfterFilterChange();
partial void OnExcludeSystemGroupsChanged(bool value) => RefreshAfterFilterChange();
private void RefreshAfterFilterChange()
{
if (Results.Count == 0) return;
RebuildFilteredResults();
if (IsSimplifiedMode) RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource));
}
private void RebuildFilteredResults()
{
IEnumerable<PermissionEntry> filtered = Results;
if (ExcludeSharingLinks)
filtered = filtered.Where(e => string.IsNullOrEmpty(e.SharingLinkType));
if (ExcludeSystemGroups)
filtered = filtered.Where(e => !e.GrantedThrough.Contains("Limited Access System Group", StringComparison.OrdinalIgnoreCase));
FilteredResults = filtered.ToList();
}
// ── Commands ──────────────────────────────────────────────────────────── // ── Commands ────────────────────────────────────────────────────────────
public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportCsvCommand { get; }
@@ -163,8 +212,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
_settingsService = settingsService; _settingsService = settingsService;
_ownershipService = ownershipService; _ownershipService = ownershipService;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ct => ExportCsvAsync(ct), CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ct => ExportHtmlAsync(ct), CanExport);
} }
/// <summary> /// <summary>
@@ -190,8 +239,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
_settingsService = settingsService; _settingsService = settingsService;
_ownershipService = ownershipService; _ownershipService = ownershipService;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ct => ExportCsvAsync(ct), CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ct => ExportHtmlAsync(ct), CanExport);
} }
// ── FeatureViewModelBase implementation ───────────────────────────────── // ── FeatureViewModelBase implementation ─────────────────────────────────
@@ -212,9 +261,18 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// Recomputes SimplifiedResults and Summaries from the current Results collection. /// Recomputes SimplifiedResults and Summaries from the current Results collection.
/// Called when Results changes or when simplified mode is toggled on. /// Called when Results changes or when simplified mode is toggled on.
/// </summary> /// </summary>
private static bool IsSimplifiedModeNoise(PermissionEntry e)
{
if (e.Users.Contains("SharePointHome", StringComparison.OrdinalIgnoreCase)) return true;
if (e.GrantedThrough.Contains("SharePointHome", StringComparison.OrdinalIgnoreCase)) return true;
if (e.UserLogins.Split(';').Any(l => l.Trim().StartsWith("c:0u.c|tenant|", StringComparison.OrdinalIgnoreCase))) return true;
return false;
}
private void RebuildSimplifiedData() private void RebuildSimplifiedData()
{ {
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results); var forSimplified = FilteredResults.Where(e => !IsSimplifiedModeNoise(e));
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(forSimplified);
Summaries = PermissionSummaryBuilder.Build(SimplifiedResults); Summaries = PermissionSummaryBuilder.Build(SimplifiedResults);
} }
@@ -294,6 +352,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
await dispatcher.InvokeAsync(() => await dispatcher.InvokeAsync(() =>
{ {
Results = new ObservableCollection<PermissionEntry>(allEntries); Results = new ObservableCollection<PermissionEntry>(allEntries);
RebuildFilteredResults();
if (IsSimplifiedMode) if (IsSimplifiedMode)
RebuildSimplifiedData(); RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource)); OnPropertyChanged(nameof(ActiveItemsSource));
@@ -302,6 +361,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
else else
{ {
Results = new ObservableCollection<PermissionEntry>(allEntries); Results = new ObservableCollection<PermissionEntry>(allEntries);
RebuildFilteredResults();
if (IsSimplifiedMode) if (IsSimplifiedMode)
RebuildSimplifiedData(); RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource)); OnPropertyChanged(nameof(ActiveItemsSource));
@@ -375,6 +435,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
{ {
_currentProfile = profile; _currentProfile = profile;
Results = new ObservableCollection<PermissionEntry>(); Results = new ObservableCollection<PermissionEntry>();
FilteredResults = Array.Empty<PermissionEntry>();
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>(); SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
Summaries = Array.Empty<PermissionSummary>(); Summaries = Array.Empty<PermissionSummary>();
OnPropertyChanged(nameof(ActiveItemsSource)); OnPropertyChanged(nameof(ActiveItemsSource));
@@ -395,7 +456,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
private bool CanExport() => Results.Count > 0; private bool CanExport() => Results.Count > 0;
private async Task ExportCsvAsync() private async Task ExportCsvAsync(CancellationToken ct)
{ {
if (_csvExportService == null || Results.Count == 0) return; if (_csvExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog var dialog = new SaveFileDialog
@@ -409,9 +470,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
try try
{ {
if (IsSimplifiedMode && SimplifiedResults.Count > 0) if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None); await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, ct);
else else
await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None); await _csvExportService.WriteAsync(FilteredResults, dialog.FileName, CurrentSplit, ct);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -421,7 +482,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
} }
} }
private async Task ExportHtmlAsync() private async Task ExportHtmlAsync(CancellationToken ct)
{ {
if (_htmlExportService == null || Results.Count == 0) return; if (_htmlExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog var dialog = new SaveFileDialog
@@ -449,7 +510,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
// by the site it was observed on, then resolve against that // by the site it was observed on, then resolve against that
// site's context. Using the root tenant ctx for a group that // site's context. Using the root tenant ctx for a group that
// lives on a sub-site makes CSOM fail with "Group not found". // lives on a sub-site makes CSOM fail with "Group not found".
var groupsBySite = Results var groupsBySite = FilteredResults
.Where(r => r.PrincipalType == "SharePointGroup") .Where(r => r.PrincipalType == "SharePointGroup")
.SelectMany(r => r.Users .SelectMany(r => r.Users
.Split(';', StringSplitOptions.RemoveEmptyEntries) .Split(';', StringSplitOptions.RemoveEmptyEntries)
@@ -479,9 +540,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
Name = _currentProfile.Name Name = _currentProfile.Name
}; };
var ctx = await _sessionManager.GetOrCreateContextAsync( var ctx = await _sessionManager.GetOrCreateContextAsync(
siteProfile, CancellationToken.None); siteProfile, ct);
var resolved = await _groupResolver.ResolveGroupsAsync( var resolved = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None); ctx, _currentProfile.ClientId, distinctNames, ct);
foreach (var kv in resolved) foreach (var kv in resolved)
merged[kv.Key] = kv.Value; merged[kv.Key] = kv.Value;
} }
@@ -498,9 +559,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
} }
if (IsSimplifiedMode && SimplifiedResults.Count > 0) 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, ct, branding, groupMembers, HideSystemGroupRaw);
else else
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers); await _htmlExportService.WriteAsync(FilteredResults, dialog.FileName, CurrentSplit, CurrentLayout, ct, branding, groupMembers, HideSystemGroupRaw);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -41,6 +41,39 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDonutChart = true; private bool _isDonutChart = true;
// ── Scan-time flags (control what is captured during the CSOM scan) ─────
[ObservableProperty] private bool _scanHiddenLibraries = true;
[ObservableProperty] private bool _scanPreservationHold = true;
[ObservableProperty] private bool _scanListAttachments = true;
[ObservableProperty] private bool _scanRecycleBin = true;
// ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
[ObservableProperty] private bool _showLibraries = true;
[ObservableProperty] private bool _showHiddenLibraries = true;
[ObservableProperty] private bool _showPreservationHold = true;
[ObservableProperty] private bool _showListAttachments = true;
[ObservableProperty] private bool _showRecycleBin = true;
[ObservableProperty] private bool _showSubsites = true;
/// <summary>
/// When true, recycle bin stage 1 + stage 2 collapse into a single
/// "[Recycle Bin] Total" row whose size is the sum of both stages.
/// When false, both stages render as separate rows.
/// </summary>
[ObservableProperty] private bool _combineRecycleBinStages = true;
// SPO-reported site total (Site.Usage.Storage). Independent reference
// value the user can compare against the scanned total.
[ObservableProperty] private long _spoReportedTotalSize;
partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();
/// <summary>0 = Single file, 1 = Split by site.</summary> /// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex; [ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary> /// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
@@ -111,6 +144,10 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
} }
// Raw scan output — never filtered. RebuildFilteredResults projects this
// into Results based on the Show* flags.
private List<StorageNode> _allNodes = new();
private ObservableCollection<StorageNode> _results = new(); private ObservableCollection<StorageNode> _results = new();
public ObservableCollection<StorageNode> Results public ObservableCollection<StorageNode> Results
{ {
@@ -126,15 +163,36 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
// ── Summary properties (computed from root-level library nodes) ───────── // ── Summary properties (computed from root-level library nodes) ─────────
//
// Recycle-bin contents are rolled into each library's TotalSizeBytes by the
// StorageService (matches storman.aspx). Including the synthetic root-level
// RecycleBin nodes here would double-count those bytes — filter them out.
// SummaryRecycleBinSize below still reads from _allNodes so the bin metric
// remains visible to the user.
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary> /// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes); public long SummaryTotalSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary> /// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes); public long SummaryVersionSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.VersionSizeBytes);
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary> /// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount); public long SummaryFileCount => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalFileCount);
/// <summary>
/// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
/// from the raw scan so it stays visible even when the user hides the
/// recycle-bin row in the report filter.
/// </summary>
public long SummaryRecycleBinSize => _allNodes
.Where(n => n.Kind == StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
public bool HasResults => Results.Count > 0; public bool HasResults => Results.Count > 0;
@@ -142,6 +200,7 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
OnPropertyChanged(nameof(SummaryTotalSize)); OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize)); OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryRecycleBinSize));
OnPropertyChanged(nameof(SummaryFileCount)); OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults)); OnPropertyChanged(nameof(HasResults));
} }
@@ -220,10 +279,15 @@ public partial class StorageViewModel : FeatureViewModelBase
var options = new StorageScanOptions( var options = new StorageScanOptions(
PerLibrary: PerLibrary, PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites, IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth); FolderDepth: FolderDepth,
IncludeHiddenLibraries: ScanHiddenLibraries,
IncludePreservationHold: ScanPreservationHold,
IncludeListAttachments: ScanListAttachments,
IncludeRecycleBin: ScanRecycleBin);
var allNodes = new List<StorageNode>(); var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>(); var allTypeMetrics = new List<FileTypeMetric>();
long spoReportedTotal = 0;
var autoOwnership = await IsAutoTakeOwnershipEnabled(); var autoOwnership = await IsAutoTakeOwnershipEnabled();
@@ -273,6 +337,8 @@ public partial class StorageViewModel : FeatureViewModelBase
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}...")); progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct); var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics); allTypeMetrics.AddRange(typeMetrics);
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
} }
// Flatten tree for DataGrid display // Flatten tree for DataGrid display
@@ -291,20 +357,113 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
await dispatcher.InvokeAsync(() => await dispatcher.InvokeAsync(() =>
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}); });
} }
else else
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
} }
} }
/// <summary>
/// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
/// Show* flags. Nodes whose root ancestor is excluded by the flags are
/// dropped along with their entire subtree, preserving DFS ordering.
/// </summary>
private void RebuildFilteredResults()
{
if (_allNodes.Count == 0)
{
Results = new ObservableCollection<StorageNode>();
return;
}
var filtered = new List<StorageNode>(_allNodes.Count);
bool includeCurrentSubtree = true;
foreach (var node in _allNodes)
{
if (node.IndentLevel == 0)
includeCurrentSubtree = IsKindShown(node.Kind);
if (includeCurrentSubtree)
filtered.Add(node);
}
if (CombineRecycleBinStages)
filtered = CombineRecycleBins(filtered);
Results = new ObservableCollection<StorageNode>(filtered);
}
/// <summary>
/// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
/// aggregate row inserted at the position of the first recycle-bin node
/// encountered. Preserves SiteTitle grouping when scans cover multiple
/// sites by aggregating per SiteTitle.
/// </summary>
private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
{
var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
var result = new List<StorageNode>(input.Count);
foreach (var node in input)
{
if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
{
string key = node.SiteTitle ?? string.Empty;
if (!byPath.TryGetValue(key, out var agg))
{
agg = new StorageNode
{
Name = "[Recycle Bin] Total",
SiteTitle = node.SiteTitle ?? string.Empty,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
IndentLevel = 0,
Children = new List<StorageNode>()
};
byPath[key] = agg;
result.Add(agg);
}
agg.TotalSizeBytes += node.TotalSizeBytes;
agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
agg.TotalFileCount += node.TotalFileCount;
if (node.LastModified.HasValue &&
(!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
agg.LastModified = node.LastModified;
}
else
{
result.Add(node);
}
}
return result;
}
private bool IsKindShown(StorageNodeKind kind) => kind switch
{
StorageNodeKind.Library => ShowLibraries,
StorageNodeKind.HiddenLibrary => ShowHiddenLibraries,
StorageNodeKind.PreservationHold => ShowPreservationHold,
StorageNodeKind.ListAttachments => ShowListAttachments,
StorageNodeKind.RecycleBin => ShowRecycleBin,
StorageNodeKind.Subsite => ShowSubsites,
_ => true
};
protected override void OnTenantSwitched(TenantProfile profile) protected override void OnTenantSwitched(TenantProfile profile)
{ {
_currentProfile = profile; _currentProfile = profile;
_allNodes = new List<StorageNode>();
SpoReportedTotalSize = 0;
Results = new ObservableCollection<StorageNode>(); Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(); FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile)); OnPropertyChanged(nameof(CurrentProfile));
@@ -0,0 +1,39 @@
<UserControl x:Class="SharepointToolbox.Views.Common.InfoButton"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root"
Width="18" Height="18"
VerticalAlignment="Center">
<Grid>
<Button x:Name="Btn"
Style="{StaticResource InfoButtonStyle}"
Click="Btn_Click" />
<Popup x:Name="InfoPopup"
StaysOpen="False"
AllowsTransparency="True"
Placement="Bottom"
PlacementTarget="{Binding ElementName=Btn}"
MaxWidth="340">
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource BorderStrongBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="14,10">
<Border.Effect>
<DropShadowEffect ShadowDepth="2" BlurRadius="10" Opacity="0.18" Color="#000000" />
</Border.Effect>
<StackPanel MaxWidth="310">
<TextBlock Text="{Binding Title, ElementName=Root}"
FontWeight="SemiBold"
FontSize="13"
Margin="0,0,0,6"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Body, ElementName=Root}"
FontSize="12"
TextWrapping="Wrap"
LineHeight="18" />
</StackPanel>
</Border>
</Popup>
</Grid>
</UserControl>
@@ -0,0 +1,36 @@
using System.Windows;
using System.Windows.Controls;
namespace SharepointToolbox.Views.Common
{
public partial class InfoButton : UserControl
{
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register(nameof(Title), typeof(string), typeof(InfoButton), new PropertyMetadata(string.Empty));
public static readonly DependencyProperty BodyProperty =
DependencyProperty.Register(nameof(Body), typeof(string), typeof(InfoButton), new PropertyMetadata(string.Empty));
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public string Body
{
get => (string)GetValue(BodyProperty);
set => SetValue(BodyProperty, value);
}
public InfoButton()
{
InitializeComponent();
}
private void Btn_Click(object sender, RoutedEventArgs e)
{
InfoPopup.IsOpen = !InfoPopup.IsOpen;
}
}
}
@@ -96,3 +96,30 @@ public class ListToStringConverter : IValueConverter
=> throw new NotImplementedException(); => throw new NotImplementedException();
} }
/// <summary>
/// Converts a <see cref="SharepointToolbox.Core.Models.StorageNodeKind"/> enum
/// to a localized display string via the translation source.
/// </summary>
[ValueConversion(typeof(SharepointToolbox.Core.Models.StorageNodeKind), typeof(string))]
public class StorageKindConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not SharepointToolbox.Core.Models.StorageNodeKind kind) return string.Empty;
var T = SharepointToolbox.Localization.TranslationSource.Instance;
return kind switch
{
SharepointToolbox.Core.Models.StorageNodeKind.Library => T["stor.kind.library"],
SharepointToolbox.Core.Models.StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
SharepointToolbox.Core.Models.StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
SharepointToolbox.Core.Models.StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
SharepointToolbox.Core.Models.StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
SharepointToolbox.Core.Models.StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
@@ -8,8 +8,13 @@
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0" <ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel> <StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}" <StackPanel Orientation="Horizontal" Margin="0,0,0,5">
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" /> <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
Command="{Binding ImportCsvCommand}" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulkmembers.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulkmembers.body]}" />
</StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" /> Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
@@ -41,8 +46,8 @@
<Style TargetType="DataGridRow"> <Style TargetType="DataGridRow">
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsValid}" Value="False"> <DataTrigger Binding="{Binding IsValid}" Value="False">
<Setter Property="Background" Value="#FFFDE0E0" /> <Setter Property="Background" Value="{DynamicResource ErrorRowBgBrush}" />
<Setter Property="Foreground" Value="#FFB00020" /> <Setter Property="Foreground" Value="{DynamicResource ErrorRowFgBrush}" />
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@@ -7,8 +7,13 @@
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0" <ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel> <StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}" <StackPanel Orientation="Horizontal" Margin="0,0,0,5">
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" /> <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
Command="{Binding ImportCsvCommand}" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulksites.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.bulksites.body]}" />
</StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" /> Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
@@ -39,8 +44,8 @@
<Style TargetType="DataGridRow"> <Style TargetType="DataGridRow">
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsValid}" Value="False"> <DataTrigger Binding="{Binding IsValid}" Value="False">
<Setter Property="Background" Value="#FFFDE0E0" /> <Setter Property="Background" Value="{DynamicResource ErrorRowBgBrush}" />
<Setter Property="Foreground" Value="#FFB00020" /> <Setter Property="Foreground" Value="{DynamicResource ErrorRowFgBrush}" />
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@@ -1,7 +1,8 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView" <UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<!-- Options panel --> <!-- Options panel -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8"> <ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
@@ -15,7 +16,15 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8"> <GroupBox Margin="0,0,0,8">
<GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.dup.criteria.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.dup.criteria.body]}" />
</StackPanel>
</GroupBox.Header>
<StackPanel Margin="4"> <StackPanel Margin="4">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,6" /> TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,6" />
@@ -12,8 +12,13 @@
Margin="0,0,0,3" /> Margin="0,0,0,3" />
<TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" /> <TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}" <StackPanel Orientation="Horizontal" Margin="0,0,0,5">
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" /> <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}"
Command="{Binding ImportCsvCommand}" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.folderstruct.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.folderstruct.body]}" />
</StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}"
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" /> Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
@@ -34,8 +34,13 @@
<!-- Checkboxes --> <!-- Checkboxes -->
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" /> IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}" <StackPanel Orientation="Horizontal" Margin="0,0,0,4">
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" /> <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
IsChecked="{Binding IncludeInherited}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.inherited.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.inherited.body]}" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.recursive]}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.recursive]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,0,0,8" /> IsChecked="{Binding IncludeSubsites}" Margin="0,0,0,8" />
@@ -57,8 +62,13 @@
<StackPanel> <StackPanel>
<!-- Simplified Mode toggle --> <!-- Simplified Mode toggle -->
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.simplified.mode]}" <StackPanel Orientation="Horizontal" Margin="0,0,0,8">
IsChecked="{Binding IsSimplifiedMode}" Margin="0,0,0,8" /> <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.simplified.mode]}"
IsChecked="{Binding IsSimplifiedMode}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.simplified.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.simplified.body]}" />
</StackPanel>
<!-- Detail Level radio buttons (only enabled when simplified mode is on) --> <!-- Detail Level radio buttons (only enabled when simplified mode is on) -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.detail.level]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.detail.level]}"
@@ -90,8 +100,46 @@
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.export]}" <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.export]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8"> DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel> <StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.merge.permissions]}" <StackPanel Orientation="Horizontal" Margin="0,0,0,4">
IsChecked="{Binding MergePermissions}" /> <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.merge.permissions]}"
IsChecked="{Binding MergePermissions}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.merge.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.merge.body]}" />
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" IsChecked="{Binding HideSystemGroupRaw}" VerticalAlignment="Top">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.hide.system.group.raw]}"
TextWrapping="Wrap" MaxWidth="210" />
</CheckBox>
<common:InfoButton Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Top"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.hidesys.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.hidesys.body]}" />
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.exclude.sharing.links]}"
IsChecked="{Binding ExcludeSharingLinks}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.sharing.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.sharing.body]}" />
</StackPanel>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" IsChecked="{Binding ExcludeSystemGroups}" VerticalAlignment="Top">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.exclude.system.groups]}"
TextWrapping="Wrap" MaxWidth="210" />
</CheckBox>
<common:InfoButton Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Top"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.system.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.excl.system.body]}" />
</Grid>
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -111,7 +159,12 @@
Command="{Binding CancelCommand}" Command="{Binding CancelCommand}"
Margin="0,0,0,4" Padding="6,3" /> Margin="0,0,0,4" Padding="6,3" />
</Grid> </Grid>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" /> <StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" VerticalAlignment="Center" />
<common:InfoButton Margin="4,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.splitmode.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.perm.splitmode.body]}" />
</StackPanel>
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4"> <ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
+17 -4
View File
@@ -1,19 +1,32 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView" <UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<!-- Filters panel --> <!-- Filters panel -->
<ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8"> <ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel> <StackPanel>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}" <GroupBox Margin="0,0,0,8">
Margin="0,0,0,8"> <GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.body]}" />
</StackPanel>
</GroupBox.Header>
<StackPanel Margin="4"> <StackPanel Margin="4">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" />
<TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26" <TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0,0,0,2" /> <StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0" VerticalAlignment="Center" />
<common:InfoButton Margin="4,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.regex.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.search.regex.body]}" />
</StackPanel>
<TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26" <TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" />
+64 -3
View File
@@ -3,11 +3,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters" xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"> xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<!-- Options panel --> <!-- Options panel -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" <ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
Margin="8,8,4,8"> HorizontalScrollBarVisibility="Disabled" Margin="8,8,4,8">
<StackPanel> <StackPanel>
<!-- Scan options group --> <!-- Scan options group -->
@@ -33,6 +34,55 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<!-- Scan sources group: control what the scan captures -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.sources]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,2">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.hidden]}"
IsChecked="{Binding ScanHiddenLibraries}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.hidden.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.hidden.body]}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.preservation]}"
IsChecked="{Binding ScanPreservationHold}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.preservation.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.storage.preservation.body]}" />
</StackPanel>
<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 IsChecked="{Binding CombineRecycleBinStages}" Margin="0,2">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.combine.recyclebin]}"
TextWrapping="Wrap" MaxWidth="195" />
</CheckBox>
</StackPanel>
</GroupBox>
<!-- Action buttons --> <!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}" Command="{Binding RunCommand}"
@@ -112,10 +162,18 @@
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.recyclebin_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryRecycleBinSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.spo_reported_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SpoReportedTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -140,6 +198,9 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" /> Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.kind]}"
Binding="{Binding Kind, Converter={StaticResource StorageKindConverter}}"
Width="120" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}" Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" /> Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
@@ -1,15 +1,23 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.TemplatesView" <UserControl x:Class="SharepointToolbox.Views.Tabs.TemplatesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10"> <DockPanel Margin="10">
<!-- Left panel: Capture and Apply --> <!-- Left panel: Capture and Apply -->
<ScrollViewer DockPanel.Dock="Left" Width="320" Margin="0,0,10,0" <ScrollViewer DockPanel.Dock="Left" Width="320" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel> <StackPanel>
<!-- Capture Section --> <!-- Capture Section -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}" <GroupBox Margin="0,0,0,10">
Margin="0,0,0,10"> <GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.capture.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.capture.body]}" />
</StackPanel>
</GroupBox.Header>
<StackPanel Margin="5"> <StackPanel Margin="5">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}"
Margin="0,0,0,3" /> Margin="0,0,0,3" />
@@ -35,8 +43,15 @@
</GroupBox> </GroupBox>
<!-- Apply Section --> <!-- Apply Section -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.apply]}" <GroupBox Margin="0,0,0,10">
Margin="0,0,0,10"> <GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.apply]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.apply.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.templates.apply.body]}" />
</StackPanel>
</GroupBox.Header>
<StackPanel Margin="5"> <StackPanel Margin="5">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.newtitle]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.newtitle]}"
Margin="0,0,0,3" /> Margin="0,0,0,3" />
+27 -10
View File
@@ -24,14 +24,24 @@
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11" <TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.text.files_selected]}" /> Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.text.files_selected]}" />
</StackPanel> </StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source]}" <StackPanel Orientation="Horizontal" Margin="0,6,0,0">
IsChecked="{Binding IncludeSourceFolder}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source]}"
Margin="0,6,0,0" IsChecked="{Binding IncludeSourceFolder}"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" /> VerticalAlignment="Center"
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}" ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" />
IsChecked="{Binding CopyFolderContents}" <common:InfoButton Margin="6,0,0,0"
Margin="0,4,0,0" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.incsource.title]}"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" /> Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.incsource.body]}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}"
IsChecked="{Binding CopyFolderContents}"
VerticalAlignment="Center"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.copycontent.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.copycontent.body]}" />
</StackPanel>
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -61,8 +71,15 @@
</GroupBox> </GroupBox>
<!-- Conflict Policy --> <!-- Conflict Policy -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}" <GroupBox Margin="0,0,0,10">
Margin="0,0,0,10"> <GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.conflict.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.transfer.conflict.body]}" />
</StackPanel>
</GroupBox.Header>
<ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged"> <ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" />
@@ -22,9 +22,12 @@
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.search]}" <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.search]}"
IsChecked="{Binding IsBrowseMode, Converter={StaticResource InverseBoolConverter}}" IsChecked="{Binding IsBrowseMode, Converter={StaticResource InverseBoolConverter}}"
Margin="0,0,12,0" /> Margin="0,0,12,0" VerticalAlignment="Center" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.browse]}" <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.browse]}"
IsChecked="{Binding IsBrowseMode}" /> IsChecked="{Binding IsBrowseMode}" VerticalAlignment="Center" />
<common:InfoButton Margin="8,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.mode.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.mode.body]}" />
</StackPanel> </StackPanel>
<!-- SEARCH MODE PANEL (visible when IsBrowseMode=false) --> <!-- SEARCH MODE PANEL (visible when IsBrowseMode=false) -->
@@ -201,8 +204,15 @@
</StackPanel> </StackPanel>
<!-- Scan Options (always visible) --> <!-- Scan Options (always visible) -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}" <GroupBox DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8"> <GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.vs.perms.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.audit.vs.perms.body]}" />
</StackPanel>
</GroupBox.Header>
<StackPanel> <StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" /> IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" />
@@ -1,7 +1,8 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView" <UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8"> <ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel> <StackPanel>
@@ -17,8 +18,15 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}" <GroupBox Margin="0,0,0,8">
Margin="0,0,0,8"> <GroupBox.Header>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.policy.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.policy.body]}" />
</StackPanel>
</GroupBox.Header>
<StackPanel Margin="4"> <StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,2"> <StackPanel Orientation="Horizontal" Margin="0,2">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}" <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}"
@@ -26,10 +34,20 @@
<TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}"
Width="50" Height="22" VerticalAlignment="Center" /> Width="50" Height="22" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}" <StackPanel Orientation="Horizontal" Margin="0,4,0,2">
IsChecked="{Binding KeepFirstVersion}" Margin="0,4,0,2" /> <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}"
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}" IsChecked="{Binding KeepFirstVersion}" VerticalAlignment="Center" />
IsChecked="{Binding ConfirmDelete}" Margin="0,4,0,2" /> <common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.keepfirst.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.keepfirst.body]}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4,0,2">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}"
IsChecked="{Binding ConfirmDelete}" VerticalAlignment="Center" />
<common:InfoButton Margin="6,0,0,0"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.confirm.title]}"
Body="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[help.versions.confirm.body]}" />
</StackPanel>
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
Margin="0,6,0,0" /> Margin="0,6,0,0" />