Compare commits

..

12 Commits

Author SHA1 Message Date
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
90 changed files with 7689 additions and 942 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() public void BuildPagedViewXml_NullInput_ReturnsViewWithRowLimit()
{ {
var result = SharePointPaginationHelper.BuildPagedViewXml(null, 2000); 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] [Fact]
public void BuildPagedViewXml_EmptyString_ReturnsViewWithRowLimit() public void BuildPagedViewXml_EmptyString_ReturnsViewWithRowLimit()
{ {
var result = SharePointPaginationHelper.BuildPagedViewXml("", 2000); var result = SharePointPaginationHelper.BuildPagedViewXml("", 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result); Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
} }
[Fact] [Fact]
public void BuildPagedViewXml_WhitespaceOnly_ReturnsViewWithRowLimit() public void BuildPagedViewXml_WhitespaceOnly_ReturnsViewWithRowLimit()
{ {
var result = SharePointPaginationHelper.BuildPagedViewXml(" ", 2000); var result = SharePointPaginationHelper.BuildPagedViewXml(" ", 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result); Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
} }
[Fact] [Fact]
@@ -31,7 +31,15 @@ public class SharePointPaginationHelperTests
{ {
var input = "<View><RowLimit>100</RowLimit></View>"; var input = "<View><RowLimit>100</RowLimit></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000); 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] [Fact]
@@ -39,10 +47,9 @@ public class SharePointPaginationHelperTests
{ {
var input = "<View><Query><OrderBy><FieldRef Name='Title'/></OrderBy></Query></View>"; var input = "<View><Query><OrderBy><FieldRef Name='Title'/></OrderBy></Query></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000); var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
Assert.Contains("<RowLimit>2000</RowLimit>", result); Assert.Contains("<RowLimit Paged='TRUE'>2000</RowLimit>", result);
Assert.EndsWith("</View>", result); Assert.EndsWith("</View>", result);
// Ensure RowLimit is inserted before the closing </View> var rowLimitIndex = result.IndexOf("<RowLimit Paged='TRUE'>2000</RowLimit>", StringComparison.Ordinal);
var rowLimitIndex = result.IndexOf("<RowLimit>2000</RowLimit>", StringComparison.Ordinal);
var closingViewIndex = result.LastIndexOf("</View>", StringComparison.Ordinal); var closingViewIndex = result.LastIndexOf("</View>", StringComparison.Ordinal);
Assert.True(rowLimitIndex < closingViewIndex, "RowLimit should appear before </View>"); Assert.True(rowLimitIndex < closingViewIndex, "RowLimit should appear before </View>");
} }
@@ -115,7 +115,7 @@ public class HtmlExportServiceTests
var svc = new HtmlExportService(); var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers); 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); Assert.Contains("class=\"user-pill group-expandable\"", html);
} }
@@ -152,7 +152,8 @@ public class HtmlExportServiceTests
var svc = new HtmlExportService(); var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers); 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);
} }
[Fact] [Fact]
@@ -165,7 +166,7 @@ public class HtmlExportServiceTests
var svc = new HtmlExportService(); var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { simplifiedEntry }, null, groupMembers); 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); Assert.Contains("class=\"user-pill group-expandable\"", html);
} }
} }
@@ -28,4 +28,21 @@ public class StorageServiceTests
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L }; var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes); Assert.Equal(2000L, node.VersionSizeBytes);
} }
[Fact]
public void StorageNode_DefaultKind_IsLibrary()
{
var node = new StorageNode();
Assert.Equal(StorageNodeKind.Library, node.Kind);
}
[Fact]
public void StorageScanOptions_DefaultIncludeFlags_AreAllTrue()
{
var opts = new StorageScanOptions();
Assert.True(opts.IncludeHiddenLibraries);
Assert.True(opts.IncludePreservationHold);
Assert.True(opts.IncludeListAttachments);
Assert.True(opts.IncludeRecycleBin);
}
} }
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));
}
}
@@ -87,11 +87,11 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
} }
[Fact] [Fact]
public async Task RegisterApp_ShowsFallback_WhenNotAdmin() public async Task RegisterApp_ShowsFallback_WhenGraphReturnsFallbackRequired()
{ {
_mockAppReg _mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false); .ReturnsAsync(AppRegistrationResult.FallbackRequired());
var vm = CreateViewModel(); var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: null); vm.SelectedProfile = MakeProfile(appId: null);
@@ -105,10 +105,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
public async Task RegisterApp_SetsAppId_OnSuccess() public async Task RegisterApp_SetsAppId_OnSuccess()
{ {
_mockAppReg _mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockAppReg
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(AppRegistrationResult.Success("new-app-id-123")); .ReturnsAsync(AppRegistrationResult.Success("new-app-id-123"));
var profileService = new ProfileService(new ProfileRepository(_tempFile)); var profileService = new ProfileService(new ProfileRepository(_tempFile));
@@ -132,7 +129,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
public async Task RemoveApp_ClearsAppId() public async Task RemoveApp_ClearsAppId()
{ {
_mockAppReg _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); .Returns(Task.CompletedTask);
_mockAppReg _mockAppReg
.Setup(s => s.ClearMsalSessionAsync(It.IsAny<string>(), It.IsAny<string>())) .Setup(s => s.ClearMsalSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -0,0 +1,141 @@
using System.Reflection;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Verifies that the report filter flags (Show*) project the raw scan output
/// (<c>_allNodes</c>) into <c>Results</c> correctly: hiding a kind drops the
/// matching root nodes plus their entire subtree, preserving DFS ordering.
/// </summary>
public class StorageViewModelFilterTests
{
public StorageViewModelFilterTests() => WeakReferenceMessenger.Default.Reset();
private static StorageViewModel CreateVm()
{
var vm = new StorageViewModel(
new Mock<IStorageService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance);
vm.SetCurrentProfile(new TenantProfile { Name = "T", TenantUrl = "https://t", ClientId = "c" });
return vm;
}
/// <summary>Inject a flat node list straight into the private _allNodes field
/// and trigger a rebuild via toggling a Show flag.</summary>
private static void Seed(StorageViewModel vm, List<StorageNode> flat)
{
var field = typeof(StorageViewModel).GetField("_allNodes",
BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(vm, flat);
// Toggle off+on to force RebuildFilteredResults().
vm.ShowLibraries = false;
vm.ShowLibraries = true;
}
private static List<StorageNode> MakeMixedTree() => new()
{
new() { Name = "Documents", Kind = StorageNodeKind.Library, IndentLevel = 0, TotalSizeBytes = 100 },
new() { Name = "Sub", Kind = StorageNodeKind.Library, IndentLevel = 1, TotalSizeBytes = 50 },
new() { Name = "Preserve", Kind = StorageNodeKind.PreservationHold, IndentLevel = 0, TotalSizeBytes = 200 },
new() { Name = "[Recycle]", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0, TotalSizeBytes = 300 },
new() { Name = "[Attach] L",Kind = StorageNodeKind.ListAttachments, IndentLevel = 0, TotalSizeBytes = 75 },
};
[Fact]
public void AllShowFlagsTrue_AllNodesAppear()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
Assert.Equal(5, vm.Results.Count);
}
[Fact]
public void HideRecycleBin_RemovesOnlyRecycleNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowRecycleBin = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.RecycleBin);
Assert.Equal(4, vm.Results.Count);
}
[Fact]
public void HidePreservationHold_RemovesPreservationNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowPreservationHold = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.PreservationHold);
}
[Fact]
public void HideLibraries_DropsLibraryRootAndItsChildren()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
// "Documents" + "Sub" both gone — Sub's subtree dropped with its parent root.
Assert.DoesNotContain(vm.Results, n => n.Name == "Documents");
Assert.DoesNotContain(vm.Results, n => n.Name == "Sub");
}
[Fact]
public void CombineRecycleBinStages_True_MergesStagesIntoSingleRow()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100, FileStreamSizeBytes = 100, TotalFileCount = 3 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250, FileStreamSizeBytes = 250, TotalFileCount = 7 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = true;
var bins = vm.Results.Where(n => n.Kind == StorageNodeKind.RecycleBin).ToList();
Assert.Single(bins);
Assert.Equal(350, bins[0].TotalSizeBytes);
Assert.Equal(10, bins[0].TotalFileCount);
}
[Fact]
public void CombineRecycleBinStages_False_KeepsSeparateRows()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = false;
Assert.Equal(2, vm.Results.Count(n => n.Kind == StorageNodeKind.RecycleBin));
}
[Fact]
public void HideAll_LeavesEmptyResults()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
vm.ShowHiddenLibraries = false;
vm.ShowPreservationHold = false;
vm.ShowListAttachments = false;
vm.ShowRecycleBin = false;
vm.ShowSubsites = false;
Assert.Empty(vm.Results);
}
}
+1
View File
@@ -16,6 +16,7 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" /> <conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" /> <conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" /> <conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:StorageKindConverter x:Key="StorageKindConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" /> <conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="HorizontalAlignment" Value="Right" />
+6
View File
@@ -142,6 +142,12 @@ public partial class App : Application
services.AddTransient<DuplicatesViewModel>(); services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>(); services.AddTransient<DuplicatesView>();
// Versions cleanup
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
services.AddTransient<VersionCleanupHtmlExportService>();
services.AddTransient<VersionCleanupViewModel>();
services.AddTransient<VersionCleanupView>();
// Phase 2: Permissions // Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>(); services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>(); services.AddTransient<ISiteListService, SiteListService>();
@@ -6,9 +6,12 @@ namespace SharepointToolbox.Core.Helpers;
public static class SharePointPaginationHelper public static class SharePointPaginationHelper
{ {
// Max page size SharePoint honors with Paged='TRUE' (threshold bypass).
private const int DefaultRowLimit = 5000;
/// <summary> /// <summary>
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold. /// 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. /// Never call ExecuteQuery directly on a list — always use this helper.
/// </summary> /// </summary>
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync( public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
@@ -18,7 +21,7 @@ public static class SharePointPaginationHelper
[EnumeratorCancellation] CancellationToken ct = default) [EnumeratorCancellation] CancellationToken ct = default)
{ {
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery(); var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000); query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit);
query.ListItemCollectionPosition = null; query.ListItemCollectionPosition = null;
do do
@@ -36,21 +39,75 @@ public static class SharePointPaginationHelper
while (query.ListItemCollectionPosition != null); 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) internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
{ {
// Inject or replace RowLimit in existing CAML, or create minimal view
if (string.IsNullOrWhiteSpace(existingXml)) 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 // Replace any existing <RowLimit ...>n</RowLimit> with paged form.
if (existingXml.Contains("<RowLimit>", StringComparison.OrdinalIgnoreCase)) if (System.Text.RegularExpressions.Regex.IsMatch(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{ {
return System.Text.RegularExpressions.Regex.Replace( return System.Text.RegularExpressions.Regex.Replace(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>", existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
$"<RowLimit>{rowLimit}</RowLimit>", $"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase); 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); StringComparison.OrdinalIgnoreCase);
} }
} }
@@ -10,4 +10,8 @@ public class DuplicateItem
public DateTime? Modified { get; set; } public DateTime? Modified { get; set; }
public int? FolderCount { get; set; } public int? FolderCount { get; set; }
public int? FileCount { 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;
} }
+40 -1
View File
@@ -1,3 +1,42 @@
namespace SharepointToolbox.Core.Models; 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 long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; } public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; } public int IndentLevel { get; set; }
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
public List<StorageNode> Children { get; set; } = new(); public List<StorageNode> Children { get; set; } = new();
} }
@@ -0,0 +1,15 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classification used to filter storage report output. Every node is captured
/// during a scan; the report user picks which categories appear.
/// </summary>
public enum StorageNodeKind
{
Library,
HiddenLibrary,
PreservationHold,
ListAttachments,
RecycleBin,
Subsite
}
@@ -3,5 +3,9 @@ namespace SharepointToolbox.Core.Models;
public record StorageScanOptions( public record StorageScanOptions(
bool PerLibrary = true, bool PerLibrary = true,
bool IncludeSubsites = false, bool IncludeSubsites = false,
int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels int FolderDepth = 0, // 0 = library root only; >0 = recurse N levels
bool IncludeHiddenLibraries = true,
bool IncludePreservationHold = true,
bool IncludeListAttachments = true,
bool IncludeRecycleBin = true
); );
@@ -0,0 +1,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> /// <summary>
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA /// 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> /// </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); 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 accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault(); 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); var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider); return new GraphServiceClient(authProvider);
} }
@@ -37,14 +67,16 @@ public class GraphClientFactory
internal class MsalTokenProvider : IAccessTokenProvider internal class MsalTokenProvider : IAccessTokenProvider
{ {
private readonly IPublicClientApplication _pca; private readonly IPublicClientApplication _pca;
private readonly IAccount? _account; private IAccount? _account;
private readonly string[] _scopes; 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; _pca = pca;
_account = account; _account = account;
_scopes = scopes; _scopes = scopes;
_tenantId = tenantId;
} }
public AllowedHostsValidator AllowedHostsValidator { get; } = new(); public AllowedHostsValidator AllowedHostsValidator { get; } = new();
@@ -54,18 +86,35 @@ internal class MsalTokenProvider : IAccessTokenProvider
Dictionary<string, object>? additionalAuthenticationContext = null, Dictionary<string, object>? additionalAuthenticationContext = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try // 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 result = await _pca.AcquireTokenSilent(_scopes, _account) var accounts = await _pca.GetAccountsAsync();
.ExecuteAsync(cancellationToken); _account = accounts.FirstOrDefault();
return result.AccessToken;
} }
catch (MsalUiRequiredException)
if (_account is not null)
{ {
// If silent fails, try interactive try
var result = await _pca.AcquireTokenInteractive(_scopes) {
.ExecuteAsync(cancellationToken); var silent = _pca.AcquireTokenSilent(_scopes, _account);
return result.AccessToken; if (_tenantId is not null) silent = silent.WithTenantId(_tenantId);
var result = await silent.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
catch (MsalUiRequiredException)
{
// 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;
} }
} }
+248 -2
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve"> <data name="tab.duplicates" xml:space="preserve">
<value>Doublons</value> <value>Doublons</value>
</data> </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"> <data name="tab.templates" xml:space="preserve">
<value>Modèles</value> <value>Modèles</value>
</data> </data>
@@ -142,12 +239,24 @@
<data name="profile.add" xml:space="preserve"> <data name="profile.add" xml:space="preserve">
<value>Ajouter</value> <value>Ajouter</value>
</data> </data>
<data name="profile.rename" xml:space="preserve"> <data name="profile.save" xml:space="preserve">
<value>Renommer</value> <value>Enregistrer</value>
</data> </data>
<data name="profile.delete" xml:space="preserve"> <data name="profile.delete" xml:space="preserve">
<value>Supprimer</value> <value>Supprimer</value>
</data> </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"> <data name="status.ready" xml:space="preserve">
<value>Prêt</value> <value>Prêt</value>
</data> </data>
@@ -197,6 +306,28 @@
<data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Type</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Bibliothèque</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Bibliothèque masquée</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Sous-site</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Sources analysées</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Afficher dans le rapport</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Sous-sites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combiner les corbeilles (afficher le total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>Total rapporté par SPO : </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Corbeille : </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data>
@@ -372,6 +503,27 @@
<data name="audit.btn.exportHtml" xml:space="preserve"> <data name="audit.btn.exportHtml" xml:space="preserve">
<value>Exporter HTML</value> <value>Exporter HTML</value>
</data> </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"> <data name="audit.summary.total" xml:space="preserve">
<value>Total des acc&#232;s</value> <value>Total des acc&#232;s</value>
</data> </data>
@@ -462,6 +614,8 @@
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de d&#233;tection de doublons</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" 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.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.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.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.sites_scanned" xml:space="preserve"><value>Sites analys&#233;s</value></data>
@@ -515,6 +669,7 @@
<data name="report.col.error" xml:space="preserve"><value>Erreur</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.timestamp" xml:space="preserve"><value>Horodatage</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</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.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.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_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
@@ -537,4 +692,95 @@
<data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</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.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> <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>
</root> </root>
+248 -2
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve"> <data name="tab.duplicates" xml:space="preserve">
<value>Duplicates</value> <value>Duplicates</value>
</data> </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"> <data name="tab.templates" xml:space="preserve">
<value>Templates</value> <value>Templates</value>
</data> </data>
@@ -142,12 +239,24 @@
<data name="profile.add" xml:space="preserve"> <data name="profile.add" xml:space="preserve">
<value>Add</value> <value>Add</value>
</data> </data>
<data name="profile.rename" xml:space="preserve"> <data name="profile.save" xml:space="preserve">
<value>Rename</value> <value>Save</value>
</data> </data>
<data name="profile.delete" xml:space="preserve"> <data name="profile.delete" xml:space="preserve">
<value>Delete</value> <value>Delete</value>
</data> </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"> <data name="status.ready" xml:space="preserve">
<value>Ready</value> <value>Ready</value>
</data> </data>
@@ -197,6 +306,28 @@
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Kind</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Library</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Hidden Library</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Subsite</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Scan Sources</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Show in Report</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Subsites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combine Recycle Bin Stages (show total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>SPO reported total: </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Recycle Bin: </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
@@ -372,6 +503,27 @@
<data name="audit.btn.exportHtml" xml:space="preserve"> <data name="audit.btn.exportHtml" xml:space="preserve">
<value>Export HTML</value> <value>Export HTML</value>
</data> </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"> <data name="audit.summary.total" xml:space="preserve">
<value>Total Accesses</value> <value>Total Accesses</value>
</data> </data>
@@ -462,6 +614,8 @@
<data name="report.title.duplicates_short" xml:space="preserve"><value>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" 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.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.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.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.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
@@ -515,6 +669,7 @@
<data name="report.col.error" xml:space="preserve"><value>Error</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.timestamp" xml:space="preserve"><value>Timestamp</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</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.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.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_mb" xml:space="preserve"><value>Size (MB)</value></data>
@@ -537,4 +692,95 @@
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</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.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> <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>
</root> </root>
+4 -2
View File
@@ -8,6 +8,7 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs" xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Icon="pack://application:,,,/Resources/SPToolbox-logo-ico.png"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal" TextOptions.TextFormattingMode="Ideal"
@@ -18,8 +19,6 @@
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}" <ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
SelectedItem="{Binding SelectedProfile}" SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" /> 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]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
Command="{Binding ManageProfilesCommand}" /> Command="{Binding ManageProfilesCommand}" />
<Separator /> <Separator />
@@ -63,6 +62,9 @@
<TabItem x:Name="DuplicatesTabItem" <TabItem x:Name="DuplicatesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}"> Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
</TabItem> </TabItem>
<TabItem x:Name="VersionsTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.versions]}">
</TabItem>
<TabItem x:Name="TransferTabItem" <TabItem x:Name="TransferTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}"> Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
</TabItem> </TabItem>
+3
View File
@@ -40,6 +40,9 @@ public partial class MainWindow : Window
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView // Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>(); DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
// Versions cleanup tab
VersionsTabItem.Content = serviceProvider.GetRequiredService<VersionCleanupView>();
// Phase 4: Replace stub tabs with DI-resolved Views // Phase 4: Replace stub tabs with DI-resolved Views
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>(); TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>(); 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. /// Manages Azure AD app registration and removal using the Microsoft Graph API.
/// All operations use <see cref="GraphClientFactory"/> for token acquisition. /// All operations use <see cref="GraphClientFactory"/> for token acquisition.
/// </summary> /// </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 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"; 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 GraphAppId = "00000003-0000-0000-c000-000000000000";
private const string SharePointAppId = "00000003-0000-0ff1-ce00-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 AppGraphClientFactory _graphFactory;
private readonly AppMsalClientFactory _msalFactory; private readonly AppMsalClientFactory _msalFactory;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
@@ -35,38 +64,33 @@ public class AppRegistrationService : IAppRegistrationService
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct) public async Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct)
{ {
try // No $filter: isof() on directoryObject requires advanced query params
{ // (ConsistencyLevel: eventual + $count=true) and fails with 400 otherwise.
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); // The user's membership list is small; filtering client-side is fine.
var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req => var tenantId = ResolveTenantId(tenantUrl);
{ var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, ct);
req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')"; var memberships = await graphClient.Me.TransitiveMemberOf.GetAsync(cancellationToken: ct);
}, ct);
return roles?.Value? return memberships?.Value?
.OfType<DirectoryRole>() .OfType<DirectoryRole>()
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId, .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
StringComparison.OrdinalIgnoreCase)) ?? false; StringComparison.OrdinalIgnoreCase)) ?? false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId);
return false;
}
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<AppRegistrationResult> RegisterAsync( public async Task<AppRegistrationResult> RegisterAsync(
string clientId, string clientId,
string tenantUrl,
string tenantDisplayName, string tenantDisplayName,
CancellationToken ct) CancellationToken ct)
{ {
var tenantId = ResolveTenantId(tenantUrl);
Application? createdApp = null; Application? createdApp = null;
try try
{ {
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
// Step 1: Create Application object // Step 1: Create Application object
var appRequest = new Application var appRequest = new Application
@@ -78,7 +102,10 @@ public class AppRegistrationService : IAppRegistrationService
{ {
RedirectUris = new List<string> 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() RequiredResourceAccess = BuildRequiredResourceAccess()
@@ -131,34 +158,45 @@ public class AppRegistrationService : IAppRegistrationService
_logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId); _logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId);
return AppRegistrationResult.Success(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) catch (Exception ex)
{ {
_logger.LogError(ex, "RegisterAsync failed. Attempting rollback."); _logger.LogError(ex, "RegisterAsync failed. Attempting rollback.");
await RollbackAsync(createdApp, clientId, tenantId, ct);
if (createdApp?.Id is not null)
{
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
}
catch (Exception rollbackEx)
{
_logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id);
}
}
return AppRegistrationResult.Failure(ex.Message); return AppRegistrationResult.Failure(ex.Message);
} }
} }
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, tenantId, RegistrationScopes, ct);
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
}
catch (Exception rollbackEx)
{
_logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id);
}
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task RemoveAsync(string clientId, string appId, CancellationToken ct) public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct)
{ {
try 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); await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Removed Application appId={AppId}", appId); _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/> /// <inheritdoc/>
public async Task ClearMsalSessionAsync(string clientId, string tenantUrl) public async Task ClearMsalSessionAsync(string clientId, string tenantUrl)
{ {
+72 -11
View File
@@ -1,7 +1,9 @@
using System.Diagnostics;
using Microsoft.SharePoint.Client; using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query; using Microsoft.SharePoint.Client.Search.Query;
using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Services; namespace SharepointToolbox.Services;
@@ -13,9 +15,27 @@ namespace SharepointToolbox.Services;
/// </summary> /// </summary>
public class DuplicatesService : IDuplicatesService public class DuplicatesService : IDuplicatesService
{ {
// SharePoint Search REST API caps RowLimit at 500 per request; larger values are silently clamped.
private const int BatchSize = 500; 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; 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( public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
ClientContext ctx, ClientContext ctx,
DuplicateScanOptions options, DuplicateScanOptions options,
@@ -70,6 +90,8 @@ public class DuplicatesService : IDuplicatesService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct);
// KQL: all documents, optionally scoped to a library // KQL: all documents, optionally scoped to a library
var kqlParts = new List<string> { "ContentType:Document" }; var kqlParts = new List<string> { "ContentType:Document" };
if (!string.IsNullOrEmpty(options.Library)) if (!string.IsNullOrEmpty(options.Library))
@@ -147,7 +169,9 @@ public class DuplicatesService : IDuplicatesService
Library = library, Library = library,
SizeBytes = size, SizeBytes = size,
Created = created, Created = created,
Modified = modified Modified = modified,
SiteUrl = siteUrl,
SiteTitle = siteTitle
}); });
} }
@@ -171,10 +195,16 @@ public class DuplicatesService : IDuplicatesService
{ {
// Load all document libraries on the site // Load all document libraries on the site
ctx.Load(ctx.Web, ctx.Load(ctx.Web,
w => w.Title,
w => w.Lists.Include( w => w.Lists.Include(
l => l.Title, l => l.Hidden, l => l.BaseType)); l => l.Title, l => l.Hidden, l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); 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 var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList(); .ToList();
@@ -187,19 +217,15 @@ public class DuplicatesService : IDuplicatesService
.ToList(); .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 var camlQuery = new CamlQuery
{ {
ViewXml = """ ViewXml = """
<View Scope='RecursiveAll'> <View Scope='RecursiveAll'>
<Query> <Query></Query>
<Where> <RowLimit Paged='TRUE'>5000</RowLimit>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>1</Value>
</Eq>
</Where>
</Query>
<RowLimit>2000</RowLimit>
</View> </View>
""" """
}; };
@@ -215,6 +241,8 @@ public class DuplicatesService : IDuplicatesService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
var fv = item.FieldValues; var fv = item.FieldValues;
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty; string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty; string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
@@ -232,7 +260,9 @@ public class DuplicatesService : IDuplicatesService
FolderCount = subCount, FolderCount = subCount,
FileCount = fileCount, FileCount = fileCount,
Created = created, Created = created,
Modified = modified Modified = modified,
SiteUrl = siteUrl,
SiteTitle = siteTitle
}); });
} }
} }
@@ -261,6 +291,37 @@ public class DuplicatesService : IDuplicatesService
private static DateTime? ParseDate(string s) => private static DateTime? ParseDate(string s) =>
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null; 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) private static string ExtractLibraryFromPath(string path, string siteUrl)
{ {
// Extract first path segment after the site URL as library name // Extract first path segment after the site URL as library name
@@ -2,18 +2,36 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using CsvHelper; using CsvHelper;
using CsvHelper.Configuration;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export; 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 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) public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
{ {
var TL = TranslationSource.Instance; var TL = TranslationSource.Instance;
using var writer = new StringWriter(); using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); using var csv = new CsvWriter(writer, CsvConfig);
csv.WriteHeader<T>(); csv.WriteHeader<T>();
csv.WriteField(TL["report.col.error"]); csv.WriteField(TL["report.col.error"]);
@@ -31,12 +49,13 @@ public class BulkResultCsvExportService
return writer.ToString(); return writer.ToString();
} }
/// <summary>Writes the failed-items CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteFailedItemsCsvAsync<T>( public async Task WriteFailedItemsCsvAsync<T>(
IReadOnlyList<BulkItemResult<T>> failedItems, IReadOnlyList<BulkItemResult<T>> failedItems,
string filePath, string filePath,
CancellationToken ct) CancellationToken ct)
{ {
var content = BuildFailedItemsCsv(failedItems); var content = BuildFailedItemsCsv(failedItems);
await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct); await ExportFileWriter.WriteCsvAsync(filePath, content, ct);
} }
} }
@@ -59,7 +59,7 @@ public class CsvExportService
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(entries); var csv = BuildCsv(entries);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
} }
/// <summary> /// <summary>
@@ -116,13 +116,57 @@ public class CsvExportService
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(entries); 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> /// <summary>
private static string Csv(string value) /// 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 entries
return $"\"{value.Replace("\"", "\"\"")}\""; .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;
}
}
@@ -12,10 +12,52 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class DuplicatesCsvExportService public class DuplicatesCsvExportService
{ {
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
public async Task WriteAsync( public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups, IReadOnlyList<DuplicateGroup> groups,
string filePath, string filePath,
CancellationToken ct) 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 T = TranslationSource.Instance;
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -30,8 +72,9 @@ public class DuplicatesCsvExportService
sb.AppendLine(string.Join(",", new[] sb.AppendLine(string.Join(",", new[]
{ {
Csv(T["report.col.number"]), Csv(T["report.col.number"]),
Csv("Group"), Csv(T["report.col.group"]),
Csv(T["report.text.copies"]), Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
Csv(T["report.col.name"]), Csv(T["report.col.name"]),
Csv(T["report.col.library"]), Csv(T["report.col.library"]),
Csv(T["report.col.path"]), Csv(T["report.col.path"]),
@@ -40,7 +83,6 @@ public class DuplicatesCsvExportService
Csv(T["report.col.modified"]), Csv(T["report.col.modified"]),
})); }));
// Rows
foreach (var g in groups) foreach (var g in groups)
{ {
int i = 0; int i = 0;
@@ -52,6 +94,7 @@ public class DuplicatesCsvExportService
Csv(i.ToString()), Csv(i.ToString()),
Csv(g.Name), Csv(g.Name),
Csv(g.Items.Count.ToString()), Csv(g.Items.Count.ToString()),
Csv(item.SiteTitle),
Csv(item.Name), Csv(item.Name),
Csv(item.Library), Csv(item.Library),
Csv(item.Path), Csv(item.Path),
@@ -62,13 +105,8 @@ public class DuplicatesCsvExportService
} }
} }
await File.WriteAllTextAsync(filePath, sb.ToString(), return sb.ToString();
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
} }
private static string Csv(string value) private static string Csv(string value) => CsvSanitizer.Escape(value);
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
} }
@@ -11,6 +11,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class DuplicatesHtmlExportService 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) public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
@@ -122,12 +127,53 @@ public class DuplicatesHtmlExportService
return sb.ToString(); 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) public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(groups, branding); var html = BuildHtml(groups, branding);
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); 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) => private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty); System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
@@ -0,0 +1,27 @@
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);
}
@@ -2,101 +2,47 @@ using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
/// <summary> /// <summary>
/// Exports permission entries to a self-contained interactive HTML report. /// Exports permission entries to a self-contained interactive HTML report.
/// Ports PowerShell Export-PermissionsToHTML functionality. /// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
/// No external CSS/JS dependencies — everything is inline. /// 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> /// </summary>
public class HtmlExportService public class HtmlExportService
{ {
/// <summary> /// <summary>
/// Builds a self-contained HTML string from the supplied permission entries. /// Builds a self-contained HTML string from the supplied permission
/// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills. /// entries. Standard report: columns are Object / Title / URL / Unique /
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable. /// Users / Permission / Granted Through. When
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
/// become expandable rows listing resolved members.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null, public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
// Compute stats var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
var totalEntries = entries.Count; entries.Count,
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count(); entries.Select(e => e.PermissionLevels),
var distinctUsers = entries entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder(); var sb = new StringBuilder();
AppendHead(sb, T["report.title.permissions"], includeRiskCss: false);
// ── 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>{T["report.title.permissions"]}</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 ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>"); sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>"); sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
// Stats cards AppendFilterInput(sb);
sb.AppendLine("<div class=\"stats\">"); AppendTableOpen(sb);
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>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.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("</tr></thead>");
@@ -109,206 +55,68 @@ a:hover { text-decoration: underline; }
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
// Build user pills: zip UserLogins and Users (both semicolon-delimited) var (pills, subRows) = BuildUserPillsCell(
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); colSpan: 7, grpMemIdx: ref grpMemIdx);
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\">{T["report.text.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>");
}
}
sb.AppendLine("<tr>"); sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>"); sb.AppendLine($" <td><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><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.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
if (memberSubRows.Length > 0) if (subRows.Length > 0) sb.Append(subRows);
sb.Append(memberSubRows);
} }
sb.AppendLine("</tbody>"); AppendTableClose(sb);
sb.AppendLine("</table>"); AppendInlineJs(sb);
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>");
sb.AppendLine("</body>"); sb.AppendLine("</body>");
sb.AppendLine("</html>"); sb.AppendLine("</html>");
return sb.ToString(); 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)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
/// <summary>Returns inline CSS background and text color for a risk level.</summary>
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
{
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB")
};
/// <summary> /// <summary>
/// Builds a self-contained HTML string from simplified permission entries. /// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column. /// Adds a risk-level summary card strip plus two columns (Simplified,
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable. /// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null, public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries); var summaries = PermissionSummaryBuilder.Build(entries);
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
var totalEntries = entries.Count; entries.Count,
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count(); entries.Select(e => e.PermissionLevels),
var distinctUsers = entries entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder(); var sb = new StringBuilder();
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
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.permissions_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.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>"); sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
// Stats cards
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>");
// Risk-level summary cards
sb.AppendLine("<div class=\"risk-cards\">"); sb.AppendLine("<div class=\"risk-cards\">");
foreach (var summary in summaries) foreach (var s in summaries)
{ {
var (bg, text, border) = RiskLevelColors(summary.RiskLevel); 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=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>"); sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>"); sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>"); sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
sb.AppendLine(" </div>"); sb.AppendLine(" </div>");
} }
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Filter input AppendFilterInput(sb);
sb.AppendLine("<div class=\"filter-wrap\">"); AppendTableOpen(sb);
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_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("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>"); sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
@@ -322,115 +130,165 @@ function toggleGroup(id) {
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel); var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var (pills, subRows) = BuildUserPillsCell(
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
var pillsBuilder = new StringBuilder(); colSpan: 9, grpMemIdx: ref grpMemIdx);
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\">{T["report.text.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("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>"); sb.AppendLine($" <td><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><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.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</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><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
if (memberSubRows.Length > 0) if (subRows.Length > 0) sb.Append(subRows);
sb.Append(memberSubRows);
} }
sb.AppendLine("</tbody>"); AppendTableClose(sb);
sb.AppendLine("</table>"); AppendInlineJs(sb);
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("</body>");
sb.AppendLine("</html>"); sb.AppendLine("</html>");
return sb.ToString(); return sb.ToString();
} }
/// <summary> /// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
/// Writes the simplified HTML report to the specified file path. public async Task WriteAsync(
/// </summary> IReadOnlyList<PermissionEntry> entries,
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct, string filePath,
CancellationToken ct,
ReportBranding? branding = null, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var html = BuildHtml(entries, branding, groupMembers); var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
} }
/// <summary>Returns the CSS class for the object-type badge.</summary> /// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
private static string ObjectTypeCss(string t) => t switch public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
"Site Collection" => "badge site-coll", var html = BuildHtml(entries, branding, groupMembers);
"Site" => "badge site", await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
"List" => "badge list", }
"Folder" => "badge folder",
_ => "badge" /// <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)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers);
return;
}
var partitions = CsvExportService.PartitionBySite(entries).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
.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);
}
}
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</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)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers);
return;
}
var partitions = CsvExportService.PartitionBySite(entries).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
.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);
}
}
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"),
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB")
}; };
/// <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,207 @@
using System.Text;
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; }
";
internal const string InlineJs = @"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';
});
}
document.addEventListener('click', function(ev) {
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)
{
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);
bool isExpandable = principalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandable && groupMembers != null && groupMembers.TryGetValue(name, out var resolved))
{
var grpId = $"grpmem{grpMemIdx}";
pills.Append($"<span class=\"user-pill group-expandable\" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolved.Count > 0)
{
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", parts);
}
else
{
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
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>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
}
@@ -11,6 +11,10 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class SearchCsvExportService 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) public string BuildCsv(IReadOnlyList<SearchResult> results)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
@@ -35,19 +39,14 @@ public class SearchCsvExportService
return sb.ToString(); 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) public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(results); 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) private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static string IfEmpty(string? value, string fallback = "") private static string IfEmpty(string? value, string fallback = "")
=> string.IsNullOrEmpty(value) ? fallback : value!; => string.IsNullOrEmpty(value) ? fallback : value!;
@@ -12,6 +12,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class SearchHtmlExportService 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) public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
@@ -140,6 +145,7 @@ public class SearchHtmlExportService
return sb.ToString(); 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) public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(results, branding); var html = BuildHtml(results, branding);
@@ -11,18 +11,24 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class StorageCsvExportService 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) public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var sb = new StringBuilder(); var sb = new StringBuilder();
// Header // Header
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}"); sb.AppendLine($"{T["report.col.library"]},{T["stor.col.kind"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", sb.AppendLine(string.Join(",",
Csv(node.Name), Csv(node.Name),
Csv(KindLabel(node.Kind)),
Csv(node.SiteTitle), Csv(node.SiteTitle),
node.TotalFileCount.ToString(), node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes), FormatMb(node.TotalSizeBytes),
@@ -35,10 +41,11 @@ public class StorageCsvExportService
return sb.ToString(); return sb.ToString();
} }
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(nodes); var csv = BuildCsv(nodes);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
} }
/// <summary> /// <summary>
@@ -79,10 +86,55 @@ public class StorageCsvExportService
return sb.ToString(); return sb.ToString();
} }
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(nodes, fileTypeMetrics); var csv = BuildCsv(nodes, fileTypeMetrics);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); await ExportFileWriter.WriteCsvAsync(filePath, csv, 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 ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
@@ -90,12 +142,21 @@ public class StorageCsvExportService
private static string FormatMb(long bytes) private static string FormatMb(long bytes)
=> (bytes / (1024.0 * 1024.0)).ToString("F2"); => (bytes / (1024.0 * 1024.0)).ToString("F2");
/// <summary>RFC 4180 CSV field quoting.</summary> /// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
private static string Csv(string value) private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
private static string KindLabel(StorageNodeKind kind)
{ {
if (string.IsNullOrEmpty(value)) return string.Empty; var T = TranslationSource.Instance;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) return kind switch
return $"\"{value.Replace("\"", "\"\"")}\""; {
return value; 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()
};
} }
} }
@@ -14,6 +14,12 @@ public class StorageHtmlExportService
{ {
private int _togIdx; private int _togIdx;
/// <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) public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
@@ -73,6 +79,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -83,7 +90,10 @@ public class StorageHtmlExportService
<tbody> <tbody>
"""); """);
foreach (var node in nodes) // Only iterate root-level nodes; RenderNode recurses into Children
// inline. Iterating the flat list would render every descendant a
// second time as a top-level row.
foreach (var node in nodes.Where(n => n.IndentLevel == 0))
{ {
RenderNode(sb, node); RenderNode(sb, node);
} }
@@ -174,7 +184,7 @@ public class StorageHtmlExportService
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount); var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">"); sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>{T["report.section.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", var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" }; "#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -190,7 +200,7 @@ public class StorageHtmlExportService
<div class="bar-row"> <div class="bar-row">
<span class="bar-label">{HtmlEncode(label)}</span> <span class="bar-label">{HtmlEncode(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div> <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> </div>
"""); """);
idx++; idx++;
@@ -206,6 +216,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -216,7 +227,10 @@ public class StorageHtmlExportService
<tbody> <tbody>
"""); """);
foreach (var node in nodes) // Only iterate root-level nodes; RenderNode recurses into Children
// inline. Iterating the flat list would render every descendant a
// second time as a top-level row.
foreach (var node in nodes.Where(n => n.IndentLevel == 0))
{ {
RenderNode(sb, node); RenderNode(sb, node);
} }
@@ -232,18 +246,62 @@ public class StorageHtmlExportService
return sb.ToString(); 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) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(nodes, branding); var html = BuildHtml(nodes, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, 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) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(nodes, fileTypeMetrics, branding); var html = BuildHtml(nodes, fileTypeMetrics, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, 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 ──────────────────────────────────────────────────── // ── Private rendering ────────────────────────────────────────────────────
private void RenderNode(StringBuilder sb, StorageNode node) private void RenderNode(StringBuilder sb, StorageNode node)
@@ -262,6 +320,7 @@ public class StorageHtmlExportService
sb.AppendLine($""" sb.AppendLine($"""
<tr> <tr>
<td>{nameCell}</td> <td>{nameCell}</td>
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
<td>{HtmlEncode(node.SiteTitle)}</td> <td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td> <td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td> <td class="num">{FormatSize(node.TotalSizeBytes)}</td>
@@ -272,7 +331,7 @@ public class StorageHtmlExportService
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -300,6 +359,7 @@ public class StorageHtmlExportService
sb.AppendLine($""" sb.AppendLine($"""
<tr> <tr>
<td>{nameCell}</td> <td>{nameCell}</td>
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
<td>{HtmlEncode(node.SiteTitle)}</td> <td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td> <td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td> <td class="num">{FormatSize(node.TotalSizeBytes)}</td>
@@ -310,7 +370,7 @@ public class StorageHtmlExportService
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -331,4 +391,19 @@ public class StorageHtmlExportService
private static string HtmlEncode(string value) private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty); => System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
} }
@@ -87,8 +87,69 @@ public class UserAccessCsvExportService
var filePath = Path.Combine(directoryPath, fileName); var filePath = Path.Combine(directoryPath, fileName);
var csv = BuildCsv(displayName, userLogin, entries); var csv = BuildCsv(displayName, userLogin, entries);
await File.WriteAllTextAsync(filePath, csv, await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), 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);
} }
} }
@@ -169,17 +230,12 @@ public class UserAccessCsvExportService
})); }));
} }
await File.WriteAllTextAsync(filePath, sb.ToString(), await ExportFileWriter.WriteCsvAsync(filePath, sb.ToString(), ct);
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
} }
} }
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary> /// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
private static string Csv(string value) private static string Csv(string value) => CsvSanitizer.Escape(value);
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
private static string SanitizeFileName(string name) private static string SanitizeFileName(string name)
{ {
@@ -19,6 +19,70 @@ public class UserAccessHtmlExportService
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user /// 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. /// report with an expandable Sites column instead of the dual by-user/by-site view.
/// </summary> /// </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) public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
{ {
if (mergePermissions) if (mergePermissions)
@@ -344,7 +408,7 @@ function sortTable(view, col) {
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null) public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
{ {
var html = BuildHtml(entries, mergePermissions, branding); var html = BuildHtml(entries, mergePermissions, branding);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
} }
/// <summary> /// <summary>
@@ -531,7 +595,7 @@ a:hover { text-decoration: underline; }
{ {
// Multiple locations — expandable badge // Multiple locations — expandable badge
var currentLocId = $"loc{locIdx++}"; 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>"); sb.AppendLine("</tr>");
// Hidden sub-rows — one per location // Hidden sub-rows — one per location
@@ -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";
}
}
@@ -6,8 +6,23 @@ using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services; namespace SharepointToolbox.Services;
/// <summary>
/// Orchestrates server-side file copy/move between two SharePoint libraries
/// (same or different tenants). Uses <see cref="MoveCopyUtil"/> for the
/// transfer itself so bytes never round-trip through the local machine.
/// Folder creation and enumeration are done via CSOM; all ambient retries
/// flow through <see cref="ExecuteQueryRetryHelper"/>.
/// </summary>
public class FileTransferService : IFileTransferService public class FileTransferService : IFileTransferService
{ {
/// <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( public async Task<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx, ClientContext sourceCtx,
ClientContext destCtx, ClientContext destCtx,
@@ -166,37 +181,49 @@ public class FileTransferService : IFileTransferService
if (!string.IsNullOrEmpty(job.SourceFolderPath)) if (!string.IsNullOrEmpty(job.SourceFolderPath))
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}"; 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>(); var files = new List<string>();
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, list, baseFolderUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileRef", "FileDirRef" },
ct: ct))
{
ct.ThrowIfCancellationRequested();
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; return files;
} }
private async Task CollectFilesRecursiveAsync( private static bool HasSystemFolderSegment(string fileDirRef, string baseFolderUrl)
ClientContext ctx,
Folder folder,
List<string> files,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); if (string.IsNullOrEmpty(fileDirRef)) return false;
var baseTrim = baseFolderUrl.TrimEnd('/');
if (!fileDirRef.StartsWith(baseTrim, StringComparison.OrdinalIgnoreCase))
return false;
ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl), var tail = fileDirRef.Substring(baseTrim.Length).Trim('/');
f => f.Folders); if (string.IsNullOrEmpty(tail)) return false;
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var file in folder.Files) foreach (var seg in tail.Split('/', StringSplitOptions.RemoveEmptyEntries))
{ {
files.Add(file.ServerRelativeUrl); if (seg.StartsWith("_", StringComparison.Ordinal) ||
} seg.Equals("Forms", StringComparison.OrdinalIgnoreCase))
return true;
foreach (var subFolder in folder.Folders)
{
// Skip system folders
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue;
await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct);
} }
return false;
} }
private async Task EnsureFolderAsync( private async Task EnsureFolderAsync(
@@ -9,23 +9,25 @@ public interface IAppRegistrationService
{ {
/// <summary> /// <summary>
/// Returns true if the currently-authenticated user has the Global Administrator /// Returns true if the currently-authenticated user has the Global Administrator
/// directory role (checked via transitiveMemberOf for nested-group coverage). /// directory role in the target tenant (checked via transitiveMemberOf for
/// Returns false on any failure, including 403, rather than throwing. /// 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> /// </summary>
Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct); Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct);
/// <summary> /// <summary>
/// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants /// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants
/// atomically. On any intermediate failure the Application is deleted before /// atomically in the tenant identified by <paramref name="tenantUrl"/>.
/// returning a Failure result (best-effort rollback). /// On any intermediate failure the Application is deleted before returning
/// a Failure result (best-effort rollback).
/// </summary> /// </summary>
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct); Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantUrl, string tenantDisplayName, CancellationToken ct);
/// <summary> /// <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. /// Logs a warning on failure but does not throw.
/// </summary> /// </summary>
Task RemoveAsync(string clientId, string appId, CancellationToken ct); Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct);
/// <summary> /// <summary>
/// Clears the live SessionManager context, evicts all in-memory MSAL accounts, /// 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 public interface IStorageService
{ {
/// <summary> /// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API. /// Collects storage metrics for a site, capturing every storage source
/// Returns a tree of StorageNode objects with aggregate size data. /// SharePoint reports (visible + hidden libraries, Preservation Hold,
/// list attachments, recycle bin, and optionally subsites). Each
/// <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/> so
/// callers can filter what appears in the report.
/// </summary> /// </summary>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync( Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
@@ -18,9 +21,6 @@ public interface IStorageService
/// <summary> /// <summary>
/// Enumerates files across all non-hidden document libraries in the site /// Enumerates files across all non-hidden document libraries in the site
/// and aggregates storage consumption grouped by file extension. /// and aggregates storage consumption grouped by file extension.
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
/// This is a separate operation from CollectStorageAsync -- it provides
/// file-type breakdown data for chart visualization.
/// </summary> /// </summary>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
@@ -29,13 +29,24 @@ public interface IStorageService
/// <summary> /// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes /// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery. /// by enumerating files per library via CamlQuery. Only re-runs against
/// This works around the StorageMetrics API returning zeros when the /// document-library kinds (Library, HiddenLibrary, PreservationHold).
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// </summary> /// </summary>
Task BackfillZeroNodesAsync( Task BackfillZeroNodesAsync(
ClientContext ctx, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct); CancellationToken ct);
/// <summary>
/// Returns the SharePoint-reported total storage usage for the site
/// (Site.Usage.Storage). This includes everything that counts toward
/// the site quota — recycle bin, version history, hidden libraries,
/// list attachments — and serves as the ground-truth reference total.
/// Returns 0 if the call is denied or the property is unavailable.
/// </summary>
Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
} }
@@ -0,0 +1,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);
}
@@ -69,7 +69,12 @@ public class SiteListService : ISiteListService
if (s.Status == "Active" if (s.Status == "Active"
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase)) && !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
});
} }
} }
} }
+529 -158
View File
@@ -7,20 +7,39 @@ namespace SharepointToolbox.Services;
/// <summary> /// <summary>
/// CSOM-based storage metrics scanner. /// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern. /// Captures every storage source SharePoint reports for a site:
/// document libraries (visible + hidden), the Preservation Hold Library,
/// list attachments, the recycle bin (1st + 2nd stage), and optionally
/// subsites. Each <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/>
/// so the caller can filter what appears in the report.
/// </summary> /// </summary>
public class StorageService : IStorageService public class StorageService : IStorageService
{ {
// PreservationHoldLibrary base template id.
private const int PreservationHoldTemplate = 851;
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync( public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
StorageScanOptions options, StorageScanOptions options,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{
var result = new List<StorageNode>();
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
return result;
}
private async Task CollectForWebAsync(
ClientContext ctx,
Web web,
StorageScanOptions options,
List<StorageNode> result,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load web-level metadata in one round-trip ctx.Load(web,
ctx.Load(ctx.Web,
w => w.Title, w => w.Title,
w => w.Url, w => w.Url,
w => w.ServerRelativeUrl, w => w.ServerRelativeUrl,
@@ -28,41 +47,324 @@ public class StorageService : IStorageService
l => l.Title, l => l.Title,
l => l.Hidden, l => l.Hidden,
l => l.BaseType, l => l.BaseType,
l => l.BaseTemplate,
l => l.ItemCount,
l => l.RootFolder.ServerRelativeUrl)); l => l.RootFolder.ServerRelativeUrl));
if (options.IncludeSubsites)
ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/'); string siteTitle = web.Title;
string siteTitle = ctx.Web.Title; var lists = web.Lists.ToList();
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// ── Document libraries (incl. hidden + Preservation Hold) ───────────
// Track each library's RootFolder server-relative URL so bin items can
// be attributed back to their source library (matches storman.aspx,
// which folds bin contents into the owning library's Total Size).
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
var libsByRoot = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
int idx = 0; int idx = 0;
foreach (var lib in libs) foreach (var lib in docLibs)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
idx++; idx++;
progress.Report(new OperationProgress(idx, libs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})")); StorageNodeKind kind = ClassifyLibrary(lib);
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
progress.Report(new OperationProgress(idx, docLibs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{docLibs.Count})"));
var libNode = await LoadFolderNodeAsync( var libNode = await LoadFolderNodeAsync(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title, ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct); siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0) if (options.FolderDepth > 0)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, lib.RootFolder.ServerRelativeUrl, ctx, lib, lib.RootFolder.ServerRelativeUrl,
libNode, 1, options.FolderDepth, libNode, 1, options.FolderDepth,
siteTitle, lib.Title, progress, ct); siteTitle, lib.Title, kind, progress, ct);
} }
// CSOM Folder.StorageMetrics is unreliable across the board for
// larger libraries — sometimes returns the storman value, sometimes
// returns a fraction of it, sometimes zero. Subfolder StorageMetrics
// are equally inconsistent. The only CSOM path that matches storman
// is per-file File.Length + File.Versions[*].Size enumeration, so
// run it unconditionally, replacing the CSOM totals.
ResetNodeCounts(libNode);
await BackfillLibFromFilesAsync(ctx, lib, libNode, progress, ct);
result.Add(libNode); result.Add(libNode);
libsByRoot[NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl)] = libNode;
} }
return result; // ── List attachments (non-document-library lists) ───────────────────
if (options.IncludeListAttachments)
{
var nonDocLists = lists
.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0)
.ToList();
int aIdx = 0;
foreach (var list in nonDocLists)
{
ct.ThrowIfCancellationRequested();
aIdx++;
progress.Report(new OperationProgress(aIdx, nonDocLists.Count,
$"Scanning list attachments: {list.Title} ({aIdx}/{nonDocLists.Count})"));
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
if (attachNode != null && attachNode.TotalSizeBytes > 0)
result.Add(attachNode);
}
}
// ── Recycle bin (stage 1 + stage 2) ─────────────────────────────────
if (options.IncludeRecycleBin)
{
progress.Report(OperationProgress.Indeterminate(
$"Scanning recycle bin: {siteTitle}..."));
var (rbNodes, perDir) = await LoadRecycleBinNodesAsync(ctx, web, siteTitle, progress, ct);
// Attribute bin items to owning library (longest-prefix match on DirName)
// so library Total Size matches storman.aspx, which counts an item's
// bytes against its source library even after deletion.
if (perDir.Count > 0 && libsByRoot.Count > 0)
{
var libRootsByLength = libsByRoot
.OrderByDescending(kv => kv.Key.Length)
.ToList();
foreach (var kv in perDir)
{
string dirNorm = NormalizeServerRelative(kv.Key);
foreach (var lib in libRootsByLength)
{
if (dirNorm.Equals(lib.Key, StringComparison.OrdinalIgnoreCase) ||
dirNorm.StartsWith(lib.Key + "/", StringComparison.OrdinalIgnoreCase))
{
lib.Value.TotalSizeBytes += kv.Value.Size;
lib.Value.TotalFileCount += kv.Value.Count;
break;
}
}
}
}
result.AddRange(rbNodes);
}
// ── Subsites (recursive) ────────────────────────────────────────────
if (options.IncludeSubsites)
{
var subwebs = web.Webs.ToList();
foreach (var sub in subwebs)
{
ct.ThrowIfCancellationRequested();
// Build a node header so subsite results are visually grouped.
var subResult = new List<StorageNode>();
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
if (subResult.Count == 0) continue;
// Bin contents already rolled up into each library's TotalSizeBytes
// (storman behavior); summing root RecycleBin children too would
// double-count. Filter them out here.
var subRoot = new StorageNode
{
Name = sub.Title,
Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
SiteTitle = sub.Title,
Library = string.Empty,
Kind = StorageNodeKind.Subsite,
IndentLevel = 0,
Children = subResult,
TotalSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalSizeBytes),
FileStreamSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.FileStreamSizeBytes),
TotalFileCount = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalFileCount)
};
result.Add(subRoot);
}
}
}
private static StorageNodeKind ClassifyLibrary(List lib)
{
if (lib.BaseTemplate == PreservationHoldTemplate ||
string.Equals(lib.Title, "Preservation Hold Library", StringComparison.OrdinalIgnoreCase))
return StorageNodeKind.PreservationHold;
return lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
}
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(
ClientContext ctx,
List list,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Per-list attachments live in <listRootFolder>/Attachments/<itemId>/<file>.
// The Attachments folder may or may not exist depending on whether any
// item ever had an attachment — guard with try/catch.
string attachmentsUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(attachmentsUrl);
ctx.Load(folder,
f => f.Exists,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0)
return null;
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
? folder.StorageMetrics.LastModified
: folder.TimeLastModified > DateTime.MinValue
? folder.TimeLastModified
: (DateTime?)null;
return new StorageNode
{
Name = $"[Attachments] {list.Title}",
Url = ctx.Url.TrimEnd('/') + attachmentsUrl,
SiteTitle = siteTitle,
Library = list.Title,
Kind = StorageNodeKind.ListAttachments,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
LastModified = lastMod,
IndentLevel = 0,
Children = new List<StorageNode>()
};
}
catch
{
// Attachments folder absent for this list — not an error.
return null;
}
}
private static async Task<(List<StorageNode> Nodes, Dictionary<string, (long Size, int Count)> PerDir)> LoadRecycleBinNodesAsync(
ClientContext ctx,
Web web,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var nodes = new List<StorageNode>();
var perDir = new Dictionary<string, (long Size, int Count)>(StringComparer.OrdinalIgnoreCase);
try
{
// Web-scoped: ctx.Site.RecycleBin would return the entire site-collection
// bin and inflate totals by (1 + N_subsites) when IncludeSubsites is on.
var bin = web.RecycleBin;
ctx.Load(bin, b => b.Include(
i => i.Size,
i => i.ItemState,
i => i.DeletedDate,
i => i.DirName));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// RecycleBinItem.DirName is web-relative on SharePoint Online
// (e.g. "Documents/SubFolder" without leading slash or web URL).
// Prepend the web's ServerRelativeUrl so the result matches
// List.RootFolder.ServerRelativeUrl form used by libsByRoot.
string webSrl = NormalizeServerRelative(web.ServerRelativeUrl);
long stage1Size = 0, stage2Size = 0;
int stage1Count = 0, stage2Count = 0;
DateTime? stage1Last = null, stage2Last = null;
foreach (var item in bin)
{
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin)
{
stage2Size += item.Size;
stage2Count++;
if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate;
}
else
{
stage1Size += item.Size;
stage1Count++;
if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate;
}
string raw = item.DirName ?? string.Empty;
string dirSrl;
if (raw.StartsWith('/'))
dirSrl = NormalizeServerRelative(raw);
else if (string.IsNullOrEmpty(raw))
dirSrl = webSrl;
else
dirSrl = NormalizeServerRelative(webSrl + "/" + raw);
if (perDir.TryGetValue(dirSrl, out var tally))
perDir[dirSrl] = (tally.Size + item.Size, tally.Count + 1);
else
perDir[dirSrl] = (item.Size, 1);
}
if (stage1Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] First-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage1Size,
FileStreamSizeBytes = stage1Size,
TotalFileCount = stage1Count,
LastModified = stage1Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
if (stage2Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] Second-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage2Size,
FileStreamSizeBytes = stage2Size,
TotalFileCount = stage2Count,
LastModified = stage2Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
}
catch
{
// Insufficient permission to read recycle bin or feature unavailable.
}
return (nodes, perDir);
}
/// <summary>
/// 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( public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
@@ -72,7 +374,6 @@ public class StorageService : IStorageService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web, ctx.Load(ctx.Web,
w => w.Lists.Include( w => w.Lists.Include(
l => l.Title, l => l.Title,
@@ -85,7 +386,6 @@ public class StorageService : IStorageService
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList(); .ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase); var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0; int libIdx = 0;
@@ -96,22 +396,17 @@ public class StorageService : IStorageService
progress.Report(new OperationProgress(libIdx, libs.Count, progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})")); $"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Use CamlQuery to enumerate all files in the library // No <Where> clause: filtering on FSObjType (non-indexed) on a list
// Paginate with 500 items per batch to avoid list view threshold issues // 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 var query = new CamlQuery
{ {
ViewXml = @"<View Scope='RecursiveAll'> ViewXml = @"<View Scope='RecursiveAll'>
<Query> <Query></Query>
<Where>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>0</Value>
</Eq>
</Where>
</Query>
<ViewFields> <ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileLeafRef' /> <FieldRef Name='FileLeafRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields> </ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit> <RowLimit Paged='TRUE'>500</RowLimit>
</View>" </View>"
@@ -124,20 +419,41 @@ public class StorageService : IStorageService
items = lib.GetItems(query); items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition, ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include( ic => ic.Include(
i => i["FileLeafRef"], i => i["FSObjType"],
i => i["File_x0020_Size"])); i => i["FileLeafRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string Name)>();
foreach (var item in items) foreach (var item in items)
{ {
if (item["FSObjType"]?.ToString() != "0") continue;
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty; string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0"; fileRows.Add((item, fileName));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (!long.TryParse(sizeStr, out long fileSize)) if (fileRows.Count > 0)
fileSize = 0; {
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
string ext = Path.GetExtension(fileName).ToLowerInvariant(); foreach (var row in fileRows)
// ext is "" for extensionless files, ".docx" etc. for others {
long current;
try { current = row.Item.File.Length; }
catch { continue; }
long versions = 0;
try
{
foreach (var v in row.Item.File.Versions)
versions += v.Size;
}
catch { /* no version history */ }
long fileSize = current + versions;
string ext = Path.GetExtension(row.Name).ToLowerInvariant();
if (extensionMap.TryGetValue(ext, out var existing)) if (extensionMap.TryGetValue(ext, out var existing))
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1); extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
@@ -145,132 +461,178 @@ public class StorageService : IStorageService
extensionMap[ext] = (fileSize, 1); extensionMap[ext] = (fileSize, 1);
} }
// Move to next page
query.ListItemCollectionPosition = items.ListItemCollectionPosition; query.ListItemCollectionPosition = items.ListItemCollectionPosition;
} }
while (items.ListItemCollectionPosition != null); while (items.ListItemCollectionPosition != null);
} }
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count)) .Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes) .OrderByDescending(m => m.TotalSizeBytes)
.ToList(); .ToList();
} }
public async Task BackfillZeroNodesAsync( /// <summary>
/// Per-library backfill executed inline by CollectForWebAsync when CSOM's
/// Folder.StorageMetrics returns zero counts. Enumerates every file via
/// CamlQuery and explicitly loads File.Length + File.Versions.Size so
/// version bytes are summed accurately — matches what storman.aspx reports.
/// </summary>
private static async Task BackfillLibFromFilesAsync(
ClientContext ctx,
List lib,
StorageNode libNode,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
progress.Report(OperationProgress.Indeterminate(
$"Counting files: {libNode.Name}..."));
string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl);
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
// beyond the 5000-item view threshold throws "The attempted operation
// is prohibited because it exceeds the list view threshold". Paged
// retrieval without Where is unaffected by the threshold; we filter
// out folders client-side and skip File.Length access for them.
// Smaller page size because each row carries the full Versions collection.
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FSObjType"],
i => i["FileDirRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// Second pass: queue File.Length + File.Versions[*].Size only for
// file rows. Including these in the page 1 query throws a
// ServerObjectNullReferenceException on folder rows (item.File is
// null for folders). Filtering FSObjType client-side here keeps
// per-page round-trips at two regardless of file count.
var fileRows = new List<(ListItem Item, string DirRef)>();
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue;
var dirRef = item["FileDirRef"]?.ToString() ?? string.Empty;
fileRows.Add((item, dirRef));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (fileRows.Count > 0)
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
foreach (var row in fileRows)
{
long current;
try { current = row.Item.File.Length; }
catch { continue; }
long versions = 0;
try
{
foreach (var v in row.Item.File.Versions)
versions += v.Size;
}
catch
{
// Versioning disabled / no version history — leave at 0.
}
long totalSize = current + versions;
// Attribute each file to its deepest matching folder only.
// Parent rollup happens once after all pages are processed,
// adding direct + descendants — matches storman's per-folder
// total. Fall back to libNode for files at lib root or in
// folders excluded from the tree (Forms, _-prefixed system
// folders, depth-limited subfolders).
var target = FindDeepestFolder(row.DirRef, folderLookup) ?? libNode;
target.TotalSizeBytes += totalSize;
target.FileStreamSizeBytes += current;
target.TotalFileCount++;
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
// Post-pass rollup: each folder's totals become own-direct + sum of
// descendants. libNode ends up as total of every file in the tree.
RollupFolderTotals(libNode);
}
/// <summary>
/// Recursively rolls up direct-file totals into ancestor folders so each
/// node's reported size includes everything beneath it. Pre-condition: each
/// node holds only its directly-attributed files (no descendant amounts).
/// </summary>
private static void RollupFolderTotals(StorageNode node)
{
foreach (var child in node.Children)
{
RollupFolderTotals(child);
node.TotalSizeBytes += child.TotalSizeBytes;
node.FileStreamSizeBytes += child.FileStreamSizeBytes;
node.TotalFileCount += child.TotalFileCount;
}
}
/// <summary>
/// No-op retained for interface compatibility. Backfill now runs inline
/// inside <see cref="CollectStorageAsync"/> via BackfillLibFromFilesAsync,
/// which has access to the CSOM library reference and runs before bin
/// distribution so the count==0 trigger is not polluted by bin items.
/// </summary>
public Task BackfillZeroNodesAsync(
ClientContext ctx, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
=> Task.CompletedTask;
public async Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
// Find root-level library nodes that have any zero-valued nodes in their tree try
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
var needsBackfill = libNodes.Where(lib =>
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
if (needsBackfill.Count == 0) return;
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
ctx.Load(ctx.Web, w => w.ServerRelativeUrl,
w => w.Lists.Include(
l => l.Title, l => l.Hidden, l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
int idx = 0;
foreach (var libNode in needsBackfill)
{ {
ct.ThrowIfCancellationRequested(); ctx.Load(ctx.Site, s => s.Usage);
idx++; await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return ctx.Site.Usage.Storage;
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
progress.Report(new OperationProgress(idx, needsBackfill.Count,
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Reset all nodes in this tree to zero before accumulating
ResetNodeCounts(libNode);
// Enumerate all files with their folder path
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query><Where>
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
</Where></Query>
<ViewFields>
<FieldRef Name='FileDirRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FileDirRef"],
i => i["File_x0020_Size"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
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++;
}
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
} }
} catch
private static bool HasZeroChild(StorageNode node)
{
foreach (var child in node.Children)
{ {
if (child.TotalFileCount == 0) return true; return 0L;
if (HasZeroChild(child)) return true;
} }
return false;
} }
private static void ResetNodeCounts(StorageNode node) private static void ResetNodeCounts(StorageNode node)
{ {
node.TotalSizeBytes = 0; node.TotalSizeBytes = 0;
node.FileStreamSizeBytes = 0; node.FileStreamSizeBytes = 0;
node.TotalFileCount = 0; node.TotalFileCount = 0;
foreach (var child in node.Children) foreach (var child in node.Children)
ResetNodeCounts(child); ResetNodeCounts(child);
} }
@@ -290,8 +652,6 @@ public class StorageService : IStorageService
private static StorageNode? FindDeepestFolder(string fileDirRef, private static StorageNode? FindDeepestFolder(string fileDirRef,
Dictionary<string, StorageNode> lookup) Dictionary<string, StorageNode> lookup)
{ {
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
// Try exact match, then walk up until we find a match
string path = fileDirRef.TrimEnd('/'); string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path)) while (!string.IsNullOrEmpty(path))
{ {
@@ -304,7 +664,7 @@ public class StorageService : IStorageService
return null; return null;
} }
// -- Private helpers ----------------------------------------------------- // ── Library/folder loading helpers ──────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync( private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx, ClientContext ctx,
@@ -313,6 +673,7 @@ public class StorageService : IStorageService
string siteTitle, string siteTitle,
string library, string library,
int indentLevel, int indentLevel,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -338,6 +699,7 @@ public class StorageService : IStorageService
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle, SiteTitle = siteTitle,
Library = library, Library = library,
Kind = kind,
TotalSizeBytes = folder.StorageMetrics.TotalSize, TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount, TotalFileCount = folder.StorageMetrics.TotalFileCount,
@@ -349,45 +711,54 @@ public class StorageService : IStorageService
private static async Task CollectSubfoldersAsync( private static async Task CollectSubfoldersAsync(
ClientContext ctx, ClientContext ctx,
List list,
string parentServerRelativeUrl, string parentServerRelativeUrl,
StorageNode parentNode, StorageNode parentNode,
int currentDepth, int currentDepth,
int maxDepth, int maxDepth,
string siteTitle, string siteTitle,
string library, string library,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
if (currentDepth > maxDepth) return; if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load direct child folders of this folder var subfolders = new List<(string Name, string ServerRelativeUrl)>();
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);
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(); ct.ThrowIfCancellationRequested();
// Skip SharePoint system folders
if (subFolder.Name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
subFolder.Name.StartsWith("_", StringComparison.Ordinal))
continue;
var childNode = await LoadFolderNodeAsync( var childNode = await LoadFolderNodeAsync(
ctx, subFolder.ServerRelativeUrl, subFolder.Name, ctx, sub.ServerRelativeUrl, sub.Name,
siteTitle, library, currentDepth, progress, ct); siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth) if (currentDepth < maxDepth)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, subFolder.ServerRelativeUrl, childNode, ctx, list, sub.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth, currentDepth + 1, maxDepth,
siteTitle, library, progress, ct); siteTitle, library, kind, progress, ct);
} }
parentNode.Children.Add(childNode); parentNode.Children.Add(childNode);
+55 -23
View File
@@ -93,8 +93,7 @@ public class TemplateService : ITemplateService
{ {
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl); ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
libInfo.Folders = await EnumerateFoldersRecursiveAsync( libInfo.Folders = await EnumerateLibraryFoldersAsync(ctx, list, ct);
ctx, list.RootFolder, string.Empty, progress, ct);
} }
template.Libraries.Add(libInfo); template.Libraries.Add(libInfo);
@@ -293,39 +292,72 @@ public class TemplateService : ITemplateService
return siteUrl; 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, ClientContext ctx,
Folder parentFolder, List list,
string parentRelativePath,
IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var result = new List<TemplateFolderInfo>();
ctx.Load(parentFolder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl)); var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
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 (item["FSObjType"]?.ToString() != "1") continue; // folders only
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
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; continue;
var relativePath = string.IsNullOrEmpty(parentRelativePath) // Paths relative to the library root.
? subFolder.Name var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
: $"{parentRelativePath}/{subFolder.Name}"; ? fileRef.Substring(rootUrl.Length).TrimStart('/')
: name;
var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
? dirRef.Substring(rootUrl.Length).TrimStart('/')
: string.Empty;
var folderInfo = new TemplateFolderInfo folders.Add((rel, parentRel));
{
Name = subFolder.Name,
RelativePath = relativePath,
Children = await EnumerateFoldersRecursiveAsync(ctx, subFolder, relativePath, progress, ct),
};
result.Add(folderInfo);
} }
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( private static async Task CreateFoldersFromTemplateAsync(
@@ -0,0 +1,189 @@
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)
{
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();
int 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 (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),
Error = ex.Message,
};
}
}
}
@@ -8,6 +8,7 @@
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<StartupObject>SharepointToolbox.App</StartupObject> <StartupObject>SharepointToolbox.App</StartupObject>
<ApplicationIcon>Resources\SPToolbox-logo.ico</ApplicationIcon>
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime --> <!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn> <NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -43,6 +44,8 @@
<EmbeddedResource Include="Resources\bulk_add_members.csv" /> <EmbeddedResource Include="Resources\bulk_add_members.csv" />
<EmbeddedResource Include="Resources\bulk_create_sites.csv" /> <EmbeddedResource Include="Resources\bulk_create_sites.csv" />
<EmbeddedResource Include="Resources\folder_structure.csv" /> <EmbeddedResource Include="Resources\folder_structure.csv" />
<Resource Include="Resources\SPToolbox-logo-ico.png" />
<Resource Include="Resources\SPToolbox-logo.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -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;
}
}
@@ -50,7 +50,6 @@ public partial class MainWindowViewModel : ObservableRecipient
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count) ? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
: Localization.TranslationSource.Instance["toolbar.globalSites.none"]; : Localization.TranslationSource.Instance["toolbar.globalSites.none"];
public IAsyncRelayCommand ConnectCommand { get; }
public IAsyncRelayCommand ClearSessionCommand { get; } public IAsyncRelayCommand ClearSessionCommand { get; }
public RelayCommand ManageProfilesCommand { get; } public RelayCommand ManageProfilesCommand { get; }
public RelayCommand OpenGlobalSitePickerCommand { get; } public RelayCommand OpenGlobalSitePickerCommand { get; }
@@ -64,7 +63,6 @@ public partial class MainWindowViewModel : ObservableRecipient
_sessionManager = sessionManager; _sessionManager = sessionManager;
_logger = logger; _logger = logger;
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null); ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
ManageProfilesCommand = new RelayCommand(OpenProfileManagement); ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null); OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
@@ -96,7 +94,6 @@ public partial class MainWindowViewModel : ObservableRecipient
{ {
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value)); WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
} }
ConnectCommand.NotifyCanExecuteChanged();
ClearSessionCommand.NotifyCanExecuteChanged(); ClearSessionCommand.NotifyCanExecuteChanged();
// Clear global site selection on tenant switch (sites belong to a tenant) // Clear global site selection on tenant switch (sites belong to a tenant)
GlobalSelectedSites.Clear(); 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() private async Task ClearSessionAsync()
{ {
if (SelectedProfile == null) return; if (SelectedProfile == null) return;
@@ -60,7 +60,7 @@ public partial class ProfileManagementViewModel : ObservableObject
public ObservableCollection<TenantProfile> Profiles { get; } = new(); public ObservableCollection<TenantProfile> Profiles { get; } = new();
public IAsyncRelayCommand AddCommand { get; } public IAsyncRelayCommand AddCommand { get; }
public IAsyncRelayCommand RenameCommand { get; } public IAsyncRelayCommand SaveCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; } public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand BrowseClientLogoCommand { get; } public IAsyncRelayCommand BrowseClientLogoCommand { get; }
public IAsyncRelayCommand ClearClientLogoCommand { get; } public IAsyncRelayCommand ClearClientLogoCommand { get; }
@@ -82,7 +82,7 @@ public partial class ProfileManagementViewModel : ObservableObject
_appRegistrationService = appRegistrationService; _appRegistrationService = appRegistrationService;
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd); 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); DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null); BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null); ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
@@ -112,11 +112,24 @@ public partial class ProfileManagementViewModel : ObservableObject
partial void OnSelectedProfileChanged(TenantProfile? value) 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); ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
BrowseClientLogoCommand.NotifyCanExecuteChanged(); BrowseClientLogoCommand.NotifyCanExecuteChanged();
ClearClientLogoCommand.NotifyCanExecuteChanged(); ClearClientLogoCommand.NotifyCanExecuteChanged();
AutoPullClientLogoCommand.NotifyCanExecuteChanged(); AutoPullClientLogoCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged(); SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(HasRegisteredApp)); OnPropertyChanged(nameof(HasRegisteredApp));
RegisterAppCommand.NotifyCanExecuteChanged(); RegisterAppCommand.NotifyCanExecuteChanged();
@@ -135,17 +148,30 @@ public partial class ProfileManagementViewModel : ObservableObject
private void NotifyCommandsCanExecuteChanged() private void NotifyCommandsCanExecuteChanged()
{ {
AddCommand.NotifyCanExecuteChanged(); AddCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged(); SaveCommand.NotifyCanExecuteChanged();
} }
private bool CanAdd() private bool CanAdd()
{ {
if (string.IsNullOrWhiteSpace(NewName)) return false; if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false; if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) 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. // ClientId is optional — leaving it blank lets the user register the app from within the tool.
return true; 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;
}
private async Task AddAsync() private async Task AddAsync()
{ {
if (!CanAdd()) return; if (!CanAdd()) return;
@@ -171,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 try
{ {
await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim()); var newName = NewName.Trim();
SelectedProfile.Name = NewName.Trim(); var newUrl = NewTenantUrl.Trim();
NewName = string.Empty; 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) catch (Exception ex)
{ {
ValidationMessage = ex.Message; ValidationMessage = ex.Message;
_logger.LogError(ex, "Failed to rename profile."); _logger.LogError(ex, "Failed to save profile.");
} }
} }
@@ -296,9 +346,25 @@ public partial class ProfileManagementViewModel : ObservableObject
private bool CanRemoveApp() private bool CanRemoveApp()
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering; => 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) private async Task RegisterAppAsync(CancellationToken ct)
{ {
if (SelectedProfile == null) return; 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; IsRegistering = true;
ShowFallbackInstructions = false; ShowFallbackInstructions = false;
RegistrationStatus = TranslationSource.Instance["profile.register.checking"]; RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
@@ -306,21 +372,17 @@ public partial class ProfileManagementViewModel : ObservableObject
{ {
// Use the profile's own ClientId if it has one; otherwise bootstrap with the // 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 // Microsoft Graph Command Line Tools public client so a first-time profile
// (name + URL only) can still perform the admin check and registration. // (name + URL only) can still perform registration.
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId) var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
? BootstrapClientId ? BootstrapClientId
: SelectedProfile.ClientId; : SelectedProfile.ClientId;
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(authClientId, ct); // No preflight admin check: it used Global Admin as the criterion and
if (!isAdmin) // rejected Application Admins / Cloud Application Admins who can also
{ // create apps. Let Entra enforce authorization via the POST itself —
ShowFallbackInstructions = true; // any 401/403 returns FallbackRequired and triggers the tutorial.
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
return;
}
RegistrationStatus = TranslationSource.Instance["profile.register.registering"]; RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct); var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.TenantUrl, SelectedProfile.Name, ct);
if (result.IsSuccess) if (result.IsSuccess)
{ {
@@ -330,9 +392,18 @@ public partial class ProfileManagementViewModel : ObservableObject
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId)) if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
SelectedProfile.ClientId = result.AppId!; SelectedProfile.ClientId = result.AppId!;
await _profileService.UpdateProfileAsync(SelectedProfile); 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"]; RegistrationStatus = TranslationSource.Instance["profile.register.success"];
OnPropertyChanged(nameof(HasRegisteredApp)); OnPropertyChanged(nameof(HasRegisteredApp));
} }
else if (result.IsFallback)
{
ShowFallbackInstructions = true;
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
}
else else
{ {
RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"]; RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"];
@@ -356,7 +427,7 @@ public partial class ProfileManagementViewModel : ObservableObject
RegistrationStatus = TranslationSource.Instance["profile.remove.removing"]; RegistrationStatus = TranslationSource.Instance["profile.remove.removing"];
try 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); await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl);
SelectedProfile.AppId = null; SelectedProfile.AppId = null;
await _profileService.UpdateProfileAsync(SelectedProfile); await _profileService.UpdateProfileAsync(SelectedProfile);
@@ -105,9 +105,9 @@ public partial class BulkMembersViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress) 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) 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"], var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{_validRows.Count} members will be added"); $"{_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) 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) 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"], var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{_validRows.Count} sites will be created"); $"{_validRows.Count} sites will be created");
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Services.Export; using SharepointToolbox.Services.Export;
@@ -47,6 +48,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
[ObservableProperty] private bool _includeSubsites; [ObservableProperty] private bool _includeSubsites;
[ObservableProperty] private string _library = string.Empty; [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(); private ObservableCollection<DuplicateRow> _results = new();
public ObservableCollection<DuplicateRow> Results public ObservableCollection<DuplicateRow> Results
{ {
@@ -88,14 +97,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
{ {
if (_currentProfile == null) if (_currentProfile == null)
{ {
StatusMessage = "No tenant selected. Please connect to a tenant first."; StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return; return;
} }
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0) if (urls.Count == 0)
{ {
StatusMessage = "Select at least one site from the toolbar."; StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return; return;
} }
@@ -186,7 +195,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo); 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 }); Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
} }
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
@@ -205,7 +214,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return; if (dialog.ShowDialog() != true) return;
try try
{ {
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None); await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
} }
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); } 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) 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) 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)) 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(); var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0) 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 uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"], var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using SharepointToolbox.Core.Messages; using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Services.Export; using SharepointToolbox.Services.Export;
@@ -84,6 +85,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDetailView = true; 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> /// <summary>
/// Simplified wrappers computed from Results. Rebuilt when Results changes. /// Simplified wrappers computed from Results. Rebuilt when Results changes.
/// </summary> /// </summary>
@@ -214,7 +223,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0) if (urls.Count == 0)
{ {
StatusMessage = "Select at least one site from the toolbar."; StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return; return;
} }
@@ -400,9 +409,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
try try
{ {
if (IsSimplifiedMode && SimplifiedResults.Count > 0) if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None); await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None);
else else
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None); await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -489,9 +498,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
} }
if (IsSimplifiedMode && SimplifiedResults.Count > 0) 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, CancellationToken.None, branding, groupMembers);
else else
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers); await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Services.Export; using SharepointToolbox.Services.Export;
@@ -79,14 +80,14 @@ public partial class SearchViewModel : FeatureViewModelBase
{ {
if (_currentProfile == null) if (_currentProfile == null)
{ {
StatusMessage = "No tenant selected. Please connect to a tenant first."; StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return; return;
} }
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0) if (urls.Count == 0)
{ {
StatusMessage = "Select at least one site from the toolbar."; StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return; return;
} }
@@ -9,6 +9,7 @@ using LiveChartsCore.SkiaSharpView.Painting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Services.Export; using SharepointToolbox.Services.Export;
using SkiaSharp; using SkiaSharp;
@@ -40,6 +41,47 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDonutChart = true; private bool _isDonutChart = true;
// ── Scan-time flags (control what is captured during the CSOM scan) ─────
[ObservableProperty] private bool _scanHiddenLibraries = true;
[ObservableProperty] private bool _scanPreservationHold = true;
[ObservableProperty] private bool _scanListAttachments = true;
[ObservableProperty] private bool _scanRecycleBin = true;
// ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
[ObservableProperty] private bool _showLibraries = true;
[ObservableProperty] private bool _showHiddenLibraries = true;
[ObservableProperty] private bool _showPreservationHold = true;
[ObservableProperty] private bool _showListAttachments = true;
[ObservableProperty] private bool _showRecycleBin = true;
[ObservableProperty] private bool _showSubsites = true;
/// <summary>
/// When true, recycle bin stage 1 + stage 2 collapse into a single
/// "[Recycle Bin] Total" row whose size is the sum of both stages.
/// When false, both stages render as separate rows.
/// </summary>
[ObservableProperty] private bool _combineRecycleBinStages = true;
// SPO-reported site total (Site.Usage.Storage). Independent reference
// value the user can compare against the scanned total.
[ObservableProperty] private long _spoReportedTotalSize;
partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();
/// <summary>0 = Single file, 1 = Split by site.</summary>
[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(); private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
public ObservableCollection<FileTypeMetric> FileTypeMetrics public ObservableCollection<FileTypeMetric> FileTypeMetrics
{ {
@@ -82,6 +124,15 @@ public partial class StorageViewModel : FeatureViewModelBase
private set { _barYAxes = value; OnPropertyChanged(); } 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 public bool IsMaxDepth
{ {
get => FolderDepth >= 999; get => FolderDepth >= 999;
@@ -93,6 +144,10 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
} }
// Raw scan output — never filtered. RebuildFilteredResults projects this
// into Results based on the Show* flags.
private List<StorageNode> _allNodes = new();
private ObservableCollection<StorageNode> _results = new(); private ObservableCollection<StorageNode> _results = new();
public ObservableCollection<StorageNode> Results public ObservableCollection<StorageNode> Results
{ {
@@ -108,15 +163,36 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
// ── Summary properties (computed from root-level library nodes) ───────── // ── Summary properties (computed from root-level library nodes) ─────────
//
// Recycle-bin contents are rolled into each library's TotalSizeBytes by the
// StorageService (matches storman.aspx). Including the synthetic root-level
// RecycleBin nodes here would double-count those bytes — filter them out.
// SummaryRecycleBinSize below still reads from _allNodes so the bin metric
// remains visible to the user.
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary> /// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes); public long SummaryTotalSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary> /// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes); public long SummaryVersionSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.VersionSizeBytes);
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary> /// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount); public long SummaryFileCount => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalFileCount);
/// <summary>
/// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
/// from the raw scan so it stays visible even when the user hides the
/// recycle-bin row in the report filter.
/// </summary>
public long SummaryRecycleBinSize => _allNodes
.Where(n => n.Kind == StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
public bool HasResults => Results.Count > 0; public bool HasResults => Results.Count > 0;
@@ -124,6 +200,7 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
OnPropertyChanged(nameof(SummaryTotalSize)); OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize)); OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryRecycleBinSize));
OnPropertyChanged(nameof(SummaryFileCount)); OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults)); OnPropertyChanged(nameof(HasResults));
} }
@@ -158,6 +235,7 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ApplyChartThemeColors();
if (_themeManager is not null) if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries(); _themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
} }
@@ -185,14 +263,14 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
if (_currentProfile == null) if (_currentProfile == null)
{ {
StatusMessage = "No tenant selected. Please connect to a tenant first."; StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return; return;
} }
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0) if (urls.Count == 0)
{ {
StatusMessage = "Select at least one site from the toolbar."; StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return; return;
} }
@@ -201,10 +279,15 @@ public partial class StorageViewModel : FeatureViewModelBase
var options = new StorageScanOptions( var options = new StorageScanOptions(
PerLibrary: PerLibrary, PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites, IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth); FolderDepth: FolderDepth,
IncludeHiddenLibraries: ScanHiddenLibraries,
IncludePreservationHold: ScanPreservationHold,
IncludeListAttachments: ScanListAttachments,
IncludeRecycleBin: ScanRecycleBin);
var allNodes = new List<StorageNode>(); var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>(); var allTypeMetrics = new List<FileTypeMetric>();
long spoReportedTotal = 0;
var autoOwnership = await IsAutoTakeOwnershipEnabled(); var autoOwnership = await IsAutoTakeOwnershipEnabled();
@@ -254,6 +337,8 @@ public partial class StorageViewModel : FeatureViewModelBase
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}...")); progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct); var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics); allTypeMetrics.AddRange(typeMetrics);
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
} }
// Flatten tree for DataGrid display // Flatten tree for DataGrid display
@@ -272,20 +357,113 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
await dispatcher.InvokeAsync(() => await dispatcher.InvokeAsync(() =>
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}); });
} }
else else
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
} }
} }
/// <summary>
/// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
/// Show* flags. Nodes whose root ancestor is excluded by the flags are
/// dropped along with their entire subtree, preserving DFS ordering.
/// </summary>
private void RebuildFilteredResults()
{
if (_allNodes.Count == 0)
{
Results = new ObservableCollection<StorageNode>();
return;
}
var filtered = new List<StorageNode>(_allNodes.Count);
bool includeCurrentSubtree = true;
foreach (var node in _allNodes)
{
if (node.IndentLevel == 0)
includeCurrentSubtree = IsKindShown(node.Kind);
if (includeCurrentSubtree)
filtered.Add(node);
}
if (CombineRecycleBinStages)
filtered = CombineRecycleBins(filtered);
Results = new ObservableCollection<StorageNode>(filtered);
}
/// <summary>
/// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
/// aggregate row inserted at the position of the first recycle-bin node
/// encountered. Preserves SiteTitle grouping when scans cover multiple
/// sites by aggregating per SiteTitle.
/// </summary>
private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
{
var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
var result = new List<StorageNode>(input.Count);
foreach (var node in input)
{
if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
{
string key = node.SiteTitle ?? string.Empty;
if (!byPath.TryGetValue(key, out var agg))
{
agg = new StorageNode
{
Name = "[Recycle Bin] Total",
SiteTitle = node.SiteTitle ?? string.Empty,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
IndentLevel = 0,
Children = new List<StorageNode>()
};
byPath[key] = agg;
result.Add(agg);
}
agg.TotalSizeBytes += node.TotalSizeBytes;
agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
agg.TotalFileCount += node.TotalFileCount;
if (node.LastModified.HasValue &&
(!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
agg.LastModified = node.LastModified;
}
else
{
result.Add(node);
}
}
return result;
}
private bool IsKindShown(StorageNodeKind kind) => kind switch
{
StorageNodeKind.Library => ShowLibraries,
StorageNodeKind.HiddenLibrary => ShowHiddenLibraries,
StorageNodeKind.PreservationHold => ShowPreservationHold,
StorageNodeKind.ListAttachments => ShowListAttachments,
StorageNodeKind.RecycleBin => ShowRecycleBin,
StorageNodeKind.Subsite => ShowSubsites,
_ => true
};
protected override void OnTenantSwitched(TenantProfile profile) protected override void OnTenantSwitched(TenantProfile profile)
{ {
_currentProfile = profile; _currentProfile = profile;
_allNodes = new List<StorageNode>();
SpoReportedTotalSize = 0;
Results = new ObservableCollection<StorageNode>(); Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(); FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile)); OnPropertyChanged(nameof(CurrentProfile));
@@ -331,7 +509,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return; if (dialog.ShowDialog() != true) return;
try try
{ {
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None); await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -362,7 +540,7 @@ public partial class StorageViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo); 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); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -379,12 +557,23 @@ public partial class StorageViewModel : FeatureViewModelBase
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30); 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 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() private void UpdateChartSeries()
{ {
var metrics = FileTypeMetrics.ToList(); var metrics = FileTypeMetrics.ToList();
OnPropertyChanged(nameof(HasChartData)); OnPropertyChanged(nameof(HasChartData));
ApplyChartThemeColors();
if (metrics.Count == 0) if (metrics.Count == 0)
{ {
PieChartSeries = Enumerable.Empty<ISeries>(); PieChartSeries = Enumerable.Empty<ISeries>();
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using Serilog; using Serilog;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence; using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
namespace SharepointToolbox.ViewModels.Tabs; namespace SharepointToolbox.ViewModels.Tabs;
@@ -39,6 +40,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
// Apply options // Apply options
[ObservableProperty] private string _newSiteTitle = string.Empty; [ObservableProperty] private string _newSiteTitle = string.Empty;
[ObservableProperty] private string _newSiteAlias = string.Empty; [ObservableProperty] private string _newSiteAlias = string.Empty;
private bool _aliasManuallyEdited;
public IAsyncRelayCommand CaptureCommand { get; } public IAsyncRelayCommand CaptureCommand { get; }
public IAsyncRelayCommand ApplyCommand { get; } public IAsyncRelayCommand ApplyCommand { get; }
@@ -78,19 +80,20 @@ public partial class TemplatesViewModel : FeatureViewModelBase
private async Task CaptureAsync() private async Task CaptureAsync()
{ {
var T = TranslationSource.Instance;
if (_currentProfile == null) if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected."); throw new InvalidOperationException(T["err.no_tenant"]);
if (string.IsNullOrWhiteSpace(TemplateName)) if (string.IsNullOrWhiteSpace(TemplateName))
throw new InvalidOperationException("Template name is required."); throw new InvalidOperationException(T["err.template_name_required"]);
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u)); var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
if (string.IsNullOrWhiteSpace(captureSiteUrl)) if (string.IsNullOrWhiteSpace(captureSiteUrl))
throw new InvalidOperationException("Select at least one site from the toolbar."); throw new InvalidOperationException(T["err.no_sites_selected"]);
try try
{ {
IsRunning = true; IsRunning = true;
StatusMessage = "Capturing template..."; StatusMessage = T["templates.status.capturing"];
var profile = new TenantProfile var profile = new TenantProfile
{ {
@@ -117,11 +120,11 @@ public partial class TemplatesViewModel : FeatureViewModelBase
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl); Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
await RefreshListAsync(); await RefreshListAsync();
StatusMessage = $"Template captured successfully."; StatusMessage = T["templates.status.success"];
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Capture failed: {ex.Message}"; StatusMessage = string.Format(T["templates.status.capture_failed"], ex.Message);
Log.Error(ex, "Template capture failed"); Log.Error(ex, "Template capture failed");
} }
finally finally
@@ -133,15 +136,23 @@ public partial class TemplatesViewModel : FeatureViewModelBase
private async Task ApplyAsync() private async Task ApplyAsync()
{ {
if (_currentProfile == null || SelectedTemplate == null) return; if (_currentProfile == null || SelectedTemplate == null) return;
var T = TranslationSource.Instance;
if (string.IsNullOrWhiteSpace(NewSiteTitle)) if (string.IsNullOrWhiteSpace(NewSiteTitle))
throw new InvalidOperationException("New site title is required."); throw new InvalidOperationException(T["err.site_title_required"]);
// Auto-fill alias from title if user left it blank
if (string.IsNullOrWhiteSpace(NewSiteAlias)) if (string.IsNullOrWhiteSpace(NewSiteAlias))
throw new InvalidOperationException("New site alias is required."); {
var generated = GenerateAliasFromTitle(NewSiteTitle);
if (string.IsNullOrWhiteSpace(generated))
throw new InvalidOperationException(T["err.site_alias_required"]);
NewSiteAlias = generated;
}
try try
{ {
IsRunning = true; IsRunning = true;
StatusMessage = $"Applying template..."; StatusMessage = T["templates.status.applying"];
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None); var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message); var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
@@ -150,12 +161,12 @@ public partial class TemplatesViewModel : FeatureViewModelBase
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias, ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
progress, CancellationToken.None); progress, CancellationToken.None);
StatusMessage = $"Template applied. Site created at: {siteUrl}"; StatusMessage = string.Format(T["templates.status.applied"], siteUrl);
Log.Information("Template applied. New site: {Url}", siteUrl); Log.Information("Template applied. New site: {Url}", siteUrl);
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Apply failed: {ex.Message}"; StatusMessage = string.Format(T["templates.status.apply_failed"], ex.Message);
Log.Error(ex, "Template apply failed"); Log.Error(ex, "Template apply failed");
} }
finally finally
@@ -204,6 +215,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
TemplateName = string.Empty; TemplateName = string.Empty;
NewSiteTitle = string.Empty; NewSiteTitle = string.Empty;
NewSiteAlias = string.Empty; NewSiteAlias = string.Empty;
_aliasManuallyEdited = false;
StatusMessage = string.Empty; StatusMessage = string.Empty;
_ = RefreshListAsync(); _ = RefreshListAsync();
@@ -215,4 +227,44 @@ public partial class TemplatesViewModel : FeatureViewModelBase
RenameCommand.NotifyCanExecuteChanged(); RenameCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
} }
partial void OnNewSiteTitleChanged(string value)
{
if (_aliasManuallyEdited) return;
var generated = GenerateAliasFromTitle(value);
if (NewSiteAlias != generated)
{
// Bypass user-edit flag while we sync alias to title
_suppressAliasEditedFlag = true;
NewSiteAlias = generated;
_suppressAliasEditedFlag = false;
}
}
private bool _suppressAliasEditedFlag;
partial void OnNewSiteAliasChanged(string value)
{
if (_suppressAliasEditedFlag) return;
_aliasManuallyEdited = !string.IsNullOrWhiteSpace(value)
&& value != GenerateAliasFromTitle(NewSiteTitle);
if (string.IsNullOrWhiteSpace(value)) _aliasManuallyEdited = false;
}
internal static string GenerateAliasFromTitle(string title)
{
if (string.IsNullOrWhiteSpace(title)) return string.Empty;
var normalized = title.Normalize(System.Text.NormalizationForm.FormD);
var sb = new System.Text.StringBuilder(normalized.Length);
foreach (var c in normalized)
{
var cat = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
if (cat == System.Globalization.UnicodeCategory.NonSpacingMark) continue;
if (char.IsLetterOrDigit(c)) sb.Append(c);
else if (c == ' ' || c == '-' || c == '_') sb.Append('-');
}
var collapsed = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), "-+", "-");
return collapsed.Trim('-');
}
} }
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using Serilog; using Serilog;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Services.Export; using SharepointToolbox.Services.Export;
@@ -101,14 +102,15 @@ public partial class TransferViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress) protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{ {
var T = TranslationSource.Instance;
if (_currentProfile == null) if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected."); throw new InvalidOperationException(T["err.no_tenant"]);
if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary)) if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
throw new InvalidOperationException("Source site and library must be selected."); throw new InvalidOperationException(T["err.transfer_source_required"]);
if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary)) if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
throw new InvalidOperationException("Destination site and library must be selected."); throw new InvalidOperationException(T["err.transfer_dest_required"]);
// Confirmation dialog // Confirmation dialog
var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)"; var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Services.Export; using SharepointToolbox.Services.Export;
@@ -107,6 +108,19 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _mergePermissions; private bool _mergePermissions;
/// <summary>0 = Single file, 1 = Split by site, 2 = Split by user.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex switch
{
1 => ReportSplitMode.BySite,
2 => ReportSplitMode.ByUser,
_ => ReportSplitMode.Single
};
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
private CancellationTokenSource? _directoryCts = null; private CancellationTokenSource? _directoryCts = null;
// ── Computed summary properties ───────────────────────────────────────── // ── Computed summary properties ─────────────────────────────────────────
@@ -254,13 +268,13 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
{ {
if (SelectedUsers.Count == 0) if (SelectedUsers.Count == 0)
{ {
StatusMessage = "Add at least one user to audit."; StatusMessage = TranslationSource.Instance["err.no_users_selected"];
return; return;
} }
if (GlobalSites.Count == 0) if (GlobalSites.Count == 0)
{ {
StatusMessage = "Select at least one site from the toolbar."; StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return; return;
} }
@@ -275,7 +289,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
if (_currentProfile == null) if (_currentProfile == null)
{ {
StatusMessage = "No tenant profile selected. Please connect first."; StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
return; return;
} }
@@ -458,7 +472,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
var clientId = _currentProfile?.ClientId; var clientId = _currentProfile?.ClientId;
if (string.IsNullOrEmpty(clientId)) if (string.IsNullOrEmpty(clientId))
{ {
StatusMessage = "No tenant profile selected. Please connect first."; StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
return; return;
} }
@@ -552,7 +566,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return; if (dialog.ShowDialog() != true) return;
try try
{ {
await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions); await _csvExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None, MergePermissions);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -583,7 +597,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo); branding = new ReportBranding(mspLogo, clientLogo);
} }
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding); await _htmlExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, MergePermissions, branding);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -0,0 +1,279 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
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;
namespace SharepointToolbox.ViewModels.Tabs;
public partial class VersionCleanupViewModel : FeatureViewModelBase
{
private readonly IVersionCleanupService _versionService;
private readonly ISessionManager _sessionManager;
private readonly VersionCleanupHtmlExportService _htmlExportService;
private readonly IBrandingService _brandingService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
[ObservableProperty]
private int _keepLast = 5;
[ObservableProperty]
private bool _keepFirstVersion;
[ObservableProperty]
private bool _confirmDelete = true;
[ObservableProperty]
private string _selectedLibrariesLabel = string.Empty;
public ObservableCollection<string> SelectedLibraries { get; } = new();
public ObservableCollection<VersionCleanupResult> Results { get; } = new();
public long TotalBytesFreed => Results.Sum(r => r.BytesFreed);
public int TotalVersionsDeleted => Results.Sum(r => r.VersionsDeleted);
public int TotalFilesAffected => Results.Count(r => r.VersionsDeleted > 0);
public bool HasResults => Results.Count > 0;
public TenantProfile? CurrentProfile => _currentProfile;
/// <summary>Set by the view to invoke <see cref="LibraryPickerDialog"/> against the current site.</summary>
public Func<string, IReadOnlyCollection<string>, Task<IReadOnlyList<string>?>>? PickLibrariesAsync { get; set; }
/// <summary>Set by the view to display a confirm dialog before destructive run.</summary>
public Func<string, bool>? ConfirmAction { get; set; }
public IAsyncRelayCommand SelectLibrariesCommand { get; }
public IRelayCommand ClearLibrariesCommand { get; }
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public VersionCleanupViewModel(
IVersionCleanupService versionService,
ISessionManager sessionManager,
VersionCleanupHtmlExportService htmlExportService,
IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_versionService = versionService;
_sessionManager = sessionManager;
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_logger = logger;
SelectLibrariesCommand = new AsyncRelayCommand(SelectLibrariesAsync, CanPickLibraries);
ClearLibrariesCommand = new RelayCommand(ClearLibraries);
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, () => Results.Count > 0);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, () => Results.Count > 0);
SelectedLibraries.CollectionChanged += (_, _) => UpdateSelectedLibrariesLabel();
Results.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasResults));
OnPropertyChanged(nameof(TotalBytesFreed));
OnPropertyChanged(nameof(TotalVersionsDeleted));
OnPropertyChanged(nameof(TotalFilesAffected));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
};
UpdateSelectedLibrariesLabel();
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
SelectedLibraries.Clear();
Results.Clear();
OnPropertyChanged(nameof(CurrentProfile));
SelectLibrariesCommand.NotifyCanExecuteChanged();
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
// Site changes invalidate library list — clear so user re-picks.
SelectedLibraries.Clear();
SelectLibrariesCommand.NotifyCanExecuteChanged();
}
private bool CanPickLibraries() => _currentProfile != null && GlobalSites.Count > 0;
private async Task SelectLibrariesAsync()
{
if (PickLibrariesAsync == null || _currentProfile == null) return;
var first = GlobalSites.FirstOrDefault();
if (first == null)
{
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
var picked = await PickLibrariesAsync(first.Url, SelectedLibraries.ToArray());
if (picked == null) return;
SelectedLibraries.Clear();
foreach (var t in picked) SelectedLibraries.Add(t);
}
private void ClearLibraries() => SelectedLibraries.Clear();
private void UpdateSelectedLibrariesLabel()
{
SelectedLibrariesLabel = SelectedLibraries.Count == 0
? TranslationSource.Instance["versions.libs.all"]
: string.Format(TranslationSource.Instance["versions.libs.count"], SelectedLibraries.Count);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
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 = TranslationSource.Instance["err.no_sites_selected"];
return;
}
if (KeepLast < 0)
{
StatusMessage = TranslationSource.Instance["versions.err.keepLast"];
return;
}
if (ConfirmDelete && ConfirmAction != null)
{
var msg = string.Format(
TranslationSource.Instance["versions.confirm"],
KeepLast,
KeepFirstVersion ? TranslationSource.Instance["versions.confirm.keepFirst"] : string.Empty);
if (!ConfirmAction(msg)) return;
}
var options = new VersionCleanupOptions(
SelectedLibraries.ToList(), KeepLast, KeepFirstVersion);
Results.Clear();
int siteIdx = 0;
foreach (var url in urls)
{
ct.ThrowIfCancellationRequested();
siteIdx++;
progress.Report(new OperationProgress(siteIdx, urls.Count, $"Cleaning {url}..."));
var siteProfile = new TenantProfile
{
TenantUrl = url.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var siteResults = await _versionService.DeleteOldVersionsAsync(ctx, options, progress, ct);
if (Application.Current?.Dispatcher is { } dispatcher)
{
await dispatcher.InvokeAsync(() =>
{
foreach (var r in siteResults) Results.Add(r);
});
}
else
{
foreach (var r in siteResults) Results.Add(r);
}
}
}
private async Task ExportCsvAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export version cleanup results",
Filter = "CSV files (*.csv)|*.csv",
DefaultExt = "csv",
FileName = "version_cleanup",
};
if (dialog.ShowDialog() != true) return;
try
{
using var w = new System.IO.StreamWriter(dialog.FileName);
await w.WriteLineAsync("Site,Library,File,Versions Before,Versions Deleted,Versions Remaining,Bytes Freed,Error");
foreach (var r in Results)
{
await w.WriteLineAsync(string.Join(",",
Csv(r.SiteUrl),
Csv(r.Library),
Csv(r.FileServerRelativeUrl),
r.VersionsBefore,
r.VersionsDeleted,
r.VersionsRemaining,
r.BytesFreed,
Csv(r.Error ?? string.Empty)));
}
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "Version cleanup CSV export failed.");
}
}
private async Task ExportHtmlAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export version cleanup results to HTML",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
FileName = "version_cleanup",
};
if (dialog.ShowDialog() != true) return;
try
{
var mspLogo = await _brandingService.GetMspLogoAsync();
var clientLogo = _currentProfile?.ClientLogo;
var branding = new ReportBranding(mspLogo, clientLogo);
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "Version cleanup HTML export failed.");
}
}
private static void OpenFile(string filePath)
{
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
catch { }
}
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;
}
partial void OnKeepLastChanged(int value)
{
if (value < 0) KeepLast = 0;
}
}
@@ -96,3 +96,30 @@ public class ListToStringConverter : IValueConverter
=> throw new NotImplementedException(); => throw new NotImplementedException();
} }
/// <summary>
/// Converts a <see cref="SharepointToolbox.Core.Models.StorageNodeKind"/> enum
/// to a localized display string via the translation source.
/// </summary>
[ValueConversion(typeof(SharepointToolbox.Core.Models.StorageNodeKind), typeof(string))]
public class StorageKindConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not SharepointToolbox.Core.Models.StorageNodeKind kind) return string.Empty;
var T = SharepointToolbox.Localization.TranslationSource.Instance;
return kind switch
{
SharepointToolbox.Core.Models.StorageNodeKind.Library => T["stor.kind.library"],
SharepointToolbox.Core.Models.StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
SharepointToolbox.Core.Models.StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
SharepointToolbox.Core.Models.StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
SharepointToolbox.Core.Models.StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
SharepointToolbox.Core.Models.StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
@@ -16,7 +16,8 @@
<!-- Action bar: new folder (destination mode only) --> <!-- Action bar: new folder (destination mode only) -->
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal" <StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
Margin="0,0,0,6" Visibility="Collapsed"> Margin="0,0,0,6" Visibility="Collapsed">
<Button x:Name="NewFolderButton" Content="+ New Folder" <Button x:Name="NewFolderButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" /> Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
</StackPanel> </StackPanel>
@@ -22,6 +22,7 @@ public partial class FolderBrowserDialog : Window
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>(); public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
private readonly List<CheckBox> _fileCheckboxes = new(); private readonly List<CheckBox> _fileCheckboxes = new();
private readonly List<TreeViewItem> _expandedNodes = new();
/// <summary> /// <summary>
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/> /// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
@@ -80,6 +81,7 @@ public partial class FolderBrowserDialog : Window
// Placeholder child so the expand arrow appears. // Placeholder child so the expand arrow appears.
node.Items.Add(new TreeViewItem { Header = "Loading..." }); node.Items.Add(new TreeViewItem { Header = "Loading..." });
node.Expanded += FolderNode_Expanded; node.Expanded += FolderNode_Expanded;
_expandedNodes.Add(node);
return node; return node;
} }
@@ -99,12 +101,9 @@ public partial class FolderBrowserDialog : Window
{ {
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl); var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
_ctx.Load(folder, f => f.StorageMetrics.TotalSize, _ctx.Load(folder, f => f.StorageMetrics.TotalSize,
f => f.StorageMetrics.TotalFileCount, f => f.StorageMetrics.TotalFileCount);
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl, var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
sf => sf.StorageMetrics.TotalSize, _ctx.Load(list, l => l.Title);
sf => sf.StorageMetrics.TotalFileCount),
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
fi => fi.ServerRelativeUrl));
var progress = new Progress<Core.Models.OperationProgress>(); var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
@@ -115,23 +114,56 @@ public partial class FolderBrowserDialog : Window
folder.StorageMetrics.TotalFileCount, folder.StorageMetrics.TotalFileCount,
folder.StorageMetrics.TotalSize); folder.StorageMetrics.TotalSize);
// Child folders first // Enumerate direct children via paginated CAML — Folder.Folders /
foreach (var subFolder in folder.Folders) // Folder.Files lazy loading hits the list-view threshold on libraries
// above 5,000 items even when only a small folder is being expanded.
var subFolders = new List<(string Name, string Url)>();
var filesInFolder = new List<(string Name, long Length, string Url)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
_ctx, list, info.ServerRelativeUrl, recursive: false,
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "File_x0020_Size" },
ct: CancellationToken.None))
{ {
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms") var fsType = item["FSObjType"]?.ToString();
continue; var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
var fileRef = item["FileRef"]?.ToString() ?? string.Empty;
if (fsType == "1")
{
if (name.StartsWith("_") || name == "Forms") continue;
subFolders.Add((name, fileRef));
}
else if (fsType == "0" && _allowFileSelection)
{
long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long size);
filesInFolder.Add((name, size, fileRef));
}
}
// Batch-load StorageMetrics for each subfolder in one round-trip.
var metricFolders = subFolders
.Select(sf => _ctx.Web.GetFolderByServerRelativeUrl(sf.Url))
.ToList();
foreach (var mf in metricFolders)
_ctx.Load(mf, f => f.StorageMetrics.TotalSize,
f => f.StorageMetrics.TotalFileCount);
if (metricFolders.Count > 0)
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
for (int i = 0; i < subFolders.Count; i++)
{
var (name, url) = subFolders[i];
var childRelative = string.IsNullOrEmpty(info.RelativePath) var childRelative = string.IsNullOrEmpty(info.RelativePath)
? subFolder.Name ? name
: $"{info.RelativePath}/{subFolder.Name}"; : $"{info.RelativePath}/{name}";
var childInfo = new FolderNodeInfo( var childInfo = new FolderNodeInfo(info.LibraryTitle, childRelative, url);
info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
var childNode = MakeFolderNode( var childNode = MakeFolderNode(
FormatFolderHeader(subFolder.Name, FormatFolderHeader(name,
subFolder.StorageMetrics.TotalFileCount, metricFolders[i].StorageMetrics.TotalFileCount,
subFolder.StorageMetrics.TotalSize), metricFolders[i].StorageMetrics.TotalSize),
childInfo); childInfo);
node.Items.Add(childNode); node.Items.Add(childNode);
} }
@@ -139,16 +171,15 @@ public partial class FolderBrowserDialog : Window
// Files under this folder — only shown when selection is enabled. // Files under this folder — only shown when selection is enabled.
if (_allowFileSelection) if (_allowFileSelection)
{ {
foreach (var file in folder.Files) foreach (var (fileName, fileSize, _) in filesInFolder)
{ {
// Library-relative path for the file (used by the transfer service)
var fileRel = string.IsNullOrEmpty(info.RelativePath) var fileRel = string.IsNullOrEmpty(info.RelativePath)
? file.Name ? fileName
: $"{info.RelativePath}/{file.Name}"; : $"{info.RelativePath}/{fileName}";
var cb = new CheckBox var cb = new CheckBox
{ {
Content = $"{file.Name} ({FormatSize(file.Length)})", Content = $"{fileName} ({FormatSize(fileSize)})",
Tag = new FileNodeInfo(info.LibraryTitle, fileRel), Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
Margin = new Thickness(4, 2, 0, 2), Margin = new Thickness(4, 2, 0, 2),
}; };
@@ -274,6 +305,21 @@ public partial class FolderBrowserDialog : Window
Close(); Close();
} }
protected override void OnClosed(EventArgs e)
{
Loaded -= OnLoaded;
foreach (var node in _expandedNodes)
node.Expanded -= FolderNode_Expanded;
_expandedNodes.Clear();
foreach (var cb in _fileCheckboxes)
{
cb.Checked -= FileCheckbox_Toggled;
cb.Unchecked -= FileCheckbox_Toggled;
}
_fileCheckboxes.Clear();
base.OnClosed(e);
}
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl); private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
private record FileNodeInfo(string LibraryTitle, string RelativePath); private record FileNodeInfo(string LibraryTitle, string RelativePath);
} }
@@ -1,7 +1,8 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog" <Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Input" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
Width="340" Height="140" Width="340" Height="140"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
@@ -11,7 +12,8 @@
<DockPanel Margin="12"> <DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,10,0,0"> HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Cancel" Width="70" IsCancel="True" Margin="0,0,8,0" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Width="70" IsCancel="True" Margin="0,0,8,0"
Click="Cancel_Click" /> Click="Cancel_Click" />
<Button Content="OK" Width="70" IsDefault="True" <Button Content="OK" Width="70" IsDefault="True"
Click="Ok_Click" /> Click="Ok_Click" />
@@ -0,0 +1,42 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.LibraryPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.title]}"
Width="420" Height="520" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="CanResizeWithGrip">
<DockPanel Margin="10">
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,8"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.loading]}" />
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,6">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.selectAll]}"
Click="SelectAll_Click" Margin="0,0,6,0" Padding="6,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.selectNone]}"
Click="SelectNone_Click" Padding="6,2" />
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.cancel]}"
Width="80" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click" />
<Button x:Name="OkButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.select]}"
Width="80" IsDefault="True" IsEnabled="False" Click="Ok_Click" />
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="LibrariesList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsSelected, Mode=TwoWay}"
Margin="2,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,105 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Services;
namespace SharepointToolbox.Views.Dialogs;
public partial class LibraryPickerDialog : Window
{
private readonly ClientContext _ctx;
private readonly IVersionCleanupService _libraryLister;
private readonly ObservableCollection<LibraryItem> _items = new();
public IReadOnlyList<string> SelectedLibraryTitles { get; private set; } = Array.Empty<string>();
public LibraryPickerDialog(
ClientContext ctx,
IVersionCleanupService libraryLister,
IReadOnlyCollection<string>? preselected = null)
{
InitializeComponent();
_ctx = ctx;
_libraryLister = libraryLister;
LibrariesList.ItemsSource = _items;
Loaded += async (_, _) => await LoadAsync(preselected ?? Array.Empty<string>());
}
private async Task LoadAsync(IReadOnlyCollection<string> preselected)
{
try
{
var titles = await _libraryLister.ListLibraryTitlesAsync(_ctx, CancellationToken.None);
var preset = new HashSet<string>(preselected, StringComparer.OrdinalIgnoreCase);
foreach (var t in titles)
{
var item = new LibraryItem { Title = t, IsSelected = preset.Contains(t) };
item.PropertyChanged += OnItemChanged;
_items.Add(item);
}
StatusText.Text = string.Format(
Localization.TranslationSource.Instance["librarypicker.loaded"],
_items.Count);
UpdateOkEnabled();
}
catch (Exception ex)
{
StatusText.Text = $"Error: {ex.Message}";
}
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LibraryItem.IsSelected)) UpdateOkEnabled();
}
private void UpdateOkEnabled()
=> OkButton.IsEnabled = _items.Any(i => i.IsSelected);
private void SelectAll_Click(object sender, RoutedEventArgs e)
{
foreach (var i in _items) i.IsSelected = true;
}
private void SelectNone_Click(object sender, RoutedEventArgs e)
{
foreach (var i in _items) i.IsSelected = false;
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
SelectedLibraryTitles = _items.Where(i => i.IsSelected).Select(i => i.Title).ToList();
DialogResult = true;
Close();
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
protected override void OnClosed(EventArgs e)
{
foreach (var i in _items) i.PropertyChanged -= OnItemChanged;
base.OnClosed(e);
}
public class LibraryItem : INotifyPropertyChanged
{
private bool _isSelected;
public string Title { get; init; } = string.Empty;
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected == value) return;
_isSelected = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
}
@@ -2,7 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="Manage Profiles" Width="500" Height="750" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profmgmt.title]}"
Width="500" Height="750"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
@@ -19,10 +20,11 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Profile list --> <!-- Profile list -->
<Label Content="Profiles" Grid.Row="0" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profmgmt.group]}" Grid.Row="0" />
<ListBox Grid.Row="1" Margin="0,0,0,8" <ListBox Grid.Row="1" Margin="0,0,0,8"
ItemsSource="{Binding Profiles}" ItemsSource="{Binding Profiles}"
SelectedItem="{Binding SelectedProfile}" SelectedItem="{Binding SelectedProfile}"
@@ -57,8 +59,31 @@
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" /> Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
</Grid> </Grid>
<!-- Profile CRUD buttons (placed under fields for natural flow) -->
<Grid Grid.Row="3" Margin="0,4,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}"
MinWidth="90" Padding="10,4" Margin="0,0,6,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add.tooltip]}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
Command="{Binding SaveCommand}"
MinWidth="90" Padding="10,4"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save.tooltip]}" />
</StackPanel>
<Button Grid.Column="1" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}"
MinWidth="90" Padding="10,4"
Foreground="{DynamicResource DangerBrush}"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete.tooltip]}" />
</Grid>
<!-- Client Logo --> <!-- Client Logo -->
<StackPanel Grid.Row="3" Margin="0,8,0,8"> <StackPanel Grid.Row="4" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4" <Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
HorizontalAlignment="Left" MinWidth="200" MinHeight="50"> HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
@@ -95,7 +120,7 @@
</StackPanel> </StackPanel>
<!-- App Registration --> <!-- App Registration -->
<StackPanel Grid.Row="4" Margin="0,8,0,8"> <StackPanel Grid.Row="5" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}" <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
FontWeight="SemiBold" Padding="0,0,0,4" FontWeight="SemiBold" Padding="0,0,0,4"
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" /> Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
@@ -133,15 +158,10 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Buttons --> <!-- Close button -->
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
Command="{Binding AddCommand}" Width="60" Margin="4,0" /> MinWidth="80" Padding="10,4"
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.rename]}"
Command="{Binding RenameCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
<Button Content="Close" Width="60" Margin="4,0"
Click="CloseButton_Click" IsCancel="True" /> Click="CloseButton_Click" IsCancel="True" />
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -9,6 +9,15 @@ public partial class ProfileManagementDialog : Window
{ {
InitializeComponent(); InitializeComponent();
DataContext = viewModel; DataContext = viewModel;
viewModel.ConfirmRegisterApp = msg =>
{
var result = MessageBox.Show(
this, msg,
Localization.TranslationSource.Instance["profile.register"],
MessageBoxButton.OKCancel,
MessageBoxImage.Information);
return result == MessageBoxResult.OK;
};
Loaded += async (_, _) => await viewModel.LoadAsync(); Loaded += async (_, _) => await viewModel.LoadAsync();
} }
@@ -1,7 +1,9 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.SitePickerDialog" <Window x:Class="SharepointToolbox.Views.Dialogs.SitePickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Select Sites" Width="600" Height="500" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.title]}"
Width="760" Height="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
@@ -9,25 +11,63 @@
Loaded="Window_Loaded"> Loaded="Window_Loaded">
<Grid Margin="12"> <Grid Margin="12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Filter row --> <!-- Text filter row -->
<DockPanel Grid.Row="0" Margin="0,0,0,8"> <DockPanel Grid.Row="0" Margin="0,0,0,6">
<TextBlock Text="Filter:" VerticalAlignment="Center" Margin="0,0,8,0" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.filter]}"
VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBox x:Name="FilterBox" TextChanged="FilterBox_TextChanged" /> <TextBox x:Name="FilterBox" TextChanged="FilterBox_TextChanged" />
</DockPanel> </DockPanel>
<!-- Size + type filter row -->
<DockPanel Grid.Row="1" Margin="0,0,0,8" LastChildFill="False">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type]}"
VerticalAlignment="Center" Margin="0,0,6,0" />
<ComboBox x:Name="TypeFilter" Width="170" SelectedIndex="0"
SelectionChanged="TypeFilter_SelectionChanged" Margin="0,0,12,0">
<ComboBoxItem Tag="All"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.all]}" />
<ComboBoxItem Tag="TeamSite"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.team]}" />
<ComboBoxItem Tag="CommunicationSite"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.communication]}" />
<ComboBoxItem Tag="Classic"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.classic]}" />
<ComboBoxItem Tag="Unknown"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.other]}" />
</ComboBox>
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size]}"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size.min]}"
VerticalAlignment="Center" Margin="0,0,4,0"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBox x:Name="MinSizeBox" Width="80" Margin="0,0,8,0"
TextChanged="SizeBox_TextChanged" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size.max]}"
VerticalAlignment="Center" Margin="0,0,4,0"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBox x:Name="MaxSizeBox" Width="80"
TextChanged="SizeBox_TextChanged" />
</DockPanel>
<!-- Site list with checkboxes --> <!-- Site list with checkboxes -->
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8" <ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8"
SelectionMode="Single" SelectionMode="Single"
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"> BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
<ListView.View> <ListView.View>
<GridView> <GridView>
<GridViewColumn Header="" Width="32"> <GridViewColumn Width="32">
<GridViewColumn.Header>
<GridViewColumnHeader Tag="IsSelected" Content="" />
</GridViewColumn.Header>
<GridViewColumn.CellTemplate> <GridViewColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" <CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
@@ -35,28 +75,56 @@
</DataTemplate> </DataTemplate>
</GridViewColumn.CellTemplate> </GridViewColumn.CellTemplate>
</GridViewColumn> </GridViewColumn>
<GridViewColumn Header="Title" Width="200" DisplayMemberBinding="{Binding Title}" /> <GridViewColumn Width="200" DisplayMemberBinding="{Binding Title}">
<GridViewColumn Header="URL" Width="320" DisplayMemberBinding="{Binding Url}" /> <GridViewColumn.Header>
<GridViewColumnHeader Tag="Title"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.title]}" />
</GridViewColumn.Header>
</GridViewColumn>
<GridViewColumn Width="280" DisplayMemberBinding="{Binding Url}">
<GridViewColumn.Header>
<GridViewColumnHeader Tag="Url"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.url]}" />
</GridViewColumn.Header>
</GridViewColumn>
<GridViewColumn Width="110" DisplayMemberBinding="{Binding KindDisplay}">
<GridViewColumn.Header>
<GridViewColumnHeader Tag="Kind"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.type]}" />
</GridViewColumn.Header>
</GridViewColumn>
<GridViewColumn Width="80" DisplayMemberBinding="{Binding SizeDisplay}">
<GridViewColumn.Header>
<GridViewColumnHeader Tag="StorageUsedMb"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.size]}" />
</GridViewColumn.Header>
</GridViewColumn>
</GridView> </GridView>
</ListView.View> </ListView.View>
</ListView> </ListView>
<!-- Status text --> <!-- Status text -->
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8" <TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" /> Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
<!-- Button row --> <!-- Button row -->
<DockPanel Grid.Row="3"> <DockPanel Grid.Row="4">
<Button x:Name="LoadButton" Content="Load Sites" Width="80" Margin="0,0,8,0" <Button x:Name="LoadButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.load]}"
Width="110" Margin="0,0,8,0"
Click="LoadButton_Click" /> Click="LoadButton_Click" />
<Button Content="Select All" Width="80" Margin="0,0,8,0" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.selectAll]}"
Width="120" Margin="0,0,8,0"
Click="SelectAll_Click" /> Click="SelectAll_Click" />
<Button Content="Deselect All" Width="80" Margin="0,0,8,0" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.deselectAll]}"
Width="140" Margin="0,0,8,0"
Click="DeselectAll_Click" /> Click="DeselectAll_Click" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
<Button Content="OK" Width="70" Margin="4,0" IsDefault="True" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.ok]}"
Width="80" Margin="4,0" IsDefault="True"
Click="OK_Click" /> Click="OK_Click" />
<Button Content="Cancel" Width="70" Margin="4,0" IsCancel="True" /> <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.cancel]}"
Width="80" Margin="4,0" IsCancel="True" />
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</Grid> </Grid>
@@ -1,52 +1,61 @@
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Globalization;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.ViewModels.Dialogs;
namespace SharepointToolbox.Views.Dialogs; namespace SharepointToolbox.Views.Dialogs;
/// <summary> /// <summary>
/// Dialog for selecting multiple SharePoint sites. /// Dialog for selecting multiple SharePoint sites.
/// Loads sites from ISiteListService, shows them in a filterable list with checkboxes. /// Delegates loading and filter/sort logic to <see cref="SitePickerDialogLogic"/>
/// so the code-behind only handles WPF plumbing.
/// </summary> /// </summary>
public partial class SitePickerDialog : Window public partial class SitePickerDialog : Window
{ {
private readonly ISiteListService _siteListService; private readonly SitePickerDialogLogic _logic;
private readonly TenantProfile _profile;
private List<SitePickerItem> _allItems = new(); private List<SitePickerItem> _allItems = new();
private string _sortColumn = "Url";
private ListSortDirection _sortDirection = ListSortDirection.Ascending;
/// <summary> /// <summary>
/// Returns the list of sites the user checked before clicking OK. /// Returns the list of sites the user checked before clicking OK.
/// </summary> /// </summary>
public IReadOnlyList<SiteInfo> SelectedUrls => public IReadOnlyList<SiteInfo> SelectedUrls =>
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)).ToList(); _allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)
{
StorageUsedMb = i.StorageUsedMb,
StorageQuotaMb = i.StorageQuotaMb,
Template = i.Template
}).ToList();
public SitePickerDialog(ISiteListService siteListService, TenantProfile profile) public SitePickerDialog(ISiteListService siteListService, TenantProfile profile)
{ {
InitializeComponent(); InitializeComponent();
_siteListService = siteListService; _logic = new SitePickerDialogLogic(siteListService, profile);
_profile = profile;
} }
private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync(); private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync();
private async Task LoadSitesAsync() private async Task LoadSitesAsync()
{ {
StatusText.Text = "Loading sites..."; StatusText.Text = TranslationSource.Instance["sitepicker.status.loading"];
LoadButton.IsEnabled = false; LoadButton.IsEnabled = false;
try try
{ {
var sites = await _siteListService.GetSitesAsync( var items = await _logic.LoadAsync(
_profile,
new Progress<OperationProgress>(), new Progress<OperationProgress>(),
System.Threading.CancellationToken.None); System.Threading.CancellationToken.None);
_allItems = items.ToList();
_allItems = sites.Select(s => new SitePickerItem(s.Url, s.Title)).ToList();
ApplyFilter(); ApplyFilter();
StatusText.Text = $"{_allItems.Count} sites loaded."; StatusText.Text = string.Format(
CultureInfo.CurrentUICulture,
TranslationSource.Instance["sitepicker.status.loaded"],
_allItems.Count);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
@@ -54,7 +63,10 @@ public partial class SitePickerDialog : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusText.Text = $"Error: {ex.Message}"; StatusText.Text = string.Format(
CultureInfo.CurrentUICulture,
TranslationSource.Instance["sitepicker.status.error"],
ex.Message);
} }
finally finally
{ {
@@ -64,25 +76,66 @@ public partial class SitePickerDialog : Window
private void ApplyFilter() private void ApplyFilter()
{ {
var filter = FilterBox.Text.Trim(); var text = FilterBox.Text.Trim();
SiteList.ItemsSource = string.IsNullOrEmpty(filter) var minMb = SitePickerDialogLogic.ParseLongOrDefault(MinSizeBox.Text, 0);
? (IEnumerable<SitePickerItem>)_allItems var maxMb = SitePickerDialogLogic.ParseLongOrDefault(MaxSizeBox.Text, long.MaxValue);
: _allItems.Where(i => var kindFilter = (TypeFilter.SelectedItem as ComboBoxItem)?.Tag as string ?? "All";
i.Url.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
i.Title.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); var filtered = SitePickerDialogLogic.ApplyFilter(_allItems, text, minMb, maxMb, kindFilter);
var sorted = SitePickerDialogLogic.ApplySort(filtered, _sortColumn, _sortDirection);
var list = sorted.ToList();
SiteList.ItemsSource = list;
if (_allItems.Count > 0)
StatusText.Text = string.Format(
CultureInfo.CurrentUICulture,
TranslationSource.Instance["sitepicker.status.shown"],
list.Count, _allItems.Count);
}
private void SiteList_ColumnHeaderClick(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is not GridViewColumnHeader header) return;
if (header.Role == GridViewColumnHeaderRole.Padding) return;
if (header.Tag is not string column || string.IsNullOrEmpty(column)) return;
if (_sortColumn == column)
{
_sortDirection = _sortDirection == ListSortDirection.Ascending
? ListSortDirection.Descending
: ListSortDirection.Ascending;
}
else
{
_sortColumn = column;
_sortDirection = ListSortDirection.Ascending;
}
ApplyFilter();
} }
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter(); private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
private void SizeBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
private void TypeFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded) return;
ApplyFilter();
}
private void SelectAll_Click(object sender, RoutedEventArgs e) private void SelectAll_Click(object sender, RoutedEventArgs e)
{ {
foreach (var item in _allItems) item.IsSelected = true; if (SiteList.ItemsSource is IEnumerable<SitePickerItem> visible)
{
foreach (var item in visible) item.IsSelected = true;
}
ApplyFilter(); ApplyFilter();
} }
private void DeselectAll_Click(object sender, RoutedEventArgs e) private void DeselectAll_Click(object sender, RoutedEventArgs e)
{ {
foreach (var item in _allItems) item.IsSelected = false; if (SiteList.ItemsSource is IEnumerable<SitePickerItem> visible)
{
foreach (var item in visible) item.IsSelected = false;
}
ApplyFilter(); ApplyFilter();
} }
@@ -105,6 +158,13 @@ public class SitePickerItem : INotifyPropertyChanged
public string Url { get; init; } = string.Empty; public string Url { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
public long StorageUsedMb { get; init; }
public long StorageQuotaMb { get; init; }
public string Template { get; init; } = string.Empty;
public SiteKind Kind => SiteKindHelper.FromTemplate(Template);
public string KindDisplay => SiteKindHelper.DisplayName(Kind);
public string SizeDisplay => FormatSize(StorageUsedMb);
public bool IsSelected public bool IsSelected
{ {
@@ -119,9 +179,19 @@ public class SitePickerItem : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
public SitePickerItem(string url, string title) public SitePickerItem(string url, string title, long storageUsedMb = 0, long storageQuotaMb = 0, string template = "")
{ {
Url = url; Url = url;
Title = title; Title = title;
StorageUsedMb = storageUsedMb;
StorageQuotaMb = storageQuotaMb;
Template = template;
}
private static string FormatSize(long mb)
{
if (mb <= 0) return "—";
if (mb >= 1024) return $"{mb / 1024.0:F1} GB";
return $"{mb} MB";
} }
} }
@@ -48,14 +48,15 @@
</Style> </Style>
</DataGrid.RowStyle> </DataGrid.RowStyle>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.valid]}"
Binding="{Binding IsValid}" Width="50" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}"
Binding="{Binding Record.GroupName}" Width="*" /> Binding="{Binding Record.GroupName}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
Binding="{Binding Record.Email}" Width="*" /> Binding="{Binding Record.Email}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}"
Binding="{Binding Record.Role}" Width="80" /> Binding="{Binding Record.Role}" Width="80" />
<DataGridTextColumn Header="Errors" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.errors]}"
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
Width="*" /> Width="*" />
</DataGrid.Columns> </DataGrid.Columns>
@@ -46,7 +46,8 @@
</Style> </Style>
</DataGrid.RowStyle> </DataGrid.RowStyle>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.valid]}"
Binding="{Binding IsValid}" Width="50" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}"
Binding="{Binding Record.Name}" Width="*" /> Binding="{Binding Record.Name}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}"
@@ -55,7 +56,7 @@
Binding="{Binding Record.Type}" Width="100" /> Binding="{Binding Record.Type}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}"
Binding="{Binding Record.Owners}" Width="*" /> Binding="{Binding Record.Owners}" Width="*" />
<DataGridTextColumn Header="Errors" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.errors]}"
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
Width="*" /> Width="*" />
</DataGrid.Columns> </DataGrid.Columns>
@@ -44,6 +44,17 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" /> Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,4,0,0" />
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="26" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
</ComboBox>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Margin="0,2,0,0" />
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="26" Margin="0,0,0,6">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
</ComboBox>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" /> Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
@@ -60,17 +71,24 @@
ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"
ColumnWidth="Auto"> ColumnWidth="Auto">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[duplicates.col.group]}"
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60" Binding="{Binding GroupName}" Width="160" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[duplicates.col.copies]}"
Binding="{Binding GroupSize}" Width="60"
ElementStyle="{StaticResource RightAlignStyle}" /> ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="160" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.name]}"
<DataGridTextColumn Header="Library" Binding="{Binding Library}" Width="120" /> Binding="{Binding Name}" Width="160" />
<DataGridTextColumn Header="Size" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.library]}"
Binding="{Binding Library}" Width="120" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}"
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}" Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
Width="90" ElementStyle="{StaticResource RightAlignStyle}" /> Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}"
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" /> Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}"
Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}"
Binding="{Binding Path}" Width="400" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
@@ -49,12 +49,17 @@
</Style> </Style>
</DataGrid.RowStyle> </DataGrid.RowStyle>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.valid]}"
<DataGridTextColumn Header="Level 1" Binding="{Binding Record.Level1}" Width="*" /> Binding="{Binding IsValid}" Width="50" />
<DataGridTextColumn Header="Level 2" Binding="{Binding Record.Level2}" Width="*" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level1]}"
<DataGridTextColumn Header="Level 3" Binding="{Binding Record.Level3}" Width="*" /> Binding="{Binding Record.Level1}" Width="*" />
<DataGridTextColumn Header="Level 4" Binding="{Binding Record.Level4}" Width="*" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level2]}"
<DataGridTextColumn Header="Errors" Binding="{Binding Record.Level2}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level3]}"
Binding="{Binding Record.Level3}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level4]}"
Binding="{Binding Record.Level4}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.errors]}"
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
Width="*" /> Width="*" />
</DataGrid.Columns> </DataGrid.Columns>
@@ -68,7 +68,7 @@
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsSimplifiedMode}" Value="False"> <DataTrigger Binding="{Binding IsSimplifiedMode}" Value="False">
<Setter Property="Foreground" Value="Gray" /> <Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@@ -111,6 +111,16 @@
Command="{Binding CancelCommand}" Command="{Binding CancelCommand}"
Margin="0,0,0,4" Padding="6,3" /> Margin="0,0,0,4" Padding="6,3" />
</Grid> </Grid>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" />
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
</ComboBox>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Padding="0,2" />
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="24" Margin="0,0,0,6">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
</ComboBox>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -271,15 +281,22 @@
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="100" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.object_type]}"
<DataGridTextColumn Header="Title" Binding="{Binding Title}" Width="140" /> Binding="{Binding ObjectType}" Width="100" />
<DataGridTextColumn Header="URL" Binding="{Binding Url}" Width="200" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.title]}"
<DataGridTextColumn Header="Unique Perms" Binding="{Binding HasUniquePermissions}" Width="90" /> Binding="{Binding Title}" Width="140" />
<DataGridTextColumn Header="Users" Binding="{Binding Users}" Width="140" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.url]}"
<DataGridTextColumn Header="Permission Levels" Binding="{Binding PermissionLevels}" Width="140" /> Binding="{Binding Url}" Width="200" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.col.unique_perms]}"
Binding="{Binding HasUniquePermissions}" Width="90" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.users_groups]}"
Binding="{Binding Users}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.col.permission_levels]}"
Binding="{Binding PermissionLevels}" Width="140" />
<!-- Simplified Labels column (only visible in simplified mode) --> <!-- Simplified Labels column (only visible in simplified mode) -->
<DataGridTextColumn Header="Simplified" Binding="{Binding SimplifiedLabels}" Width="200"> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.simplified]}"
Binding="{Binding SimplifiedLabels}" Width="200">
<DataGridTextColumn.Visibility> <DataGridTextColumn.Visibility>
<Binding Path="DataContext.IsSimplifiedMode" <Binding Path="DataContext.IsSimplifiedMode"
RelativeSource="{RelativeSource AncestorType=DataGrid}" RelativeSource="{RelativeSource AncestorType=DataGrid}"
@@ -287,8 +304,10 @@
</DataGridTextColumn.Visibility> </DataGridTextColumn.Visibility>
</DataGridTextColumn> </DataGridTextColumn>
<DataGridTextColumn Header="Granted Through" Binding="{Binding GrantedThrough}" Width="140" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.granted_through]}"
<DataGridTextColumn Header="Principal Type" Binding="{Binding PrincipalType}" Width="110" /> Binding="{Binding GrantedThrough}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.col.principal_type]}"
Binding="{Binding PrincipalType}" Width="110" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</Grid> </Grid>
+78 -14
View File
@@ -33,6 +33,43 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<!-- Scan sources group: control what the scan captures -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.sources]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.hidden]}"
IsChecked="{Binding ScanHiddenLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.preservation]}"
IsChecked="{Binding ScanPreservationHold}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.attachments]}"
IsChecked="{Binding ScanListAttachments}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.recyclebin]}"
IsChecked="{Binding ScanRecycleBin}" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Report filter group: control what appears in DataGrid + exports -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.report.filter]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.libraries]}"
IsChecked="{Binding ShowLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.hidden]}"
IsChecked="{Binding ShowHiddenLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.preservation]}"
IsChecked="{Binding ShowPreservationHold}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.attachments]}"
IsChecked="{Binding ShowListAttachments}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.recyclebin]}"
IsChecked="{Binding ShowRecycleBin}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.subsites]}"
IsChecked="{Binding ShowSubsites}" Margin="0,2" />
<Separator Margin="0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.combine.recyclebin]}"
IsChecked="{Binding CombineRecycleBinStages}" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Action buttons --> <!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}" Command="{Binding RunCommand}"
@@ -45,6 +82,16 @@
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}" <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}"
Margin="0,0,0,8"> Margin="0,0,0,8">
<StackPanel Margin="4"> <StackPanel Margin="4">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,0,0,0" Padding="0,2" />
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
</ComboBox>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Padding="0,2" />
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="24" Margin="0,0,0,6">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
</ComboBox>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.csv]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.csv]}"
Command="{Binding ExportCsvCommand}" Command="{Binding ExportCsvCommand}"
Height="26" Margin="0,2" /> Height="26" Margin="0,2" />
@@ -94,18 +141,26 @@
</Style> </Style>
</Border.Style> </Border.Style>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Margin="0,0,24,0"> <StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<Run Text="Total Size: " FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.total_size_colon]}" FontWeight="SemiBold" />
<Run Text="{Binding SummaryTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</TextBlock> </StackPanel>
<TextBlock Margin="0,0,24,0"> <StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<Run Text="Version Size: " FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" />
<Run Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</TextBlock> </StackPanel>
<TextBlock> <StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<Run Text="Files: " FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.recyclebin_colon]}" FontWeight="SemiBold" />
<Run Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryRecycleBinSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</TextBlock> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.spo_reported_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SpoReportedTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -130,6 +185,9 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" /> Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.kind]}"
Binding="{Binding Kind, Converter={StaticResource StorageKindConverter}}"
Width="120" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}" Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" /> Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
@@ -192,7 +250,11 @@
</Grid.Style> </Grid.Style>
<lvc:PieChart x:Name="StoragePieChart" <lvc:PieChart x:Name="StoragePieChart"
Series="{Binding PieChartSeries}" Series="{Binding PieChartSeries}"
LegendPosition="Right" /> LegendPosition="Right"
LegendTextPaint="{Binding LegendTextPaint}"
LegendBackgroundPaint="{Binding LegendBackgroundPaint}"
TooltipTextPaint="{Binding TooltipTextPaint}"
TooltipBackgroundPaint="{Binding TooltipBackgroundPaint}" />
</Grid> </Grid>
<!-- Bar chart wrapper (visible when IsDonutChart=false AND HasChartData=true) --> <!-- Bar chart wrapper (visible when IsDonutChart=false AND HasChartData=true) -->
@@ -214,7 +276,9 @@
<lvc:CartesianChart Series="{Binding BarChartSeries}" <lvc:CartesianChart Series="{Binding BarChartSeries}"
XAxes="{Binding BarXAxes}" XAxes="{Binding BarXAxes}"
YAxes="{Binding BarYAxes}" YAxes="{Binding BarYAxes}"
LegendPosition="Hidden" /> LegendPosition="Hidden"
TooltipTextPaint="{Binding TooltipTextPaint}"
TooltipBackgroundPaint="{Binding TooltipBackgroundPaint}" />
</Grid> </Grid>
</Grid> </Grid>
</Border> </Border>
@@ -22,16 +22,20 @@ public partial class StorageView : UserControl
/// </summary> /// </summary>
internal sealed class SingleSliceTooltip : IChartTooltip internal sealed class SingleSliceTooltip : IChartTooltip
{ {
private readonly System.Windows.Controls.ToolTip _tip = new() private readonly System.Windows.Controls.ToolTip _tip;
public SingleSliceTooltip()
{ {
Padding = new System.Windows.Thickness(8, 4, 8, 4), _tip = new System.Windows.Controls.ToolTip
FontSize = 13, {
Background = new System.Windows.Media.SolidColorBrush( Padding = new System.Windows.Thickness(8, 4, 8, 4),
System.Windows.Media.Color.FromRgb(255, 255, 255)), FontSize = 13,
BorderBrush = new System.Windows.Media.SolidColorBrush( BorderThickness = new System.Windows.Thickness(1),
System.Windows.Media.Color.FromRgb(200, 200, 200)), };
BorderThickness = new System.Windows.Thickness(1), _tip.SetResourceReference(System.Windows.Controls.Control.BackgroundProperty, "SurfaceBrush");
}; _tip.SetResourceReference(System.Windows.Controls.Control.ForegroundProperty, "TextBrush");
_tip.SetResourceReference(System.Windows.Controls.Control.BorderBrushProperty, "BorderSoftBrush");
}
public void Show(IEnumerable<ChartPoint> foundPoints, Chart chart) public void Show(IEnumerable<ChartPoint> foundPoints, Chart chart)
{ {
@@ -71,10 +71,14 @@
AutoGenerateColumns="False" IsReadOnly="True" AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Single" CanUserSortColumns="True"> SelectionMode="Single" CanUserSortColumns="True">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.name]}"
<DataGridTextColumn Header="Type" Binding="{Binding SiteType}" Width="100" /> Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="Source" Binding="{Binding SourceUrl}" Width="*" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.type]}"
<DataGridTextColumn Header="Captured" Binding="{Binding CapturedAt, StringFormat=yyyy-MM-dd HH:mm}" Width="140" /> Binding="{Binding SiteType}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.col.source]}"
Binding="{Binding SourceUrl}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.col.captured]}"
Binding="{Binding CapturedAt, StringFormat=yyyy-MM-dd HH:mm}" Width="140" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
+10 -8
View File
@@ -18,18 +18,20 @@
Click="BrowseSource_Click" Margin="0,0,0,5" /> Click="BrowseSource_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" /> <TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" /> <TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"> <StackPanel Orientation="Horizontal">
<Run Text="{Binding SelectedFileCount, Mode=OneWay}" /> <TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
<Run Text=" file(s) selected" /> Text="{Binding SelectedFileCount, Mode=OneWay}" />
</TextBlock> <TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
<CheckBox Content="Include source folder at destination" Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.text.files_selected]}" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source]}"
IsChecked="{Binding IncludeSourceFolder}" IsChecked="{Binding IncludeSourceFolder}"
Margin="0,6,0,0" Margin="0,6,0,0"
ToolTip="When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder." /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" />
<CheckBox Content="Copy folder contents" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}"
IsChecked="{Binding CopyFolderContents}" IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0" Margin="0,4,0,0"
ToolTip="When on (default), transfer files inside the folder. When off, only the folder is created at the destination." /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -44,7 +44,7 @@
</GroupBox.Style> </GroupBox.Style>
<StackPanel> <StackPanel>
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" /> <TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2"> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" /> <Setter Property="Visibility" Value="Collapsed" />
@@ -147,15 +147,15 @@
HeadersVisibility="Column" GridLinesVisibility="Horizontal" HeadersVisibility="Column" GridLinesVisibility="Horizontal"
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"> BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Name" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.name]}"
Binding="{Binding DisplayName}" Width="120" /> Binding="{Binding DisplayName}" Width="120" />
<DataGridTextColumn Header="Email" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
Binding="{Binding UserPrincipalName}" Width="140" /> Binding="{Binding UserPrincipalName}" Width="140" />
<DataGridTextColumn Header="Department" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.department]}"
Binding="{Binding Department}" Width="90" /> Binding="{Binding Department}" Width="90" />
<DataGridTextColumn Header="Job Title" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.jobtitle]}"
Binding="{Binding JobTitle}" Width="90" /> Binding="{Binding JobTitle}" Width="90" />
<DataGridTemplateColumn Header="Type" Width="60"> <DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.type]}" Width="60">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding UserType}" VerticalAlignment="Center"> <TextBlock Text="{Binding UserType}" VerticalAlignment="Center">
@@ -236,6 +236,17 @@
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Margin="0,0,0,0" Padding="6,3" /> Command="{Binding CancelCommand}" Margin="0,0,0,0" Padding="6,3" />
</Grid> </Grid>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" />
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.byUser]}" />
</ComboBox>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Padding="0,2" />
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="24" Margin="0,0,0,6">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
</ComboBox>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -316,12 +327,15 @@
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding AccessType}" Value="Direct"> <DataTrigger Binding="{Binding AccessType}" Value="Direct">
<Setter Property="Background" Value="#EBF5FB" /> <Setter Property="Background" Value="#EBF5FB" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding AccessType}" Value="Group"> <DataTrigger Binding="{Binding AccessType}" Value="Group">
<Setter Property="Background" Value="#EAFAF1" /> <Setter Property="Background" Value="#EAFAF1" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding AccessType}" Value="Inherited"> <DataTrigger Binding="{Binding AccessType}" Value="Inherited">
<Setter Property="Background" Value="#F4F6F6" /> <Setter Property="Background" Value="#F4F6F6" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding IsHighPrivilege}" Value="True"> <DataTrigger Binding="{Binding IsHighPrivilege}" Value="True">
<Setter Property="FontWeight" Value="Bold" /> <Setter Property="FontWeight" Value="Bold" />
@@ -342,7 +356,7 @@
</GroupStyle> </GroupStyle>
</DataGrid.GroupStyle> </DataGrid.GroupStyle>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTemplateColumn Header="User" Width="180"> <DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.user]}" Width="180">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
@@ -359,16 +373,20 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Border.Style> </Border.Style>
<TextBlock Text="Guest" FontSize="10" Foreground="White" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.guest]}"
FontSize="10" Foreground="White" FontWeight="SemiBold" />
</Border> </Border>
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="Site" Binding="{Binding SiteTitle}" Width="120" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.site]}"
<DataGridTextColumn Header="Object" Binding="{Binding ObjectTitle}" Width="140" /> Binding="{Binding SiteTitle}" Width="120" />
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="90" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.object]}"
<DataGridTemplateColumn Header="Permission Level" Width="140"> Binding="{Binding ObjectTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.object_type]}"
Binding="{Binding ObjectType}" Width="90" />
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.permission_level]}" Width="140">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
@@ -390,7 +408,7 @@
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTemplateColumn Header="Access Type" Width="110"> <DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.access_type]}" Width="110">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock> <TextBlock>
@@ -414,7 +432,8 @@
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="Granted Through" Binding="{Binding GrantedThrough}" Width="140" /> <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.granted_through]}"
Binding="{Binding GrantedThrough}" Width="140" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
@@ -0,0 +1,120 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<DockPanel LastChildFill="True">
<ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.libs]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<TextBlock Text="{Binding SelectedLibrariesLabel}" Margin="0,0,0,6"
Foreground="{DynamicResource TextMutedBrush}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.pickLibs]}"
Command="{Binding SelectLibrariesCommand}" Height="26" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.clearLibs]}"
Command="{Binding ClearLibrariesCommand}" Height="26" />
</StackPanel>
</GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,2">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}"
VerticalAlignment="Center" Padding="0,0,4,0" />
<TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}"
Width="50" Height="22" VerticalAlignment="Center" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}"
IsChecked="{Binding KeepFirstVersion}" Margin="0,4,0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}"
IsChecked="{Binding ConfirmDelete}" Margin="0,4,0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
Margin="0,6,0,0" />
</StackPanel>
</GroupBox>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.run]}"
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource TextMutedBrush}" Margin="0,6" />
</StackPanel>
</ScrollViewer>
<Grid Margin="4,8,8,8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{DynamicResource AccentSoftBrush}" CornerRadius="4"
Padding="12,8" Margin="0,0,0,6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasResults}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.files]}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding TotalFilesAffected, StringFormat=N0, Mode=OneWay}" Margin="4,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.deleted]}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding TotalVersionsDeleted, StringFormat=N0, Mode=OneWay}" Margin="4,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.freed]}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding TotalBytesFreed, Converter={StaticResource BytesConverter}, Mode=OneWay}"
Margin="4,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
<DataGrid Grid.Row="1" ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
<DataGrid.Columns>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.library]}"
Binding="{Binding Library}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.file]}"
Binding="{Binding FileName}" Width="200" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.before]}"
Binding="{Binding VersionsBefore, StringFormat=N0}" Width="80"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.deleted]}"
Binding="{Binding VersionsDeleted, StringFormat=N0}" Width="80"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.remaining]}"
Binding="{Binding VersionsRemaining, StringFormat=N0}" Width="90"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.freed]}"
Binding="{Binding BytesFreed, Converter={StaticResource BytesConverter}}" Width="100"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.path]}"
Binding="{Binding FileServerRelativeUrl}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.error]}"
Binding="{Binding Error}" Width="160" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</DockPanel>
</UserControl>
@@ -0,0 +1,54 @@
using System.Windows;
using System.Windows.Controls;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Views.Dialogs;
namespace SharepointToolbox.Views.Tabs;
public partial class VersionCleanupView : UserControl
{
private readonly ViewModels.Tabs.VersionCleanupViewModel _viewModel;
private readonly ISessionManager _sessionManager;
private readonly IVersionCleanupService _versionService;
public VersionCleanupView(
ViewModels.Tabs.VersionCleanupViewModel viewModel,
ISessionManager sessionManager,
IVersionCleanupService versionService)
{
InitializeComponent();
_viewModel = viewModel;
_sessionManager = sessionManager;
_versionService = versionService;
DataContext = viewModel;
viewModel.PickLibrariesAsync = async (siteUrl, preselected) =>
{
if (viewModel.CurrentProfile == null) return null;
var profile = new TenantProfile
{
TenantUrl = siteUrl,
ClientId = viewModel.CurrentProfile.ClientId,
Name = viewModel.CurrentProfile.Name,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var dlg = new LibraryPickerDialog(ctx, _versionService, preselected)
{
Owner = Window.GetWindow(this)
};
if (dlg.ShowDialog() != true) return null;
return dlg.SelectedLibraryTitles;
};
viewModel.ConfirmAction = msg =>
{
var result = MessageBox.Show(
Window.GetWindow(this), msg,
Localization.TranslationSource.Instance["versions.tab"],
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
return result == MessageBoxResult.OK;
};
}
}