Compare commits

..

21 Commits

Author SHA1 Message Date
Dev 8c66fe6518 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-05-28 17:51:45 +02:00
Dev 5d305ccc4c Added max list size circumvention for file transfers between sites. 2026-05-28 17:50:23 +02:00
Dev e9065f2410 Added max list size circumvention for file transfers between sites. 2026-05-13 15:58:16 +02:00
Dev 4b51c8e3c3 Fixed Kiota CVE : pinned 1.17.1 → 1.22.2 2026-05-12 15:25:43 +02:00
Dev 1312dcdb1e Added new feature : display the file/folder and link of a SharingLink object in the permissions reports. 2026-05-12 15:20:51 +02:00
Dev ecc7b329d4 Fix storage metrics not being accurate 2026-05-06 09:17:40 +02:00
Dev f56e8813e5 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-05-05 09:33:10 +02:00
Dev 461c7d5bb4 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-05-05 09:32:58 +02:00
Dev 4dc4022405 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-05-04 12:31:24 +02:00
Dev 3f24fdd01e Merge remote-tracking branch 'kawa/main' 2026-05-04 12:31:13 +02:00
Dev 55e5cfc506 Merge remote-tracking branch 'kawa/main' 2026-04-29 17:55:56 +02:00
Dev 23a638a10a Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-29 17:27:51 +02:00
Dev a48df65f2e Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-29 10:44:47 +02:00
Dev df179be2ed Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-29 10:44:30 +02:00
Dev efb3d2ad11 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-24 10:54:47 +02:00
Dev 2c9dbe39d3 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-24 10:54:41 +02:00
Dev b8c09655ac Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-24 10:50:19 +02:00
Dev 12dd1de9f2 chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:50:03 +02:00
Dev f4cc81bb71 chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:23:11 +02:00
Dev 8f30a60d2a Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-15 14:27:36 +02:00
kawa 6e05d26114 Update README.md 2026-04-15 14:27:31 +02:00
135 changed files with 13079 additions and 1530 deletions
+16
View File
@@ -0,0 +1,16 @@
root = true
[*.cs]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# ConfigureAwait(false) is required in non-UI service/infrastructure code so
# callers that may still sync-wait cannot deadlock on the WPF dispatcher.
# Scoped to Services/ and Infrastructure/ — ViewModels legitimately resume on
# the UI thread for INotifyPropertyChanged updates.
[{SharepointToolbox/Services/**.cs,SharepointToolbox/Infrastructure/**.cs}]
dotnet_diagnostic.CA2007.severity = suggestion
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -9,21 +9,21 @@ public class SharePointPaginationHelperTests
public void BuildPagedViewXml_NullInput_ReturnsViewWithRowLimit()
{
var result = SharePointPaginationHelper.BuildPagedViewXml(null, 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_EmptyString_ReturnsViewWithRowLimit()
{
var result = SharePointPaginationHelper.BuildPagedViewXml("", 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_WhitespaceOnly_ReturnsViewWithRowLimit()
{
var result = SharePointPaginationHelper.BuildPagedViewXml(" ", 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
}
[Fact]
@@ -31,7 +31,15 @@ public class SharePointPaginationHelperTests
{
var input = "<View><RowLimit>100</RowLimit></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_ExistingPagedRowLimit_ReplacesWithNewSize()
{
var input = "<View><RowLimit Paged='TRUE'>100</RowLimit></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 5000);
Assert.Equal("<View><RowLimit Paged='TRUE'>5000</RowLimit></View>", result);
}
[Fact]
@@ -39,10 +47,9 @@ public class SharePointPaginationHelperTests
{
var input = "<View><Query><OrderBy><FieldRef Name='Title'/></OrderBy></Query></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
Assert.Contains("<RowLimit>2000</RowLimit>", result);
Assert.Contains("<RowLimit Paged='TRUE'>2000</RowLimit>", result);
Assert.EndsWith("</View>", result);
// Ensure RowLimit is inserted before the closing </View>
var rowLimitIndex = result.IndexOf("<RowLimit>2000</RowLimit>", StringComparison.Ordinal);
var rowLimitIndex = result.IndexOf("<RowLimit Paged='TRUE'>2000</RowLimit>", StringComparison.Ordinal);
var closingViewIndex = result.LastIndexOf("</View>", StringComparison.Ordinal);
Assert.True(rowLimitIndex < closingViewIndex, "RowLimit should appear before </View>");
}
@@ -48,6 +48,54 @@ public class CsvExportServiceTests
Assert.Contains("Object", csv);
}
[Fact]
public void BuildCsv_WithResolvedTarget_IncludesTargetColumns()
{
var entry = new PermissionEntry(
ObjectType: "Site",
Title: "HR",
Url: "https://contoso.sharepoint.com/sites/HR",
HasUniquePermissions: true,
Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
PermissionLevels: "Limited Access: Edit",
GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll",
TargetLabel: "Payroll");
var svc = new CsvExportService();
var csv = svc.BuildCsv(new[] { entry });
Assert.Contains("TargetLabel", csv);
Assert.Contains("TargetUrl", csv);
Assert.Contains("Payroll", csv);
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", csv);
}
[Fact]
public void BuildCsv_WithSharingLink_IncludesLinkType()
{
var entry = new PermissionEntry(
ObjectType: "Folder", Title: "Reports", Url: "https://contoso.sharepoint.com/sites/HR/Docs",
HasUniquePermissions: true,
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PermissionLevels: "Contribute",
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
PrincipalType: "SharePointGroup",
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
TargetLabel: "Q4.xlsx",
SharingLinkType: "OrganizationEdit");
var svc = new CsvExportService();
var csv = svc.BuildCsv(new[] { entry });
Assert.Contains("SharingLinkType", csv);
Assert.Contains("OrganizationEdit", csv);
Assert.Contains("Q4.xlsx", csv);
}
[Fact]
public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations()
{
@@ -115,7 +115,7 @@ public class HtmlExportServiceTests
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html);
Assert.Contains("data-group-target=\"grpmem0\"", html);
Assert.Contains("class=\"user-pill group-expandable\"", html);
}
@@ -152,7 +152,148 @@ public class HtmlExportServiceTests
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
Assert.Contains("function toggleGroup", html);
Assert.Contains("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]
@@ -165,7 +306,7 @@ public class HtmlExportServiceTests
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { simplifiedEntry }, null, groupMembers);
Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html);
Assert.Contains("data-group-target=\"grpmem0\"", html);
Assert.Contains("class=\"user-pill group-expandable\"", html);
}
}
@@ -86,20 +86,15 @@ public class UserAccessCsvExportServiceTests
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry });
// Find the header row and count its quoted comma-separated fields
// Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through"
// That is 7 fields.
// Header: Site, Object Type, Object, URL, Permission Level, Access Type,
// Granted Through, TargetLabel, TargetUrl, SharingLinkType → 10 fields.
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Find a data row (after the blank line separating summary from data)
// Data rows contain the entry content (not the header line itself)
// We want to count fields in the header row:
var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\""));
Assert.NotNull(headerLine);
// Count comma-separated quoted fields: split by "," boundary
var fields = CountCsvFields(headerLine!);
Assert.Equal(7, fields);
Assert.Equal(10, fields);
}
// ── Test 5: WriteSingleFileAsync includes entries for all users ───────────
@@ -190,8 +185,8 @@ public class UserAccessCsvExportServiceTests
await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true);
var content = await File.ReadAllTextAsync(tmpFile);
// Header must contain consolidated columns
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content);
// Header must contain consolidated columns (now includes Target* + SharingLinkType)
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"", content);
// Alice's two entries merged — locations column contains both site titles
Assert.Contains("Contoso", content);
@@ -3,7 +3,8 @@ using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Tests for PERM-03: external user detection and permission-level filtering.
/// Tests for PERM-03: external user detection, permission-level filtering,
/// and SharePoint system-group classification (Limited Access / SharingLinks).
/// Pure static logic — runs immediately without stubs.
/// </summary>
public class PermissionEntryClassificationTests
@@ -13,7 +14,6 @@ public class PermissionEntryClassificationTests
[Fact]
public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue()
{
// B2B guest login names contain the literal "#EXT#" fragment
Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com"));
}
@@ -28,8 +28,6 @@ public class PermissionEntryClassificationTests
[Fact]
public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess()
{
// A principal whose sole permission level is "Limited Access" should produce
// an empty list after filtering — used to decide whether to include the entry.
var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" });
Assert.Empty(result);
}
@@ -41,23 +39,100 @@ public class PermissionEntryClassificationTests
Assert.Equal(new[] { "Contribute" }, result);
}
// ── IsSharingLinksGroup ────────────────────────────────────────────────────
// ── IsBareLimitedAccessSystemGroup ─────────────────────────────────────────
[Fact]
public void IsSharingLinksGroup_WithSharingLinksPrefix_ReturnsTrue()
public void IsBareLimitedAccessSystemGroup_WithExactMatch_ReturnsTrue()
{
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("SharingLinks.abc123.Edit"));
Assert.True(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Limited Access System Group"));
}
[Fact]
public void IsSharingLinksGroup_WithLimitedAccessSystemGroup_ReturnsTrue()
public void IsBareLimitedAccessSystemGroup_WithForWebVariant_ReturnsFalse()
{
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("Limited Access System Group"));
// The "For Web {guid}" variant is enriched, not filtered.
Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup(
"Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"));
}
[Fact]
public void IsSharingLinksGroup_WithNormalGroup_ReturnsFalse()
public void IsBareLimitedAccessSystemGroup_WithNormalGroup_ReturnsFalse()
{
Assert.False(PermissionEntryHelper.IsSharingLinksGroup("Owners"));
Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Owners"));
}
// ── Classify ───────────────────────────────────────────────────────────────
[Fact]
public void Classify_WithNormalGroupTitle_ReturnsNone()
{
var c = PermissionEntryHelper.Classify("Site Owners");
Assert.Equal(SystemGroupKind.None, c.Kind);
}
[Fact]
public void Classify_WithBareLimitedAccess_ReturnsLimitedAccessBare()
{
var c = PermissionEntryHelper.Classify("Limited Access System Group");
Assert.Equal(SystemGroupKind.LimitedAccessBare, c.Kind);
}
[Fact]
public void Classify_WithLimitedAccessForWeb_ExtractsWebId()
{
var c = PermissionEntryHelper.Classify(
"Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80");
Assert.Equal(SystemGroupKind.LimitedAccessWeb, c.Kind);
Assert.Equal(Guid.Parse("e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"), c.WebId);
Assert.Null(c.ListId);
Assert.Null(c.ItemUniqueId);
}
[Fact]
public void Classify_WithLimitedAccessForList_ExtractsListId()
{
var c = PermissionEntryHelper.Classify(
"Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc");
Assert.Equal(SystemGroupKind.LimitedAccessList, c.Kind);
Assert.Equal(Guid.Parse("b20e3b22-2b09-4c99-9ba4-37b42f3a12dc"), c.ListId);
Assert.Null(c.WebId);
Assert.Null(c.ItemUniqueId);
}
[Fact]
public void Classify_WithSharingLink_ExtractsItemAndShareId()
{
var c = PermissionEntryHelper.Classify(
"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6");
Assert.Equal(SystemGroupKind.SharingLink, c.Kind);
Assert.Equal(Guid.Parse("e686221e-d1cb-43c5-8c68-04aa7f90f329"), c.ItemUniqueId);
Assert.Equal(Guid.Parse("64c27910-66ea-421d-b0f0-8f0f72dcfaf6"), c.ShareId);
Assert.Equal("OrganizationEdit", c.LinkType);
}
[Theory]
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.AnonymousView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "AnonymousView")]
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Flexible.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "Flexible")]
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "OrganizationView")]
public void Classify_WithVariousSharingLinkTypes_PreservesLinkType(string title, string expected)
{
var c = PermissionEntryHelper.Classify(title);
Assert.Equal(SystemGroupKind.SharingLink, c.Kind);
Assert.Equal(expected, c.LinkType);
}
[Fact]
public void Classify_WithMalformedSharingLink_ReturnsNone()
{
// Missing share GUID at the end
var c = PermissionEntryHelper.Classify("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Edit");
Assert.Equal(SystemGroupKind.None, c.Kind);
}
[Fact]
public void Classify_WithEmptyString_ReturnsNone()
{
var c = PermissionEntryHelper.Classify("");
Assert.Equal(SystemGroupKind.None, c.Kind);
}
}
@@ -28,4 +28,21 @@ public class StorageServiceTests
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes);
}
[Fact]
public void StorageNode_DefaultKind_IsLibrary()
{
var node = new StorageNode();
Assert.Equal(StorageNodeKind.Library, node.Kind);
}
[Fact]
public void StorageScanOptions_DefaultIncludeFlags_AreAllTrue()
{
var opts = new StorageScanOptions();
Assert.True(opts.IncludeHiddenLibraries);
Assert.True(opts.IncludePreservationHold);
Assert.True(opts.IncludeListAttachments);
Assert.True(opts.IncludeRecycleBin);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System.ComponentModel;
using SharepointToolbox.ViewModels.Dialogs;
using SharepointToolbox.Views.Dialogs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels.Dialogs;
public class SitePickerDialogLogicTests
{
private static SitePickerItem Item(string url, string title, long sizeMb = 0, string template = "")
=> new(url, title, sizeMb, 0, template);
[Fact]
public void ApplyFilter_TextFilter_MatchesUrlOrTitle_CaseInsensitive()
{
var items = new[]
{
Item("https://t/sites/hr", "HR Team"),
Item("https://t/sites/finance", "Finance"),
};
var result = SitePickerDialogLogic.ApplyFilter(items, "fINaNce", 0, long.MaxValue, "All").ToList();
Assert.Single(result);
Assert.Equal("Finance", result[0].Title);
}
[Fact]
public void ApplyFilter_SizeRange_FiltersInclusively()
{
var items = new[]
{
Item("a", "A", sizeMb: 100),
Item("b", "B", sizeMb: 500),
Item("c", "C", sizeMb: 1200),
};
var result = SitePickerDialogLogic.ApplyFilter(items, "", 100, 600, "All").ToList();
Assert.Equal(2, result.Count);
Assert.Contains(result, i => i.Title == "A");
Assert.Contains(result, i => i.Title == "B");
}
[Fact]
public void ApplyFilter_KindAll_SkipsKindCheck()
{
var items = new[] { Item("a", "A", template: "STS#3"), Item("b", "B", template: "GROUP#0") };
var result = SitePickerDialogLogic.ApplyFilter(items, "", 0, long.MaxValue, "All").ToList();
Assert.Equal(2, result.Count);
}
[Fact]
public void ApplySort_UnknownColumn_ReturnsInputUnchanged()
{
var items = new[] { Item("b", "B"), Item("a", "A") };
var result = SitePickerDialogLogic.ApplySort(items, "Nonexistent", ListSortDirection.Ascending).ToList();
Assert.Equal("B", result[0].Title);
Assert.Equal("A", result[1].Title);
}
[Fact]
public void ApplySort_Title_Ascending_And_Descending()
{
var items = new[] { Item("b", "B"), Item("a", "A"), Item("c", "C") };
var asc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Ascending).ToList();
var desc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Descending).ToList();
Assert.Equal(new[] { "A", "B", "C" }, asc.Select(i => i.Title));
Assert.Equal(new[] { "C", "B", "A" }, desc.Select(i => i.Title));
}
[Theory]
[InlineData("", 42L, 42L)]
[InlineData(" ", 42L, 42L)]
[InlineData("not-a-number", 42L, 42L)]
[InlineData("100", 42L, 100L)]
[InlineData(" 100 ", 42L, 100L)]
public void ParseLongOrDefault_HandlesEmptyAndInvalid(string input, long fallback, long expected)
{
Assert.Equal(expected, SitePickerDialogLogic.ParseLongOrDefault(input, fallback));
}
}
@@ -44,19 +44,25 @@ public class FeatureViewModelBaseTests
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
{
var vm = new TestViewModel();
int midProgress = -1;
string? midStatus = null;
vm.OperationFunc = async (ct, progress) =>
{
progress.Report(new OperationProgress(50, 100, "halfway"));
await Task.Yield();
// Let the Progress<T> callback dispatch before sampling.
await Task.Delay(20, ct);
midProgress = vm.ProgressValue;
midStatus = vm.StatusMessage;
};
await vm.RunCommand.ExecuteAsync(null);
// Allow dispatcher to process
await Task.Delay(20);
Assert.Equal(50, vm.ProgressValue);
Assert.Equal("halfway", vm.StatusMessage);
// Mid-operation snapshot confirms IProgress reaches bound properties.
// Post-completion, FeatureViewModelBase snaps to 100% / "Complete"
// so stale "Scanning X" labels don't linger after a successful run.
Assert.Equal(50, midProgress);
Assert.Equal("halfway", midStatus);
}
[Fact]
@@ -87,11 +87,11 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
}
[Fact]
public async Task RegisterApp_ShowsFallback_WhenNotAdmin()
public async Task RegisterApp_ShowsFallback_WhenGraphReturnsFallbackRequired()
{
_mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(AppRegistrationResult.FallbackRequired());
var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: null);
@@ -105,10 +105,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
public async Task RegisterApp_SetsAppId_OnSuccess()
{
_mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockAppReg
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(AppRegistrationResult.Success("new-app-id-123"));
var profileService = new ProfileService(new ProfileRepository(_tempFile));
@@ -132,7 +129,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
public async Task RemoveApp_ClearsAppId()
{
_mockAppReg
.Setup(s => s.RemoveAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Setup(s => s.RemoveAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_mockAppReg
.Setup(s => s.ClearMsalSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -32,7 +32,7 @@ public class SettingsViewModelLogoTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger);
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
@@ -30,7 +30,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger);
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
@@ -50,7 +50,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
var vm = new SettingsViewModel(settingsService, mockBranding, logger);
var vm = new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
await vm.LoadAsync();
vm.AutoTakeOwnership = true;
@@ -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);
}
}
+8
View File
@@ -4,6 +4,11 @@
xmlns:local="clr-namespace:SharepointToolbox"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Themes/LightPalette.xaml" />
<ResourceDictionary Source="/Themes/ModernTheme.xaml" />
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" />
@@ -11,9 +16,12 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:StorageKindConverter x:Key="StorageKindConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
+34
View File
@@ -34,9 +34,34 @@ public partial class App : Application
.Build();
host.Start();
// Apply persisted language before any UI is created so bindings resolve to the saved culture.
try
{
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(settings.Lang))
Localization.TranslationSource.Instance.CurrentCulture = new System.Globalization.CultureInfo(settings.Lang);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted language at startup");
}
App app = new();
app.InitializeComponent();
// Apply persisted theme (System/Light/Dark) before MainWindow constructs so brushes resolve correctly.
try
{
var theme = host.Services.GetRequiredService<ThemeManager>();
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
theme.ApplyFromString(settings.Theme);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted theme at startup");
}
var mainWindow = host.Services.GetRequiredService<MainWindow>();
// Wire LogPanelSink now that we have the RichTextBox
@@ -88,6 +113,7 @@ public partial class App : Application
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<ThemeManager>();
services.AddSingleton<MainWindowViewModel>();
// Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory
services.AddTransient<ProfileManagementViewModel>();
@@ -112,10 +138,18 @@ public partial class App : Application
// Phase 3: Duplicates
services.AddTransient<IDuplicatesService, DuplicatesService>();
services.AddTransient<DuplicatesHtmlExportService>();
services.AddTransient<DuplicatesCsvExportService>();
services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>();
// Versions cleanup
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
services.AddTransient<VersionCleanupHtmlExportService>();
services.AddTransient<VersionCleanupViewModel>();
services.AddTransient<VersionCleanupView>();
// Phase 2: Permissions
services.AddTransient<ISystemGroupTargetResolver, SystemGroupTargetResolver>();
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>();
services.AddTransient<CsvExportService>();
@@ -50,7 +50,10 @@ public static class PermissionConsolidator
first.GrantedThrough,
first.IsHighPrivilege,
first.IsExternalUser,
locations);
locations,
first.TargetUrl,
first.TargetLabel,
first.SharingLinkType);
})
.OrderBy(c => c.UserLogin)
.ThenBy(c => c.PermissionLevel)
@@ -1,10 +1,56 @@
using System.Text.RegularExpressions;
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Classifies a SharePoint group name into one of the known system-group shapes.
/// </summary>
public enum SystemGroupKind
{
/// <summary>Not a system group — a normal SharePoint group, user, or external user.</summary>
None,
/// <summary>Bare "Limited Access System Group" pseudo-principal (no embedded target).</summary>
LimitedAccessBare,
/// <summary>"Limited Access System Group For Web {webId}".</summary>
LimitedAccessWeb,
/// <summary>"Limited Access System Group For List {listId}".</summary>
LimitedAccessList,
/// <summary>"SharingLinks.{itemUniqueId}.{linkType}.{shareId}".</summary>
SharingLink
}
/// <summary>
/// Result of classifying a SharePoint group name.
/// Carries the embedded GUIDs / link type so a resolver can look up the target.
/// </summary>
public readonly record struct SystemGroupClassification(
SystemGroupKind Kind,
Guid? WebId,
Guid? ListId,
Guid? ItemUniqueId,
string? LinkType,
Guid? ShareId);
/// <summary>
/// Pure static helpers for classifying SharePoint permission entries.
/// </summary>
public static class PermissionEntryHelper
{
// "Limited Access System Group For Web {guid}"
private static readonly Regex LimitedAccessWebRegex = new(
@"^Limited Access System Group For Web\s+(?<id>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// "Limited Access System Group For List {guid}"
private static readonly Regex LimitedAccessListRegex = new(
@"^Limited Access System Group For List\s+(?<id>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// "SharingLinks.{itemUniqueId}.{linkType}.{shareId}"
private static readonly Regex SharingLinkRegex = new(
@"^SharingLinks\.(?<item>[0-9a-fA-F-]{36})\.(?<type>[^.]+)\.(?<share>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Returns true when the login name is a B2B guest (contains #EXT#).
/// </summary>
@@ -21,10 +67,45 @@ public static class PermissionEntryHelper
.ToList();
/// <summary>
/// Returns true when the login name represents an internal sharing-link group
/// or the "Limited Access System Group" pseudo-principal.
/// Returns true when the supplied name/login is the bare "Limited Access System Group"
/// pseudo-principal — the noise entry with no embedded GUID. The
/// per-Web/List and SharingLinks variants are NOT filtered: they are enriched.
/// </summary>
public static bool IsSharingLinksGroup(string loginName) =>
loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase)
|| loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
public static bool IsBareLimitedAccessSystemGroup(string name) =>
name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Classifies a SharePoint group title (the value used in Granted Through). When the
/// title matches a known system pattern, the embedded GUIDs / link type are parsed
/// out so a resolver can look up the actual targeted Web/List/Item.
/// </summary>
public static SystemGroupClassification Classify(string groupTitle)
{
if (string.IsNullOrWhiteSpace(groupTitle))
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
var trimmed = groupTitle.Trim();
if (IsBareLimitedAccessSystemGroup(trimmed))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessBare, null, null, null, null, null);
var mWeb = LimitedAccessWebRegex.Match(trimmed);
if (mWeb.Success && Guid.TryParse(mWeb.Groups["id"].Value, out var webId))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessWeb, webId, null, null, null, null);
var mList = LimitedAccessListRegex.Match(trimmed);
if (mList.Success && Guid.TryParse(mList.Groups["id"].Value, out var listId))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessList, null, listId, null, null, null);
var mShare = SharingLinkRegex.Match(trimmed);
if (mShare.Success
&& Guid.TryParse(mShare.Groups["item"].Value, out var itemId)
&& Guid.TryParse(mShare.Groups["share"].Value, out var shareId))
{
return new SystemGroupClassification(
SystemGroupKind.SharingLink, null, null, itemId, mShare.Groups["type"].Value, shareId);
}
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
}
}
@@ -6,9 +6,12 @@ namespace SharepointToolbox.Core.Helpers;
public static class SharePointPaginationHelper
{
// Max page size SharePoint honors with Paged='TRUE' (threshold bypass).
private const int DefaultRowLimit = 5000;
/// <summary>
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold.
/// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination.
/// Uses CamlQuery with Paged='TRUE' RowLimit and ListItemCollectionPosition for pagination.
/// Never call ExecuteQuery directly on a list — always use this helper.
/// </summary>
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
@@ -18,7 +21,7 @@ public static class SharePointPaginationHelper
[EnumeratorCancellation] CancellationToken ct = default)
{
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000);
query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit);
query.ListItemCollectionPosition = null;
do
@@ -36,21 +39,75 @@ public static class SharePointPaginationHelper
while (query.ListItemCollectionPosition != null);
}
/// <summary>
/// Enumerates items within a specific folder (direct children by default, or
/// recursive when <paramref name="recursive"/> is true). Uses paginated CAML
/// with no WHERE clause so it works on libraries above the 5,000-item threshold.
/// Callers filter by FSObjType client-side via the returned ListItem fields.
/// </summary>
public static async IAsyncEnumerable<ListItem> GetItemsInFolderAsync(
ClientContext ctx,
List list,
string folderServerRelativeUrl,
bool recursive,
string[]? viewFields = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var fields = viewFields ?? new[]
{
"FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size"
};
var viewFieldsXml = string.Join(string.Empty,
fields.Select(f => $"<FieldRef Name='{f}' />"));
var scope = recursive ? " Scope='RecursiveAll'" : string.Empty;
var viewXml =
$"<View{scope}>" +
"<Query></Query>" +
$"<ViewFields>{viewFieldsXml}</ViewFields>" +
$"<RowLimit Paged='TRUE'>{DefaultRowLimit}</RowLimit>" +
"</View>";
var query = new CamlQuery
{
ViewXml = viewXml,
FolderServerRelativeUrl = folderServerRelativeUrl,
ListItemCollectionPosition = null
};
do
{
ct.ThrowIfCancellationRequested();
var items = list.GetItems(query);
ctx.Load(items);
await ctx.ExecuteQueryAsync();
foreach (var item in items)
yield return item;
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (query.ListItemCollectionPosition != null);
}
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
{
// Inject or replace RowLimit in existing CAML, or create minimal view
if (string.IsNullOrWhiteSpace(existingXml))
return $"<View><RowLimit>{rowLimit}</RowLimit></View>";
return $"<View><RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>";
// Simple replacement approach — adequate for Phase 1
if (existingXml.Contains("<RowLimit>", StringComparison.OrdinalIgnoreCase))
// Replace any existing <RowLimit ...>n</RowLimit> with paged form.
if (System.Text.RegularExpressions.Regex.IsMatch(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
return System.Text.RegularExpressions.Regex.Replace(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
$"<RowLimit>{rowLimit}</RowLimit>",
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
return existingXml.Replace("</View>", $"<RowLimit>{rowLimit}</RowLimit></View>",
return existingXml.Replace("</View>",
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>",
StringComparison.OrdinalIgnoreCase);
}
}
@@ -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"),
};
}
@@ -5,4 +5,5 @@ public class AppSettings
public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false;
public string Theme { get; set; } = "System"; // System | Light | Dark
}
@@ -13,7 +13,10 @@ public record ConsolidatedPermissionEntry(
string GrantedThrough,
bool IsHighPrivilege,
bool IsExternalUser,
IReadOnlyList<LocationInfo> Locations
IReadOnlyList<LocationInfo> Locations,
string? TargetUrl = null,
string? TargetLabel = null,
string? SharingLinkType = null
)
{
/// <summary>Convenience count — equals Locations.Count.</summary>
@@ -10,4 +10,8 @@ public class DuplicateItem
public DateTime? Modified { get; set; }
public int? FolderCount { get; set; }
public int? FileCount { get; set; }
/// <summary>URL of the site the item was collected from.</summary>
public string SiteUrl { get; set; } = string.Empty;
/// <summary>Friendly site title; falls back to a derived label when unknown.</summary>
public string SiteTitle { get; set; } = string.Empty;
}
@@ -1,7 +1,7 @@
namespace SharepointToolbox.Core.Models;
public record OperationProgress(int Current, int Total, string Message)
public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false)
{
public static OperationProgress Indeterminate(string message) =>
new(0, 0, message);
new(0, 0, message, IsIndeterminate: true);
}
@@ -14,5 +14,10 @@ public record PermissionEntry(
string PermissionLevels, // Semicolon-joined role names (Limited Access already removed)
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType, // "SharePointGroup" | "User" | "External User"
bool WasAutoElevated = false // Set to true when site admin was auto-granted to access this entry
bool WasAutoElevated = false, // Set to true when site admin was auto-granted to access this entry
// Resolved target for Limited Access System Group For Web/List and SharingLinks
// groups — populated when GrantedThrough is one of those system groups, null otherwise.
string? TargetUrl = null,
string? TargetLabel = null,
string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | AnonymousView | etc.
);
@@ -41,6 +41,9 @@ public class SimplifiedPermissionEntry
public string PermissionLevels => Inner.PermissionLevels;
public string GrantedThrough => Inner.GrantedThrough;
public string PrincipalType => Inner.PrincipalType;
public string? TargetUrl => Inner.TargetUrl;
public string? TargetLabel => Inner.TargetLabel;
public string? SharingLinkType => Inner.SharingLinkType;
public SimplifiedPermissionEntry(PermissionEntry entry)
{
+40 -1
View File
@@ -1,3 +1,42 @@
namespace SharepointToolbox.Core.Models;
public record SiteInfo(string Url, string Title);
public record SiteInfo(string Url, string Title)
{
public long StorageUsedMb { get; init; }
public long StorageQuotaMb { get; init; }
public string Template { get; init; } = string.Empty;
public SiteKind Kind => SiteKindHelper.FromTemplate(Template);
}
public enum SiteKind
{
Unknown,
TeamSite,
CommunicationSite,
Classic
}
public static class SiteKindHelper
{
public static SiteKind FromTemplate(string template)
{
if (string.IsNullOrEmpty(template)) return SiteKind.Unknown;
if (template.StartsWith("GROUP#", StringComparison.OrdinalIgnoreCase)) return SiteKind.TeamSite;
if (template.StartsWith("SITEPAGEPUBLISHING#", StringComparison.OrdinalIgnoreCase)) return SiteKind.CommunicationSite;
if (template.StartsWith("STS#", StringComparison.OrdinalIgnoreCase)) return SiteKind.Classic;
return SiteKind.Unknown;
}
public static string DisplayName(SiteKind kind)
{
var key = kind switch
{
SiteKind.TeamSite => "sitepicker.kind.teamsite",
SiteKind.CommunicationSite => "sitepicker.kind.communication",
SiteKind.Classic => "sitepicker.kind.classic",
_ => "sitepicker.kind.other"
};
return Localization.TranslationSource.Instance[key];
}
}
@@ -12,5 +12,6 @@ public class StorageNode
public long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; }
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
public List<StorageNode> Children { get; set; } = new();
}
@@ -0,0 +1,15 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classification used to filter storage report output. Every node is captured
/// during a scan; the report user picks which categories appear.
/// </summary>
public enum StorageNodeKind
{
Library,
HiddenLibrary,
PreservationHold,
ListAttachments,
RecycleBin,
Subsite
}
@@ -3,5 +3,9 @@ namespace SharepointToolbox.Core.Models;
public record StorageScanOptions(
bool PerLibrary = true,
bool IncludeSubsites = false,
int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels
int FolderDepth = 0, // 0 = library root only; >0 = recurse N levels
bool IncludeHiddenLibraries = true,
bool IncludePreservationHold = true,
bool IncludeListAttachments = true,
bool IncludeRecycleBin = true
);
@@ -0,0 +1,15 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Resolved target for a SharePoint system group (Limited Access For Web/List or SharingLinks).
/// Carries the human-readable label, the navigable URL, and (for sharing links) the link type
/// so the export layer can render a clickable link in the Granted Through cell.
/// </summary>
public record SystemGroupTarget(
SystemGroupKind Kind,
string Label, // e.g. "MyList" | "Shared Documents/Folder/File.docx" | "Site - HR"
string Url, // Navigable URL of the targeted resource
string? LinkType = null // For SharingLinks: "OrganizationEdit", "AnonymousView", etc.
);
@@ -10,4 +10,24 @@ public class TransferJob
public string DestinationFolderPath { get; set; } = string.Empty;
public TransferMode Mode { get; set; } = TransferMode.Copy;
public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
/// <summary>
/// Optional library-relative file paths. When non-empty, only these files
/// are transferred; SourceFolderPath recursive enumeration is skipped.
/// </summary>
public IReadOnlyList<string> SelectedFilePaths { get; set; } = Array.Empty<string>();
/// <summary>
/// When true, recreate the source folder name under the destination folder
/// (dest/srcFolderName/... ). When false, the source folder's contents land
/// directly inside the destination folder.
/// </summary>
public bool IncludeSourceFolder { get; set; }
/// <summary>
/// When true (default), transfer the files inside the source folder.
/// When false, only create the folder structure (useful together with
/// <see cref="IncludeSourceFolder"/> to clone an empty scaffold).
/// </summary>
public bool CopyFolderContents { get; set; } = true;
}
@@ -29,5 +29,8 @@ public record UserAccessEntry(
AccessType AccessType, // Direct | Group | Inherited
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc.
bool IsHighPrivilege, // True for Full Control, Site Collection Administrator
bool IsExternalUser // True if login contains #EXT#
bool IsExternalUser, // True if login contains #EXT#
string? TargetUrl = null, // Resolved URL when GrantedThrough is a Limited Access / SharingLinks system group
string? TargetLabel = null, // Resolved name (list title / file name / web title)
string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | ...
);
@@ -0,0 +1,9 @@
namespace SharepointToolbox.Core.Models;
public record VersionCleanupOptions(
IReadOnlyList<string> LibraryTitles,
int KeepLast,
bool KeepFirst)
{
public static VersionCleanupOptions Default => new(Array.Empty<string>(), 5, false);
}
@@ -0,0 +1,14 @@
namespace SharepointToolbox.Core.Models;
public class VersionCleanupResult
{
public string SiteUrl { get; init; } = string.Empty;
public string Library { get; init; } = string.Empty;
public string FileServerRelativeUrl { get; init; } = string.Empty;
public string FileName { get; init; } = string.Empty;
public int VersionsBefore { get; init; }
public int VersionsDeleted { get; init; }
public int VersionsRemaining { get; init; }
public long BytesFreed { get; init; }
public string? Error { get; init; }
}
@@ -15,17 +15,47 @@ public class GraphClientFactory
/// <summary>
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA
/// used for SharePoint auth, but with Graph scopes.
/// used for SharePoint auth, but with Graph scopes. Uses the /common authority
/// and the <c>.default</c> scope (whatever the client is pre-consented for).
/// </summary>
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
public Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
=> CreateClientAsync(clientId, tenantId: null, scopes: null, ct);
/// <summary>
/// Creates a GraphServiceClient pinned to a specific tenant authority.
/// Pass the tenant domain (e.g. "contoso.onmicrosoft.com") or tenant GUID.
/// Null <paramref name="tenantId"/> falls back to /common.
/// </summary>
public Task<GraphServiceClient> CreateClientAsync(string clientId, string? tenantId, CancellationToken ct)
=> CreateClientAsync(clientId, tenantId, scopes: null, ct);
/// <summary>
/// Creates a GraphServiceClient with explicit Graph delegated scopes.
/// Use when <c>.default</c> is insufficient — typically for admin actions that
/// need scopes not pre-consented on the bootstrap client (e.g. app registration
/// requires <c>Application.ReadWrite.All</c> and
/// <c>DelegatedPermissionGrant.ReadWrite.All</c>). Triggers an admin-consent
/// prompt on first use if the tenant has not yet consented.
/// </summary>
public async Task<GraphServiceClient> CreateClientAsync(
string clientId,
string? tenantId,
string[]? scopes,
CancellationToken ct)
{
var pca = await _msalFactory.GetOrCreateAsync(clientId);
// Always reuse a cached account when one exists — `WithTenantId` on the
// silent/interactive call redirects the authority, and MSAL stores
// refresh tokens per tenant. Skipping the cached account forces an
// interactive prompt on every Graph call (the bug that produced 45
// sign-in windows during app registration).
var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
var graphScopes = new[] { "https://graph.microsoft.com/.default" };
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes);
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes, tenantId);
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider);
}
@@ -37,14 +67,16 @@ public class GraphClientFactory
internal class MsalTokenProvider : IAccessTokenProvider
{
private readonly IPublicClientApplication _pca;
private readonly IAccount? _account;
private IAccount? _account;
private readonly string[] _scopes;
private readonly string? _tenantId;
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes)
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes, string? tenantId = null)
{
_pca = pca;
_account = account;
_scopes = scopes;
_tenantId = tenantId;
}
public AllowedHostsValidator AllowedHostsValidator { get; } = new();
@@ -53,19 +85,36 @@ internal class MsalTokenProvider : IAccessTokenProvider
Uri uri,
Dictionary<string, object>? additionalAuthenticationContext = null,
CancellationToken cancellationToken = default)
{
// Refresh _account from PCA cache each call — interactive flows on a
// sibling token provider populate the cache, and we want the next
// request on this provider to use that account silently.
if (_account is null)
{
var accounts = await _pca.GetAccountsAsync();
_account = accounts.FirstOrDefault();
}
if (_account is not null)
{
try
{
var result = await _pca.AcquireTokenSilent(_scopes, _account)
.ExecuteAsync(cancellationToken);
var silent = _pca.AcquireTokenSilent(_scopes, _account);
if (_tenantId is not null) silent = silent.WithTenantId(_tenantId);
var result = await silent.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
catch (MsalUiRequiredException)
{
// If silent fails, try interactive
var result = await _pca.AcquireTokenInteractive(_scopes)
.ExecuteAsync(cancellationToken);
return result.AccessToken;
// fall through to interactive
}
}
var interactive = _pca.AcquireTokenInteractive(_scopes);
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
var interactiveResult = await interactive.ExecuteAsync(cancellationToken);
// Cache the account so subsequent calls on this provider go silent.
_account = interactiveResult.Account;
return interactiveResult.AccessToken;
}
}
+435 -2
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve">
<value>Doublons</value>
</data>
<data name="tab.versions" xml:space="preserve">
<value>Versions</value>
</data>
<data name="versions.tab" xml:space="preserve">
<value>Nettoyage des versions</value>
</data>
<data name="versions.grp.libs" xml:space="preserve">
<value>Bibliothèques</value>
</data>
<data name="versions.grp.policy" xml:space="preserve">
<value>Politique de conservation</value>
</data>
<data name="versions.btn.pickLibs" xml:space="preserve">
<value>Choisir des bibliothèques…</value>
</data>
<data name="versions.btn.clearLibs" xml:space="preserve">
<value>Réinitialiser (toutes les bibliothèques)</value>
</data>
<data name="versions.btn.run" xml:space="preserve">
<value>Supprimer les anciennes versions</value>
</data>
<data name="versions.lbl.keepLast" xml:space="preserve">
<value>Conserver les dernières&#160;:</value>
</data>
<data name="versions.chk.keepFirst" xml:space="preserve">
<value>Conserver aussi la toute première version</value>
</data>
<data name="versions.chk.confirm" xml:space="preserve">
<value>Demander confirmation avant l'exécution</value>
</data>
<data name="versions.note" xml:space="preserve">
<value>Seules les versions historiques sont supprimées. La version courante publiée est toujours conservée. L'action est irréversible.</value>
</data>
<data name="versions.libs.all" xml:space="preserve">
<value>Toutes les bibliothèques (aucun filtre)</value>
</data>
<data name="versions.libs.count" xml:space="preserve">
<value>{0} bibliothèque(s) sélectionnée(s)</value>
</data>
<data name="versions.confirm" xml:space="preserve">
<value>Supprimer les versions historiques en gardant les {0} dernières {1}&#160;?
Cette action est irréversible.</value>
</data>
<data name="versions.confirm.keepFirst" xml:space="preserve">
<value>(plus la première version)</value>
</data>
<data name="versions.err.keepLast" xml:space="preserve">
<value>«&#160;Conserver les dernières&#160;» doit être supérieur ou égal à 0.</value>
</data>
<data name="versions.summary.files" xml:space="preserve">
<value>Fichiers nettoyés&#160;:</value>
</data>
<data name="versions.summary.deleted" xml:space="preserve">
<value>Versions supprimées&#160;:</value>
</data>
<data name="versions.summary.freed" xml:space="preserve">
<value>Octets libérés&#160;:</value>
</data>
<data name="versions.col.library" xml:space="preserve">
<value>Bibliothèque</value>
</data>
<data name="versions.col.file" xml:space="preserve">
<value>Fichier</value>
</data>
<data name="versions.col.before" xml:space="preserve">
<value>Avant</value>
</data>
<data name="versions.col.deleted" xml:space="preserve">
<value>Supprimées</value>
</data>
<data name="versions.col.remaining" xml:space="preserve">
<value>Restantes</value>
</data>
<data name="versions.col.freed" xml:space="preserve">
<value>Libérés</value>
</data>
<data name="versions.col.path" xml:space="preserve">
<value>Chemin</value>
</data>
<data name="versions.col.error" xml:space="preserve">
<value>Erreur</value>
</data>
<data name="librarypicker.title" xml:space="preserve">
<value>Sélectionner les bibliothèques</value>
</data>
<data name="librarypicker.loading" xml:space="preserve">
<value>Chargement des bibliothèques…</value>
</data>
<data name="librarypicker.loaded" xml:space="preserve">
<value>{0} bibliothèques chargées.</value>
</data>
<data name="librarypicker.selectAll" xml:space="preserve">
<value>Tout sélectionner</value>
</data>
<data name="librarypicker.selectNone" xml:space="preserve">
<value>Tout désélectionner</value>
</data>
<data name="tab.templates" xml:space="preserve">
<value>Modèles</value>
</data>
@@ -109,6 +206,18 @@
<data name="settings.lang.fr" xml:space="preserve">
<value>Français</value>
</data>
<data name="settings.theme" xml:space="preserve">
<value>Thème</value>
</data>
<data name="settings.theme.system" xml:space="preserve">
<value>Utiliser le paramètre système</value>
</data>
<data name="settings.theme.light" xml:space="preserve">
<value>Clair</value>
</data>
<data name="settings.theme.dark" xml:space="preserve">
<value>Sombre</value>
</data>
<data name="settings.folder" xml:space="preserve">
<value>Dossier de sortie des données</value>
</data>
@@ -124,18 +233,36 @@
<data name="profile.clientid" xml:space="preserve">
<value>ID client</value>
</data>
<data name="profile.clientid.hint" xml:space="preserve">
<value>Optionnel — laissez vide pour enregistrer l'application automatiquement</value>
</data>
<data name="profile.add" xml:space="preserve">
<value>Ajouter</value>
</data>
<data name="profile.rename" xml:space="preserve">
<value>Renommer</value>
<data name="profile.save" xml:space="preserve">
<value>Enregistrer</value>
</data>
<data name="profile.delete" xml:space="preserve">
<value>Supprimer</value>
</data>
<data name="profile.add.tooltip" xml:space="preserve">
<value>Créer un nouveau profil à partir des valeurs ci-dessus.</value>
</data>
<data name="profile.save.tooltip" xml:space="preserve">
<value>Enregistrer les modifications du profil sélectionné.</value>
</data>
<data name="profile.delete.tooltip" xml:space="preserve">
<value>Supprimer le profil sélectionné.</value>
</data>
<data name="profile.register.warning" xml:space="preserve">
<value>L'enregistrement de l'application peut nécessiter jusqu'à {0} connexions. Continuer&#160;?</value>
</data>
<data name="status.ready" xml:space="preserve">
<value>Prêt</value>
</data>
<data name="status.complete" xml:space="preserve">
<value>Termin&#233;</value>
</data>
<data name="status.cancelled" xml:space="preserve">
<value>Opération annulée</value>
</data>
@@ -179,6 +306,28 @@
<data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Type</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Bibliothèque</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Bibliothèque masquée</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Sous-site</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Sources analysées</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Afficher dans le rapport</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Sous-sites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combiner les corbeilles (afficher le total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>Total rapporté par SPO : </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Corbeille : </value></data>
<!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data>
@@ -354,6 +503,27 @@
<data name="audit.btn.exportHtml" xml:space="preserve">
<value>Exporter HTML</value>
</data>
<data name="export.split.label" xml:space="preserve">
<value>D&#233;couper</value>
</data>
<data name="export.split.single" xml:space="preserve">
<value>Fichier unique</value>
</data>
<data name="export.split.bySite" xml:space="preserve">
<value>Par site</value>
</data>
<data name="export.split.byUser" xml:space="preserve">
<value>Par utilisateur</value>
</data>
<data name="export.html.layout.label" xml:space="preserve">
<value>Mise en page HTML</value>
</data>
<data name="export.html.layout.separate" xml:space="preserve">
<value>Fichiers s&#233;par&#233;s</value>
</data>
<data name="export.html.layout.tabbed" xml:space="preserve">
<value>Fichier unique &#224; onglets</value>
</data>
<data name="audit.summary.total" xml:space="preserve">
<value>Total des acc&#232;s</value>
</data>
@@ -412,6 +582,9 @@
<!-- Phase 16: Report Consolidation Toggle -->
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data>
<data name="chk.merge.permissions" xml:space="preserve"><value>Fusionner les permissions en double</value></data>
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Masquer les noms bruts (SharingLinks, Limited Access)</value></data>
<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 -->
<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>
@@ -434,4 +607,264 @@
<data name="settings.ownership.auto" xml:space="preserve"><value>Prendre automatiquement la propri&#233;t&#233; d'administrateur de collection de sites en cas de refus d'acc&#232;s</value></data>
<data name="settings.ownership.description" xml:space="preserve"><value>Lorsqu'activ&#233;, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'acc&#232;s. N&#233;cessite les permissions d'administrateur de tenant.</value></data>
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>Ce site a &#233;t&#233; &#233;lev&#233; automatiquement — la propri&#233;t&#233; a &#233;t&#233; prise pour compl&#233;ter le scan</value></data>
<!-- Report export localization -->
<data name="report.title.user_access" xml:space="preserve"><value>Rapport d'audit des acc&#232;s utilisateurs</value></data>
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>Rapport d'audit des acc&#232;s utilisateurs (consolid&#233;)</value></data>
<data name="report.title.permissions" xml:space="preserve"><value>Rapport des permissions SharePoint</value></data>
<data name="report.title.permissions_simplified" xml:space="preserve"><value>Rapport des permissions SharePoint (simplifi&#233;)</value></data>
<data name="report.title.storage" xml:space="preserve"><value>M&#233;triques de stockage SharePoint</value></data>
<data name="report.title.duplicates" xml:space="preserve"><value>Rapport de d&#233;tection de doublons SharePoint</value></data>
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de d&#233;tection de doublons</value></data>
<data name="report.title.search" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers SharePoint</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers</value></data>
<data name="report.title.versions" xml:space="preserve"><value>Rapport de nettoyage des versions SharePoint</value></data>
<data name="report.title.versions_short" xml:space="preserve"><value>Rapport de nettoyage des versions</value></data>
<data name="report.stat.total_accesses" xml:space="preserve"><value>Acc&#232;s totaux</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audit&#233;s</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analys&#233;s</value></data>
<data name="report.stat.high_privilege" xml:space="preserve"><value>Privil&#232;ge &#233;lev&#233;</value></data>
<data name="report.stat.external_users" xml:space="preserve"><value>Utilisateurs externes</value></data>
<data name="report.stat.total_entries" xml:space="preserve"><value>Entr&#233;es totales</value></data>
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Ensembles de permissions uniques</value></data>
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Utilisateurs/Groupes distincts</value></data>
<data name="report.stat.libraries" xml:space="preserve"><value>Biblioth&#232;ques</value></data>
<data name="report.stat.files" xml:space="preserve"><value>Fichiers</value></data>
<data name="report.stat.total_size" xml:space="preserve"><value>Taille totale</value></data>
<data name="report.stat.version_size" xml:space="preserve"><value>Taille des versions</value></data>
<data name="report.badge.guest" xml:space="preserve"><value>Invit&#233;</value></data>
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
<data name="report.badge.group" xml:space="preserve"><value>Groupe</value></data>
<data name="report.badge.inherited" xml:space="preserve"><value>H&#233;rit&#233;</value></data>
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
<data name="report.view.by_user" xml:space="preserve"><value>Par utilisateur</value></data>
<data name="report.view.by_site" xml:space="preserve"><value>Par site</value></data>
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filtrer les r&#233;sultats...</value></data>
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filtrer les permissions...</value></data>
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filtrer les lignes…</value></data>
<data name="report.filter.label" xml:space="preserve"><value>Filtre :</value></data>
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
<data name="report.col.object_type" xml:space="preserve"><value>Type d'objet</value></data>
<data name="report.col.object" xml:space="preserve"><value>Objet</value></data>
<data name="report.col.permission_level" xml:space="preserve"><value>Niveau de permission</value></data>
<data name="report.col.access_type" xml:space="preserve"><value>Type d'acc&#232;s</value></data>
<data name="report.col.granted_through" xml:space="preserve"><value>Accord&#233; via</value></data>
<data name="report.col.user" xml:space="preserve"><value>Utilisateur</value></data>
<data name="report.col.title" xml:space="preserve"><value>Titre</value></data>
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
<data name="report.col.users_groups" xml:space="preserve"><value>Utilisateurs/Groupes</value></data>
<data name="report.col.simplified" xml:space="preserve"><value>Simplifi&#233;</value></data>
<data name="report.col.risk" xml:space="preserve"><value>Risque</value></data>
<data name="report.col.library_folder" xml:space="preserve"><value>Biblioth&#232;que / Dossier</value></data>
<data name="report.col.last_modified" xml:space="preserve"><value>Derni&#232;re modification</value></data>
<data name="report.col.name" xml:space="preserve"><value>Nom</value></data>
<data name="report.col.library" xml:space="preserve"><value>Biblioth&#232;que</value></data>
<data name="report.col.path" xml:space="preserve"><value>Chemin</value></data>
<data name="report.col.size" xml:space="preserve"><value>Taille</value></data>
<data name="report.col.created" xml:space="preserve"><value>Cr&#233;&#233;</value></data>
<data name="report.col.modified" xml:space="preserve"><value>Modifi&#233;</value></data>
<data name="report.col.created_by" xml:space="preserve"><value>Cr&#233;&#233; par</value></data>
<data name="report.col.modified_by" xml:space="preserve"><value>Modifi&#233; par</value></data>
<data name="report.col.file_name" xml:space="preserve"><value>Nom de fichier</value></data>
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
<data name="report.col.file_type" xml:space="preserve"><value>Type de fichier</value></data>
<data name="report.col.file_count" xml:space="preserve"><value>Nombre de fichiers</value></data>
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
<data name="report.col.size_bytes" xml:space="preserve"><value>Taille (octets)</value></data>
<data name="report.text.accesses" xml:space="preserve"><value>acc&#232;s</value></data>
<data name="report.text.access_es" xml:space="preserve"><value>acc&#232;s</value></data>
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>groupe(s) de doublons trouv&#233;(s).</value></data>
<data name="report.text.results_parens" xml:space="preserve"><value>r&#233;sultat(s)</value></data>
<data name="report.text.of" xml:space="preserve"><value>sur</value></data>
<data name="report.text.shown" xml:space="preserve"><value>affich&#233;(s)</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.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.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.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>D&#233;tails des biblioth&#232;ques</value></data>
<!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>S&#233;lectionner les sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre&#160;:</value></data>
<data name="sitepicker.type" xml:space="preserve"><value>Type&#160;:</value></data>
<data name="sitepicker.type.all" xml:space="preserve"><value>Tous</value></data>
<data name="sitepicker.type.team" xml:space="preserve"><value>Sites d'&#233;quipe (MS Teams)</value></data>
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classique</value></data>
<data name="sitepicker.type.other" xml:space="preserve"><value>Autre</value></data>
<data name="sitepicker.size" xml:space="preserve"><value>Taille (Mo)&#160;:</value></data>
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
<data name="sitepicker.col.title" xml:space="preserve"><value>Titre</value></data>
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
<data name="sitepicker.col.size" xml:space="preserve"><value>Taille</value></data>
<data name="sitepicker.btn.load" xml:space="preserve"><value>Charger les sites</value></data>
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Tout s&#233;lectionner</value></data>
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Tout d&#233;s&#233;lectionner</value></data>
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Annuler</value></data>
<data name="sitepicker.status.loading" xml:space="preserve"><value>Chargement des sites...</value></data>
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites charg&#233;s.</value></data>
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites affich&#233;s.</value></data>
<data name="sitepicker.status.error" xml:space="preserve"><value>Erreur&#160;: {0}</value></data>
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Site d'&#233;quipe</value></data>
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classique</value></data>
<data name="sitepicker.kind.other" xml:space="preserve"><value>Autre</value></data>
<!-- Common UI -->
<data name="common.valid" xml:space="preserve"><value>Valide</value></data>
<data name="common.errors" xml:space="preserve"><value>Erreurs</value></data>
<data name="common.close" xml:space="preserve"><value>Fermer</value></data>
<data name="common.new_folder" xml:space="preserve"><value>+ Nouveau dossier</value></data>
<data name="common.guest" xml:space="preserve"><value>Invit&#233;</value></data>
<!-- InputDialog -->
<data name="input.title" xml:space="preserve"><value>Saisie</value></data>
<!-- ProfileManagementDialog -->
<data name="profmgmt.title" xml:space="preserve"><value>G&#233;rer les profils</value></data>
<data name="profmgmt.group" xml:space="preserve"><value>Profils</value></data>
<!-- Duplicates columns -->
<data name="duplicates.col.group" xml:space="preserve"><value>Groupe</value></data>
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
<!-- Folder structure levels -->
<data name="folderstruct.col.level1" xml:space="preserve"><value>Niveau 1</value></data>
<data name="folderstruct.col.level2" xml:space="preserve"><value>Niveau 2</value></data>
<data name="folderstruct.col.level3" xml:space="preserve"><value>Niveau 3</value></data>
<data name="folderstruct.col.level4" xml:space="preserve"><value>Niveau 4</value></data>
<!-- Permissions extra columns -->
<data name="perm.col.unique_perms" xml:space="preserve"><value>Perm. uniques</value></data>
<data name="perm.col.permission_levels" xml:space="preserve"><value>Niveaux d'autorisation</value></data>
<data name="perm.col.principal_type" xml:space="preserve"><value>Type de principal</value></data>
<!-- Storage summary labels -->
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Taille totale&#160;: </value></data>
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Taille des versions&#160;: </value></data>
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Fichiers&#160;: </value></data>
<!-- Templates columns -->
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
<data name="templates.col.captured" xml:space="preserve"><value>Captur&#233;</value></data>
<!-- Transfer view -->
<data name="transfer.text.files_selected" xml:space="preserve"><value> fichier(s) s&#233;lectionn&#233;(s)</value></data>
<data name="transfer.chk.include_source" xml:space="preserve"><value>Inclure le dossier source dans la destination</value></data>
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>Si activ&#233;, recr&#233;e le dossier source sous la destination. Sinon, d&#233;pose le contenu directement dans le dossier de destination.</value></data>
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copier le contenu du dossier</value></data>
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>Si activ&#233; (par d&#233;faut), transf&#232;re les fichiers du dossier. Sinon, seul le dossier est cr&#233;&#233; &#224; la destination.</value></data>
<!-- Shared ViewModel errors and statuses -->
<data name="err.no_tenant" xml:space="preserve"><value>Aucun tenant connect&#233;.</value></data>
<data name="err.no_tenant_connected" xml:space="preserve"><value>Aucun tenant s&#233;lectionn&#233;. Connectez-vous &#224; un tenant d'abord.</value></data>
<data name="err.no_profile_selected" xml:space="preserve"><value>Aucun profil de tenant s&#233;lectionn&#233;. Connectez-vous d'abord.</value></data>
<data name="err.no_sites_selected" xml:space="preserve"><value>S&#233;lectionnez au moins un site dans la barre d'outils.</value></data>
<data name="err.no_users_selected" xml:space="preserve"><value>Ajoutez au moins un utilisateur &#224; auditer.</value></data>
<data name="err.no_valid_rows" xml:space="preserve"><value>Aucune ligne valide &#224; traiter. Importez un CSV d'abord.</value></data>
<data name="err.template_name_required" xml:space="preserve"><value>Le nom du mod&#232;le est requis.</value></data>
<data name="err.site_title_required" xml:space="preserve"><value>Le titre du nouveau site est requis.</value></data>
<data name="err.site_alias_required" xml:space="preserve"><value>L'alias du nouveau site est requis.</value></data>
<data name="err.transfer_source_required" xml:space="preserve"><value>Le site source et la biblioth&#232;que doivent &#234;tre s&#233;lectionn&#233;s.</value></data>
<data name="err.transfer_dest_required" xml:space="preserve"><value>Le site de destination et la biblioth&#232;que doivent &#234;tre s&#233;lectionn&#233;s.</value></data>
<data name="err.library_title_required" xml:space="preserve"><value>Le titre de la biblioth&#232;que est requis.</value></data>
<!-- Templates status -->
<data name="templates.status.capturing" xml:space="preserve"><value>Capture du mod&#232;le...</value></data>
<data name="templates.status.success" xml:space="preserve"><value>Mod&#232;le captur&#233; avec succ&#232;s.</value></data>
<data name="templates.status.capture_failed" xml:space="preserve"><value>&#201;chec de la capture&#160;: {0}</value></data>
<data name="templates.status.applying" xml:space="preserve"><value>Application du mod&#232;le...</value></data>
<data name="templates.status.applied" xml:space="preserve"><value>Mod&#232;le appliqu&#233;. Site cr&#233;&#233; &#224;&#160;: {0}</value></data>
<data name="templates.status.apply_failed" xml:space="preserve"><value>&#201;chec de l'application&#160;: {0}</value></data>
<!-- UI text -->
<data name="audit.searching" xml:space="preserve"><value>Recherche en cours...</value></data>
<!-- Report text -->
<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.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>
+435 -2
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve">
<value>Duplicates</value>
</data>
<data name="tab.versions" xml:space="preserve">
<value>Versions</value>
</data>
<data name="versions.tab" xml:space="preserve">
<value>Version cleanup</value>
</data>
<data name="versions.grp.libs" xml:space="preserve">
<value>Libraries</value>
</data>
<data name="versions.grp.policy" xml:space="preserve">
<value>Retention policy</value>
</data>
<data name="versions.btn.pickLibs" xml:space="preserve">
<value>Select libraries...</value>
</data>
<data name="versions.btn.clearLibs" xml:space="preserve">
<value>Reset (all libraries)</value>
</data>
<data name="versions.btn.run" xml:space="preserve">
<value>Delete old versions</value>
</data>
<data name="versions.lbl.keepLast" xml:space="preserve">
<value>Keep last:</value>
</data>
<data name="versions.chk.keepFirst" xml:space="preserve">
<value>Also keep the very first version</value>
</data>
<data name="versions.chk.confirm" xml:space="preserve">
<value>Ask for confirmation before running</value>
</data>
<data name="versions.note" xml:space="preserve">
<value>Only historical versions are removed. The current published version is always kept. The action cannot be undone.</value>
</data>
<data name="versions.libs.all" xml:space="preserve">
<value>All libraries (no filter)</value>
</data>
<data name="versions.libs.count" xml:space="preserve">
<value>{0} library/libraries selected</value>
</data>
<data name="versions.confirm" xml:space="preserve">
<value>Delete historical file versions, keeping the last {0} {1}?
This cannot be undone.</value>
</data>
<data name="versions.confirm.keepFirst" xml:space="preserve">
<value>(plus the first version)</value>
</data>
<data name="versions.err.keepLast" xml:space="preserve">
<value>"Keep last" must be 0 or greater.</value>
</data>
<data name="versions.summary.files" xml:space="preserve">
<value>Files trimmed:</value>
</data>
<data name="versions.summary.deleted" xml:space="preserve">
<value>Versions deleted:</value>
</data>
<data name="versions.summary.freed" xml:space="preserve">
<value>Bytes freed:</value>
</data>
<data name="versions.col.library" xml:space="preserve">
<value>Library</value>
</data>
<data name="versions.col.file" xml:space="preserve">
<value>File</value>
</data>
<data name="versions.col.before" xml:space="preserve">
<value>Before</value>
</data>
<data name="versions.col.deleted" xml:space="preserve">
<value>Deleted</value>
</data>
<data name="versions.col.remaining" xml:space="preserve">
<value>Remaining</value>
</data>
<data name="versions.col.freed" xml:space="preserve">
<value>Freed</value>
</data>
<data name="versions.col.path" xml:space="preserve">
<value>Path</value>
</data>
<data name="versions.col.error" xml:space="preserve">
<value>Error</value>
</data>
<data name="librarypicker.title" xml:space="preserve">
<value>Select libraries</value>
</data>
<data name="librarypicker.loading" xml:space="preserve">
<value>Loading libraries...</value>
</data>
<data name="librarypicker.loaded" xml:space="preserve">
<value>{0} libraries loaded.</value>
</data>
<data name="librarypicker.selectAll" xml:space="preserve">
<value>Select all</value>
</data>
<data name="librarypicker.selectNone" xml:space="preserve">
<value>Select none</value>
</data>
<data name="tab.templates" xml:space="preserve">
<value>Templates</value>
</data>
@@ -109,6 +206,18 @@
<data name="settings.lang.fr" xml:space="preserve">
<value>French</value>
</data>
<data name="settings.theme" xml:space="preserve">
<value>Theme</value>
</data>
<data name="settings.theme.system" xml:space="preserve">
<value>Use system setting</value>
</data>
<data name="settings.theme.light" xml:space="preserve">
<value>Light</value>
</data>
<data name="settings.theme.dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="settings.folder" xml:space="preserve">
<value>Data output folder</value>
</data>
@@ -124,18 +233,36 @@
<data name="profile.clientid" xml:space="preserve">
<value>Client ID</value>
</data>
<data name="profile.clientid.hint" xml:space="preserve">
<value>Optional — leave blank to register the app automatically</value>
</data>
<data name="profile.add" xml:space="preserve">
<value>Add</value>
</data>
<data name="profile.rename" xml:space="preserve">
<value>Rename</value>
<data name="profile.save" xml:space="preserve">
<value>Save</value>
</data>
<data name="profile.delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="profile.add.tooltip" xml:space="preserve">
<value>Create a new profile from the values entered above.</value>
</data>
<data name="profile.save.tooltip" xml:space="preserve">
<value>Save changes to the selected profile.</value>
</data>
<data name="profile.delete.tooltip" xml:space="preserve">
<value>Delete the selected profile.</value>
</data>
<data name="profile.register.warning" xml:space="preserve">
<value>Registering an app may prompt you to sign in up to {0} times. Continue?</value>
</data>
<data name="status.ready" xml:space="preserve">
<value>Ready</value>
</data>
<data name="status.complete" xml:space="preserve">
<value>Complete</value>
</data>
<data name="status.cancelled" xml:space="preserve">
<value>Operation cancelled</value>
</data>
@@ -179,6 +306,28 @@
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Kind</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Library</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Hidden Library</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Subsite</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Scan Sources</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Show in Report</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Subsites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combine Recycle Bin Stages (show total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>SPO reported total: </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Recycle Bin: </value></data>
<!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
@@ -354,6 +503,27 @@
<data name="audit.btn.exportHtml" xml:space="preserve">
<value>Export HTML</value>
</data>
<data name="export.split.label" xml:space="preserve">
<value>Split</value>
</data>
<data name="export.split.single" xml:space="preserve">
<value>Single file</value>
</data>
<data name="export.split.bySite" xml:space="preserve">
<value>By site</value>
</data>
<data name="export.split.byUser" xml:space="preserve">
<value>By user</value>
</data>
<data name="export.html.layout.label" xml:space="preserve">
<value>HTML layout</value>
</data>
<data name="export.html.layout.separate" xml:space="preserve">
<value>Separate files</value>
</data>
<data name="export.html.layout.tabbed" xml:space="preserve">
<value>Single tabbed file</value>
</data>
<data name="audit.summary.total" xml:space="preserve">
<value>Total Accesses</value>
</data>
@@ -412,6 +582,9 @@
<!-- Phase 16: Report Consolidation Toggle -->
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data>
<data name="chk.merge.permissions" xml:space="preserve"><value>Merge duplicate permissions</value></data>
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Hide raw system group names (SharingLinks, Limited Access)</value></data>
<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 -->
<data name="profile.register" xml:space="preserve"><value>Register App</value></data>
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
@@ -434,4 +607,264 @@
<data name="settings.ownership.auto" xml:space="preserve"><value>Automatically take site collection admin ownership on access denied</value></data>
<data name="settings.ownership.description" xml:space="preserve"><value>When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions.</value></data>
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>This site was automatically elevated — ownership was taken to complete the scan</value></data>
<!-- Report export localization -->
<data name="report.title.user_access" xml:space="preserve"><value>User Access Audit Report</value></data>
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>User Access Audit Report (Consolidated)</value></data>
<data name="report.title.permissions" xml:space="preserve"><value>SharePoint Permissions Report</value></data>
<data name="report.title.permissions_simplified" xml:space="preserve"><value>SharePoint Permissions Report (Simplified)</value></data>
<data name="report.title.storage" xml:space="preserve"><value>SharePoint Storage Metrics</value></data>
<data name="report.title.duplicates" xml:space="preserve"><value>SharePoint Duplicate Detection Report</value></data>
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
<data name="report.title.versions" xml:space="preserve"><value>SharePoint Version Cleanup Report</value></data>
<data name="report.title.versions_short" xml:space="preserve"><value>Version Cleanup Report</value></data>
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
<data name="report.stat.high_privilege" xml:space="preserve"><value>High Privilege</value></data>
<data name="report.stat.external_users" xml:space="preserve"><value>External Users</value></data>
<data name="report.stat.total_entries" xml:space="preserve"><value>Total Entries</value></data>
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Unique Permission Sets</value></data>
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Distinct Users/Groups</value></data>
<data name="report.stat.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="report.stat.files" xml:space="preserve"><value>Files</value></data>
<data name="report.stat.total_size" xml:space="preserve"><value>Total Size</value></data>
<data name="report.stat.version_size" xml:space="preserve"><value>Version Size</value></data>
<data name="report.badge.guest" xml:space="preserve"><value>Guest</value></data>
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
<data name="report.badge.group" xml:space="preserve"><value>Group</value></data>
<data name="report.badge.inherited" xml:space="preserve"><value>Inherited</value></data>
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
<data name="report.view.by_user" xml:space="preserve"><value>By User</value></data>
<data name="report.view.by_site" xml:space="preserve"><value>By Site</value></data>
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filter results...</value></data>
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filter permissions...</value></data>
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filter rows…</value></data>
<data name="report.filter.label" xml:space="preserve"><value>Filter:</value></data>
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
<data name="report.col.object_type" xml:space="preserve"><value>Object Type</value></data>
<data name="report.col.object" xml:space="preserve"><value>Object</value></data>
<data name="report.col.permission_level" xml:space="preserve"><value>Permission Level</value></data>
<data name="report.col.access_type" xml:space="preserve"><value>Access Type</value></data>
<data name="report.col.granted_through" xml:space="preserve"><value>Granted Through</value></data>
<data name="report.col.user" xml:space="preserve"><value>User</value></data>
<data name="report.col.title" xml:space="preserve"><value>Title</value></data>
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
<data name="report.col.users_groups" xml:space="preserve"><value>Users/Groups</value></data>
<data name="report.col.simplified" xml:space="preserve"><value>Simplified</value></data>
<data name="report.col.risk" xml:space="preserve"><value>Risk</value></data>
<data name="report.col.library_folder" xml:space="preserve"><value>Library / Folder</value></data>
<data name="report.col.last_modified" xml:space="preserve"><value>Last Modified</value></data>
<data name="report.col.name" xml:space="preserve"><value>Name</value></data>
<data name="report.col.library" xml:space="preserve"><value>Library</value></data>
<data name="report.col.path" xml:space="preserve"><value>Path</value></data>
<data name="report.col.size" xml:space="preserve"><value>Size</value></data>
<data name="report.col.created" xml:space="preserve"><value>Created</value></data>
<data name="report.col.modified" xml:space="preserve"><value>Modified</value></data>
<data name="report.col.created_by" xml:space="preserve"><value>Created By</value></data>
<data name="report.col.modified_by" xml:space="preserve"><value>Modified By</value></data>
<data name="report.col.file_name" xml:space="preserve"><value>File Name</value></data>
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
<data name="report.col.file_type" xml:space="preserve"><value>File Type</value></data>
<data name="report.col.file_count" xml:space="preserve"><value>File Count</value></data>
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
<data name="report.col.size_bytes" xml:space="preserve"><value>Size (bytes)</value></data>
<data name="report.text.accesses" xml:space="preserve"><value>accesses</value></data>
<data name="report.text.access_es" xml:space="preserve"><value>access(es)</value></data>
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>duplicate group(s) found.</value></data>
<data name="report.text.results_parens" xml:space="preserve"><value>result(s)</value></data>
<data name="report.text.of" xml:space="preserve"><value>of</value></data>
<data name="report.text.shown" xml:space="preserve"><value>shown</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.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.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.high_priv" xml:space="preserve"><value>high-priv</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
<!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
<data name="sitepicker.type" xml:space="preserve"><value>Type:</value></data>
<data name="sitepicker.type.all" xml:space="preserve"><value>All</value></data>
<data name="sitepicker.type.team" xml:space="preserve"><value>Team sites (MS Teams)</value></data>
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classic</value></data>
<data name="sitepicker.type.other" xml:space="preserve"><value>Other</value></data>
<data name="sitepicker.size" xml:space="preserve"><value>Size (MB):</value></data>
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
<data name="sitepicker.col.title" xml:space="preserve"><value>Title</value></data>
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
<data name="sitepicker.col.size" xml:space="preserve"><value>Size</value></data>
<data name="sitepicker.btn.load" xml:space="preserve"><value>Load Sites</value></data>
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Select All</value></data>
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Deselect All</value></data>
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Cancel</value></data>
<data name="sitepicker.status.loading" xml:space="preserve"><value>Loading sites...</value></data>
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites loaded.</value></data>
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites shown.</value></data>
<data name="sitepicker.status.error" xml:space="preserve"><value>Error: {0}</value></data>
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Team site</value></data>
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classic</value></data>
<data name="sitepicker.kind.other" xml:space="preserve"><value>Other</value></data>
<!-- Common UI -->
<data name="common.valid" xml:space="preserve"><value>Valid</value></data>
<data name="common.errors" xml:space="preserve"><value>Errors</value></data>
<data name="common.close" xml:space="preserve"><value>Close</value></data>
<data name="common.new_folder" xml:space="preserve"><value>+ New Folder</value></data>
<data name="common.guest" xml:space="preserve"><value>Guest</value></data>
<!-- InputDialog -->
<data name="input.title" xml:space="preserve"><value>Input</value></data>
<!-- ProfileManagementDialog -->
<data name="profmgmt.title" xml:space="preserve"><value>Manage Profiles</value></data>
<data name="profmgmt.group" xml:space="preserve"><value>Profiles</value></data>
<!-- Duplicates columns -->
<data name="duplicates.col.group" xml:space="preserve"><value>Group</value></data>
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
<!-- Folder structure levels -->
<data name="folderstruct.col.level1" xml:space="preserve"><value>Level 1</value></data>
<data name="folderstruct.col.level2" xml:space="preserve"><value>Level 2</value></data>
<data name="folderstruct.col.level3" xml:space="preserve"><value>Level 3</value></data>
<data name="folderstruct.col.level4" xml:space="preserve"><value>Level 4</value></data>
<!-- Permissions extra columns -->
<data name="perm.col.unique_perms" xml:space="preserve"><value>Unique Perms</value></data>
<data name="perm.col.permission_levels" xml:space="preserve"><value>Permission Levels</value></data>
<data name="perm.col.principal_type" xml:space="preserve"><value>Principal Type</value></data>
<!-- Storage summary labels -->
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Total Size: </value></data>
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Version Size: </value></data>
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Files: </value></data>
<!-- Templates columns -->
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
<data name="templates.col.captured" xml:space="preserve"><value>Captured</value></data>
<!-- Transfer view -->
<data name="transfer.text.files_selected" xml:space="preserve"><value> file(s) selected</value></data>
<data name="transfer.chk.include_source" xml:space="preserve"><value>Include source folder at destination</value></data>
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder.</value></data>
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copy folder contents</value></data>
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>When on (default), transfer files inside the folder. When off, only the folder is created at the destination.</value></data>
<!-- Shared ViewModel errors and statuses -->
<data name="err.no_tenant" xml:space="preserve"><value>No tenant connected.</value></data>
<data name="err.no_tenant_connected" xml:space="preserve"><value>No tenant selected. Please connect to a tenant first.</value></data>
<data name="err.no_profile_selected" xml:space="preserve"><value>No tenant profile selected. Please connect first.</value></data>
<data name="err.no_sites_selected" xml:space="preserve"><value>Select at least one site from the toolbar.</value></data>
<data name="err.no_users_selected" xml:space="preserve"><value>Add at least one user to audit.</value></data>
<data name="err.no_valid_rows" xml:space="preserve"><value>No valid rows to process. Import a CSV first.</value></data>
<data name="err.template_name_required" xml:space="preserve"><value>Template name is required.</value></data>
<data name="err.site_title_required" xml:space="preserve"><value>New site title is required.</value></data>
<data name="err.site_alias_required" xml:space="preserve"><value>New site alias is required.</value></data>
<data name="err.transfer_source_required" xml:space="preserve"><value>Source site and library must be selected.</value></data>
<data name="err.transfer_dest_required" xml:space="preserve"><value>Destination site and library must be selected.</value></data>
<data name="err.library_title_required" xml:space="preserve"><value>Library title is required.</value></data>
<!-- Templates status -->
<data name="templates.status.capturing" xml:space="preserve"><value>Capturing template...</value></data>
<data name="templates.status.success" xml:space="preserve"><value>Template captured successfully.</value></data>
<data name="templates.status.capture_failed" xml:space="preserve"><value>Capture failed: {0}</value></data>
<data name="templates.status.applying" xml:space="preserve"><value>Applying template...</value></data>
<data name="templates.status.applied" xml:space="preserve"><value>Template applied. Site created at: {0}</value></data>
<data name="templates.status.apply_failed" xml:space="preserve"><value>Apply failed: {0}</value></data>
<!-- UI text -->
<data name="audit.searching" xml:space="preserve"><value>Searching...</value></data>
<!-- Report text -->
<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.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>
+8 -3
View File
@@ -8,6 +8,10 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Icon="pack://application:,,,/Resources/SPToolbox-logo-ico.png"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
MinWidth="900" MinHeight="600" Height="700" Width="1100">
<DockPanel>
<!-- Toolbar -->
@@ -15,8 +19,6 @@
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.connect]}"
Command="{Binding ConnectCommand}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
Command="{Binding ManageProfilesCommand}" />
<Separator />
@@ -28,7 +30,7 @@
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" />
<TextBlock Text="{Binding GlobalSitesSelectedLabel}"
VerticalAlignment="Center" Margin="6,0,0,0"
Foreground="Gray" />
Foreground="{DynamicResource TextMutedBrush}" />
</ToolBar>
<!-- StatusBar: tenant name | operation status text | progress % -->
@@ -60,6 +62,9 @@
<TabItem x:Name="DuplicatesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
</TabItem>
<TabItem x:Name="VersionsTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.versions]}">
</TabItem>
<TabItem x:Name="TransferTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
</TabItem>
+3
View File
@@ -40,6 +40,9 @@ public partial class MainWindow : Window
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
// Versions cleanup tab
VersionsTabItem.Content = serviceProvider.GetRequiredService<VersionCleanupView>();
// Phase 4: Replace stub tabs with DI-resolved Views
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -11,12 +11,41 @@ namespace SharepointToolbox.Services;
/// Manages Azure AD app registration and removal using the Microsoft Graph API.
/// All operations use <see cref="GraphClientFactory"/> for token acquisition.
/// </summary>
/// <remarks>
/// <para>GraphServiceClient lifecycle: a fresh client is created per public call
/// (<see cref="IsGlobalAdminAsync"/>, <see cref="RegisterAsync"/>,
/// <see cref="RollbackAsync"/>, <see cref="RemoveAsync"/>). This is intentional —
/// each call may use different scopes (RegistrationScopes vs. default) and target
/// a different tenant, so a cached per-service instance would bind the wrong
/// authority. The factory itself caches the underlying MSAL PCA and token cache,
/// so client construction is cheap (no network hit when tokens are valid).</para>
/// <para>Do not cache a GraphServiceClient at call sites — always go through
/// <see cref="GraphClientFactory"/> so tenant pinning and scope selection stay
/// correct.</para>
/// </remarks>
public class AppRegistrationService : IAppRegistrationService
{
// Entra built-in directory role template IDs are global constants shared across all tenants.
// GlobalAdminTemplateId: "Global Administrator" directoryRoleTemplate.
// See https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator
private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10";
// First-party Microsoft service appIds (constant across tenants).
private const string GraphAppId = "00000003-0000-0000-c000-000000000000";
private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000";
// Explicit scopes for the registration flow. The bootstrap client
// (Microsoft Graph Command Line Tools) does not pre-consent these, so
// requesting `.default` returns a token without them → POST /applications
// fails with 403 even for a Global Admin. Requesting them explicitly
// triggers the admin-consent prompt on first use.
private static readonly string[] RegistrationScopes = new[]
{
"https://graph.microsoft.com/Application.ReadWrite.All",
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All",
"https://graph.microsoft.com/Directory.Read.All",
};
private readonly AppGraphClientFactory _graphFactory;
private readonly AppMsalClientFactory _msalFactory;
private readonly ISessionManager _sessionManager;
@@ -35,38 +64,33 @@ public class AppRegistrationService : IAppRegistrationService
}
/// <inheritdoc/>
public async Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct)
public async Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct)
{
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req =>
{
req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')";
}, ct);
// No $filter: isof() on directoryObject requires advanced query params
// (ConsistencyLevel: eventual + $count=true) and fails with 400 otherwise.
// The user's membership list is small; filtering client-side is fine.
var tenantId = ResolveTenantId(tenantUrl);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, ct);
var memberships = await graphClient.Me.TransitiveMemberOf.GetAsync(cancellationToken: ct);
return roles?.Value?
return memberships?.Value?
.OfType<DirectoryRole>()
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
StringComparison.OrdinalIgnoreCase)) ?? false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId);
return false;
}
}
/// <inheritdoc/>
public async Task<AppRegistrationResult> RegisterAsync(
string clientId,
string tenantUrl,
string tenantDisplayName,
CancellationToken ct)
{
var tenantId = ResolveTenantId(tenantUrl);
Application? createdApp = null;
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
// Step 1: Create Application object
var appRequest = new Application
@@ -78,7 +102,10 @@ public class AppRegistrationService : IAppRegistrationService
{
RedirectUris = new List<string>
{
"https://login.microsoftonline.com/common/oauth2/nativeclient"
// Loopback URI for MSAL desktop default (any port accepted by Entra).
"http://localhost",
// Legacy native-client URI for embedded WebView fallback.
"https://login.microsoftonline.com/common/oauth2/nativeclient",
}
},
RequiredResourceAccess = BuildRequiredResourceAccess()
@@ -131,15 +158,29 @@ public class AppRegistrationService : IAppRegistrationService
_logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId);
return AppRegistrationResult.Success(createdApp.AppId!);
}
catch (Microsoft.Graph.Models.ODataErrors.ODataError odataEx)
when (odataEx.ResponseStatusCode == 401 || odataEx.ResponseStatusCode == 403)
{
_logger.LogWarning(odataEx,
"RegisterAsync refused by Graph (status {Status}) — user lacks role/consent. Surfacing fallback.",
odataEx.ResponseStatusCode);
await RollbackAsync(createdApp, clientId, tenantId, ct);
return AppRegistrationResult.FallbackRequired();
}
catch (Exception ex)
{
_logger.LogError(ex, "RegisterAsync failed. Attempting rollback.");
await RollbackAsync(createdApp, clientId, tenantId, ct);
return AppRegistrationResult.Failure(ex.Message);
}
}
if (createdApp?.Id is not null)
private async Task RollbackAsync(Application? createdApp, string clientId, string tenantId, CancellationToken ct)
{
if (createdApp?.Id is null) return;
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
}
@@ -149,16 +190,13 @@ public class AppRegistrationService : IAppRegistrationService
}
}
return AppRegistrationResult.Failure(ex.Message);
}
}
/// <inheritdoc/>
public async Task RemoveAsync(string clientId, string appId, CancellationToken ct)
public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct)
{
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var tenantId = ResolveTenantId(tenantUrl);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Removed Application appId={AppId}", appId);
}
@@ -168,6 +206,32 @@ public class AppRegistrationService : IAppRegistrationService
}
}
/// <summary>
/// Derives a tenant identifier (domain) from a SharePoint tenant URL so MSAL
/// can pin the authority to the correct tenant. Examples:
/// https://contoso.sharepoint.com → contoso.onmicrosoft.com
/// https://contoso-admin.sharepoint.com → contoso.onmicrosoft.com
/// Throws <see cref="ArgumentException"/> when the URL is not a recognisable
/// SharePoint URL — falling back to /common would silently route registration
/// to the signed-in user's home tenant, which is the bug this guards against.
/// </summary>
internal static string ResolveTenantId(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
throw new ArgumentException($"Invalid tenant URL: '{tenantUrl}'", nameof(tenantUrl));
var host = uri.Host;
var firstDot = host.IndexOf('.');
if (firstDot <= 0)
throw new ArgumentException($"Cannot derive tenant from host '{host}'", nameof(tenantUrl));
var prefix = host.Substring(0, firstDot);
if (prefix.EndsWith("-admin", StringComparison.OrdinalIgnoreCase))
prefix = prefix.Substring(0, prefix.Length - "-admin".Length);
return $"{prefix}.onmicrosoft.com";
}
/// <inheritdoc/>
public async Task ClearMsalSessionAsync(string clientId, string tenantUrl)
{
@@ -64,6 +64,7 @@ public class BulkMemberService : IBulkMemberService
return;
}
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}",
@@ -83,7 +84,7 @@ public class BulkMemberService : IBulkMemberService
{
// Resolve user by email
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
if (user == null)
if (user?.Id == null)
throw new InvalidOperationException($"User not found: {email}");
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;
}
catch
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Debug("Could not resolve M365 group ID for {SiteUrl}: {Error}", siteUrl, ex.Message);
return null;
}
}
@@ -7,29 +7,72 @@ public static class BulkOperationRunner
/// <summary>
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
/// and cancellation support. OperationCanceledException propagates immediately.
///
/// Progress is reported AFTER each item completes (success or failure), so the bar
/// reflects actual work done rather than work queued. A final "Complete" report
/// guarantees 100% when the total was determinate.
///
/// Set <paramref name="maxConcurrency"/> > 1 to run items in parallel. Callers must
/// ensure processItem is safe to invoke concurrently (e.g. each invocation uses its
/// own CSOM ClientContext — a shared CSOM context is NOT thread-safe).
/// </summary>
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
IReadOnlyList<TItem> items,
Func<TItem, int, CancellationToken, Task> processItem,
IProgress<OperationProgress> progress,
CancellationToken ct)
CancellationToken ct,
int maxConcurrency = 1)
{
var results = new List<BulkItemResult<TItem>>();
for (int i = 0; i < items.Count; i++)
if (items.Count == 0)
{
progress.Report(new OperationProgress(0, 0, "Nothing to do."));
return new BulkOperationSummary<TItem>(Array.Empty<BulkItemResult<TItem>>());
}
progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}..."));
var results = new BulkItemResult<TItem>[items.Count];
int completed = 0;
async Task RunOne(int i, CancellationToken token)
{
ct.ThrowIfCancellationRequested();
progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}..."));
try
{
await processItem(items[i], i, ct);
results.Add(BulkItemResult<TItem>.Success(items[i]));
await processItem(items[i], i, token);
results[i] = BulkItemResult<TItem>.Success(items[i]);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(BulkItemResult<TItem>.Failed(items[i], ex.Message));
results[i] = BulkItemResult<TItem>.Failed(items[i], ex.Message);
}
finally
{
int done = Interlocked.Increment(ref completed);
progress.Report(new OperationProgress(done, items.Count,
$"Processed {done}/{items.Count}"));
}
}
if (maxConcurrency <= 1)
{
for (int i = 0; i < items.Count; i++)
{
ct.ThrowIfCancellationRequested();
await RunOne(i, ct);
}
}
else
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = maxConcurrency,
CancellationToken = ct
};
await Parallel.ForEachAsync(Enumerable.Range(0, items.Count), options,
async (i, token) => await RunOne(i, token));
}
progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
return new BulkOperationSummary<TItem>(results);
}
@@ -54,6 +54,9 @@ public class BulkSiteService : IBulkSiteService
var owners = ParseEmails(row.Owners);
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
{
DisplayName = row.Name,
@@ -88,6 +91,7 @@ public class BulkSiteService : IBulkSiteService
membersGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
@@ -142,6 +146,7 @@ public class BulkSiteService : IBulkSiteService
ownersGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Failed to add owner {Email} to {Site}: {Error}",
@@ -162,6 +167,7 @@ public class BulkSiteService : IBulkSiteService
membersGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
+90 -14
View File
@@ -1,7 +1,9 @@
using System.Diagnostics;
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Services;
@@ -13,9 +15,27 @@ namespace SharepointToolbox.Services;
/// </summary>
public class DuplicatesService : IDuplicatesService
{
// SharePoint Search REST API caps RowLimit at 500 per request; larger values are silently clamped.
private const int BatchSize = 500;
// SharePoint Search hard ceiling — StartRow > 50,000 returns an error regardless of pagination state.
// See https://learn.microsoft.com/sharepoint/dev/general-development/customizing-search-results-in-sharepoint
private const int MaxStartRow = 50_000;
/// <summary>
/// Scans a site for duplicate files or folders and groups matches by the
/// composite key configured in <paramref name="options"/> (name plus any
/// of size / created / modified / subfolder-count / file-count).
/// File mode uses the SharePoint Search API — it is fast but capped at
/// 50,000 rows (see <see cref="MaxStartRow"/>). Folder mode uses paginated
/// CSOM CAML over every document library on the site. Groups with fewer
/// than two items are dropped before return.
/// </summary>
/// <param name="ctx">Authenticated <see cref="ClientContext"/> for the target site.</param>
/// <param name="options">Scope (Files/Folders), optional library filter, and match-key toggles.</param>
/// <param name="progress">Receives row-count progress during collection.</param>
/// <param name="ct">Cancellation token — honoured between paged requests.</param>
/// <returns>Duplicate groups ordered by descending size, then name.</returns>
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
ClientContext ctx,
DuplicateScanOptions options,
@@ -70,6 +90,8 @@ public class DuplicatesService : IDuplicatesService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct);
// KQL: all documents, optionally scoped to a library
var kqlParts = new List<string> { "ContentType:Document" };
if (!string.IsNullOrEmpty(options.Library))
@@ -102,10 +124,25 @@ public class DuplicatesService : IDuplicatesService
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows)
foreach (var rawRow in table.ResultRows)
{
var dict = row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
// CSOM has returned ResultRows as either Hashtable or
// Dictionary<string,object> across versions — accept both.
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic)
{
dict = generic;
}
else if (rawRow is System.Collections.IDictionary legacy)
{
dict = new Dictionary<string, object>();
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
else
{
continue;
}
string path = GetStr(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
@@ -132,7 +169,9 @@ public class DuplicatesService : IDuplicatesService
Library = library,
SizeBytes = size,
Created = created,
Modified = modified
Modified = modified,
SiteUrl = siteUrl,
SiteTitle = siteTitle
});
}
@@ -156,10 +195,16 @@ public class DuplicatesService : IDuplicatesService
{
// Load all document libraries on the site
ctx.Load(ctx.Web,
w => w.Title,
w => w.Lists.Include(
l => l.Title, l => l.Hidden, l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var siteUrl = ctx.Url;
var siteTitle = string.IsNullOrWhiteSpace(ctx.Web.Title)
? ReportSplitHelper.DeriveSiteLabel(siteUrl)
: ctx.Web.Title;
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
@@ -172,19 +217,15 @@ public class DuplicatesService : IDuplicatesService
.ToList();
}
// No WHERE clause — a WHERE on non-indexed fields (FSObjType) throws the
// list-view threshold on libraries > 5,000 items even with pagination.
// Filter for folders client-side via FileSystemObjectType below.
var camlQuery = new CamlQuery
{
ViewXml = """
<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>1</Value>
</Eq>
</Where>
</Query>
<RowLimit>2000</RowLimit>
<Query></Query>
<RowLimit Paged='TRUE'>5000</RowLimit>
</View>
"""
};
@@ -200,6 +241,8 @@ public class DuplicatesService : IDuplicatesService
{
ct.ThrowIfCancellationRequested();
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
var fv = item.FieldValues;
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
@@ -217,7 +260,9 @@ public class DuplicatesService : IDuplicatesService
FolderCount = subCount,
FileCount = fileCount,
Created = created,
Modified = modified
Modified = modified,
SiteUrl = siteUrl,
SiteTitle = siteTitle
});
}
}
@@ -246,6 +291,37 @@ public class DuplicatesService : IDuplicatesService
private static DateTime? ParseDate(string s) =>
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
private static async Task<(string Url, string Title)> LoadSiteIdentityAsync(
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
{
try
{
ctx.Load(ctx.Web, w => w.Title);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Best-effort — fall back to URL-derived label
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: failed to load Web.Title: {ex.GetType().Name}: {ex.Message}");
}
var url = ctx.Url ?? string.Empty;
string title;
try { title = ctx.Web.Title; }
catch (Exception ex)
{
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: Web.Title getter threw: {ex.GetType().Name}: {ex.Message}");
title = string.Empty;
}
if (string.IsNullOrWhiteSpace(title))
title = ReportSplitHelper.DeriveSiteLabel(url);
return (url, title);
}
private static string ExtractLibraryFromPath(string path, string siteUrl)
{
// Extract first path segment after the site URL as library name
@@ -2,20 +2,40 @@ using System.Globalization;
using System.IO;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports the failed subset of a <see cref="BulkOperationSummary{T}"/> run
/// to CSV. CsvHelper is used so the <typeparamref name="T"/> payload's
/// properties become columns automatically, plus one error-message and one
/// timestamp column appended at the end.
/// </summary>
public class BulkResultCsvExportService
{
private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture)
{
// Prevent CSV formula injection: prefix =, +, -, @, tab, CR with single quote
InjectionOptions = InjectionOptions.Escape,
};
/// <summary>
/// Builds a CSV containing only items whose <see cref="BulkItemResult{T}.IsSuccess"/>
/// is <c>false</c>. Columns: every public property of <typeparamref name="T"/>
/// followed by Error and Timestamp (ISO-8601).
/// </summary>
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
{
var TL = TranslationSource.Instance;
using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
using var csv = new CsvWriter(writer, CsvConfig);
csv.WriteHeader<T>();
csv.WriteField("Error");
csv.WriteField("Timestamp");
csv.WriteField(TL["report.col.error"]);
csv.WriteField(TL["report.col.timestamp"]);
csv.NextRecord();
foreach (var item in failedItems.Where(r => !r.IsSuccess))
@@ -29,12 +49,13 @@ public class BulkResultCsvExportService
return writer.ToString();
}
/// <summary>Writes the failed-items CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteFailedItemsCsvAsync<T>(
IReadOnlyList<BulkItemResult<T>> failedItems,
string filePath,
CancellationToken ct)
{
var content = BuildFailedItemsCsv(failedItems);
await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct);
await ExportFileWriter.WriteCsvAsync(filePath, content, ct);
}
}
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class CsvExportService
{
private const string Header =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
private static string BuildHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
/// Builds a CSV string from the supplied permission entries.
@@ -20,7 +24,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(BuildHeader());
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
var merged = entries
@@ -35,7 +39,10 @@ public class CsvExportService
UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
GrantedThrough = g.Key.GrantedThrough
GrantedThrough = g.Key.GrantedThrough,
TargetLabel = g.First().TargetLabel ?? string.Empty,
TargetUrl = g.First().TargetUrl ?? string.Empty,
SharingLinkType = g.First().SharingLinkType ?? string.Empty
});
foreach (var row in merged)
@@ -43,7 +50,8 @@ public class CsvExportService
{
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough)
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough),
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
@@ -55,14 +63,17 @@ public class CsvExportService
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
/// </summary>
private const string SimplifiedHeader =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
private static string BuildSimplifiedHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
/// Builds a CSV string from simplified permission entries.
@@ -72,7 +83,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(SimplifiedHeader);
sb.AppendLine(BuildSimplifiedHeader());
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
@@ -88,7 +99,10 @@ public class CsvExportService
Permissions = g.Key.PermissionLevels,
SimplifiedLabels = g.First().SimplifiedLabels,
RiskLevel = g.First().RiskLevel.ToString(),
GrantedThrough = g.Key.GrantedThrough
GrantedThrough = g.Key.GrantedThrough,
TargetLabel = g.First().TargetLabel ?? string.Empty,
TargetUrl = g.First().TargetUrl ?? string.Empty,
SharingLinkType = g.First().SharingLinkType ?? string.Empty
});
foreach (var row in merged)
@@ -97,7 +111,8 @@ public class CsvExportService
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels),
Csv(row.RiskLevel), Csv(row.GrantedThrough)
Csv(row.RiskLevel), Csv(row.GrantedThrough),
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
@@ -109,13 +124,57 @@ public class CsvExportService
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
private static string Csv(string value)
/// <summary>
/// Writes permission entries with optional per-site partitioning.
/// Single → writes one file at <paramref name="basePath"/>.
/// BySite → one file per site-collection URL, suffixed on the base path.
/// </summary>
public Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
entries, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
/// <summary>Simplified-entry split variant.</summary>
public Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
entries, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
internal static IEnumerable<(string Label, IReadOnlyList<PermissionEntry> Partition)> PartitionBySite(
IReadOnlyList<PermissionEntry> entries)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
return entries
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
Partition: (IReadOnlyList<PermissionEntry>)g.ToList()));
}
internal static IEnumerable<(string Label, IReadOnlyList<SimplifiedPermissionEntry> Partition)> PartitionBySite(
IReadOnlyList<SimplifiedPermissionEntry> entries)
{
return entries
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
Partition: (IReadOnlyList<SimplifiedPermissionEntry>)g.ToList()));
}
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
private static string Csv(string value) => CsvSanitizer.Escape(value);
}
@@ -0,0 +1,47 @@
namespace SharepointToolbox.Services.Export;
/// <summary>
/// CSV field sanitization. Adds RFC 4180 quoting plus formula-injection
/// protection: Excel and other spreadsheet apps treat cells starting with
/// '=', '+', '-', '@', tab, or CR as formulas. Prefixing with a single
/// quote neutralizes the formula while remaining readable.
/// </summary>
internal static class CsvSanitizer
{
/// <summary>
/// Escapes a value for inclusion in a CSV row. Always wraps in double
/// quotes. Doubles internal quotes per RFC 4180. Prepends an apostrophe
/// when the value begins with a character a spreadsheet would evaluate.
/// </summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
var safe = NeutralizeFormulaPrefix(value).Replace("\"", "\"\"");
return $"\"{safe}\"";
}
/// <summary>
/// Minimal quoting variant: only wraps in quotes when the value contains
/// a delimiter, quote, or newline. Still guards against formula injection.
/// </summary>
public static string EscapeMinimal(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
var safe = NeutralizeFormulaPrefix(value);
if (safe.Contains(',') || safe.Contains('"') || safe.Contains('\n') || safe.Contains('\r'))
return $"\"{safe.Replace("\"", "\"\"")}\"";
return safe;
}
private static string NeutralizeFormulaPrefix(string value)
{
if (value.Length == 0) return value;
char first = value[0];
if (first == '=' || first == '+' || first == '-' || first == '@'
|| first == '\t' || first == '\r')
{
return "'" + value;
}
return value;
}
}
@@ -0,0 +1,112 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row;
/// the Group column ties copies together and a Copies column gives the group size.
/// Header row is built at write-time so culture switches are honoured.
/// </summary>
public class DuplicatesCsvExportService
{
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string filePath,
CancellationToken ct)
{
var csv = BuildCsv(groups);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>
/// Writes one or more CSVs depending on <paramref name="splitMode"/>.
/// Single → <paramref name="basePath"/> as-is. BySite → one file per site,
/// filenames derived from <paramref name="basePath"/> with a site suffix.
/// </summary>
public Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
groups, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
internal static IEnumerable<(string Label, IReadOnlyList<DuplicateGroup> Partition)> PartitionBySite(
IReadOnlyList<DuplicateGroup> groups)
{
return groups
.GroupBy(g =>
{
var first = g.Items.FirstOrDefault();
return (Url: first?.SiteUrl ?? string.Empty, Title: first?.SiteTitle ?? string.Empty);
})
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.Url, g.Key.Title),
Partition: (IReadOnlyList<DuplicateGroup>)g.ToList()));
}
/// <summary>
/// Builds the CSV payload. Emits a header summary (group count, generated
/// timestamp), then one row per duplicate item with its group index and
/// group size. CSV fields are escaped via <see cref="CsvSanitizer.Escape"/>.
/// </summary>
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary
sb.AppendLine($"\"{T["report.title.duplicates_short"]}\"");
sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine(string.Join(",", new[]
{
Csv(T["report.col.number"]),
Csv(T["report.col.group"]),
Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
Csv(T["report.col.name"]),
Csv(T["report.col.library"]),
Csv(T["report.col.path"]),
Csv(T["report.col.size_bytes"]),
Csv(T["report.col.created"]),
Csv(T["report.col.modified"]),
}));
foreach (var g in groups)
{
int i = 0;
foreach (var item in g.Items)
{
i++;
sb.AppendLine(string.Join(",", new[]
{
Csv(i.ToString()),
Csv(g.Name),
Csv(g.Items.Count.ToString()),
Csv(item.SiteTitle),
Csv(item.Name),
Csv(item.Library),
Csv(item.Path),
Csv(item.SizeBytes?.ToString() ?? string.Empty),
Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty),
Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty),
}));
}
}
return sb.ToString();
}
private static string Csv(string value) => CsvSanitizer.Escape(value);
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.Text;
namespace SharepointToolbox.Services.Export;
@@ -10,17 +11,23 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class DuplicatesHtmlExportService
{
/// <summary>
/// Builds a self-contained HTML string rendering one collapsible card per
/// <see cref="DuplicateGroup"/>. The document ships with inline CSS and a
/// tiny JS toggle so no external assets are needed.
/// </summary>
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.duplicates"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Duplicate Detection Report</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -54,11 +61,9 @@ public class DuplicatesHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>Duplicate Detection Report</h1>
""");
sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} {T["report.text.duplicate_groups_found"]}</p>");
for (int i = 0; i < groups.Count; i++)
{
@@ -70,19 +75,19 @@ public class DuplicatesHtmlExportService
<div class="group-card">
<div class="group-header" onclick="toggleGroup({i})">
<span class="group-name">{H(g.Name)}</span>
<span class="badge {badgeClass}">{count} copies</span>
<span class="badge {badgeClass}">{count} {T["report.text.copies"]}</span>
</div>
<div class="group-body" id="gb-{i}">
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Library</th>
<th>Path</th>
<th>Size</th>
<th>Created</th>
<th>Modified</th>
<th>{T["report.col.number"]}</th>
<th>{T["report.col.name"]}</th>
<th>{T["report.col.library"]}</th>
<th>{T["report.col.path"]}</th>
<th>{T["report.col.size"]}</th>
<th>{T["report.col.created"]}</th>
<th>{T["report.col.modified"]}</th>
</tr>
</thead>
<tbody>
@@ -116,18 +121,59 @@ public class DuplicatesHtmlExportService
""");
}
sb.AppendLine($"<p class=\"generated\">Generated: {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>");
return sb.ToString();
}
/// <summary>Writes the HTML report to the specified file path using UTF-8.</summary>
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(groups, branding);
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
/// <summary>
/// Writes one or more HTML reports depending on <paramref name="splitMode"/> and
/// <paramref name="layout"/>. Single → one file. BySite + SeparateFiles → one
/// file per site. BySite + SingleTabbed → one file with per-site iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(groups, basePath, ct, branding);
return;
}
var partitions = DuplicatesCsvExportService.PartitionBySite(groups).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding)))
.ToList();
var T = TranslationSource.Instance;
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, T["report.title.duplicates_short"]);
await System.IO.File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
return;
}
foreach (var partition in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, partition.Label);
await WriteAsync(partition.Partition, path, ct, branding);
}
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
@@ -0,0 +1,64 @@
using System.IO;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Central file-write plumbing for export services so every CSV and HTML
/// artefact gets a consistent encoding: CSV files are written with a UTF-8
/// BOM (required for Excel to detect the encoding when opening a
/// double-clicked .csv), HTML files are written without a BOM (some browsers
/// and iframe <c>srcdoc</c> paths render the BOM as a visible character).
/// Export services should call these helpers rather than constructing
/// <see cref="UTF8Encoding"/> inline.
/// </summary>
internal static class ExportFileWriter
{
private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true);
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
/// <summary>Writes <paramref name="csv"/> to <paramref name="filePath"/> as UTF-8 with BOM.</summary>
public static Task WriteCsvAsync(string filePath, string csv, CancellationToken ct)
=> File.WriteAllTextAsync(filePath, csv, Utf8WithBom, ct);
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
/// <summary>
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 with
/// BOM, chunk by chunk. Avoids the full-document <c>ToString()</c> copy
/// and the separate UTF-8 byte buffer that <see cref="File.WriteAllTextAsync(string, string, Encoding, CancellationToken)"/>
/// would otherwise allocate — meaningful for large CSV exports.
/// </summary>
public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
=> WriteChunksAsync(filePath, builder, Utf8WithBom, ct);
/// <summary>
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 without
/// BOM. Same rationale as <see cref="WriteCsvChunksAsync"/> — for large
/// HTML reports it halves peak memory by skipping the intermediate string.
/// </summary>
public static Task WriteHtmlChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
=> WriteChunksAsync(filePath, builder, Utf8NoBom, ct);
private static async Task WriteChunksAsync(string filePath, StringBuilder builder, Encoding encoding, CancellationToken ct)
{
// FileOptions.Asynchronous lets StreamWriter use true async I/O.
await using var fs = new FileStream(
filePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 64 * 1024,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var sw = new StreamWriter(fs, encoding, bufferSize: 64 * 1024);
foreach (var chunk in builder.GetChunks())
{
ct.ThrowIfCancellationRequested();
await sw.WriteAsync(chunk, ct);
}
await sw.FlushAsync(ct);
}
}
@@ -1,102 +1,51 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports permission entries to a self-contained interactive HTML report.
/// Ports PowerShell Export-PermissionsToHTML functionality.
/// No external CSS/JS dependencies — everything is inline.
/// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
/// No external CSS/JS dependencies — everything is inline so the file can be
/// emailed or served from any static host. The standard and simplified
/// variants share their document shell, stats cards, CSS, pill rendering, and
/// inline script via <see cref="PermissionHtmlFragments"/>; this class only
/// owns the table column sets and the simplified risk summary.
/// </summary>
public class HtmlExportService
{
/// <summary>
/// Builds a self-contained HTML string from the supplied permission entries.
/// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// Builds a self-contained HTML string from the supplied permission
/// entries. Standard report: columns are Object / Title / URL / Unique /
/// Users / Permission / Granted Through. When
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
/// become expandable rows listing resolved members.
/// </summary>
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
// Compute stats
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var T = TranslationSource.Instance;
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count,
entries.Select(e => e.PermissionLevels),
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
var sb = new StringBuilder();
// ── HTML HEAD ──────────────────────────────────────────────────────────
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* Type badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
/* Unique/Inherited badges */
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
/* User pills */
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
// ── BODY ───────────────────────────────────────────────────────────────
AppendHead(sb, T["report.title.permissions"], includeRiskCss: false);
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
AppendFilterInput(sb);
AppendTableOpen(sb);
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Granted Through</th>");
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.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
@@ -105,99 +54,255 @@ a:hover { text-decoration: underline; }
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out var members);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></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>{pillsBuilder}</td>");
sb.AppendLine($" <td>{pills}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
if (subRows.Length > 0) sb.Append(subRows);
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
// Inline JS
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}");
sb.AppendLine("</script>");
AppendTableClose(sb);
AppendInlineJs(sb);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the HTML report to the specified file path using UTF-8 without BOM.
/// Builds a self-contained HTML string from simplified permission entries.
/// Adds a risk-level summary card strip plus two columns (Simplified,
/// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
/// </summary>
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct,
public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count,
entries.Select(e => e.PermissionLevels),
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
var sb = new StringBuilder();
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var s in summaries)
{
var (bg, text, border) = RiskLevelColors(s.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
AppendFilterInput(sb);
AppendTableOpen(sb);
sb.AppendLine("<thead><tr>");
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("<tbody>");
int grpMemIdx = 0;
int sectionIdx = 0;
var groups = entries.GroupBy(e => (e.ObjectType, e.Title, e.Url)).ToList();
foreach (var group in groups)
{
var sectionId = $"sec{sectionIdx++}";
var first = group.First();
var typeCss = ObjectTypeCss(group.Key.ObjectType);
var uniqueCss = first.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = first.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var count = group.Count();
sb.AppendLine($"<tr class=\"section-header collapsed\" data-section=\"{sectionId}\">");
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>");
sb.AppendLine("</tr>");
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);
}
}
/// <summary>Returns inline CSS background and text color for a risk level.</summary>
AppendTableClose(sb);
AppendInlineJs(sb);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>
/// Split-aware write for permission entries.
/// Single → one file. BySite + SeparateFiles → one file per site.
/// BySite + SingleTabbed → one file with per-site iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
var partitions = CsvExportService.PartitionBySite(entries).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
.ToList();
var title = TranslationSource.Instance["report.title.permissions"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
return;
}
foreach (var (label, partEntries) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
/// <summary>Simplified-entry split variant.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
var partitions = CsvExportService.PartitionBySite(entries).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
.ToList();
var title = TranslationSource.Instance["report.title.permissions_simplified"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
return;
}
foreach (var (label, partEntries) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
private static (int total, int uniquePerms, int distinctUsers) ComputeStats(
int totalEntries,
IEnumerable<string> permissionLevels,
IEnumerable<string> userLogins)
{
var uniquePermSets = permissionLevels.Distinct().Count();
var distinctUsers = userLogins
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
return (totalEntries, uniquePermSets, distinctUsers);
}
private static void AppendTableOpen(StringBuilder sb)
{
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
}
private static void AppendTableClose(StringBuilder sb)
{
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
/// <summary>Returns inline CSS background, text, and border colors for a risk level.</summary>
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
{
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
@@ -206,228 +311,4 @@ function toggleGroup(id) {
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB")
};
/// <summary>
/// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report (Simplified)</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(0,0,0,.03); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var summary in summaries)
{
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
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\">Link</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</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>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the simplified HTML report to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
private static string ObjectTypeCss(string t) => t switch
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
@@ -0,0 +1,378 @@
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Shared HTML-rendering fragments for the permission exports (standard and
/// simplified). Extracted so the two <see cref="HtmlExportService"/> variants
/// share the document shell, stats cards, filter input, user-pill logic, and
/// inline script — leaving each caller only its own table headers and row
/// cells to render.
/// </summary>
internal static class PermissionHtmlFragments
{
internal const string BaseCss = @"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
";
internal const string RiskCardsCss = @"
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.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; }
.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() {
var input = document.getElementById('filter').value.toLowerCase();
var sections = document.querySelectorAll('#permTable tbody tr.section-header');
if (sections.length === 0) {
document.querySelectorAll('#permTable tbody tr').forEach(function(row) {
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) {
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');
if (!trigger) return;
var id = trigger.getAttribute('data-group-target');
if (!id) return;
document.querySelectorAll('#permTable tbody tr').forEach(function(r) {
if (r.getAttribute('data-group') === id) {
r.style.display = r.style.display === 'none' ? '' : 'none';
}
});
});";
/// <summary>
/// Appends the shared HTML head (doctype, meta, inline CSS, title) to
/// <paramref name="sb"/>. Pass <paramref name="includeRiskCss"/> when the
/// caller renders risk cards/badges (simplified report only).
/// </summary>
internal static void AppendHead(StringBuilder sb, string title, bool includeRiskCss)
{
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{title}</title>");
sb.AppendLine("<style>");
sb.AppendLine(BaseCss);
if (includeRiskCss)
sb.AppendLine(RiskCardsCss);
sb.AppendLine("</style>");
sb.AppendLine("</head>");
}
/// <summary>
/// Appends the three stat cards (total entries, unique permission sets,
/// distinct users/groups) inside a single <c>.stats</c> row.
/// </summary>
internal static void AppendStatsCards(StringBuilder sb, int totalEntries, int uniquePermSets, int distinctUsers)
{
var T = TranslationSource.Instance;
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
}
/// <summary>Appends the live-filter input bound to <c>#permTable</c>.</summary>
internal static void AppendFilterInput(StringBuilder sb)
{
var T = TranslationSource.Instance;
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
}
/// <summary>Appends the inline &lt;script&gt; that powers filter and group toggle.</summary>
internal static void AppendInlineJs(StringBuilder sb)
{
sb.AppendLine("<script>");
sb.AppendLine(InlineJs);
sb.AppendLine("</script>");
}
/// <summary>
/// Renders the user-pill cell content plus any group-member sub-rows for a
/// single permission entry. Callers pass their row colspan so sub-rows
/// span the full table; <paramref name="grpMemIdx"/> must be mutated
/// across rows so sub-row IDs stay unique.
/// </summary>
internal static (string Pills, string MemberSubRows) BuildUserPillsCell(
string userLogins,
string userNames,
string? principalType,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
int colSpan,
ref int grpMemIdx,
string? targetLabel = null,
string? sharingLinkType = null,
bool hideSystemGroupRaw = false,
string? sectionId = null)
{
var T = TranslationSource.Instance;
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = userNames.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pills = new StringBuilder();
var subRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
// When the principal is a resolved system group and the user wants the raw
// name hidden, replace the pill's visible text with the link-type badge
// (sharing links) and/or the target label. Falls back to the raw name when
// resolution failed (no targetLabel).
var classification = principalType == "SharePointGroup"
? PermissionEntryHelper.Classify(name)
: new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
bool isResolvedSystemGroup = hideSystemGroupRaw
&& classification.Kind != SystemGroupKind.None
&& classification.Kind != SystemGroupKind.LimitedAccessBare
&& !string.IsNullOrEmpty(targetLabel);
bool hasResolvedMembers = principalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
{
if (resolved.Count == 0)
{
// Members unavailable — render plain pill, skip expandable sub-row.
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
{
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++;
}
}
else if (isResolvedSystemGroup)
{
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
pills.Append("</span>");
}
else
{
var cls = isExt ? "user-pill external-user" : "user-pill";
pills.Append($"<span class=\"{cls}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
return (pills.ToString(), subRows.ToString());
}
/// <summary>
/// Renders the Granted Through cell. When the entry carries a resolved system-group
/// target (Limited Access For Web/List or SharingLinks), a clickable link to the
/// targeted resource is appended on a second line. For sharing links the link type
/// (OrganizationEdit / AnonymousView / …) is surfaced alongside the target.
///
/// When <paramref name="hideSystemGroupRaw"/> is true and a target was resolved, the
/// raw "SharePoint Group: SharingLinks.{guid}…" / "Limited Access System Group For
/// Web|List {guid}" prefix is suppressed and only the link-type badge + clickable
/// target are shown — keeps the report readable without losing information.
/// </summary>
internal static string BuildGrantedThroughCell(
string grantedThrough,
string? targetUrl,
string? targetLabel,
string? sharingLinkType,
bool hideSystemGroupRaw = false)
{
var hasTarget = !string.IsNullOrEmpty(targetUrl) && !string.IsNullOrEmpty(targetLabel);
var hasLinkType = !string.IsNullOrEmpty(sharingLinkType);
var suppressRaw = hideSystemGroupRaw && hasTarget;
var sb = new StringBuilder();
if (!suppressRaw)
sb.Append(HtmlEncode(grantedThrough));
if (!hasTarget && !hasLinkType)
return sb.ToString();
if (suppressRaw)
{
// Inline layout — no leading raw text to wrap under.
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("<a href=\"");
sb.Append(HtmlEncode(targetUrl!));
sb.Append("\" target=\"_blank\">");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("</a>");
}
return sb.ToString();
}
sb.Append("<div style=\"margin-top:4px;font-size:.75rem;color:#555\">");
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("&rarr; <a href=\"");
sb.Append(HtmlEncode(targetUrl!));
sb.Append("\" target=\"_blank\">");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("</a>");
}
sb.Append("</div>");
return sb.ToString();
}
/// <summary>
/// Builds the colored badge for a SharePoint sharing-link type. Translates the
/// raw <c>linkType</c> code (e.g. <c>OrganizationEdit</c>) into a human label
/// (e.g. <c>Org link · Edit</c>) and tints by risk tier; raw code surfaces as a
/// <c>title</c> tooltip so operators can still trace it back to the source.
/// </summary>
internal static string BuildSharingLinkBadge(string rawLinkType)
{
var (label, risk) = SharingLinkLabels.Describe(rawLinkType);
var (bg, fg) = SharingLinkLabels.Colors(risk);
return $"<span class=\"badge\" style=\"background:{bg};color:{fg};margin-right:6px\" " +
$"title=\"{HtmlEncode(rawLinkType)}\">{HtmlEncode(label)}</span>";
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
internal static string ObjectTypeCss(string t) => t switch
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
internal static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
@@ -0,0 +1,199 @@
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Shared helpers for split report exports: filename partitioning, site label
/// derivation, and bundling per-partition HTML into a single tabbed document.
/// </summary>
public static class ReportSplitHelper
{
/// <summary>
/// Returns a file-safe variant of <paramref name="name"/>. Invalid filename
/// characters are replaced with underscores; whitespace runs are collapsed.
/// </summary>
public static string SanitizeFileName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "part";
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (var c in name)
sb.Append(invalid.Contains(c) || c == ' ' ? '_' : c);
var trimmed = sb.ToString().Trim('_');
if (trimmed.Length > 80) trimmed = trimmed.Substring(0, 80);
return trimmed.Length == 0 ? "part" : trimmed;
}
/// <summary>
/// Given a user-selected <paramref name="basePath"/> (e.g. "C:\reports\duplicates.csv"),
/// returns a partitioned path like "C:\reports\duplicates_{label}.csv".
/// </summary>
public static string BuildPartitionPath(string basePath, string partitionLabel)
{
var dir = Path.GetDirectoryName(basePath);
var stem = Path.GetFileNameWithoutExtension(basePath);
var ext = Path.GetExtension(basePath);
var safe = SanitizeFileName(partitionLabel);
var file = $"{stem}_{safe}{ext}";
return string.IsNullOrEmpty(dir) ? file : Path.Combine(dir, file);
}
/// <summary>
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
/// e.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/foo.docx →
/// https://t.sharepoint.com/sites/hr
/// Falls back to scheme+host for root site collections.
/// </summary>
public static string DeriveSiteCollectionUrl(string objectUrl)
{
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
return objectUrl.TrimEnd('/');
var baseUrl = $"{uri.Scheme}://{uri.Host}";
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return $"{baseUrl}/{segments[0]}/{segments[1]}";
}
return baseUrl;
}
/// <summary>
/// Derives a short, human-friendly site label from a SharePoint site URL.
/// Falls back to the raw URL (sanitized) when parsing fails.
/// </summary>
public static string DeriveSiteLabel(string siteUrl, string? siteTitle = null)
{
if (!string.IsNullOrWhiteSpace(siteTitle)) return siteTitle!;
if (string.IsNullOrWhiteSpace(siteUrl)) return "site";
try
{
var uri = new Uri(siteUrl);
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return segments[1];
}
return uri.Host;
}
catch (Exception ex) when (ex is UriFormatException or ArgumentException)
{
Debug.WriteLine($"[ReportSplitHelper] DeriveSiteLabel: malformed URL '{siteUrl}' ({ex.GetType().Name}: {ex.Message}) — falling back to raw value.");
return siteUrl;
}
}
/// <summary>
/// Generic dispatcher for split-aware export: if
/// <paramref name="splitMode"/> is not BySite, writes a single file via
/// <paramref name="writer"/>; otherwise partitions via
/// <paramref name="partitioner"/> and writes one file per partition,
/// each at a filename derived from <paramref name="basePath"/> plus the
/// partition label.
/// </summary>
public static async Task WritePartitionedAsync<T>(
IReadOnlyList<T> items,
string basePath,
ReportSplitMode splitMode,
Func<IReadOnlyList<T>, IEnumerable<(string Label, IReadOnlyList<T> Partition)>> partitioner,
Func<IReadOnlyList<T>, string, CancellationToken, Task> writer,
CancellationToken ct)
{
if (splitMode != ReportSplitMode.BySite)
{
await writer(items, basePath, ct);
return;
}
foreach (var (label, partition) in partitioner(items))
{
ct.ThrowIfCancellationRequested();
var path = BuildPartitionPath(basePath, label);
await writer(partition, path, ct);
}
}
/// <summary>
/// Bundles per-partition HTML documents into one self-contained tabbed
/// HTML. Each partition HTML is embedded in an &lt;iframe srcdoc&gt; so
/// their inline styles and scripts remain isolated.
/// </summary>
public static string BuildTabbedHtml(
IReadOnlyList<(string Label, string Html)> parts,
string title)
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{WebUtility.HtmlEncode(title)}</title>");
sb.AppendLine("""
<style>
html, body { margin: 0; padding: 0; height: 100%; font-family: 'Segoe UI', Arial, sans-serif; background: #1a1a2e; }
.tabbar { display: flex; flex-wrap: wrap; gap: 4px; background: #1a1a2e; padding: 8px; position: sticky; top: 0; z-index: 10; }
.tab { padding: 6px 12px; background: #2d2d4e; color: #fff; cursor: pointer; border-radius: 4px;
font-size: 13px; user-select: none; white-space: nowrap; }
.tab:hover { background: #3d3d6e; }
.tab.active { background: #0078d4; }
.frame-host { background: #f5f5f5; }
iframe { width: 100%; height: calc(100vh - 52px); border: 0; display: none; background: #f5f5f5; }
iframe.active { display: block; }
</style>
</head>
<body>
""");
sb.Append("<div class=\"tabbar\">");
for (int i = 0; i < parts.Count; i++)
{
var cls = i == 0 ? "tab active" : "tab";
sb.Append($"<div class=\"{cls}\" onclick=\"showTab({i})\">{WebUtility.HtmlEncode(parts[i].Label)}</div>");
}
sb.AppendLine("</div>");
for (int i = 0; i < parts.Count; i++)
{
var cls = i == 0 ? "active" : string.Empty;
var escaped = EscapeForSrcdoc(parts[i].Html);
sb.AppendLine($"<iframe class=\"{cls}\" srcdoc=\"{escaped}\"></iframe>");
}
sb.AppendLine("""
<script>
function showTab(i) {
var frames = document.querySelectorAll('iframe');
var tabs = document.querySelectorAll('.tab');
for (var j = 0; j < frames.length; j++) {
frames[j].classList.toggle('active', i === j);
tabs[j].classList.toggle('active', i === j);
}
}
</script>
</body></html>
""");
return sb.ToString();
}
/// <summary>
/// Escapes an HTML document so it can safely appear inside an
/// &lt;iframe srcdoc="..."&gt; attribute. Only ampersands and double
/// quotes must be encoded; angle brackets are kept literal because the
/// parser treats srcdoc as CDATA-like content.
/// </summary>
private static string EscapeForSrcdoc(string html)
{
if (string.IsNullOrEmpty(html)) return string.Empty;
return html
.Replace("&", "&amp;")
.Replace("\"", "&quot;");
}
}
@@ -0,0 +1,16 @@
namespace SharepointToolbox.Services.Export;
/// <summary>How a report export is partitioned.</summary>
public enum ReportSplitMode
{
Single,
BySite,
ByUser
}
/// <summary>When a report is split, how HTML output is laid out.</summary>
public enum HtmlSplitLayout
{
SeparateFiles,
SingleTabbed
}
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -10,12 +11,17 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class SearchCsvExportService
{
/// <summary>
/// Builds the CSV payload. Column order mirrors
/// <see cref="SearchHtmlExportService.BuildHtml(IReadOnlyList{SearchResult}, ReportBranding?)"/>.
/// </summary>
public string BuildCsv(IReadOnlyList<SearchResult> results)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Header
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)");
sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}");
foreach (var r in results)
{
@@ -33,19 +39,14 @@ public class SearchCsvExportService
return sb.ToString();
}
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{
var csv = BuildCsv(results);
await System.IO.File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
private static string IfEmpty(string? value, string fallback = "")
=> string.IsNullOrEmpty(value) ? fallback : value!;
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -11,17 +12,23 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class SearchHtmlExportService
{
/// <summary>
/// Builds a self-contained HTML table with inline sort/filter scripts.
/// Each <see cref="SearchResult"/> becomes one row; the document has no
/// external dependencies.
/// </summary>
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.search"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint File Search Results</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -45,27 +52,27 @@ public class SearchHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>File Search Results</h1>
sb.AppendLine($"""
<h1>{T["report.title.search_short"]}</h1>
<div class="toolbar">
<label for="filterInput">Filter:</label>
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
<label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine("""
sb.AppendLine($"""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">File Name</th>
<th onclick="sortTable(1)">Extension</th>
<th onclick="sortTable(2)">Path</th>
<th onclick="sortTable(3)">Created</th>
<th onclick="sortTable(4)">Created By</th>
<th onclick="sortTable(5)">Modified</th>
<th onclick="sortTable(6)">Modified By</th>
<th class="num" onclick="sortTable(7)">Size</th>
<th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
<th onclick="sortTable(1)">{T["report.col.extension"]}</th>
<th onclick="sortTable(2)">{T["report.col.path"]}</th>
<th onclick="sortTable(3)">{T["report.col.created"]}</th>
<th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
<th onclick="sortTable(5)">{T["report.col.modified"]}</th>
<th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
<th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
</tr>
</thead>
<tbody>
@@ -93,7 +100,7 @@ public class SearchHtmlExportService
sb.AppendLine(" </tbody>\n</table>");
int count = results.Count;
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$"""
<script>
@@ -126,10 +133,10 @@ public class SearchHtmlExportService
rows[i].className = match ? '' : 'hidden';
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' of {{count:N0}} shown') : '';
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)';
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
};
</script>
</body></html>
@@ -138,6 +145,7 @@ public class SearchHtmlExportService
return sb.ToString();
}
/// <summary>Writes the HTML report to <paramref name="filePath"/>.</summary>
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(results, branding);
@@ -1,4 +1,6 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.Globalization;
using System.IO;
using System.Text;
@@ -10,33 +12,65 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class StorageCsvExportService
{
/// <summary>
/// Builds a single-section CSV: header row plus one row per
/// <see cref="StorageNode"/> with library, site, file count, total size
/// (MB), version size (MB), and last-modified date.
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{
var sb = new StringBuilder();
// Header
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
foreach (var node in nodes)
{
sb.AppendLine(string.Join(",",
Csv(node.Name),
Csv(node.SiteTitle),
node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes),
FormatMb(node.VersionSizeBytes),
node.LastModified.HasValue
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
: string.Empty));
}
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
return sb.ToString();
}
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
{
var T = TranslationSource.Instance;
// Hoist resource lookups out of the row loop: ResourceManager.GetString
// is a culture-aware dictionary probe — caching once per export saves
// O(rows × columns) lookups on large tenants.
string colLibrary = T["report.col.library"];
string colKind = T["stor.col.kind"];
string colSite = T["report.col.site"];
string colFiles = T["report.stat.files"];
string colTotalMb = T["report.col.total_size_mb"];
string colVerMb = T["report.col.version_size_mb"];
string colLastMod = T["report.col.last_modified"];
sb.Append(colLibrary).Append(',')
.Append(colKind).Append(',')
.Append(colSite).Append(',')
.Append(colFiles).Append(',')
.Append(colTotalMb).Append(',')
.Append(colVerMb).Append(',')
.AppendLine(colLastMod);
var kindLabels = BuildKindLabelCache();
foreach (var node in nodes)
{
AppendCsvField(sb, node.Name).Append(',');
AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
AppendCsvField(sb, node.SiteTitle).Append(',');
sb.Append(node.TotalFileCount).Append(',');
AppendMb(sb, node.TotalSizeBytes).Append(',');
AppendMb(sb, node.VersionSizeBytes).Append(',');
if (node.LastModified.HasValue)
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
sb.AppendLine();
}
}
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
// Stream straight to disk: skip the StringBuilder→string copy and the
// separate UTF-8 buffer that File.WriteAllTextAsync materializes.
var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
}
/// <summary>
@@ -44,55 +78,150 @@ public class StorageCsvExportService
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var sb = new StringBuilder();
// Library details
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
foreach (var node in nodes)
{
sb.AppendLine(string.Join(",",
Csv(node.Name),
Csv(node.SiteTitle),
node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes),
FormatMb(node.VersionSizeBytes),
node.LastModified.HasValue
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
: string.Empty));
}
// File type breakdown
if (fileTypeMetrics.Count > 0)
{
sb.AppendLine();
sb.AppendLine("File Type,Size (MB),File Count");
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
}
}
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
WriteCsv(sb, nodes, fileTypeMetrics);
return sb.ToString();
}
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var T = TranslationSource.Instance;
string colLibrary = T["report.col.library"];
string colSite = T["report.col.site"];
string colFiles = T["report.stat.files"];
string colTotalMb = T["report.col.total_size_mb"];
string colVerMb = T["report.col.version_size_mb"];
string colLastMod = T["report.col.last_modified"];
sb.Append(colLibrary).Append(',')
.Append(colSite).Append(',')
.Append(colFiles).Append(',')
.Append(colTotalMb).Append(',')
.Append(colVerMb).Append(',')
.AppendLine(colLastMod);
foreach (var node in nodes)
{
AppendCsvField(sb, node.Name).Append(',');
AppendCsvField(sb, node.SiteTitle).Append(',');
sb.Append(node.TotalFileCount).Append(',');
AppendMb(sb, node.TotalSizeBytes).Append(',');
AppendMb(sb, node.VersionSizeBytes).Append(',');
if (node.LastModified.HasValue)
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
sb.AppendLine();
}
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.Append(colFileType).Append(',')
.Append(colSizeMb).Append(',')
.AppendLine(colFileCnt);
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
AppendCsvField(sb, label).Append(',');
AppendMb(sb, m.TotalSizeBytes).Append(',');
sb.Append(m.FileCount).AppendLine();
}
}
}
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes, fileTypeMetrics);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
WriteCsv(sb, nodes, fileTypeMetrics);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
}
/// <summary>
/// Writes storage metrics with optional per-site partitioning.
/// Single → one file. BySite → one file per SiteTitle. File-type metrics
/// are replicated across all partitions because the tenant-level scan
/// does not retain per-site breakdowns.
/// </summary>
public Task WriteAsync(
IReadOnlyList<StorageNode> nodes,
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
nodes, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, fileTypeMetrics, path, c),
ct);
/// <summary>
/// Splits the flat StorageNode list into per-site slices while preserving
/// the DFS hierarchy (each root library followed by its indented descendants).
/// Siblings sharing a SiteTitle roll up into the same partition.
/// </summary>
internal static IEnumerable<(string Label, IReadOnlyList<StorageNode> Partition)> PartitionBySite(
IReadOnlyList<StorageNode> nodes)
{
var buckets = new Dictionary<string, List<StorageNode>>(StringComparer.OrdinalIgnoreCase);
string currentSite = string.Empty;
foreach (var node in nodes)
{
if (node.IndentLevel == 0)
currentSite = string.IsNullOrWhiteSpace(node.SiteTitle)
? ReportSplitHelper.DeriveSiteLabel(node.Url)
: node.SiteTitle;
if (!buckets.TryGetValue(currentSite, out var list))
{
list = new List<StorageNode>();
buckets[currentSite] = list;
}
list.Add(node);
}
return buckets.Select(kv => (kv.Key, (IReadOnlyList<StorageNode>)kv.Value));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatMb(long bytes)
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
private static StringBuilder AppendMb(StringBuilder sb, long bytes)
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
/// <summary>RFC 4180 CSV field quoting.</summary>
private static string Csv(string value)
private static StringBuilder AppendCsvField(StringBuilder sb, string value)
=> sb.Append(CsvSanitizer.EscapeMinimal(value));
/// <summary>
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
/// once per export, indexed by the enum's int value. Avoids a
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
/// </summary>
private static string[] BuildKindLabelCache()
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
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()
};
}
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO;
using System.Text;
@@ -12,19 +13,36 @@ namespace SharepointToolbox.Services.Export;
public class StorageHtmlExportService
{
private int _togIdx;
private string[] _kindLabels = Array.Empty<string>();
private string[] _kindLabelsHtml = Array.Empty<string>();
/// <summary>
/// Builds a self-contained HTML report with one collapsible row per
/// library and indented child folders. Library-only variant — use the
/// overload that accepts <see cref="FileTypeMetric"/>s when a file-type
/// breakdown section is desired.
/// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
{
_togIdx = 0;
var sb = new StringBuilder();
var sb = new StringBuilder(3072 + nodes.Count * 340);
BuildHtmlCore(sb, nodes, branding);
return sb.ToString();
}
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, ReportBranding? branding)
{
var T = TranslationSource.Instance;
_togIdx = 0;
_kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -50,40 +68,49 @@ public class StorageHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
// Single-pass root aggregation: replaces 4 separate enumerations
// (.Where().ToList() + 3× .Sum() + a final .Where() during render).
var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
foreach (var n in nodes)
{
if (n.IndentLevel != 0) continue;
rootNodes0.Add(n);
siteTotal0 += n.TotalSizeBytes;
versionTotal0 += n.VersionSizeBytes;
fileTotal0 += n.TotalFileCount;
}
sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
</div>
""");
sb.AppendLine("""
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
<th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<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);
}
@@ -93,10 +120,8 @@ public class StorageHtmlExportService
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {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>");
return sb.ToString();
}
/// <summary>
@@ -104,16 +129,25 @@ public class StorageHtmlExportService
/// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
{
_togIdx = 0;
var sb = new StringBuilder();
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
return sb.ToString();
}
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding)
{
var T = TranslationSource.Instance;
_togIdx = 0;
_kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -150,21 +184,25 @@ public class StorageHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
// ── Summary cards (single-pass aggregation) ──
var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
long siteTotal = 0, versionTotal = 0, fileTotal = 0;
foreach (var n in nodes)
{
if (n.IndentLevel != 0) continue;
rootNodes.Add(n);
siteTotal += n.TotalSizeBytes;
versionTotal += n.VersionSizeBytes;
fileTotal += n.TotalFileCount;
}
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
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(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
sb.AppendLine("</div>");
// ── File type chart section ──
@@ -175,7 +213,7 @@ public class StorageHtmlExportService
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -185,13 +223,13 @@ public class StorageHtmlExportService
{
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
sb.AppendLine($"""
<div class="bar-row">
<span class="bar-label">{HtmlEncode(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} files</span>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} {T["report.text.files_unit"]}</span>
</div>
""");
idx++;
@@ -201,23 +239,27 @@ public class StorageHtmlExportService
}
// ── Storage table ──
sb.AppendLine("<h2>Library Details</h2>");
sb.AppendLine("""
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
<th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<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);
}
@@ -227,22 +269,68 @@ public class StorageHtmlExportService
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {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>");
return sb.ToString();
}
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(nodes, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
// Build into StringBuilder, stream chunks straight to disk —
// skips a full-document char-array copy from sb.ToString().
var sb = new StringBuilder(3072 + nodes.Count * 340);
BuildHtmlCore(sb, nodes, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
}
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(nodes, fileTypeMetrics, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
}
/// <summary>
/// Split-aware HTML export for storage metrics.
/// Single → one file. BySite + SeparateFiles → one file per site.
/// BySite + SingleTabbed → one HTML with per-site iframe tabs. File-type
/// metrics are replicated across partitions because they are not
/// attributed per-site by the scanner.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<StorageNode> nodes,
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(nodes, fileTypeMetrics, basePath, ct, branding);
return;
}
var partitions = StorageCsvExportService.PartitionBySite(nodes).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, fileTypeMetrics, branding)))
.ToList();
var title = TranslationSource.Instance["report.title.storage"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
return;
}
foreach (var (label, partNodes) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partNodes, fileTypeMetrics, path, ct, branding);
}
}
// ── Private rendering ────────────────────────────────────────────────────
@@ -256,24 +344,11 @@ public class StorageHtmlExportService
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
AppendRow(sb, node, nameCell);
if (hasChildren)
{
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children)
{
@@ -294,24 +369,11 @@ public class StorageHtmlExportService
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
AppendRow(sb, node, nameCell);
if (hasChildren)
{
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children)
{
@@ -322,6 +384,35 @@ public class StorageHtmlExportService
}
}
/// <summary>
/// Appends one data row given the pre-rendered name cell. Hot path:
/// pulls localized kind labels from <see cref="_kindLabelsHtml"/> instead
/// of going through <c>ResourceManager.GetString</c> + <c>HtmlEncode</c>
/// per row.
/// </summary>
private void AppendRow(StringBuilder sb, StorageNode node, string nameCell)
{
int kindIdx = (int)node.Kind;
string kindLabel = (uint)kindIdx < (uint)_kindLabelsHtml.Length
? _kindLabelsHtml[kindIdx]
: HtmlEncode(node.Kind.ToString());
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{kindLabel}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
}
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
@@ -332,4 +423,43 @@ public class StorageHtmlExportService
private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
/// <summary>
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
/// once per export. Cached array index lookup avoids
/// <c>ResourceManager.GetString</c> per row in hot rendering loops.
/// </summary>
private static string[] BuildKindLabelCache()
{
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
int max = 0;
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
var cache = new string[max + 1];
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
foreach (var v in values) cache[(int)v] = KindLabel(v);
return cache;
}
/// <summary>HTML-encodes each entry of <paramref name="raw"/> once.</summary>
private static string[] BuildHtmlEncodedCache(string[] raw)
{
var encoded = new string[raw.Length];
for (int i = 0; i < raw.Length; i++) encoded[i] = HtmlEncode(raw[i]);
return encoded;
}
}
@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -11,8 +12,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class UserAccessCsvExportService
{
private const string DataHeader =
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\"";
private static string BuildDataHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
/// Builds a CSV string for a single user's access entries.
@@ -20,22 +24,23 @@ public class UserAccessCsvExportService
/// </summary>
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary section
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Sites\",\"{sitesCount}\"");
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); // Blank line separating summary from data
// Data rows
sb.AppendLine(DataHeader);
sb.AppendLine(BuildDataHeader());
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
@@ -46,7 +51,10 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty)
}));
}
@@ -82,8 +90,69 @@ public class UserAccessCsvExportService
var filePath = Path.Combine(directoryPath, fileName);
var csv = BuildCsv(displayName, userLogin, entries);
await File.WriteAllTextAsync(filePath, csv,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
}
/// <summary>
/// Writes all entries split per site. File naming: "{base}_{siteLabel}.csv".
/// </summary>
public async Task WriteBySiteAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string basePath,
CancellationToken ct,
bool mergePermissions = false)
{
foreach (var group in allEntries.GroupBy(e => (e.SiteUrl, e.SiteTitle)))
{
ct.ThrowIfCancellationRequested();
var label = ReportSplitHelper.DeriveSiteLabel(group.Key.SiteUrl, group.Key.SiteTitle);
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
}
}
/// <summary>
/// Split-aware export dispatcher.
/// Single → one file at <paramref name="basePath"/>.
/// BySite → one file per site. ByUser → one file per user.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct,
bool mergePermissions = false)
{
switch (splitMode)
{
case ReportSplitMode.Single:
await WriteSingleFileAsync(allEntries, basePath, ct, mergePermissions);
break;
case ReportSplitMode.BySite:
await WriteBySiteAsync(allEntries, basePath, ct, mergePermissions);
break;
case ReportSplitMode.ByUser:
await WriteByUserAsync(allEntries, basePath, ct, mergePermissions);
break;
}
}
/// <summary>
/// Writes one CSV per user using <paramref name="basePath"/> as a filename template.
/// </summary>
public async Task WriteByUserAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string basePath,
CancellationToken ct,
bool mergePermissions = false)
{
foreach (var group in allEntries.GroupBy(e => e.UserLogin))
{
ct.ThrowIfCancellationRequested();
var label = ReportSplitHelper.SanitizeFileName(group.Key);
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
}
}
@@ -99,20 +168,21 @@ public class UserAccessCsvExportService
CancellationToken ct,
bool mergePermissions = false)
{
var T = TranslationSource.Instance;
if (mergePermissions)
{
var consolidated = PermissionConsolidator.Consolidate(entries);
var sb = new StringBuilder();
// Summary section
sb.AppendLine("\"User Access Audit Report (Consolidated)\"");
sb.AppendLine($"\"Users Audited\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"Total Entries\",\"{consolidated.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
@@ -120,13 +190,16 @@ public class UserAccessCsvExportService
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
sb.AppendLine(string.Join(",", new[]
{
$"\"{entry.UserDisplayName}\"",
$"\"{entry.UserLogin}\"",
$"\"{entry.PermissionLevel}\"",
$"\"{entry.AccessType}\"",
$"\"{entry.GrantedThrough}\"",
$"\"{locations}\"",
$"\"{entry.LocationCount}\""
Csv(entry.UserDisplayName),
Csv(entry.UserLogin),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty),
Csv(locations),
Csv(entry.LocationCount.ToString())
}));
}
@@ -136,14 +209,14 @@ public class UserAccessCsvExportService
{
var sb = new StringBuilder();
var fullHeader = "\"User\",\"User Login\"," + DataHeader;
var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
// Summary
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"Users Audited\",\"{users.Count}\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
sb.AppendLine(fullHeader);
@@ -159,21 +232,19 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty)
}));
}
await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
await ExportFileWriter.WriteCsvAsync(filePath, sb.ToString(), ct);
}
}
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
private static string Csv(string value) => CsvSanitizer.Escape(value);
private static string SanitizeFileName(string name)
{
@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -18,6 +19,70 @@ public class UserAccessHtmlExportService
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user
/// report with an expandable Sites column instead of the dual by-user/by-site view.
/// </summary>
/// <summary>
/// Split-aware HTML export. Single → one file.
/// BySite/ByUser + SeparateFiles → one file per site/user.
/// BySite/ByUser + SingleTabbed → one file with per-partition iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<UserAccessEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
bool mergePermissions = false,
ReportBranding? branding = null)
{
if (splitMode == ReportSplitMode.Single)
{
await WriteAsync(entries, basePath, ct, mergePermissions, branding);
return;
}
IEnumerable<(string Label, IReadOnlyList<UserAccessEntry> Entries)> partitions;
if (splitMode == ReportSplitMode.BySite)
{
partitions = entries
.GroupBy(e => (e.SiteUrl, e.SiteTitle))
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.SiteUrl, g.Key.SiteTitle),
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
}
else // ByUser
{
partitions = entries
.GroupBy(e => e.UserLogin)
.Select(g => (
Label: ReportSplitHelper.SanitizeFileName(g.Key),
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
}
var partList = partitions.ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partList
.Select(p => (p.Label, Html: BuildHtml(p.Entries, mergePermissions, branding)))
.ToList();
var title = TranslationSource.Instance["report.title.user_access"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
return;
}
foreach (var (label, partEntries) in partList)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, mergePermissions, branding);
}
}
/// <summary>
/// Builds the user-access HTML report. Default layout is a per-entry
/// grouped-by-user table; when <paramref name="mergePermissions"/> is true
/// entries are consolidated via <see cref="PermissionConsolidator"/> into
/// a single-row-per-user format with a Locations column.
/// </summary>
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
{
if (mergePermissions)
@@ -26,6 +91,8 @@ public class UserAccessHtmlExportService
return BuildConsolidatedHtml(consolidated, entries, branding);
}
var T = TranslationSource.Instance;
// Compute stats
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -41,7 +108,7 @@ public class UserAccessHtmlExportService
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>");
sb.AppendLine($"<title>{T["report.title.user_access"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -98,15 +165,15 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>");
sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards
@@ -123,34 +190,34 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle buttons
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">By User</button>");
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── BY-USER VIEW ───────────────────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -161,10 +228,10 @@ a:hover { text-decoration: underline; }
var uName = HtmlEncode(ug.First().UserDisplayName);
var uIsExt = ug.First().IsExternalUser;
var uCount = ug.Count();
var guestBadge = uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} access(es)</td>");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in ug)
@@ -173,13 +240,17 @@ a:hover { text-decoration: underline; }
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
@@ -192,12 +263,12 @@ a:hover { text-decoration: underline; }
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
sb.AppendLine("<table id=\"tbl-site\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-site\">");
@@ -210,7 +281,7 @@ a:hover { text-decoration: underline; }
var sCount = sg.Count();
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} access(es)</td>");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in sg)
@@ -218,15 +289,19 @@ a:hover { text-decoration: underline; }
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var guestBadge = entry.IsExternalUser ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
@@ -333,7 +408,7 @@ function sortTable(view, col) {
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
{
var html = BuildHtml(entries, mergePermissions, branding);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>
@@ -345,6 +420,8 @@ function sortTable(view, col) {
IReadOnlyList<UserAccessEntry> entries,
ReportBranding? branding)
{
var T = TranslationSource.Instance;
// Stats computed from the original flat list for accurate counts
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -360,7 +437,7 @@ function sortTable(view, col) {
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>");
sb.AppendLine($"<title>{T["report.title.user_access_consolidated"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -417,15 +494,15 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>");
sb.AppendLine($"<h1>{T["report.title.user_access_consolidated"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards (from original flat entries)
@@ -442,32 +519,32 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle — only By User (By Site is suppressed for consolidated view)
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\">By User</button>");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\">{T["report.view.by_user"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>User</th>");
sb.AppendLine(" <th>Permission Level</th>");
sb.AppendLine(" <th>Access Type</th>");
sb.AppendLine(" <th>Granted Through</th>");
sb.AppendLine(" <th>Sites</th>");
sb.AppendLine($" <th>{T["report.col.user"]}</th>");
sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -486,10 +563,10 @@ a:hover { text-decoration: underline; }
var cuName = HtmlEncode(cug.First().UserDisplayName);
var cuIsExt = cug.First().IsExternalUser;
var cuCount = cug.Count();
var guestBadge = cuIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = cuIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} permission(s)</td>");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} {T["report.text.permissions_parens"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in cug)
@@ -502,13 +579,13 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
if (entry.LocationCount == 1)
{
// Single location — inline site title + object title
var loc0 = entry.Locations[0];
var locLabel = string.IsNullOrEmpty(loc0.ObjectTitle)
var locLabel = IsRedundantObjectTitle(loc0.SiteTitle, loc0.ObjectTitle)
? HtmlEncode(loc0.SiteTitle)
: $"{HtmlEncode(loc0.SiteTitle)} &rsaquo; {HtmlEncode(loc0.ObjectTitle)}";
sb.AppendLine($" <td>{locLabel}</td>");
@@ -518,13 +595,13 @@ a:hover { text-decoration: underline; }
{
// Multiple locations — expandable badge
var currentLocId = $"loc{locIdx++}";
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} sites</span></td>");
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} {TranslationSource.Instance["report.text.sites_unit"]}</span></td>");
sb.AppendLine("</tr>");
// Hidden sub-rows — one per location
foreach (var loc in entry.Locations)
{
var subLabel = string.IsNullOrEmpty(loc.ObjectTitle)
var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle)
? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>"
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> &rsaquo; {HtmlEncode(loc.ObjectTitle)}";
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
@@ -591,13 +668,31 @@ function toggleGroup(id) {
}
/// <summary>Returns a colored badge span for the given access type.</summary>
private static string AccessTypeBadge(AccessType accessType) => accessType switch
private static string AccessTypeBadge(AccessType accessType)
{
AccessType.Direct => "<span class=\"badge access-direct\">Direct</span>",
AccessType.Group => "<span class=\"badge access-group\">Group</span>",
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>",
var T = TranslationSource.Instance;
return accessType switch
{
AccessType.Direct => $"<span class=\"badge access-direct\">{T["report.badge.direct"]}</span>",
AccessType.Group => $"<span class=\"badge access-group\">{T["report.badge.group"]}</span>",
AccessType.Inherited => $"<span class=\"badge access-inherited\">{T["report.badge.inherited"]}</span>",
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
};
}
/// <summary>
/// Returns true when the ObjectTitle adds no information beyond the SiteTitle:
/// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate
/// of the other. Used to collapse "All Company &rsaquo; All Company" to "All Company".
/// </summary>
private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle)
{
if (string.IsNullOrWhiteSpace(objectTitle)) return true;
return string.Equals(
(siteTitle ?? string.Empty).Trim(),
objectTitle.Trim(),
StringComparison.OrdinalIgnoreCase);
}
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)
@@ -0,0 +1,180 @@
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports VersionCleanupResult list to a self-contained sortable/filterable HTML report.
/// Summary header shows totals (files trimmed, versions deleted, bytes freed); a single
/// table lists every processed file with sort/filter controls. No external assets.
/// </summary>
public class VersionCleanupHtmlExportService
{
public string BuildHtml(IReadOnlyList<VersionCleanupResult> results, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
long totalBytes = results.Sum(r => r.BytesFreed);
int totalDeleted = results.Sum(r => r.VersionsDeleted);
int totalFiles = results.Count(r => r.VersionsDeleted > 0);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.versions"]}</title>");
sb.AppendLine("""
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
.summary { display: flex; gap: 24px; margin: 12px 0 16px 0; padding: 12px 16px;
background: #e8f1fb; border-radius: 6px; }
.summary .item { display: flex; flex-direction: column; }
.summary .label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
.summary .value { font-size: 18px; font-weight: 600; color: #0078d4; }
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
.toolbar label { font-weight: 600; }
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
#resultCount { font-size: 12px; color: #666; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
font-weight: 600; user-select: none; white-space: nowrap; }
th:hover { background: #106ebe; }
th.sorted-asc::after { content: ' '; font-size: 10px; }
th.sorted-desc::after { content: ' '; font-size: 10px; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
tr:hover td { background: #f0f7ff; }
tr.hidden { display: none; }
tr.err td { background: #fff4f4; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.err-cell { color: #b00020; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
</style>
</head>
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.versions_short"]}</h1>");
sb.AppendLine($"""
<div class="summary">
<div class="item"><span class="label">{T["versions.summary.files"]}</span><span class="value">{totalFiles:N0}</span></div>
<div class="item"><span class="label">{T["versions.summary.deleted"]}</span><span class="value">{totalDeleted:N0}</span></div>
<div class="item"><span class="label">{T["versions.summary.freed"]}</span><span class="value">{FormatSize(totalBytes)}</span></div>
</div>
<div class="toolbar">
<label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine($"""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">{T["report.col.site"]}</th>
<th onclick="sortTable(1)">{T["versions.col.library"]}</th>
<th onclick="sortTable(2)">{T["versions.col.file"]}</th>
<th onclick="sortTable(3)">{T["versions.col.path"]}</th>
<th class="num" onclick="sortTable(4)">{T["versions.col.before"]}</th>
<th class="num" onclick="sortTable(5)">{T["versions.col.deleted"]}</th>
<th class="num" onclick="sortTable(6)">{T["versions.col.remaining"]}</th>
<th class="num" onclick="sortTable(7)">{T["versions.col.freed"]}</th>
<th onclick="sortTable(8)">{T["versions.col.error"]}</th>
</tr>
</thead>
<tbody>
""");
foreach (var r in results)
{
string rowClass = string.IsNullOrEmpty(r.Error) ? string.Empty : " class=\"err\"";
string errCell = string.IsNullOrEmpty(r.Error)
? string.Empty
: $"<span class=\"err-cell\">{H(r.Error)}</span>";
sb.AppendLine($"""
<tr{rowClass}>
<td>{H(r.SiteUrl)}</td>
<td>{H(r.Library)}</td>
<td>{H(r.FileName)}</td>
<td>{H(r.FileServerRelativeUrl)}</td>
<td class="num" data-sort="{r.VersionsBefore}">{r.VersionsBefore:N0}</td>
<td class="num" data-sort="{r.VersionsDeleted}">{r.VersionsDeleted:N0}</td>
<td class="num" data-sort="{r.VersionsRemaining}">{r.VersionsRemaining:N0}</td>
<td class="num" data-sort="{r.BytesFreed}">{FormatSize(r.BytesFreed)}</td>
<td>{errCell}</td>
</tr>
""");
}
sb.AppendLine(" </tbody>\n</table>");
int count = results.Count;
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$"""
<script>
var sortDir = {};
function sortTable(col) {
var tbl = document.getElementById('resultsTable');
var tbody = tbl.tBodies[0];
var rows = Array.from(tbody.rows);
var asc = sortDir[col] !== 'asc';
sortDir[col] = asc ? 'asc' : 'desc';
rows.sort(function(a, b) {
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
});
rows.forEach(function(r) { tbody.appendChild(r); });
var ths = tbl.tHead.rows[0].cells;
for (var i = 0; i < ths.length; i++) {
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
}
}
function filterTable() {
var q = document.getElementById('filterInput').value.toLowerCase();
var rows = document.getElementById('resultsTable').tBodies[0].rows;
var visible = 0;
for (var i = 0; i < rows.length; i++) {
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
rows[i].className = (rows[i].className.indexOf('err') >= 0 ? 'err ' : '') + (match ? '' : 'hidden');
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
};
</script>
</body></html>
""");
return sb.ToString();
}
/// <summary>Writes the HTML report to <paramref name="filePath"/> as UTF-8.</summary>
public async Task WriteAsync(IReadOnlyList<VersionCleanupResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(results, branding);
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
}
+407 -48
View File
@@ -6,8 +6,28 @@ using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Orchestrates file copy/move between two SharePoint libraries (same or
/// different tenants). Hybrid strategy: server-side <see cref="MoveCopyUtil"/>
/// first (zero local bandwidth), then transparent fallback to stream copy
/// (<c>OpenBinaryDirect</c>/<c>SaveBinaryDirect</c>) on a list-view-threshold
/// 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>
public class FileTransferService : IFileTransferService
{
private const int ListViewThresholdItemCount = 5000;
private const int LargeLibraryPageSize = 500;
/// <summary>
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
/// (unless the job is folder-only), pre-creates destination folders, then
/// copies or moves each file according to <see cref="TransferJob.Mode"/>
/// and <see cref="TransferJob.ConflictPolicy"/>. Returns a per-item
/// summary where failures are reported individually — the method does
/// not abort on first error so partial transfers are recoverable.
/// </summary>
public async Task<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx,
ClientContext destCtx,
@@ -15,21 +35,78 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// 1. Enumerate files from source
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
// 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 (files.Count == 0)
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;
if (job.CopyFolderContents)
{
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
files = await EnumerateFilesAsync(sourceCtx, job, srcItemCount, progress, ct);
}
else
{
files = Array.Empty<string>();
}
// When CopyFolderContents is off, the job is folder-only: ensure the
// destination folder is created below (IncludeSourceFolder branch) and
// return without iterating any files.
if (files.Count == 0 && !job.IncludeSourceFolder)
{
progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
}
// 2. Build source and destination base paths
var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath);
var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath);
// 3. Build source and destination base paths. Resolve library roots via
// CSOM — constructing from title breaks for localized libraries whose
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
// causing "Access denied" when CSOM tries to touch a non-existent path.
var srcBasePath = await ResolveLibraryPathAsync(
sourceCtx, job.SourceLibrary, job.SourceFolderPath, progress, ct);
var dstBasePath = await ResolveLibraryPathAsync(
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
// 3. Transfer each file using BulkOperationRunner
// 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
// destination so dest/srcFolderName/... mirrors the source tree. When
// no SourceFolderPath is set, fall back to the source library name.
// Also pre-create the folder itself — per-file EnsureFolder only fires
// for nested paths, so flat files at the root of the source folder
// would otherwise copy into a missing parent and fail.
if (job.IncludeSourceFolder)
{
var srcFolderName = !string.IsNullOrEmpty(job.SourceFolderPath)
? Path.GetFileName(job.SourceFolderPath.TrimEnd('/'))
: job.SourceLibrary;
if (!string.IsNullOrEmpty(srcFolderName))
{
dstBasePath = $"{dstBasePath}/{srcFolderName}";
await EnsureFolderCachedAsync(destCtx, dstBasePath, ensuredFolders, progress, ct);
}
}
// 4. Transfer each file using BulkOperationRunner
return await BulkOperationRunner.RunAsync(
files,
async (fileRelUrl, idx, token) =>
@@ -39,13 +116,13 @@ public class FileTransferService : IFileTransferService
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/');
// Ensure destination folder exists
// Ensure destination folder exists (cached)
var destFolderRelative = dstBasePath;
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(fileFolder))
{
destFolderRelative = $"{dstBasePath}/{fileFolder}";
await EnsureFolderAsync(destCtx, destFolderRelative, progress, token);
await EnsureFolderCachedAsync(destCtx, destFolderRelative, ensuredFolders, progress, token);
}
var fileName = Path.GetFileName(relativePart);
@@ -68,8 +145,40 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl);
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl);
// 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),
// not server-relative paths. Passing "/sites/..." silently fails or
// returns no error yet copies nothing — especially across site
// collections. Prefix with the owning site's scheme+host.
var srcAbs = ToAbsoluteUrl(sourceCtx, srcFileUrl);
var dstAbs = ToAbsoluteUrl(destCtx, dstFileUrl);
var srcPath = ResourcePath.FromDecodedUrl(srcAbs);
var dstPath = ResourcePath.FromDecodedUrl(dstAbs);
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
var options = new MoveCopyOptions
@@ -98,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(
ClientContext ctx,
TransferJob job,
int sourceItemCount,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
@@ -109,41 +363,104 @@ public class FileTransferService : IFileTransferService
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/');
var libraryRoot = rootFolder.ServerRelativeUrl.TrimEnd('/');
// Explicit per-file selection overrides folder enumeration. Paths are
// library-relative (e.g. "SubFolder/file.docx") and get resolved to
// full server-relative URLs here.
if (job.SelectedFilePaths.Count > 0)
{
return job.SelectedFilePaths
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => $"{libraryRoot}/{p.TrimStart('/')}")
.ToList();
}
var baseFolderUrl = libraryRoot;
if (!string.IsNullOrEmpty(job.SourceFolderPath))
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl);
// Paginated recursive CAML query — Folder.Files / Folder.Folders lazy
// loading hits the list-view threshold on libraries > 5,000 items.
var files = new List<string>();
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
return files;
}
private async Task CollectFilesRecursiveAsync(
ClientContext ctx,
Folder folder,
List<string> files,
IProgress<OperationProgress> progress,
CancellationToken ct)
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, list, baseFolderUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileRef", "FileDirRef" },
ct: ct))
{
ct.ThrowIfCancellationRequested();
ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl),
f => f.Folders);
if (item["FSObjType"]?.ToString() != "0") continue; // files only
var fileRef = item["FileRef"]?.ToString();
if (string.IsNullOrEmpty(fileRef)) continue;
// Skip files under SharePoint system folders (e.g. "Forms", "_*").
var dir = item["FileDirRef"]?.ToString() ?? string.Empty;
if (HasSystemFolderSegment(dir, baseFolderUrl)) continue;
files.Add(fileRef);
}
return files;
}
private static bool HasSystemFolderSegment(string fileDirRef, string baseFolderUrl)
{
if (string.IsNullOrEmpty(fileDirRef)) return false;
var baseTrim = baseFolderUrl.TrimEnd('/');
if (!fileDirRef.StartsWith(baseTrim, StringComparison.OrdinalIgnoreCase))
return false;
var tail = fileDirRef.Substring(baseTrim.Length).Trim('/');
if (string.IsNullOrEmpty(tail)) return false;
foreach (var seg in tail.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
if (seg.StartsWith("_", StringComparison.Ordinal) ||
seg.Equals("Forms", StringComparison.OrdinalIgnoreCase))
return true;
}
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);
foreach (var file in folder.Files)
return list.ItemCount;
}
catch (Exception ex)
{
files.Add(file.ServerRelativeUrl);
// 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;
}
}
foreach (var subFolder in folder.Folders)
/// <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)
{
// Skip system folders
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue;
await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct);
}
var normalized = folderServerRelativeUrl.TrimEnd('/');
if (!cache.Add(normalized)) return;
await EnsureFolderAsync(ctx, normalized, progress, ct);
}
private async Task EnsureFolderAsync(
@@ -152,28 +469,70 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
// Already there?
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
ctx.Load(folder, f => f.Exists);
var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
ctx.Load(existing, f => f.Exists);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (folder.Exists) return;
if (existing.Exists) return;
}
catch { /* folder doesn't exist, create it */ }
catch { /* not present — fall through to creation */ }
// Create folder using Folders.Add which creates intermediate folders
ctx.Web.Folders.Add(folderServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
// Walk the path, creating each missing segment. `Web.Folders.Add(url)` is
// ambiguous across CSOM versions (some treat the arg as relative to Web,
// others server-relative), which produces bogus paths + "Access denied".
// Resolve the parent explicitly and add only the leaf name instead.
int slash = folderServerRelativeUrl.LastIndexOf('/');
if (slash <= 0) return;
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath)
var parentUrl = folderServerRelativeUrl.Substring(0, slash);
var leafName = folderServerRelativeUrl.Substring(slash + 1);
if (string.IsNullOrEmpty(leafName)) return;
// Recurse to guarantee the parent exists first.
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
var parent = ctx.Web.GetFolderByServerRelativeUrl(parentUrl);
parent.Folders.Add(leafName);
try
{
// Extract site-relative URL from context URL
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (Exception ex)
{
Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}",
parentUrl, leafName, ex.Message);
throw;
}
}
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
{
if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return pathOrUrl;
var uri = new Uri(ctx.Url);
var siteRelative = uri.AbsolutePath.TrimEnd('/');
var basePath = $"{siteRelative}/{library}";
if (!string.IsNullOrEmpty(folderPath))
basePath = $"{basePath}/{folderPath.TrimStart('/')}";
return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
}
private static async Task<string> ResolveLibraryPathAsync(
ClientContext ctx,
string libraryTitle,
string relativeFolderPath,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var basePath = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
if (!string.IsNullOrEmpty(relativeFolderPath))
basePath = $"{basePath}/{relativeFolderPath.TrimStart('/')}";
return basePath;
}
}
@@ -9,23 +9,25 @@ public interface IAppRegistrationService
{
/// <summary>
/// Returns true if the currently-authenticated user has the Global Administrator
/// directory role (checked via transitiveMemberOf for nested-group coverage).
/// Returns false on any failure, including 403, rather than throwing.
/// directory role in the target tenant (checked via transitiveMemberOf for
/// nested-group coverage). Throws on Graph/network failure so the UI can
/// distinguish a confirmed non-admin from a call that could not complete.
/// </summary>
Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct);
Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct);
/// <summary>
/// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants
/// atomically. On any intermediate failure the Application is deleted before
/// returning a Failure result (best-effort rollback).
/// atomically in the tenant identified by <paramref name="tenantUrl"/>.
/// On any intermediate failure the Application is deleted before returning
/// a Failure result (best-effort rollback).
/// </summary>
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct);
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantUrl, string tenantDisplayName, CancellationToken ct);
/// <summary>
/// Deletes the registered application by its appId.
/// Deletes the registered application by its appId in the given tenant.
/// Logs a warning on failure but does not throw.
/// </summary>
Task RemoveAsync(string clientId, string appId, CancellationToken ct);
Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct);
/// <summary>
/// Clears the live SessionManager context, evicts all in-memory MSAL accounts,
+19 -8
View File
@@ -6,8 +6,11 @@ namespace SharepointToolbox.Services;
public interface IStorageService
{
/// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API.
/// Returns a tree of StorageNode objects with aggregate size data.
/// Collects storage metrics for a site, capturing every storage source
/// SharePoint reports (visible + hidden libraries, Preservation Hold,
/// list attachments, recycle bin, and optionally subsites). Each
/// <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/> so
/// callers can filter what appears in the report.
/// </summary>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
@@ -18,9 +21,6 @@ public interface IStorageService
/// <summary>
/// Enumerates files across all non-hidden document libraries in the site
/// and aggregates storage consumption grouped by file extension.
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
/// This is a separate operation from CollectStorageAsync -- it provides
/// file-type breakdown data for chart visualization.
/// </summary>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx,
@@ -29,13 +29,24 @@ public interface IStorageService
/// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery.
/// This works around the StorageMetrics API returning zeros when the
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// by enumerating files per library via CamlQuery. Only re-runs against
/// document-library kinds (Library, HiddenLibrary, PreservationHold).
/// </summary>
Task BackfillZeroNodesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <summary>
/// Returns the SharePoint-reported total storage usage for the site
/// (Site.Usage.Storage). This includes everything that counts toward
/// the site quota — recycle bin, version history, hidden libraries,
/// list attachments — and serves as the ground-truth reference total.
/// Returns 0 if the call is denied or the property is unavailable.
/// </summary>
Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
@@ -0,0 +1,23 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Resolves the targeted resource (List, Web, File, Folder) for a SharePoint system
/// group whose name encodes a GUID — Limited Access System Group For Web/List and
/// SharingLinks.{itemId}.{type}.{shareId}. Returns null when the target is missing
/// or unreachable (deleted item, access denied, etc.) — callers fall back to the
/// raw group name.
/// </summary>
public interface ISystemGroupTargetResolver
{
/// <summary>
/// Resolves the target for a classified system group. Cached per (ctx site, guid).
/// </summary>
Task<SystemGroupTarget?> ResolveAsync(
ClientContext ctx,
SystemGroupClassification classification,
CancellationToken ct);
}
@@ -27,5 +27,6 @@ public interface IUserAccessAuditService
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
}
@@ -0,0 +1,28 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IVersionCleanupService
{
/// <summary>
/// Enumerates document libraries (filtered by <see cref="VersionCleanupOptions.LibraryTitles"/>
/// when non-empty) and deletes historical file versions per file according to
/// <see cref="VersionCleanupOptions.KeepLast"/> and <see cref="VersionCleanupOptions.KeepFirst"/>.
/// The current published version is never touched. Returns one result row per file
/// where at least one version was inspected.
/// </summary>
Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <summary>
/// Lists non-hidden document libraries on the site. Used by the library picker
/// so callers can present a checkbox UI.
/// </summary>
Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx,
CancellationToken ct);
}
@@ -7,6 +7,17 @@ public class OwnershipElevationService : IOwnershipElevationService
{
public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct)
{
// Tenant.SetSiteAdmin requires a real claims/UPN login; an empty string
// makes the server raise "Cannot convert Org ID to Claims" and abort.
// When the caller doesn't specify a user, fall back to the signed-in
// admin (the owner of tenantAdminCtx).
if (string.IsNullOrWhiteSpace(loginName))
{
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
await tenantAdminCtx.ExecuteQueryAsync();
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
}
var tenant = new Tenant(tenantAdminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
await tenantAdminCtx.ExecuteQueryAsync();
@@ -1,4 +1,5 @@
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
@@ -10,6 +11,31 @@ namespace SharepointToolbox.Services;
/// </summary>
public class PermissionsService : IPermissionsService
{
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
public PermissionsService() : this(null) { }
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver)
{
_systemGroupResolver = systemGroupResolver;
}
/// <summary>
/// Detects the SharePoint server error raised when a RoleAssignment member
/// refers to a user that no longer resolves (orphaned Azure AD account).
/// Message surfaces in the user's locale — match on language-agnostic tokens.
/// </summary>
private static bool IsClaimsResolutionError(ServerException ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("Claims", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Revendications", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Org ID", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("ID org", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
}
// Port of PS lines 1914-1926: system lists excluded from permission reporting
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
{
@@ -122,7 +148,17 @@ public class PermissionsService : IPermissionsService
u => u.Title,
u => u.LoginName,
u => u.IsSiteAdmin));
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped site collection admins for {Url} — orphaned user: {Error}",
ctx.Web.Url, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
var admins = ctx.Web.SiteUsers
.Where(u => u.IsSiteAdmin)
@@ -280,7 +316,23 @@ public class PermissionsService : IPermissionsService
ra => ra.Member.LoginName,
ra => ra.Member.PrincipalType,
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
// Orphaned AD users in RoleAssignments cause the server to throw
// "Cannot convert Org ID user to Claims user" during claim resolution.
// That kills the whole batch — skip this object so the scan continues.
// Only swallow the claims-resolution signature; real access-denied errors
// must bubble up so callers (e.g. PermissionsViewModel auto-elevation)
// can react to them.
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped {Type} '{Title}' ({Url}) — orphaned user in permissions: {Error}",
objectType, title, url, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
// Skip inherited objects when IncludeInherited=false
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
@@ -294,9 +346,18 @@ public class PermissionsService : IPermissionsService
var member = ra.Member;
var loginName = member.LoginName ?? string.Empty;
var memberTitle = member.Title ?? string.Empty;
// Skip sharing links groups and limited access system groups
if (PermissionEntryHelper.IsSharingLinksGroup(loginName))
// Classify the member name. The bare "Limited Access System Group" is
// pure noise; drop it. The For-Web/For-List and SharingLinks variants
// are kept and enriched below with a resolved target URL.
var classification = PermissionEntryHelper.Classify(memberTitle);
if (classification.Kind == SystemGroupKind.None
&& PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName))
{
continue;
}
if (classification.Kind == SystemGroupKind.LimitedAccessBare)
continue;
// Collect and filter permission levels
@@ -320,19 +381,42 @@ public class PermissionsService : IPermissionsService
// Determine how the permission was granted
string grantedThrough = principalType == "SharePointGroup"
? $"SharePoint Group: {member.Title}"
? $"SharePoint Group: {memberTitle}"
: "Direct Permissions";
// Resolve system-group target (Limited Access For Web/List, SharingLinks)
string? targetUrl = null;
string? targetLabel = null;
string? sharingLinkType = null;
if (_systemGroupResolver is not null && classification.Kind != SystemGroupKind.None)
{
var target = await _systemGroupResolver.ResolveAsync(ctx, classification, ct);
if (target is not null)
{
targetUrl = target.Url;
targetLabel = target.Label;
sharingLinkType = target.LinkType;
}
else if (classification.Kind == SystemGroupKind.SharingLink)
{
// Target lookup failed (deleted item / no access) — still surface link type.
sharingLinkType = classification.LinkType;
}
}
entries.Add(new PermissionEntry(
ObjectType: objectType,
Title: title,
Url: url,
HasUniquePermissions: obj.HasUniqueRoleAssignments,
Users: member.Title ?? string.Empty,
Users: memberTitle,
UserLogins: loginName,
PermissionLevels: permLevels,
GrantedThrough: grantedThrough,
PrincipalType: principalType));
PrincipalType: principalType,
TargetUrl: targetUrl,
TargetLabel: targetLabel,
SharingLinkType: sharingLinkType));
}
return entries;
+3 -2
View File
@@ -24,8 +24,9 @@ public class ProfileService
!Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _))
throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile));
if (string.IsNullOrWhiteSpace(profile.ClientId))
throw new ArgumentException("ClientId must not be empty.", nameof(profile));
// ClientId is optional at creation time: the user can register the app from within
// the tool, which will populate ClientId/AppId on the profile afterwards.
profile.ClientId ??= string.Empty;
var existing = (await _repository.LoadAsync()).ToList();
existing.Add(profile);
+18 -3
View File
@@ -62,10 +62,25 @@ public class SearchService : ISearchService
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows)
foreach (var rawRow in table.ResultRows)
{
var dict = row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
// CSOM has returned ResultRows as either Hashtable or
// Dictionary<string,object> across versions — accept both.
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic)
{
dict = generic;
}
else if (rawRow is System.Collections.IDictionary legacy)
{
dict = new Dictionary<string, object>();
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
else
{
continue;
}
// Skip SharePoint version history paths
string path = Str(dict, "Path");
@@ -43,4 +43,14 @@ public class SettingsService
settings.AutoTakeOwnership = enabled;
await _repository.SaveAsync(settings);
}
public async Task SetThemeAsync(string mode)
{
if (mode is not ("System" or "Light" or "Dark"))
throw new ArgumentException($"Unsupported theme '{mode}'. Supported: System, Light, Dark.", nameof(mode));
var settings = await _repository.LoadAsync();
settings.Theme = mode;
await _repository.SaveAsync(settings);
}
}
@@ -23,7 +23,7 @@ namespace SharepointToolbox.Services;
/// </summary>
public class SharePointGroupResolver : ISharePointGroupResolver
{
private readonly AppGraphClientFactory? _graphClientFactory;
private readonly AppGraphClientFactory _graphClientFactory;
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
{
@@ -45,10 +45,38 @@ public class SharePointGroupResolver : ISharePointGroupResolver
GraphServiceClient? graphClient = null;
// Preload the web's SiteGroups catalog once, so we can skip missing
// groups without triggering a server round-trip per name (which fills
// logs with "Could not resolve SP group" warnings for groups that
// live on other sites or were renamed/deleted).
var groupTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
foreach (var g in ctx.Web.SiteGroups)
groupTitles.Add(g.Title);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
}
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
ct.ThrowIfCancellationRequested();
if (!groupTitles.Contains(groupName))
{
// Group not on this web — likely scoped to another site in a
// multi-site scan. Keep quiet: log at Debug, return empty.
Log.Debug("SP group '{Group}' not present on {Url}; skipping.",
groupName, ctx.Url);
result[groupName] = Array.Empty<ResolvedMember>();
continue;
}
try
{
var group = ctx.Web.SiteGroups.GetByName(groupName);
@@ -65,7 +93,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
if (IsAadGroup(user.LoginName))
{
// 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 leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
@@ -83,6 +111,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
@@ -155,6 +184,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
await pageIterator.IterateAsync(ct);
return members;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
@@ -69,7 +69,12 @@ public class SiteListService : ISiteListService
if (s.Status == "Active"
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
{
results.Add(new SiteInfo(s.Url, s.Title));
results.Add(new SiteInfo(s.Url, s.Title)
{
StorageUsedMb = s.StorageUsage,
StorageQuotaMb = s.StorageMaximumLevel,
Template = s.Template ?? string.Empty
});
}
}
}
+494 -123
View File
@@ -7,20 +7,39 @@ namespace SharepointToolbox.Services;
/// <summary>
/// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern.
/// Captures every storage source SharePoint reports for a site:
/// document libraries (visible + hidden), the Preservation Hold Library,
/// list attachments, the recycle bin (1st + 2nd stage), and optionally
/// subsites. Each <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/>
/// so the caller can filter what appears in the report.
/// </summary>
public class StorageService : IStorageService
{
// PreservationHoldLibrary base template id.
private const int PreservationHoldTemplate = 851;
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var result = new List<StorageNode>();
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
return result;
}
private async Task CollectForWebAsync(
ClientContext ctx,
Web web,
StorageScanOptions options,
List<StorageNode> result,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Load web-level metadata in one round-trip
ctx.Load(ctx.Web,
ctx.Load(web,
w => w.Title,
w => w.Url,
w => w.ServerRelativeUrl,
@@ -28,41 +47,324 @@ public class StorageService : IStorageService
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.BaseTemplate,
l => l.ItemCount,
l => l.RootFolder.ServerRelativeUrl));
if (options.IncludeSubsites)
ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/');
string siteTitle = ctx.Web.Title;
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
string siteTitle = web.Title;
var lists = web.Lists.ToList();
// ── Document libraries (incl. hidden + Preservation Hold) ───────────
// Track each library's RootFolder server-relative URL so bin items can
// be attributed back to their source library (matches storman.aspx,
// which folds bin contents into the owning library's Total Size).
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
var libsByRoot = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
int idx = 0;
foreach (var lib in libs)
foreach (var lib in docLibs)
{
ct.ThrowIfCancellationRequested();
idx++;
progress.Report(new OperationProgress(idx, libs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})"));
StorageNodeKind kind = ClassifyLibrary(lib);
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
progress.Report(new OperationProgress(idx, docLibs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{docLibs.Count})"));
var libNode = await LoadFolderNodeAsync(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct);
siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0)
{
await CollectSubfoldersAsync(
ctx, lib.RootFolder.ServerRelativeUrl,
ctx, lib, lib.RootFolder.ServerRelativeUrl,
libNode, 1, options.FolderDepth,
siteTitle, lib.Title, progress, ct);
siteTitle, lib.Title, kind, progress, ct);
}
// CSOM Folder.StorageMetrics is unreliable across the board for
// larger libraries — sometimes returns the storman value, sometimes
// returns a fraction of it, sometimes zero. Subfolder StorageMetrics
// are equally inconsistent. The only CSOM path that matches storman
// is per-file File.Length + File.Versions[*].Size enumeration, so
// run it unconditionally, replacing the CSOM totals.
ResetNodeCounts(libNode);
await BackfillLibFromFilesAsync(ctx, lib, libNode, progress, ct);
result.Add(libNode);
libsByRoot[NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl)] = libNode;
}
return result;
// ── List attachments (non-document-library lists) ───────────────────
if (options.IncludeListAttachments)
{
var nonDocLists = lists
.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0)
.ToList();
int aIdx = 0;
foreach (var list in nonDocLists)
{
ct.ThrowIfCancellationRequested();
aIdx++;
progress.Report(new OperationProgress(aIdx, nonDocLists.Count,
$"Scanning list attachments: {list.Title} ({aIdx}/{nonDocLists.Count})"));
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
if (attachNode != null && attachNode.TotalSizeBytes > 0)
result.Add(attachNode);
}
}
// ── Recycle bin (stage 1 + stage 2) ─────────────────────────────────
if (options.IncludeRecycleBin)
{
progress.Report(OperationProgress.Indeterminate(
$"Scanning recycle bin: {siteTitle}..."));
var (rbNodes, perDir) = await LoadRecycleBinNodesAsync(ctx, web, siteTitle, progress, ct);
// Attribute bin items to owning library (longest-prefix match on DirName)
// so library Total Size matches storman.aspx, which counts an item's
// bytes against its source library even after deletion.
if (perDir.Count > 0 && libsByRoot.Count > 0)
{
var libRootsByLength = libsByRoot
.OrderByDescending(kv => kv.Key.Length)
.ToList();
foreach (var kv in perDir)
{
string dirNorm = NormalizeServerRelative(kv.Key);
foreach (var lib in libRootsByLength)
{
if (dirNorm.Equals(lib.Key, StringComparison.OrdinalIgnoreCase) ||
dirNorm.StartsWith(lib.Key + "/", StringComparison.OrdinalIgnoreCase))
{
lib.Value.TotalSizeBytes += kv.Value.Size;
lib.Value.TotalFileCount += kv.Value.Count;
break;
}
}
}
}
result.AddRange(rbNodes);
}
// ── Subsites (recursive) ────────────────────────────────────────────
if (options.IncludeSubsites)
{
var subwebs = web.Webs.ToList();
foreach (var sub in subwebs)
{
ct.ThrowIfCancellationRequested();
// Build a node header so subsite results are visually grouped.
var subResult = new List<StorageNode>();
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
if (subResult.Count == 0) continue;
// Bin contents already rolled up into each library's TotalSizeBytes
// (storman behavior); summing root RecycleBin children too would
// double-count. Filter them out here.
var subRoot = new StorageNode
{
Name = sub.Title,
Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
SiteTitle = sub.Title,
Library = string.Empty,
Kind = StorageNodeKind.Subsite,
IndentLevel = 0,
Children = subResult,
TotalSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalSizeBytes),
FileStreamSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.FileStreamSizeBytes),
TotalFileCount = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalFileCount)
};
result.Add(subRoot);
}
}
}
private static StorageNodeKind ClassifyLibrary(List lib)
{
if (lib.BaseTemplate == PreservationHoldTemplate ||
string.Equals(lib.Title, "Preservation Hold Library", StringComparison.OrdinalIgnoreCase))
return StorageNodeKind.PreservationHold;
return lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
}
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(
ClientContext ctx,
List list,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Per-list attachments live in <listRootFolder>/Attachments/<itemId>/<file>.
// The Attachments folder may or may not exist depending on whether any
// item ever had an attachment — guard with try/catch.
string attachmentsUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(attachmentsUrl);
ctx.Load(folder,
f => f.Exists,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0)
return null;
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
? folder.StorageMetrics.LastModified
: folder.TimeLastModified > DateTime.MinValue
? folder.TimeLastModified
: (DateTime?)null;
return new StorageNode
{
Name = $"[Attachments] {list.Title}",
Url = ctx.Url.TrimEnd('/') + attachmentsUrl,
SiteTitle = siteTitle,
Library = list.Title,
Kind = StorageNodeKind.ListAttachments,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
LastModified = lastMod,
IndentLevel = 0,
Children = new List<StorageNode>()
};
}
catch
{
// Attachments folder absent for this list — not an error.
return null;
}
}
private static async Task<(List<StorageNode> Nodes, Dictionary<string, (long Size, int Count)> PerDir)> LoadRecycleBinNodesAsync(
ClientContext ctx,
Web web,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var nodes = new List<StorageNode>();
var perDir = new Dictionary<string, (long Size, int Count)>(StringComparer.OrdinalIgnoreCase);
try
{
// Web-scoped: ctx.Site.RecycleBin would return the entire site-collection
// bin and inflate totals by (1 + N_subsites) when IncludeSubsites is on.
var bin = web.RecycleBin;
ctx.Load(bin, b => b.Include(
i => i.Size,
i => i.ItemState,
i => i.DeletedDate,
i => i.DirName));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// RecycleBinItem.DirName is web-relative on SharePoint Online
// (e.g. "Documents/SubFolder" without leading slash or web URL).
// Prepend the web's ServerRelativeUrl so the result matches
// List.RootFolder.ServerRelativeUrl form used by libsByRoot.
string webSrl = NormalizeServerRelative(web.ServerRelativeUrl);
long stage1Size = 0, stage2Size = 0;
int stage1Count = 0, stage2Count = 0;
DateTime? stage1Last = null, stage2Last = null;
foreach (var item in bin)
{
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin)
{
stage2Size += item.Size;
stage2Count++;
if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate;
}
else
{
stage1Size += item.Size;
stage1Count++;
if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate;
}
string raw = item.DirName ?? string.Empty;
string dirSrl;
if (raw.StartsWith('/'))
dirSrl = NormalizeServerRelative(raw);
else if (string.IsNullOrEmpty(raw))
dirSrl = webSrl;
else
dirSrl = NormalizeServerRelative(webSrl + "/" + raw);
if (perDir.TryGetValue(dirSrl, out var tally))
perDir[dirSrl] = (tally.Size + item.Size, tally.Count + 1);
else
perDir[dirSrl] = (item.Size, 1);
}
if (stage1Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] First-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage1Size,
FileStreamSizeBytes = stage1Size,
TotalFileCount = stage1Count,
LastModified = stage1Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
if (stage2Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] Second-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage2Size,
FileStreamSizeBytes = stage2Size,
TotalFileCount = stage2Count,
LastModified = stage2Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
}
catch
{
// Insufficient permission to read recycle bin or feature unavailable.
}
return (nodes, perDir);
}
/// <summary>
/// Normalizes a server-relative path for consistent prefix matching:
/// trims trailing slash, ensures single leading slash. SharePoint
/// inconsistently returns DirName with or without leading slash across
/// API surfaces, so the caller cannot rely on a canonical form.
/// </summary>
private static string NormalizeServerRelative(string? path)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
string trimmed = path.Trim().TrimEnd('/');
if (trimmed.Length == 0) return string.Empty;
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
}
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
@@ -72,7 +374,6 @@ public class StorageService : IStorageService
{
ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title,
@@ -85,7 +386,6 @@ public class StorageService : IStorageService
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0;
@@ -96,22 +396,17 @@ public class StorageService : IStorageService
progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Use CamlQuery to enumerate all files in the library
// Paginate with 500 items per batch to avoid list view threshold issues
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
// beyond 5000 items breaches the list view threshold. Page lightly,
// then second-pass load File.Length + Versions[*].Size so per-type
// totals include version bytes (matches per-library totals).
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>0</Value>
</Eq>
</Where>
</Query>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileLeafRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
@@ -124,20 +419,41 @@ public class StorageService : IStorageService
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FileLeafRef"],
i => i["File_x0020_Size"]));
i => i["FSObjType"],
i => i["FileLeafRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string Name)>();
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue;
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0";
fileRows.Add((item, fileName));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (!long.TryParse(sizeStr, out long fileSize))
fileSize = 0;
if (fileRows.Count > 0)
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
string ext = Path.GetExtension(fileName).ToLowerInvariant();
// ext is "" for extensionless files, ".docx" etc. for others
foreach (var row in fileRows)
{
long current;
try { current = row.Item.File.Length; }
catch { continue; }
long versions = 0;
try
{
foreach (var v in row.Item.File.Versions)
versions += v.Size;
}
catch { /* no version history */ }
long fileSize = current + versions;
string ext = Path.GetExtension(row.Name).ToLowerInvariant();
if (extensionMap.TryGetValue(ext, out var existing))
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
@@ -145,72 +461,51 @@ public class StorageService : IStorageService
extensionMap[ext] = (fileSize, 1);
}
// Move to next page
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
}
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes)
.ToList();
}
public async Task BackfillZeroNodesAsync(
/// <summary>
/// Per-library backfill executed inline by CollectForWebAsync when CSOM's
/// Folder.StorageMetrics returns zero counts. Enumerates every file via
/// CamlQuery and explicitly loads File.Length + File.Versions.Size so
/// version bytes are summed accurately — matches what storman.aspx reports.
/// </summary>
private static async Task BackfillLibFromFilesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
List lib,
StorageNode libNode,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Find root-level library nodes that have any zero-valued nodes in their tree
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
var needsBackfill = libNodes.Where(lib =>
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
if (needsBackfill.Count == 0) return;
progress.Report(OperationProgress.Indeterminate(
$"Counting files: {libNode.Name}..."));
// 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);
string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl);
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
int idx = 0;
foreach (var libNode in needsBackfill)
{
ct.ThrowIfCancellationRequested();
idx++;
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
progress.Report(new OperationProgress(idx, needsBackfill.Count,
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Reset all nodes in this tree to zero before accumulating
ResetNodeCounts(libNode);
// Enumerate all files with their folder path
// 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><Where>
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
</Where></Query>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
@@ -223,47 +518,114 @@ public class StorageService : IStorageService
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FileDirRef"],
i => i["File_x0020_Size"]));
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)
{
long size = 0;
if (long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long s))
size = s;
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
// Always count toward the library root
libNode.TotalSizeBytes += size;
libNode.FileStreamSizeBytes += size;
libNode.TotalFileCount++;
// Also count toward the most specific matching subfolder
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
if (matchedFolder != null && matchedFolder != libNode)
{
matchedFolder.TotalSizeBytes += size;
matchedFolder.FileStreamSizeBytes += size;
matchedFolder.TotalFileCount++;
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);
}
private static bool HasZeroChild(StorageNode node)
/// <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)
{
if (child.TotalFileCount == 0) return true;
if (HasZeroChild(child)) return true;
RollupFolderTotals(child);
node.TotalSizeBytes += child.TotalSizeBytes;
node.FileStreamSizeBytes += child.FileStreamSizeBytes;
node.TotalFileCount += child.TotalFileCount;
}
}
/// <summary>
/// No-op retained for interface compatibility. Backfill now runs inline
/// inside <see cref="CollectStorageAsync"/> via BackfillLibFromFilesAsync,
/// which has access to the CSOM library reference and runs before bin
/// distribution so the count==0 trigger is not polluted by bin items.
/// </summary>
public Task BackfillZeroNodesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress,
CancellationToken ct)
=> Task.CompletedTask;
public async Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
try
{
ctx.Load(ctx.Site, s => s.Usage);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return ctx.Site.Usage.Storage;
}
catch
{
return 0L;
}
return false;
}
private static void ResetNodeCounts(StorageNode node)
@@ -290,8 +652,6 @@ public class StorageService : IStorageService
private static StorageNode? FindDeepestFolder(string fileDirRef,
Dictionary<string, StorageNode> lookup)
{
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
// Try exact match, then walk up until we find a match
string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path))
{
@@ -304,7 +664,7 @@ public class StorageService : IStorageService
return null;
}
// -- Private helpers -----------------------------------------------------
// ── Library/folder loading helpers ──────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx,
@@ -313,6 +673,7 @@ public class StorageService : IStorageService
string siteTitle,
string library,
int indentLevel,
StorageNodeKind kind,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
@@ -338,6 +699,7 @@ public class StorageService : IStorageService
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle,
Library = library,
Kind = kind,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
@@ -349,45 +711,54 @@ public class StorageService : IStorageService
private static async Task CollectSubfoldersAsync(
ClientContext ctx,
List list,
string parentServerRelativeUrl,
StorageNode parentNode,
int currentDepth,
int maxDepth,
string siteTitle,
string library,
StorageNodeKind kind,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested();
// Load direct child folders of this folder
Folder parentFolder = ctx.Web.GetFolderByServerRelativeUrl(parentServerRelativeUrl);
ctx.Load(parentFolder,
f => f.Folders.Include(
sf => sf.Name,
sf => sf.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var subfolders = new List<(string Name, string ServerRelativeUrl)>();
foreach (Folder subFolder in parentFolder.Folders)
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, list, parentServerRelativeUrl, recursive: false,
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
ct: ct))
{
if (item["FSObjType"]?.ToString() != "1") continue;
string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("_", StringComparison.Ordinal))
continue;
subfolders.Add((name, url));
}
foreach (var sub in subfolders)
{
ct.ThrowIfCancellationRequested();
// Skip SharePoint system folders
if (subFolder.Name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
subFolder.Name.StartsWith("_", StringComparison.Ordinal))
continue;
var childNode = await LoadFolderNodeAsync(
ctx, subFolder.ServerRelativeUrl, subFolder.Name,
siteTitle, library, currentDepth, progress, ct);
ctx, sub.ServerRelativeUrl, sub.Name,
siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth)
{
await CollectSubfoldersAsync(
ctx, subFolder.ServerRelativeUrl, childNode,
ctx, list, sub.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth,
siteTitle, library, progress, ct);
siteTitle, library, kind, progress, ct);
}
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}";
}
}
+55 -23
View File
@@ -93,8 +93,7 @@ public class TemplateService : ITemplateService
{
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
libInfo.Folders = await EnumerateFoldersRecursiveAsync(
ctx, list.RootFolder, string.Empty, progress, ct);
libInfo.Folders = await EnumerateLibraryFoldersAsync(ctx, list, ct);
}
template.Libraries.Add(libInfo);
@@ -293,39 +292,72 @@ public class TemplateService : ITemplateService
return siteUrl;
}
private async Task<List<TemplateFolderInfo>> EnumerateFoldersRecursiveAsync(
/// <summary>
/// Enumerates every folder in a library via one paginated CAML scan, then
/// reconstructs the hierarchy from the server-relative paths. Replaces the
/// former per-level Folder.Folders lazy loading, which hits the list-view
/// threshold on libraries above 5,000 items.
/// </summary>
private static async Task<List<TemplateFolderInfo>> EnumerateLibraryFoldersAsync(
ClientContext ctx,
Folder parentFolder,
string parentRelativePath,
IProgress<OperationProgress> progress,
List list,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var result = new List<TemplateFolderInfo>();
ctx.Load(parentFolder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
foreach (var subFolder in parentFolder.Folders)
// Collect all folders flat: (relativePath, parentRelativePath).
var folders = new List<(string Relative, string Parent)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, list, rootUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "FileDirRef" },
ct: ct))
{
// Skip system folders
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
if (item["FSObjType"]?.ToString() != "1") continue; // folders only
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
var fileRef = (item["FileRef"]?.ToString() ?? string.Empty).TrimEnd('/');
var dirRef = (item["FileDirRef"]?.ToString() ?? string.Empty).TrimEnd('/');
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(fileRef)) continue;
if (name.StartsWith("_", StringComparison.Ordinal) ||
name.Equals("Forms", StringComparison.OrdinalIgnoreCase))
continue;
var relativePath = string.IsNullOrEmpty(parentRelativePath)
? subFolder.Name
: $"{parentRelativePath}/{subFolder.Name}";
// Paths relative to the library root.
var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
? fileRef.Substring(rootUrl.Length).TrimStart('/')
: name;
var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
? dirRef.Substring(rootUrl.Length).TrimStart('/')
: string.Empty;
var folderInfo = new TemplateFolderInfo
{
Name = subFolder.Name,
RelativePath = relativePath,
Children = await EnumerateFoldersRecursiveAsync(ctx, subFolder, relativePath, progress, ct),
};
result.Add(folderInfo);
folders.Add((rel, parentRel));
}
return result;
// Build tree keyed by relative path.
var nodes = folders.ToDictionary(
f => f.Relative,
f => new TemplateFolderInfo
{
Name = System.IO.Path.GetFileName(f.Relative),
RelativePath = f.Relative,
Children = new List<TemplateFolderInfo>(),
},
StringComparer.OrdinalIgnoreCase);
var roots = new List<TemplateFolderInfo>();
foreach (var (rel, parent) in folders)
{
if (!nodes.TryGetValue(rel, out var node)) continue;
if (!string.IsNullOrEmpty(parent) && nodes.TryGetValue(parent, out var p))
p.Children.Add(node);
else
roots.Add(node);
}
return roots;
}
private static async Task CreateFoldersFromTemplateAsync(
+135
View File
@@ -0,0 +1,135 @@
using System.Windows;
using Microsoft.Win32;
using Microsoft.Extensions.Logging;
namespace SharepointToolbox.Services;
public enum ThemeMode { System, Light, Dark }
/// <summary>
/// Swaps the merged palette dictionary at runtime so all DynamicResource brush lookups retint live.
/// "System" mode reads HKCU AppsUseLightTheme (0 = dark, 1 = light) and subscribes to system theme changes.
/// </summary>
public class ThemeManager
{
private const string LightPaletteSource = "pack://application:,,,/Themes/LightPalette.xaml";
private const string DarkPaletteSource = "pack://application:,,,/Themes/DarkPalette.xaml";
private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
private readonly ILogger<ThemeManager> _logger;
private ThemeMode _mode = ThemeMode.System;
private bool _systemSubscribed;
public event EventHandler? ThemeChanged;
public ThemeMode Mode => _mode;
public bool IsDarkActive { get; private set; }
public ThemeManager(ILogger<ThemeManager> logger)
{
_logger = logger;
}
public void ApplyMode(ThemeMode mode)
{
_mode = mode;
bool dark = ResolveDark(mode);
ApplyPalette(dark);
EnsureSystemSubscription(mode);
}
public void ApplyFromString(string? value)
{
var mode = (value ?? "System") switch
{
"Light" => ThemeMode.Light,
"Dark" => ThemeMode.Dark,
_ => ThemeMode.System,
};
ApplyMode(mode);
}
private bool ResolveDark(ThemeMode mode) => mode switch
{
ThemeMode.Light => false,
ThemeMode.Dark => true,
_ => ReadSystemPrefersDark(),
};
private bool ReadSystemPrefersDark()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
if (key?.GetValue("AppsUseLightTheme") is int v)
return v == 0;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read system theme preference, defaulting to light");
}
return false;
}
private void ApplyPalette(bool dark)
{
var app = Application.Current;
if (app is null) return;
var newPalette = new ResourceDictionary
{
Source = new Uri(dark ? DarkPaletteSource : LightPaletteSource, UriKind.Absolute)
};
var dicts = app.Resources.MergedDictionaries;
int replaced = -1;
for (int i = 0; i < dicts.Count; i++)
{
var src = dicts[i].Source?.OriginalString ?? string.Empty;
if (src.EndsWith("LightPalette.xaml", StringComparison.OrdinalIgnoreCase) ||
src.EndsWith("DarkPalette.xaml", StringComparison.OrdinalIgnoreCase))
{
replaced = i;
break;
}
}
if (replaced >= 0)
dicts[replaced] = newPalette;
else
dicts.Insert(0, newPalette);
IsDarkActive = dark;
ThemeChanged?.Invoke(this, EventArgs.Empty);
}
private void EnsureSystemSubscription(ThemeMode mode)
{
if (mode == ThemeMode.System && !_systemSubscribed)
{
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
_systemSubscribed = true;
}
else if (mode != ThemeMode.System && _systemSubscribed)
{
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
_systemSubscribed = false;
}
}
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
if (_mode != ThemeMode.System) return;
var app = Application.Current;
if (app is null) return;
app.Dispatcher.BeginInvoke(new Action(() =>
{
bool dark = ReadSystemPrefersDark();
if (dark != IsDarkActive)
ApplyPalette(dark);
}));
}
}
@@ -29,7 +29,8 @@ public class UserAccessAuditService : IUserAccessAuditService
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
{
// Normalize target logins for case-insensitive matching.
// Users may be identified by email ("alice@contoso.com") or full claim
@@ -59,10 +60,21 @@ public class UserAccessAuditService : IUserAccessAuditService
};
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
IReadOnlyList<PermissionEntry> permEntries;
try
{
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
{
var elevated = await onAccessDenied(site.Url, ct);
if (!elevated)
throw;
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
}
var userEntries = TransformEntries(permEntries, targets, site);
allEntries.AddRange(userEntries);
allEntries.AddRange(TransformEntries(permEntries, targets, site));
}
progress.Report(new OperationProgress(sites.Count, sites.Count,
@@ -124,7 +136,10 @@ public class UserAccessAuditService : IUserAccessAuditService
AccessType: accessType,
GrantedThrough: entry.GrantedThrough,
IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel),
IsExternalUser: PermissionEntryHelper.IsExternalUser(login));
IsExternalUser: PermissionEntryHelper.IsExternalUser(login),
TargetUrl: entry.TargetUrl,
TargetLabel: entry.TargetLabel,
SharingLinkType: entry.SharingLinkType);
}
}
}
@@ -0,0 +1,192 @@
using Microsoft.Extensions.Logging;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public class VersionCleanupService : IVersionCleanupService
{
private readonly ILogger<VersionCleanupService> _logger;
public VersionCleanupService(ILogger<VersionCleanupService> logger)
{
_logger = logger;
}
public async Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.Select(l => l.Title)
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (options.KeepLast < 0)
throw new ArgumentOutOfRangeException(nameof(options), "KeepLast must be >= 0.");
ctx.Load(ctx.Web, w => w.Url, w => w.ServerRelativeUrl,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var allLibs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
var titleFilter = options.LibraryTitles?.Count > 0
? new HashSet<string>(options.LibraryTitles, StringComparer.OrdinalIgnoreCase)
: null;
var libs = titleFilter is null
? allLibs
: allLibs.Where(l => titleFilter.Contains(l.Title)).ToList();
var results = new List<VersionCleanupResult>();
var siteUrl = ctx.Web.Url;
int libIdx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
libIdx++;
progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning versions: {lib.Title} ({libIdx}/{libs.Count})"));
// Enumerate files via paginated CAML so libs > 5,000 items work.
var files = new List<string>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileRef" },
ct: ct))
{
if (item["FSObjType"]?.ToString() != "0") continue;
var fileRef = item["FileRef"]?.ToString();
if (!string.IsNullOrEmpty(fileRef))
files.Add(fileRef);
}
int fileIdx = 0;
foreach (var fileRef in files)
{
ct.ThrowIfCancellationRequested();
fileIdx++;
if (fileIdx % 25 == 0 || fileIdx == files.Count)
{
progress.Report(new OperationProgress(fileIdx, files.Count,
$"{lib.Title}: {fileIdx}/{files.Count} files"));
}
var result = await TrimFileVersionsAsync(
ctx, siteUrl, lib.Title, fileRef, options, progress, ct);
if (result is not null)
results.Add(result);
}
}
return results;
}
private async Task<VersionCleanupResult?> TrimFileVersionsAsync(
ClientContext ctx,
string siteUrl,
string libraryTitle,
string fileServerRelativeUrl,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
int before = 0;
try
{
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
ctx.Load(file, f => f.Name);
ctx.Load(file.Versions,
vs => vs.Include(
v => v.VersionLabel,
v => v.Created,
v => v.Size));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// file.Versions contains only HISTORICAL versions; the current published
// version lives on `file` itself and is never deletable here.
var versions = file.Versions.ToList();
before = versions.Count;
if (before == 0) return null;
// Sort by Created ascending so [0] is the oldest historical version.
var ordered = versions
.OrderBy(v => v.Created)
.ToList();
// Preserve set: the last N most recent + optionally the very first.
var keep = new HashSet<int>();
int keepLast = Math.Min(options.KeepLast, ordered.Count);
for (int i = ordered.Count - keepLast; i < ordered.Count; i++)
keep.Add(i);
if (options.KeepFirst && ordered.Count > 0)
keep.Add(0);
long bytesFreed = 0;
int deleted = 0;
for (int i = 0; i < ordered.Count; i++)
{
if (keep.Contains(i)) continue;
var v = ordered[i];
bytesFreed += v.Size;
v.DeleteObject();
deleted++;
}
if (deleted == 0) return null;
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return new VersionCleanupResult
{
SiteUrl = siteUrl,
Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
VersionsBefore = before,
VersionsDeleted = deleted,
VersionsRemaining = before - deleted,
BytesFreed = bytesFreed,
};
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
return new VersionCleanupResult
{
SiteUrl = siteUrl,
Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
VersionsBefore = before,
Error = ex.Message,
};
}
}
}
@@ -8,6 +8,7 @@
<UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed>
<StartupObject>SharepointToolbox.App</StartupObject>
<ApplicationIcon>Resources\SPToolbox-logo.ico</ApplicationIcon>
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup>
@@ -43,6 +44,8 @@
<EmbeddedResource Include="Resources\bulk_add_members.csv" />
<EmbeddedResource Include="Resources\bulk_create_sites.csv" />
<EmbeddedResource Include="Resources\folder_structure.csv" />
<Resource Include="Resources\SPToolbox-logo-ico.png" />
<Resource Include="Resources\SPToolbox-logo.ico" />
</ItemGroup>
<ItemGroup>
@@ -51,6 +54,14 @@
<PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
<!-- Override Kiota transitive deps to patch GHSA-7j59-v9qr-6fq9 (NU1903 in <1.20.0) -->
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.83.3" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
+29
View File
@@ -0,0 +1,29 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Dark palette -->
<SolidColorBrush x:Key="AppBgBrush" Color="#15181F" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E2230" />
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#272C3B" />
<SolidColorBrush x:Key="SurfaceAltBrushAlt" Color="#222636" />
<SolidColorBrush x:Key="BorderSoftBrush" Color="#323849" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#3F475B" />
<SolidColorBrush x:Key="TextBrush" Color="#E7EAF1" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#9AA3B2" />
<SolidColorBrush x:Key="AccentBrush" Color="#60A5FA" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#3B82F6" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#1E3A5F" />
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" />
<SolidColorBrush x:Key="DangerBrush" Color="#F87171" />
<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). -->
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#4A5366" />
<SolidColorBrush x:Key="TooltipBgBrush" Color="#0B1220" />
<SolidColorBrush x:Key="TooltipFgBrush" Color="#E7EAF1" />
</ResourceDictionary>
@@ -0,0 +1,28 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Light palette -->
<SolidColorBrush x:Key="AppBgBrush" Color="#F6F7FB" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F3F4F8" />
<SolidColorBrush x:Key="SurfaceAltBrushAlt" Color="#FAFAFC" />
<SolidColorBrush x:Key="BorderSoftBrush" Color="#E3E6EC" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CED2D9" />
<SolidColorBrush x:Key="TextBrush" Color="#1F2430" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#5B6472" />
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1D4ED8" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#1E40AF" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#E8F0FE" />
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
<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="SelectionBrush" Color="#DBE7FF" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" />
<SolidColorBrush x:Key="TooltipBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="TooltipFgBrush" Color="#FFFFFF" />
</ResourceDictionary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
using System.ComponentModel;
using System.Globalization;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Views.Dialogs;
namespace SharepointToolbox.ViewModels.Dialogs;
/// <summary>
/// Headless logic for <see cref="SitePickerDialog"/>. Loads sites via
/// <see cref="ISiteListService"/> and applies filter/sort in memory so the
/// dialog's code-behind stays a thin shim and the logic is unit-testable
/// without a WPF host.
/// </summary>
public class SitePickerDialogLogic
{
private readonly ISiteListService _siteListService;
private readonly TenantProfile _profile;
public SitePickerDialogLogic(ISiteListService siteListService, TenantProfile profile)
{
_siteListService = siteListService;
_profile = profile;
}
/// <summary>
/// Loads all accessible sites for the tenant profile and wraps them in
/// <see cref="SitePickerItem"/> so the dialog can bind checkboxes.
/// </summary>
public async Task<IReadOnlyList<SitePickerItem>> LoadAsync(
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var sites = await _siteListService.GetSitesAsync(_profile, progress, ct);
return sites
.Select(s => new SitePickerItem(s.Url, s.Title, s.StorageUsedMb, s.StorageQuotaMb, s.Template))
.ToList();
}
/// <summary>
/// Filters items by free-text (title/url substring), storage-size range,
/// and site kind. Empty or zero-range parameters become no-ops.
/// </summary>
public static IEnumerable<SitePickerItem> ApplyFilter(
IEnumerable<SitePickerItem> items,
string text,
long minMb,
long maxMb,
string kindFilter)
{
var result = items;
if (!string.IsNullOrEmpty(text))
{
result = result.Where(i =>
i.Url.Contains(text, StringComparison.OrdinalIgnoreCase) ||
i.Title.Contains(text, StringComparison.OrdinalIgnoreCase));
}
result = result.Where(i => i.StorageUsedMb >= minMb && i.StorageUsedMb <= maxMb);
if (!string.IsNullOrEmpty(kindFilter) && kindFilter != "All")
result = result.Where(i => i.Kind.ToString() == kindFilter);
return result;
}
/// <summary>
/// Stable sort by a named column and direction. Unknown column names
/// return the input sequence unchanged.
/// </summary>
public static IEnumerable<SitePickerItem> ApplySort(
IEnumerable<SitePickerItem> items,
string column,
ListSortDirection direction)
{
var asc = direction == ListSortDirection.Ascending;
return column switch
{
"Title" => asc ? items.OrderBy(i => i.Title) : items.OrderByDescending(i => i.Title),
"Url" => asc ? items.OrderBy(i => i.Url) : items.OrderByDescending(i => i.Url),
"Kind" => asc ? items.OrderBy(i => i.KindDisplay) : items.OrderByDescending(i => i.KindDisplay),
"StorageUsedMb" => asc
? items.OrderBy(i => i.StorageUsedMb)
: items.OrderByDescending(i => i.StorageUsedMb),
"IsSelected" => asc
? items.OrderBy(i => i.IsSelected)
: items.OrderByDescending(i => i.IsSelected),
_ => items
};
}
/// <summary>
/// Lenient long parse: whitespace-only or unparseable input yields
/// <paramref name="fallback"/> instead of throwing.
/// </summary>
public static long ParseLongOrDefault(string text, long fallback)
{
if (string.IsNullOrWhiteSpace(text)) return fallback;
return long.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)
? v
: fallback;
}
}
@@ -23,6 +23,9 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient
[ObservableProperty]
private int _progressValue;
[ObservableProperty]
private bool _isIndeterminate;
/// <summary>
/// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage.
/// Derived VMs check this in RunOperationAsync before falling back to per-tab SiteUrl.
@@ -46,24 +49,44 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient
IsRunning = true;
StatusMessage = string.Empty;
ProgressValue = 0;
IsIndeterminate = false;
try
{
var progress = new Progress<OperationProgress>(p =>
{
// Indeterminate reports (throttle waits, inner scan steps) must not
// reset the determinate bar to 0%; only update the status message
// and flip the bar into marquee mode. The next determinate report
// restores % and clears the marquee flag.
if (p.IsIndeterminate)
{
IsIndeterminate = true;
}
else
{
IsIndeterminate = false;
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
}
StatusMessage = p.Message;
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
});
await RunOperationAsync(_cts.Token, progress);
// Success path: replace any lingering "Scanning X…" with a neutral
// completion marker so stale in-progress labels don't stick around.
StatusMessage = TranslationSource.Instance["status.complete"];
ProgressValue = 100;
IsIndeterminate = false;
}
catch (OperationCanceledException)
{
StatusMessage = TranslationSource.Instance["status.cancelled"];
IsIndeterminate = false;
_logger.LogInformation("Operation cancelled by user.");
}
catch (Exception ex)
{
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
IsIndeterminate = false;
_logger.LogError(ex, "Operation failed.");
}
finally
@@ -50,7 +50,6 @@ public partial class MainWindowViewModel : ObservableRecipient
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
: Localization.TranslationSource.Instance["toolbar.globalSites.none"];
public IAsyncRelayCommand ConnectCommand { get; }
public IAsyncRelayCommand ClearSessionCommand { get; }
public RelayCommand ManageProfilesCommand { get; }
public RelayCommand OpenGlobalSitePickerCommand { get; }
@@ -64,7 +63,6 @@ public partial class MainWindowViewModel : ObservableRecipient
_sessionManager = sessionManager;
_logger = logger;
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
@@ -96,7 +94,6 @@ public partial class MainWindowViewModel : ObservableRecipient
{
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
}
ConnectCommand.NotifyCanExecuteChanged();
ClearSessionCommand.NotifyCanExecuteChanged();
// Clear global site selection on tenant switch (sites belong to a tenant)
GlobalSelectedSites.Clear();
@@ -121,22 +118,6 @@ public partial class MainWindowViewModel : ObservableRecipient
}
}
private async Task ConnectAsync()
{
if (SelectedProfile == null) return;
try
{
ConnectionStatus = "Connecting...";
await _sessionManager.GetOrCreateContextAsync(SelectedProfile, CancellationToken.None);
ConnectionStatus = SelectedProfile.Name;
}
catch (Exception ex)
{
ConnectionStatus = "Connection failed";
_logger.LogError(ex, "Failed to connect to tenant {TenantUrl}.", SelectedProfile.TenantUrl);
}
}
private async Task ClearSessionAsync()
{
if (SelectedProfile == null) return;
@@ -19,6 +19,11 @@ public partial class ProfileManagementViewModel : ObservableObject
private readonly ILogger<ProfileManagementViewModel> _logger;
private readonly IAppRegistrationService _appRegistrationService;
// Well-known public client (Microsoft Graph Command Line Tools) used as a bootstrap
// when a profile has no ClientId yet, so the user can sign in as admin and have the
// app registration created for them.
private const string BootstrapClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
[ObservableProperty]
private TenantProfile? _selectedProfile;
@@ -55,7 +60,7 @@ public partial class ProfileManagementViewModel : ObservableObject
public ObservableCollection<TenantProfile> Profiles { get; } = new();
public IAsyncRelayCommand AddCommand { get; }
public IAsyncRelayCommand RenameCommand { get; }
public IAsyncRelayCommand SaveCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
public IAsyncRelayCommand ClearClientLogoCommand { get; }
@@ -77,7 +82,7 @@ public partial class ProfileManagementViewModel : ObservableObject
_appRegistrationService = appRegistrationService;
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave);
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
@@ -107,11 +112,24 @@ public partial class ProfileManagementViewModel : ObservableObject
partial void OnSelectedProfileChanged(TenantProfile? value)
{
if (value != null)
{
NewName = value.Name;
NewTenantUrl = value.TenantUrl;
NewClientId = value.ClientId ?? string.Empty;
}
else
{
NewName = string.Empty;
NewTenantUrl = string.Empty;
NewClientId = string.Empty;
}
ValidationMessage = string.Empty;
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
BrowseClientLogoCommand.NotifyCanExecuteChanged();
ClearClientLogoCommand.NotifyCanExecuteChanged();
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(HasRegisteredApp));
RegisterAppCommand.NotifyCanExecuteChanged();
@@ -130,14 +148,27 @@ public partial class ProfileManagementViewModel : ObservableObject
private void NotifyCommandsCanExecuteChanged()
{
AddCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
}
private bool CanAdd()
{
if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
if (string.IsNullOrWhiteSpace(NewClientId)) return false;
// Fields mirror the selected profile after selection; block Add so the user doesn't
// create a duplicate — they should use Save to update, or change the name to fork.
if (SelectedProfile != null &&
string.Equals(NewName.Trim(), SelectedProfile.Name, StringComparison.Ordinal))
return false;
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
return true;
}
private bool CanSave()
{
if (SelectedProfile == null) return false;
if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
return true;
}
@@ -150,7 +181,7 @@ public partial class ProfileManagementViewModel : ObservableObject
{
Name = NewName.Trim(),
TenantUrl = NewTenantUrl.Trim(),
ClientId = NewClientId.Trim()
ClientId = NewClientId?.Trim() ?? string.Empty
};
await _profileService.AddProfileAsync(profile);
Profiles.Add(profile);
@@ -166,19 +197,43 @@ public partial class ProfileManagementViewModel : ObservableObject
}
}
private async Task RenameAsync()
private async Task SaveAsync()
{
if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return;
if (!CanSave()) return;
var target = SelectedProfile!;
try
{
await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim());
SelectedProfile.Name = NewName.Trim();
NewName = string.Empty;
var newName = NewName.Trim();
var newUrl = NewTenantUrl.Trim();
var newClientId = NewClientId?.Trim() ?? string.Empty;
var oldName = target.Name;
if (!string.Equals(oldName, newName, StringComparison.Ordinal))
{
await _profileService.RenameProfileAsync(oldName, newName);
target.Name = newName;
}
target.TenantUrl = newUrl;
target.ClientId = newClientId;
await _profileService.UpdateProfileAsync(target);
// Force ListBox to pick up the renamed entry (TenantProfile is a plain POCO,
// so mutating Name does not raise PropertyChanged).
var idx = Profiles.IndexOf(target);
if (idx >= 0)
{
Profiles.RemoveAt(idx);
Profiles.Insert(idx, target);
SelectedProfile = target;
}
ValidationMessage = string.Empty;
}
catch (Exception ex)
{
ValidationMessage = ex.Message;
_logger.LogError(ex, "Failed to rename profile.");
_logger.LogError(ex, "Failed to save profile.");
}
}
@@ -291,32 +346,64 @@ public partial class ProfileManagementViewModel : ObservableObject
private bool CanRemoveApp()
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering;
/// <summary>
/// Set by the view to display the pre-registration warning. Returns true if the
/// user accepts and registration should proceed.
/// </summary>
public Func<string, bool>? ConfirmRegisterApp { get; set; }
private async Task RegisterAppAsync(CancellationToken ct)
{
if (SelectedProfile == null) return;
// Auth caching reduces this to one prompt in the common case, but a fresh
// tenant or different admin account may still trigger up to two — warn so
// the user knows another window is expected after they sign in.
if (ConfirmRegisterApp != null)
{
var msg = string.Format(TranslationSource.Instance["profile.register.warning"], 2);
if (!ConfirmRegisterApp(msg)) return;
}
IsRegistering = true;
ShowFallbackInstructions = false;
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
try
{
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(SelectedProfile.ClientId, ct);
if (!isAdmin)
{
ShowFallbackInstructions = true;
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
return;
}
// Use the profile's own ClientId if it has one; otherwise bootstrap with the
// Microsoft Graph Command Line Tools public client so a first-time profile
// (name + URL only) can still perform registration.
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
? BootstrapClientId
: SelectedProfile.ClientId;
// No preflight admin check: it used Global Admin as the criterion and
// rejected Application Admins / Cloud Application Admins who can also
// create apps. Let Entra enforce authorization via the POST itself —
// any 401/403 returns FallbackRequired and triggers the tutorial.
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
var result = await _appRegistrationService.RegisterAsync(SelectedProfile.ClientId, SelectedProfile.Name, ct);
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.TenantUrl, SelectedProfile.Name, ct);
if (result.IsSuccess)
{
SelectedProfile.AppId = result.AppId;
// If the profile had no ClientId, adopt the freshly registered app's id
// so subsequent sign-ins use the profile's own app registration.
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
SelectedProfile.ClientId = result.AppId!;
await _profileService.UpdateProfileAsync(SelectedProfile);
// Reflect adopted ClientId in the bound TextBox. Without this the
// field stays blank and the next Save would overwrite the stored
// ClientId with an empty string.
NewClientId = SelectedProfile.ClientId;
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
OnPropertyChanged(nameof(HasRegisteredApp));
}
else if (result.IsFallback)
{
ShowFallbackInstructions = true;
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
}
else
{
RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"];
@@ -340,7 +427,7 @@ public partial class ProfileManagementViewModel : ObservableObject
RegistrationStatus = TranslationSource.Instance["profile.remove.removing"];
try
{
await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.AppId!, ct);
await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl, SelectedProfile.AppId!, ct);
await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl);
SelectedProfile.AppId = null;
await _profileService.UpdateProfileAsync(SelectedProfile);
@@ -105,9 +105,9 @@ public partial class BulkMembersViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{_validRows.Count} members will be added");
@@ -105,9 +105,9 @@ public partial class BulkSitesViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{_validRows.Count} sites will be created");
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
@@ -31,6 +32,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
private readonly IDuplicatesService _duplicatesService;
private readonly ISessionManager _sessionManager;
private readonly DuplicatesHtmlExportService _htmlExportService;
private readonly DuplicatesCsvExportService _csvExportService;
private readonly IBrandingService _brandingService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
@@ -46,6 +48,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
[ObservableProperty] private bool _includeSubsites;
[ObservableProperty] private string _library = string.Empty;
/// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
private ObservableCollection<DuplicateRow> _results = new();
public ObservableCollection<DuplicateRow> Results
{
@@ -55,16 +65,19 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_results = value;
OnPropertyChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
}
}
public IAsyncRelayCommand ExportHtmlCommand { get; }
public IAsyncRelayCommand ExportCsvCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile;
public DuplicatesViewModel(
IDuplicatesService duplicatesService,
ISessionManager sessionManager,
DuplicatesHtmlExportService htmlExportService,
DuplicatesCsvExportService csvExportService,
IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
@@ -72,24 +85,26 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_duplicatesService = duplicatesService;
_sessionManager = sessionManager;
_htmlExportService = htmlExportService;
_csvExportService = csvExportService;
_brandingService = brandingService;
_logger = logger;
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -152,6 +167,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_lastGroups = Array.Empty<DuplicateGroup>();
OnPropertyChanged(nameof(CurrentProfile));
ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
@@ -179,9 +195,28 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo);
}
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding);
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
}
private async Task ExportCsvAsync()
{
if (_lastGroups.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export duplicates report to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "duplicates_report"
};
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
}
}
@@ -103,15 +103,16 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
var T = TranslationSource.Instance;
if (_currentProfile == null) throw new InvalidOperationException(T["err.no_tenant"]);
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows. Import a CSV first.");
throw new InvalidOperationException(T["err.no_valid_rows"]);
if (string.IsNullOrWhiteSpace(LibraryTitle))
throw new InvalidOperationException("Library title is required.");
throw new InvalidOperationException(T["err.library_title_required"]);
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
throw new InvalidOperationException("Select at least one site from the toolbar.");
throw new InvalidOperationException(T["err.no_sites_selected"]);
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Services.Export;
@@ -44,6 +45,23 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _mergePermissions;
/// <summary>
/// When true, the HTML report hides the raw "SharePoint Group: SharingLinks.{guid}…"
/// / "Limited Access System Group For Web|List {guid}" text for rows where the
/// system-group target was resolved — only the link-type badge and target link are shown.
/// Rows whose target could not be resolved still display the raw name as a fallback.
/// </summary>
[ObservableProperty]
private bool _hideSystemGroupRaw = true;
/// <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]
private bool _includeSubsites;
@@ -84,6 +102,25 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _isDetailView = true;
/// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
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>
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
/// </summary>
@@ -106,16 +143,37 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// <summary>
/// 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
/// - (View handles summary display separately via Summaries property)
/// </summary>
public object ActiveItemsSource => IsSimplifiedMode
? (object)SimplifiedResults
: Results;
: FilteredResults;
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 ────────────────────────────────────────────────────────────
public IAsyncRelayCommand ExportCsvCommand { get; }
@@ -154,8 +212,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
_settingsService = settingsService;
_ownershipService = ownershipService;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ExportCsvCommand = new AsyncRelayCommand(ct => ExportCsvAsync(ct), CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ct => ExportHtmlAsync(ct), CanExport);
}
/// <summary>
@@ -181,8 +239,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
_settingsService = settingsService;
_ownershipService = ownershipService;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ExportCsvCommand = new AsyncRelayCommand(ct => ExportCsvAsync(ct), CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ct => ExportHtmlAsync(ct), CanExport);
}
// ── FeatureViewModelBase implementation ─────────────────────────────────
@@ -203,9 +261,18 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// Recomputes SimplifiedResults and Summaries from the current Results collection.
/// Called when Results changes or when simplified mode is toggled on.
/// </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()
{
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results);
var forSimplified = FilteredResults.Where(e => !IsSimplifiedModeNoise(e));
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(forSimplified);
Summaries = PermissionSummaryBuilder.Build(SimplifiedResults);
}
@@ -214,7 +281,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -285,6 +352,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<PermissionEntry>(allEntries);
RebuildFilteredResults();
if (IsSimplifiedMode)
RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource));
@@ -293,6 +361,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
else
{
Results = new ObservableCollection<PermissionEntry>(allEntries);
RebuildFilteredResults();
if (IsSimplifiedMode)
RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource));
@@ -308,6 +377,32 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// Derives the tenant admin URL from a standard tenant URL.
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
/// </summary>
/// <summary>
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
/// E.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/Reports → https://t.sharepoint.com/sites/hr
/// Falls back to scheme+host for root-collection URLs.
/// </summary>
internal static string DeriveSiteCollectionUrl(string objectUrl)
{
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
return objectUrl.TrimEnd('/');
var baseUrl = $"{uri.Scheme}://{uri.Host}";
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
// Managed paths: /sites/<name> or /teams/<name>
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return $"{baseUrl}/{segments[0]}/{segments[1]}";
}
// Root site collection
return baseUrl;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
@@ -340,6 +435,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
{
_currentProfile = profile;
Results = new ObservableCollection<PermissionEntry>();
FilteredResults = Array.Empty<PermissionEntry>();
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
Summaries = Array.Empty<PermissionSummary>();
OnPropertyChanged(nameof(ActiveItemsSource));
@@ -360,7 +456,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
private bool CanExport() => Results.Count > 0;
private async Task ExportCsvAsync()
private async Task ExportCsvAsync(CancellationToken ct)
{
if (_csvExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog
@@ -374,9 +470,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
try
{
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, ct);
else
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(FilteredResults, dialog.FileName, CurrentSplit, ct);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -386,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;
var dialog = new SaveFileDialog
@@ -408,36 +504,64 @@ public partial class PermissionsViewModel : FeatureViewModelBase
}
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
if (_groupResolver != null && Results.Count > 0)
if (_groupResolver != null && Results.Count > 0 && _currentProfile != null)
{
var groupNames = Results
// SharePoint groups live per site collection. Bucket each group
// by the site it was observed on, then resolve against 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".
var groupsBySite = FilteredResults
.Where(r => r.PrincipalType == "SharePointGroup")
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(n => n.Trim())
.Where(n => n.Length > 0)
.SelectMany(r => r.Users
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim())))
.Where(x => x.GroupName.Length > 0)
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupsBySite.Count > 0)
{
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
StringComparer.OrdinalIgnoreCase);
foreach (var bucket in groupsBySite)
{
var distinctNames = bucket
.Select(x => x.GroupName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count > 0 && _currentProfile != null)
{
try
{
var siteProfile = new TenantProfile
{
TenantUrl = bucket.Key,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(
_currentProfile, CancellationToken.None);
groupMembers = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
siteProfile, ct);
var resolved = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, distinctNames, ct);
foreach (var kv in resolved)
merged[kv.Key] = kv.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
_logger.LogWarning(ex,
"Group resolution failed for {Site} — continuing without member expansion.",
bucket.Key);
}
}
groupMembers = merged;
}
}
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, ct, branding, groupMembers, HideSystemGroupRaw);
else
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
await _htmlExportService.WriteAsync(FilteredResults, dialog.FileName, CurrentSplit, CurrentLayout, ct, branding, groupMembers, HideSystemGroupRaw);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
@@ -79,14 +80,14 @@ public partial class SearchViewModel : FeatureViewModelBase
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -12,6 +12,7 @@ public partial class SettingsViewModel : FeatureViewModelBase
{
private readonly SettingsService _settingsService;
private readonly IBrandingService _brandingService;
private readonly ThemeManager _themeManager;
private string _selectedLanguage = "en";
public string SelectedLanguage
@@ -39,6 +40,19 @@ public partial class SettingsViewModel : FeatureViewModelBase
}
}
private string _selectedTheme = "System";
public string SelectedTheme
{
get => _selectedTheme;
set
{
if (_selectedTheme == value) return;
_selectedTheme = value;
OnPropertyChanged();
_ = ApplyThemeAsync(value);
}
}
private bool _autoTakeOwnership;
public bool AutoTakeOwnership
{
@@ -63,11 +77,12 @@ public partial class SettingsViewModel : FeatureViewModelBase
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
public IAsyncRelayCommand ClearMspLogoCommand { get; }
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger<FeatureViewModelBase> logger)
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ThemeManager themeManager, ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_settingsService = settingsService;
_brandingService = brandingService;
_themeManager = themeManager;
BrowseFolderCommand = new RelayCommand(BrowseFolder);
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
@@ -79,14 +94,29 @@ public partial class SettingsViewModel : FeatureViewModelBase
_selectedLanguage = settings.Lang;
_dataFolder = settings.DataFolder;
_autoTakeOwnership = settings.AutoTakeOwnership;
_selectedTheme = settings.Theme;
OnPropertyChanged(nameof(SelectedLanguage));
OnPropertyChanged(nameof(DataFolder));
OnPropertyChanged(nameof(AutoTakeOwnership));
OnPropertyChanged(nameof(SelectedTheme));
var mspLogo = await _brandingService.GetMspLogoAsync();
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
}
private async Task ApplyThemeAsync(string mode)
{
try
{
_themeManager.ApplyFromString(mode);
await _settingsService.SetThemeAsync(mode);
}
catch (Exception ex)
{
StatusMessage = ex.Message;
}
}
private async Task ApplyLanguageAsync(string code)
{
try
@@ -9,6 +9,7 @@ using LiveChartsCore.SkiaSharpView.Painting;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
using SkiaSharp;
@@ -22,6 +23,9 @@ public partial class StorageViewModel : FeatureViewModelBase
private readonly StorageCsvExportService _csvExportService;
private readonly StorageHtmlExportService _htmlExportService;
private readonly IBrandingService? _brandingService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ThemeManager? _themeManager;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
@@ -37,6 +41,47 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _isDonutChart = true;
// ── Scan-time flags (control what is captured during the CSOM scan) ─────
[ObservableProperty] private bool _scanHiddenLibraries = true;
[ObservableProperty] private bool _scanPreservationHold = true;
[ObservableProperty] private bool _scanListAttachments = true;
[ObservableProperty] private bool _scanRecycleBin = true;
// ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
[ObservableProperty] private bool _showLibraries = true;
[ObservableProperty] private bool _showHiddenLibraries = true;
[ObservableProperty] private bool _showPreservationHold = true;
[ObservableProperty] private bool _showListAttachments = true;
[ObservableProperty] private bool _showRecycleBin = true;
[ObservableProperty] private bool _showSubsites = true;
/// <summary>
/// When true, recycle bin stage 1 + stage 2 collapse into a single
/// "[Recycle Bin] Total" row whose size is the sum of both stages.
/// When false, both stages render as separate rows.
/// </summary>
[ObservableProperty] private bool _combineRecycleBinStages = true;
// SPO-reported site total (Site.Usage.Storage). Independent reference
// value the user can compare against the scanned total.
[ObservableProperty] private long _spoReportedTotalSize;
partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();
/// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
public ObservableCollection<FileTypeMetric> FileTypeMetrics
{
@@ -79,6 +124,15 @@ public partial class StorageViewModel : FeatureViewModelBase
private set { _barYAxes = value; OnPropertyChanged(); }
}
// Stable paint instances. SKDefaultTooltip/Legend bake the Fill paint reference
// into their geometry on first Initialize() and never re-read the chart's paint
// properties. Replacing instances on theme change has no effect — we mutate
// .Color in place so the new theme color renders on the next frame.
public SolidColorPaint LegendTextPaint { get; } = new(default(SKColor));
public SolidColorPaint LegendBackgroundPaint { get; } = new(default(SKColor));
public SolidColorPaint TooltipTextPaint { get; } = new(default(SKColor));
public SolidColorPaint TooltipBackgroundPaint { get; } = new(default(SKColor));
public bool IsMaxDepth
{
get => FolderDepth >= 999;
@@ -90,6 +144,10 @@ public partial class StorageViewModel : FeatureViewModelBase
}
}
// Raw scan output — never filtered. RebuildFilteredResults projects this
// into Results based on the Show* flags.
private List<StorageNode> _allNodes = new();
private ObservableCollection<StorageNode> _results = new();
public ObservableCollection<StorageNode> Results
{
@@ -105,15 +163,36 @@ public partial class StorageViewModel : FeatureViewModelBase
}
// ── Summary properties (computed from root-level library nodes) ─────────
//
// Recycle-bin contents are rolled into each library's TotalSizeBytes by the
// StorageService (matches storman.aspx). Including the synthetic root-level
// RecycleBin nodes here would double-count those bytes — filter them out.
// SummaryRecycleBinSize below still reads from _allNodes so the bin metric
// remains visible to the user.
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
/// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
public long SummaryTotalSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
public long SummaryVersionSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.VersionSizeBytes);
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
/// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
public long SummaryFileCount => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalFileCount);
/// <summary>
/// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
/// from the raw scan so it stays visible even when the user hides the
/// recycle-bin row in the report filter.
/// </summary>
public long SummaryRecycleBinSize => _allNodes
.Where(n => n.Kind == StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
public bool HasResults => Results.Count > 0;
@@ -121,6 +200,7 @@ public partial class StorageViewModel : FeatureViewModelBase
{
OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryRecycleBinSize));
OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults));
}
@@ -136,7 +216,10 @@ public partial class StorageViewModel : FeatureViewModelBase
StorageCsvExportService csvExportService,
StorageHtmlExportService htmlExportService,
IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger)
ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null,
ThemeManager? themeManager = null)
: base(logger)
{
_storageService = storageService;
@@ -144,10 +227,17 @@ public partial class StorageViewModel : FeatureViewModelBase
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_themeManager = themeManager;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ApplyChartThemeColors();
if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
}
/// <summary>Test constructor — omits export services.</summary>
@@ -173,14 +263,14 @@ public partial class StorageViewModel : FeatureViewModelBase
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -189,10 +279,17 @@ public partial class StorageViewModel : FeatureViewModelBase
var options = new StorageScanOptions(
PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth);
FolderDepth: FolderDepth,
IncludeHiddenLibraries: ScanHiddenLibraries,
IncludePreservationHold: ScanPreservationHold,
IncludeListAttachments: ScanListAttachments,
IncludeRecycleBin: ScanRecycleBin);
var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>();
long spoReportedTotal = 0;
var autoOwnership = await IsAutoTakeOwnershipEnabled();
int i = 0;
foreach (var url in nonEmpty)
@@ -207,9 +304,30 @@ public partial class StorageViewModel : FeatureViewModelBase
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
IReadOnlyList<StorageNode> nodes;
try
{
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership)
{
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url);
var adminProfile = new TenantProfile
{
TenantUrl = adminUrl,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
}
// Backfill any libraries where StorageMetrics returned zeros
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
@@ -219,6 +337,8 @@ public partial class StorageViewModel : FeatureViewModelBase
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics);
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
}
// Flatten tree for DataGrid display
@@ -237,20 +357,113 @@ public partial class StorageViewModel : FeatureViewModelBase
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<StorageNode>(flat);
_allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
});
}
else
{
Results = new ObservableCollection<StorageNode>(flat);
_allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}
}
/// <summary>
/// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
/// Show* flags. Nodes whose root ancestor is excluded by the flags are
/// dropped along with their entire subtree, preserving DFS ordering.
/// </summary>
private void RebuildFilteredResults()
{
if (_allNodes.Count == 0)
{
Results = new ObservableCollection<StorageNode>();
return;
}
var filtered = new List<StorageNode>(_allNodes.Count);
bool includeCurrentSubtree = true;
foreach (var node in _allNodes)
{
if (node.IndentLevel == 0)
includeCurrentSubtree = IsKindShown(node.Kind);
if (includeCurrentSubtree)
filtered.Add(node);
}
if (CombineRecycleBinStages)
filtered = CombineRecycleBins(filtered);
Results = new ObservableCollection<StorageNode>(filtered);
}
/// <summary>
/// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
/// aggregate row inserted at the position of the first recycle-bin node
/// encountered. Preserves SiteTitle grouping when scans cover multiple
/// sites by aggregating per SiteTitle.
/// </summary>
private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
{
var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
var result = new List<StorageNode>(input.Count);
foreach (var node in input)
{
if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
{
string key = node.SiteTitle ?? string.Empty;
if (!byPath.TryGetValue(key, out var agg))
{
agg = new StorageNode
{
Name = "[Recycle Bin] Total",
SiteTitle = node.SiteTitle ?? string.Empty,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
IndentLevel = 0,
Children = new List<StorageNode>()
};
byPath[key] = agg;
result.Add(agg);
}
agg.TotalSizeBytes += node.TotalSizeBytes;
agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
agg.TotalFileCount += node.TotalFileCount;
if (node.LastModified.HasValue &&
(!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
agg.LastModified = node.LastModified;
}
else
{
result.Add(node);
}
}
return result;
}
private bool IsKindShown(StorageNodeKind kind) => kind switch
{
StorageNodeKind.Library => ShowLibraries,
StorageNodeKind.HiddenLibrary => ShowHiddenLibraries,
StorageNodeKind.PreservationHold => ShowPreservationHold,
StorageNodeKind.ListAttachments => ShowListAttachments,
StorageNodeKind.RecycleBin => ShowRecycleBin,
StorageNodeKind.Subsite => ShowSubsites,
_ => true
};
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_allNodes = new List<StorageNode>();
SpoReportedTotalSize = 0;
Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile));
@@ -258,6 +471,24 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportHtmlCommand.NotifyCanExecuteChanged();
}
private async Task<bool> IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
@@ -278,7 +509,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -309,7 +540,7 @@ public partial class StorageViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo);
}
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding);
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -324,11 +555,25 @@ public partial class StorageViewModel : FeatureViewModelBase
UpdateChartSeries();
}
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
private void ApplyChartThemeColors()
{
LegendTextPaint.Color = ChartFgColor;
LegendBackgroundPaint.Color = ChartSurfaceColor;
TooltipTextPaint.Color = ChartFgColor;
TooltipBackgroundPaint.Color = ChartSurfaceColor;
}
private void UpdateChartSeries()
{
var metrics = FileTypeMetrics.ToList();
OnPropertyChanged(nameof(HasChartData));
ApplyChartThemeColors();
if (metrics.Count == 0)
{
PieChartSeries = Enumerable.Empty<ISeries>();
@@ -361,6 +606,7 @@ public partial class StorageViewModel : FeatureViewModelBase
HoverPushout = 8,
MaxRadialColumnWidth = 60,
DataLabelsFormatter = _ => m.DisplayLabel,
DataLabelsPaint = new SolidColorPaint(ChartFgColor),
ToolTipLabelFormatter = _ =>
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
IsVisibleAtLegend = true,
@@ -379,7 +625,8 @@ public partial class StorageViewModel : FeatureViewModelBase
{
int idx = (int)point.Index;
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
}
},
DataLabelsPaint = new SolidColorPaint(ChartFgColor)
}
};
@@ -388,7 +635,10 @@ public partial class StorageViewModel : FeatureViewModelBase
new Axis
{
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
LabelsRotation = -45
LabelsRotation = -45,
LabelsPaint = new SolidColorPaint(ChartFgColor),
TicksPaint = new SolidColorPaint(ChartFgColor),
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
}
};
@@ -396,7 +646,10 @@ public partial class StorageViewModel : FeatureViewModelBase
{
new Axis
{
Labeler = value => FormatBytes((long)value)
Labeler = value => FormatBytes((long)value),
LabelsPaint = new SolidColorPaint(ChartFgColor),
TicksPaint = new SolidColorPaint(ChartFgColor),
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
}
};
}

Some files were not shown because too many files have changed in this diff Show More