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>
This commit is contained in:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit 12dd1de9f2
93 changed files with 8708 additions and 1159 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
@@ -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);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System.ComponentModel;
using SharepointToolbox.ViewModels.Dialogs;
using SharepointToolbox.Views.Dialogs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels.Dialogs;
public class SitePickerDialogLogicTests
{
private static SitePickerItem Item(string url, string title, long sizeMb = 0, string template = "")
=> new(url, title, sizeMb, 0, template);
[Fact]
public void ApplyFilter_TextFilter_MatchesUrlOrTitle_CaseInsensitive()
{
var items = new[]
{
Item("https://t/sites/hr", "HR Team"),
Item("https://t/sites/finance", "Finance"),
};
var result = SitePickerDialogLogic.ApplyFilter(items, "fINaNce", 0, long.MaxValue, "All").ToList();
Assert.Single(result);
Assert.Equal("Finance", result[0].Title);
}
[Fact]
public void ApplyFilter_SizeRange_FiltersInclusively()
{
var items = new[]
{
Item("a", "A", sizeMb: 100),
Item("b", "B", sizeMb: 500),
Item("c", "C", sizeMb: 1200),
};
var result = SitePickerDialogLogic.ApplyFilter(items, "", 100, 600, "All").ToList();
Assert.Equal(2, result.Count);
Assert.Contains(result, i => i.Title == "A");
Assert.Contains(result, i => i.Title == "B");
}
[Fact]
public void ApplyFilter_KindAll_SkipsKindCheck()
{
var items = new[] { Item("a", "A", template: "STS#3"), Item("b", "B", template: "GROUP#0") };
var result = SitePickerDialogLogic.ApplyFilter(items, "", 0, long.MaxValue, "All").ToList();
Assert.Equal(2, result.Count);
}
[Fact]
public void ApplySort_UnknownColumn_ReturnsInputUnchanged()
{
var items = new[] { Item("b", "B"), Item("a", "A") };
var result = SitePickerDialogLogic.ApplySort(items, "Nonexistent", ListSortDirection.Ascending).ToList();
Assert.Equal("B", result[0].Title);
Assert.Equal("A", result[1].Title);
}
[Fact]
public void ApplySort_Title_Ascending_And_Descending()
{
var items = new[] { Item("b", "B"), Item("a", "A"), Item("c", "C") };
var asc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Ascending).ToList();
var desc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Descending).ToList();
Assert.Equal(new[] { "A", "B", "C" }, asc.Select(i => i.Title));
Assert.Equal(new[] { "C", "B", "A" }, desc.Select(i => i.Title));
}
[Theory]
[InlineData("", 42L, 42L)]
[InlineData(" ", 42L, 42L)]
[InlineData("not-a-number", 42L, 42L)]
[InlineData("100", 42L, 100L)]
[InlineData(" 100 ", 42L, 100L)]
public void ParseLongOrDefault_HandlesEmptyAndInvalid(string input, long fallback, long expected)
{
Assert.Equal(expected, SitePickerDialogLogic.ParseLongOrDefault(input, fallback));
}
}
@@ -44,19 +44,25 @@ public class FeatureViewModelBaseTests
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress() public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
{ {
var vm = new TestViewModel(); var vm = new TestViewModel();
int midProgress = -1;
string? midStatus = null;
vm.OperationFunc = async (ct, progress) => vm.OperationFunc = async (ct, progress) =>
{ {
progress.Report(new OperationProgress(50, 100, "halfway")); progress.Report(new OperationProgress(50, 100, "halfway"));
await Task.Yield(); // Let the Progress<T> callback dispatch before sampling.
await Task.Delay(20, ct);
midProgress = vm.ProgressValue;
midStatus = vm.StatusMessage;
}; };
await vm.RunCommand.ExecuteAsync(null); await vm.RunCommand.ExecuteAsync(null);
// Allow dispatcher to process // Mid-operation snapshot confirms IProgress reaches bound properties.
await Task.Delay(20); // Post-completion, FeatureViewModelBase snaps to 100% / "Complete"
// so stale "Scanning X" labels don't linger after a successful run.
Assert.Equal(50, vm.ProgressValue); Assert.Equal(50, midProgress);
Assert.Equal("halfway", vm.StatusMessage); Assert.Equal("halfway", midStatus);
} }
[Fact] [Fact]
@@ -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>()))
@@ -32,7 +32,7 @@ public class SettingsViewModelLogoTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile)); var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object; var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance; var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger); return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
} }
[Fact] [Fact]
@@ -30,7 +30,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile)); var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object; var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance; var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger); return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
} }
[Fact] [Fact]
@@ -50,7 +50,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile)); var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object; var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance; var logger = NullLogger<FeatureViewModelBase>.Instance;
var vm = new SettingsViewModel(settingsService, mockBranding, logger); var vm = new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
await vm.LoadAsync(); await vm.LoadAsync();
vm.AutoTakeOwnership = true; vm.AutoTakeOwnership = true;
+18 -11
View File
@@ -4,16 +4,23 @@
xmlns:local="clr-namespace:SharepointToolbox" xmlns:local="clr-namespace:SharepointToolbox"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"> xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
<Application.Resources> <Application.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" /> <ResourceDictionary>
<conv:IndentConverter x:Key="IndentConverter" /> <ResourceDictionary.MergedDictionaries>
<conv:BytesConverter x:Key="BytesConverter" /> <ResourceDictionary Source="/Themes/LightPalette.xaml" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" /> <ResourceDictionary Source="/Themes/ModernTheme.xaml" />
<conv:EnumBoolConverter x:Key="EnumBoolConverter" /> </ResourceDictionary.MergedDictionaries>
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" /> <BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" /> <conv:IndentConverter x:Key="IndentConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" /> <conv:BytesConverter x:Key="BytesConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<Setter Property="HorizontalAlignment" Value="Right" /> <conv:EnumBoolConverter x:Key="EnumBoolConverter" />
</Style> <conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
</ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>
+14
View File
@@ -50,6 +50,18 @@ public partial class App : Application
App app = new(); App app = new();
app.InitializeComponent(); app.InitializeComponent();
// Apply persisted theme (System/Light/Dark) before MainWindow constructs so brushes resolve correctly.
try
{
var theme = host.Services.GetRequiredService<ThemeManager>();
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
theme.ApplyFromString(settings.Theme);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted theme at startup");
}
var mainWindow = host.Services.GetRequiredService<MainWindow>(); var mainWindow = host.Services.GetRequiredService<MainWindow>();
// Wire LogPanelSink now that we have the RichTextBox // Wire LogPanelSink now that we have the RichTextBox
@@ -101,6 +113,7 @@ public partial class App : Application
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>()); services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
services.AddSingleton<ProfileService>(); services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>(); services.AddSingleton<SettingsService>();
services.AddSingleton<ThemeManager>();
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
// Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory // Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory
services.AddTransient<ProfileManagementViewModel>(); services.AddTransient<ProfileManagementViewModel>();
@@ -125,6 +138,7 @@ public partial class App : Application
// Phase 3: Duplicates // Phase 3: Duplicates
services.AddTransient<IDuplicatesService, DuplicatesService>(); services.AddTransient<IDuplicatesService, DuplicatesService>();
services.AddTransient<DuplicatesHtmlExportService>(); services.AddTransient<DuplicatesHtmlExportService>();
services.AddTransient<DuplicatesCsvExportService>();
services.AddTransient<DuplicatesViewModel>(); services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>(); services.AddTransient<DuplicatesView>();
@@ -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);
} }
} }
@@ -5,4 +5,5 @@ public class AppSettings
public string DataFolder { get; set; } = string.Empty; public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en"; public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false; public bool AutoTakeOwnership { get; set; } = false;
public string Theme { get; set; } = "System"; // System | Light | Dark
} }
@@ -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;
} }
@@ -1,7 +1,7 @@
namespace SharepointToolbox.Core.Models; namespace SharepointToolbox.Core.Models;
public record OperationProgress(int Current, int Total, string Message) public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false)
{ {
public static OperationProgress Indeterminate(string message) => public static OperationProgress Indeterminate(string message) =>
new(0, 0, message); new(0, 0, message, IsIndeterminate: true);
} }
+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];
}
}
@@ -10,4 +10,24 @@ public class TransferJob
public string DestinationFolderPath { get; set; } = string.Empty; public string DestinationFolderPath { get; set; } = string.Empty;
public TransferMode Mode { get; set; } = TransferMode.Copy; public TransferMode Mode { get; set; } = TransferMode.Copy;
public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip; public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
/// <summary>
/// Optional library-relative file paths. When non-empty, only these files
/// are transferred; SourceFolderPath recursive enumeration is skipped.
/// </summary>
public IReadOnlyList<string> SelectedFilePaths { get; set; } = Array.Empty<string>();
/// <summary>
/// When true, recreate the source folder name under the destination folder
/// (dest/srcFolderName/... ). When false, the source folder's contents land
/// directly inside the destination folder.
/// </summary>
public bool IncludeSourceFolder { get; set; }
/// <summary>
/// When true (default), transfer the files inside the source folder.
/// When false, only create the folder structure (useful together with
/// <see cref="IncludeSourceFolder"/> to clone an empty scaffold).
/// </summary>
public bool CopyFolderContents { get; set; } = true;
} }
@@ -15,17 +15,48 @@ 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);
var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
var graphScopes = new[] { "https://graph.microsoft.com/.default" }; // When a tenant is specified we must NOT reuse cached accounts from /common
// (or a different tenant) — they route tokens to the wrong authority.
IAccount? account = null;
if (tenantId is null)
{
var accounts = await pca.GetAccountsAsync();
account = accounts.FirstOrDefault();
}
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes); var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
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);
} }
@@ -39,12 +70,14 @@ internal class MsalTokenProvider : IAccessTokenProvider
private readonly IPublicClientApplication _pca; private readonly IPublicClientApplication _pca;
private readonly IAccount? _account; private readonly 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();
@@ -56,15 +89,16 @@ internal class MsalTokenProvider : IAccessTokenProvider
{ {
try try
{ {
var result = await _pca.AcquireTokenSilent(_scopes, _account) var silent = _pca.AcquireTokenSilent(_scopes, _account);
.ExecuteAsync(cancellationToken); if (_tenantId is not null) silent = silent.WithTenantId(_tenantId);
var result = await silent.ExecuteAsync(cancellationToken);
return result.AccessToken; return result.AccessToken;
} }
catch (MsalUiRequiredException) catch (MsalUiRequiredException)
{ {
// If silent fails, try interactive var interactive = _pca.AcquireTokenInteractive(_scopes);
var result = await _pca.AcquireTokenInteractive(_scopes) if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
.ExecuteAsync(cancellationToken); var result = await interactive.ExecuteAsync(cancellationToken);
return result.AccessToken; return result.AccessToken;
} }
} }
+215 -2
View File
@@ -109,6 +109,18 @@
<data name="settings.lang.fr" xml:space="preserve"> <data name="settings.lang.fr" xml:space="preserve">
<value>Français</value> <value>Français</value>
</data> </data>
<data name="settings.theme" xml:space="preserve">
<value>Thème</value>
</data>
<data name="settings.theme.system" xml:space="preserve">
<value>Utiliser le paramètre système</value>
</data>
<data name="settings.theme.light" xml:space="preserve">
<value>Clair</value>
</data>
<data name="settings.theme.dark" xml:space="preserve">
<value>Sombre</value>
</data>
<data name="settings.folder" xml:space="preserve"> <data name="settings.folder" xml:space="preserve">
<value>Dossier de sortie des données</value> <value>Dossier de sortie des données</value>
</data> </data>
@@ -130,8 +142,8 @@
<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>
@@ -139,6 +151,9 @@
<data name="status.ready" xml:space="preserve"> <data name="status.ready" xml:space="preserve">
<value>Prêt</value> <value>Prêt</value>
</data> </data>
<data name="status.complete" xml:space="preserve">
<value>Termin&#233;</value>
</data>
<data name="status.cancelled" xml:space="preserve"> <data name="status.cancelled" xml:space="preserve">
<value>Opération annulée</value> <value>Opération annulée</value>
</data> </data>
@@ -357,6 +372,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>
@@ -437,4 +473,181 @@
<data name="settings.ownership.auto" xml:space="preserve"><value>Prendre automatiquement la propri&#233;t&#233; d'administrateur de collection de sites en cas de refus d'acc&#232;s</value></data> <data name="settings.ownership.auto" xml:space="preserve"><value>Prendre automatiquement la propri&#233;t&#233; d'administrateur de collection de sites en cas de refus d'acc&#232;s</value></data>
<data name="settings.ownership.description" xml:space="preserve"><value>Lorsqu'activ&#233;, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'acc&#232;s. N&#233;cessite les permissions d'administrateur de tenant.</value></data> <data name="settings.ownership.description" xml:space="preserve"><value>Lorsqu'activ&#233;, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'acc&#232;s. N&#233;cessite les permissions d'administrateur de tenant.</value></data>
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>Ce site a &#233;t&#233; &#233;lev&#233; automatiquement — la propri&#233;t&#233; a &#233;t&#233; prise pour compl&#233;ter le scan</value></data> <data name="permissions.elevated.tooltip" xml:space="preserve"><value>Ce site a &#233;t&#233; &#233;lev&#233; automatiquement — la propri&#233;t&#233; a &#233;t&#233; prise pour compl&#233;ter le scan</value></data>
<!-- Report export localization -->
<data name="report.title.user_access" xml:space="preserve"><value>Rapport d'audit des acc&#232;s utilisateurs</value></data>
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>Rapport d'audit des acc&#232;s utilisateurs (consolid&#233;)</value></data>
<data name="report.title.permissions" xml:space="preserve"><value>Rapport des permissions SharePoint</value></data>
<data name="report.title.permissions_simplified" xml:space="preserve"><value>Rapport des permissions SharePoint (simplifi&#233;)</value></data>
<data name="report.title.storage" xml:space="preserve"><value>M&#233;triques de stockage SharePoint</value></data>
<data name="report.title.duplicates" xml:space="preserve"><value>Rapport de d&#233;tection de doublons SharePoint</value></data>
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de d&#233;tection de doublons</value></data>
<data name="report.title.search" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers SharePoint</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers</value></data>
<data name="report.stat.total_accesses" xml:space="preserve"><value>Acc&#232;s totaux</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audit&#233;s</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analys&#233;s</value></data>
<data name="report.stat.high_privilege" xml:space="preserve"><value>Privil&#232;ge &#233;lev&#233;</value></data>
<data name="report.stat.external_users" xml:space="preserve"><value>Utilisateurs externes</value></data>
<data name="report.stat.total_entries" xml:space="preserve"><value>Entr&#233;es totales</value></data>
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Ensembles de permissions uniques</value></data>
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Utilisateurs/Groupes distincts</value></data>
<data name="report.stat.libraries" xml:space="preserve"><value>Biblioth&#232;ques</value></data>
<data name="report.stat.files" xml:space="preserve"><value>Fichiers</value></data>
<data name="report.stat.total_size" xml:space="preserve"><value>Taille totale</value></data>
<data name="report.stat.version_size" xml:space="preserve"><value>Taille des versions</value></data>
<data name="report.badge.guest" xml:space="preserve"><value>Invit&#233;</value></data>
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
<data name="report.badge.group" xml:space="preserve"><value>Groupe</value></data>
<data name="report.badge.inherited" xml:space="preserve"><value>H&#233;rit&#233;</value></data>
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
<data name="report.view.by_user" xml:space="preserve"><value>Par utilisateur</value></data>
<data name="report.view.by_site" xml:space="preserve"><value>Par site</value></data>
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filtrer les r&#233;sultats...</value></data>
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filtrer les permissions...</value></data>
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filtrer les lignes…</value></data>
<data name="report.filter.label" xml:space="preserve"><value>Filtre :</value></data>
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
<data name="report.col.object_type" xml:space="preserve"><value>Type d'objet</value></data>
<data name="report.col.object" xml:space="preserve"><value>Objet</value></data>
<data name="report.col.permission_level" xml:space="preserve"><value>Niveau de permission</value></data>
<data name="report.col.access_type" xml:space="preserve"><value>Type d'acc&#232;s</value></data>
<data name="report.col.granted_through" xml:space="preserve"><value>Accord&#233; via</value></data>
<data name="report.col.user" xml:space="preserve"><value>Utilisateur</value></data>
<data name="report.col.title" xml:space="preserve"><value>Titre</value></data>
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
<data name="report.col.users_groups" xml:space="preserve"><value>Utilisateurs/Groupes</value></data>
<data name="report.col.simplified" xml:space="preserve"><value>Simplifi&#233;</value></data>
<data name="report.col.risk" xml:space="preserve"><value>Risque</value></data>
<data name="report.col.library_folder" xml:space="preserve"><value>Biblioth&#232;que / Dossier</value></data>
<data name="report.col.last_modified" xml:space="preserve"><value>Derni&#232;re modification</value></data>
<data name="report.col.name" xml:space="preserve"><value>Nom</value></data>
<data name="report.col.library" xml:space="preserve"><value>Biblioth&#232;que</value></data>
<data name="report.col.path" xml:space="preserve"><value>Chemin</value></data>
<data name="report.col.size" xml:space="preserve"><value>Taille</value></data>
<data name="report.col.created" xml:space="preserve"><value>Cr&#233;&#233;</value></data>
<data name="report.col.modified" xml:space="preserve"><value>Modifi&#233;</value></data>
<data name="report.col.created_by" xml:space="preserve"><value>Cr&#233;&#233; par</value></data>
<data name="report.col.modified_by" xml:space="preserve"><value>Modifi&#233; par</value></data>
<data name="report.col.file_name" xml:space="preserve"><value>Nom de fichier</value></data>
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
<data name="report.col.file_type" xml:space="preserve"><value>Type de fichier</value></data>
<data name="report.col.file_count" xml:space="preserve"><value>Nombre de fichiers</value></data>
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
<data name="report.col.size_bytes" xml:space="preserve"><value>Taille (octets)</value></data>
<data name="report.text.accesses" xml:space="preserve"><value>acc&#232;s</value></data>
<data name="report.text.access_es" xml:space="preserve"><value>acc&#232;s</value></data>
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>groupe(s) de doublons trouv&#233;(s).</value></data>
<data name="report.text.results_parens" xml:space="preserve"><value>r&#233;sultat(s)</value></data>
<data name="report.text.of" xml:space="preserve"><value>sur</value></data>
<data name="report.text.shown" xml:space="preserve"><value>affich&#233;(s)</value></data>
<data name="report.text.generated" xml:space="preserve"><value>G&#233;n&#233;r&#233;</value></data>
<data name="report.text.generated_colon" xml:space="preserve"><value>G&#233;n&#233;r&#233; :</value></data>
<data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data>
<data name="report.text.link" xml:space="preserve"><value>Lien</value></data>
<data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data>
<data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data>
<data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>D&#233;tails des biblioth&#232;ques</value></data>
<!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>S&#233;lectionner les sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre&#160;:</value></data>
<data name="sitepicker.type" xml:space="preserve"><value>Type&#160;:</value></data>
<data name="sitepicker.type.all" xml:space="preserve"><value>Tous</value></data>
<data name="sitepicker.type.team" xml:space="preserve"><value>Sites d'&#233;quipe (MS Teams)</value></data>
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classique</value></data>
<data name="sitepicker.type.other" xml:space="preserve"><value>Autre</value></data>
<data name="sitepicker.size" xml:space="preserve"><value>Taille (Mo)&#160;:</value></data>
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
<data name="sitepicker.col.title" xml:space="preserve"><value>Titre</value></data>
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
<data name="sitepicker.col.size" xml:space="preserve"><value>Taille</value></data>
<data name="sitepicker.btn.load" xml:space="preserve"><value>Charger les sites</value></data>
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Tout s&#233;lectionner</value></data>
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Tout d&#233;s&#233;lectionner</value></data>
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Annuler</value></data>
<data name="sitepicker.status.loading" xml:space="preserve"><value>Chargement des sites...</value></data>
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites charg&#233;s.</value></data>
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites affich&#233;s.</value></data>
<data name="sitepicker.status.error" xml:space="preserve"><value>Erreur&#160;: {0}</value></data>
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Site d'&#233;quipe</value></data>
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classique</value></data>
<data name="sitepicker.kind.other" xml:space="preserve"><value>Autre</value></data>
<!-- Common UI -->
<data name="common.valid" xml:space="preserve"><value>Valide</value></data>
<data name="common.errors" xml:space="preserve"><value>Erreurs</value></data>
<data name="common.close" xml:space="preserve"><value>Fermer</value></data>
<data name="common.new_folder" xml:space="preserve"><value>+ Nouveau dossier</value></data>
<data name="common.guest" xml:space="preserve"><value>Invit&#233;</value></data>
<!-- InputDialog -->
<data name="input.title" xml:space="preserve"><value>Saisie</value></data>
<!-- ProfileManagementDialog -->
<data name="profmgmt.title" xml:space="preserve"><value>G&#233;rer les profils</value></data>
<data name="profmgmt.group" xml:space="preserve"><value>Profils</value></data>
<!-- Duplicates columns -->
<data name="duplicates.col.group" xml:space="preserve"><value>Groupe</value></data>
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
<!-- Folder structure levels -->
<data name="folderstruct.col.level1" xml:space="preserve"><value>Niveau 1</value></data>
<data name="folderstruct.col.level2" xml:space="preserve"><value>Niveau 2</value></data>
<data name="folderstruct.col.level3" xml:space="preserve"><value>Niveau 3</value></data>
<data name="folderstruct.col.level4" xml:space="preserve"><value>Niveau 4</value></data>
<!-- Permissions extra columns -->
<data name="perm.col.unique_perms" xml:space="preserve"><value>Perm. uniques</value></data>
<data name="perm.col.permission_levels" xml:space="preserve"><value>Niveaux d'autorisation</value></data>
<data name="perm.col.principal_type" xml:space="preserve"><value>Type de principal</value></data>
<!-- Storage summary labels -->
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Taille totale&#160;: </value></data>
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Taille des versions&#160;: </value></data>
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Fichiers&#160;: </value></data>
<!-- Templates columns -->
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
<data name="templates.col.captured" xml:space="preserve"><value>Captur&#233;</value></data>
<!-- Transfer view -->
<data name="transfer.text.files_selected" xml:space="preserve"><value> fichier(s) s&#233;lectionn&#233;(s)</value></data>
<data name="transfer.chk.include_source" xml:space="preserve"><value>Inclure le dossier source dans la destination</value></data>
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>Si activ&#233;, recr&#233;e le dossier source sous la destination. Sinon, d&#233;pose le contenu directement dans le dossier de destination.</value></data>
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copier le contenu du dossier</value></data>
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>Si activ&#233; (par d&#233;faut), transf&#232;re les fichiers du dossier. Sinon, seul le dossier est cr&#233;&#233; &#224; la destination.</value></data>
<!-- Shared ViewModel errors and statuses -->
<data name="err.no_tenant" xml:space="preserve"><value>Aucun tenant connect&#233;.</value></data>
<data name="err.no_tenant_connected" xml:space="preserve"><value>Aucun tenant s&#233;lectionn&#233;. Connectez-vous &#224; un tenant d'abord.</value></data>
<data name="err.no_profile_selected" xml:space="preserve"><value>Aucun profil de tenant s&#233;lectionn&#233;. Connectez-vous d'abord.</value></data>
<data name="err.no_sites_selected" xml:space="preserve"><value>S&#233;lectionnez au moins un site dans la barre d'outils.</value></data>
<data name="err.no_users_selected" xml:space="preserve"><value>Ajoutez au moins un utilisateur &#224; auditer.</value></data>
<data name="err.no_valid_rows" xml:space="preserve"><value>Aucune ligne valide &#224; traiter. Importez un CSV d'abord.</value></data>
<data name="err.template_name_required" xml:space="preserve"><value>Le nom du mod&#232;le est requis.</value></data>
<data name="err.site_title_required" xml:space="preserve"><value>Le titre du nouveau site est requis.</value></data>
<data name="err.site_alias_required" xml:space="preserve"><value>L'alias du nouveau site est requis.</value></data>
<data name="err.transfer_source_required" xml:space="preserve"><value>Le site source et la biblioth&#232;que doivent &#234;tre s&#233;lectionn&#233;s.</value></data>
<data name="err.transfer_dest_required" xml:space="preserve"><value>Le site de destination et la biblioth&#232;que doivent &#234;tre s&#233;lectionn&#233;s.</value></data>
<data name="err.library_title_required" xml:space="preserve"><value>Le titre de la biblioth&#232;que est requis.</value></data>
<!-- Templates status -->
<data name="templates.status.capturing" xml:space="preserve"><value>Capture du mod&#232;le...</value></data>
<data name="templates.status.success" xml:space="preserve"><value>Mod&#232;le captur&#233; avec succ&#232;s.</value></data>
<data name="templates.status.capture_failed" xml:space="preserve"><value>&#201;chec de la capture&#160;: {0}</value></data>
<data name="templates.status.applying" xml:space="preserve"><value>Application du mod&#232;le...</value></data>
<data name="templates.status.applied" xml:space="preserve"><value>Mod&#232;le appliqu&#233;. Site cr&#233;&#233; &#224;&#160;: {0}</value></data>
<data name="templates.status.apply_failed" xml:space="preserve"><value>&#201;chec de l'application&#160;: {0}</value></data>
<!-- UI text -->
<data name="audit.searching" xml:space="preserve"><value>Recherche en cours...</value></data>
<!-- Report text -->
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
</root> </root>
+215 -2
View File
@@ -109,6 +109,18 @@
<data name="settings.lang.fr" xml:space="preserve"> <data name="settings.lang.fr" xml:space="preserve">
<value>French</value> <value>French</value>
</data> </data>
<data name="settings.theme" xml:space="preserve">
<value>Theme</value>
</data>
<data name="settings.theme.system" xml:space="preserve">
<value>Use system setting</value>
</data>
<data name="settings.theme.light" xml:space="preserve">
<value>Light</value>
</data>
<data name="settings.theme.dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="settings.folder" xml:space="preserve"> <data name="settings.folder" xml:space="preserve">
<value>Data output folder</value> <value>Data output folder</value>
</data> </data>
@@ -130,8 +142,8 @@
<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>
@@ -139,6 +151,9 @@
<data name="status.ready" xml:space="preserve"> <data name="status.ready" xml:space="preserve">
<value>Ready</value> <value>Ready</value>
</data> </data>
<data name="status.complete" xml:space="preserve">
<value>Complete</value>
</data>
<data name="status.cancelled" xml:space="preserve"> <data name="status.cancelled" xml:space="preserve">
<value>Operation cancelled</value> <value>Operation cancelled</value>
</data> </data>
@@ -357,6 +372,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>
@@ -437,4 +473,181 @@
<data name="settings.ownership.auto" xml:space="preserve"><value>Automatically take site collection admin ownership on access denied</value></data> <data name="settings.ownership.auto" xml:space="preserve"><value>Automatically take site collection admin ownership on access denied</value></data>
<data name="settings.ownership.description" xml:space="preserve"><value>When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions.</value></data> <data name="settings.ownership.description" xml:space="preserve"><value>When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions.</value></data>
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>This site was automatically elevated — ownership was taken to complete the scan</value></data> <data name="permissions.elevated.tooltip" xml:space="preserve"><value>This site was automatically elevated — ownership was taken to complete the scan</value></data>
<!-- Report export localization -->
<data name="report.title.user_access" xml:space="preserve"><value>User Access Audit Report</value></data>
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>User Access Audit Report (Consolidated)</value></data>
<data name="report.title.permissions" xml:space="preserve"><value>SharePoint Permissions Report</value></data>
<data name="report.title.permissions_simplified" xml:space="preserve"><value>SharePoint Permissions Report (Simplified)</value></data>
<data name="report.title.storage" xml:space="preserve"><value>SharePoint Storage Metrics</value></data>
<data name="report.title.duplicates" xml:space="preserve"><value>SharePoint Duplicate Detection Report</value></data>
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
<data name="report.stat.high_privilege" xml:space="preserve"><value>High Privilege</value></data>
<data name="report.stat.external_users" xml:space="preserve"><value>External Users</value></data>
<data name="report.stat.total_entries" xml:space="preserve"><value>Total Entries</value></data>
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Unique Permission Sets</value></data>
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Distinct Users/Groups</value></data>
<data name="report.stat.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="report.stat.files" xml:space="preserve"><value>Files</value></data>
<data name="report.stat.total_size" xml:space="preserve"><value>Total Size</value></data>
<data name="report.stat.version_size" xml:space="preserve"><value>Version Size</value></data>
<data name="report.badge.guest" xml:space="preserve"><value>Guest</value></data>
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
<data name="report.badge.group" xml:space="preserve"><value>Group</value></data>
<data name="report.badge.inherited" xml:space="preserve"><value>Inherited</value></data>
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
<data name="report.view.by_user" xml:space="preserve"><value>By User</value></data>
<data name="report.view.by_site" xml:space="preserve"><value>By Site</value></data>
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filter results...</value></data>
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filter permissions...</value></data>
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filter rows…</value></data>
<data name="report.filter.label" xml:space="preserve"><value>Filter:</value></data>
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
<data name="report.col.object_type" xml:space="preserve"><value>Object Type</value></data>
<data name="report.col.object" xml:space="preserve"><value>Object</value></data>
<data name="report.col.permission_level" xml:space="preserve"><value>Permission Level</value></data>
<data name="report.col.access_type" xml:space="preserve"><value>Access Type</value></data>
<data name="report.col.granted_through" xml:space="preserve"><value>Granted Through</value></data>
<data name="report.col.user" xml:space="preserve"><value>User</value></data>
<data name="report.col.title" xml:space="preserve"><value>Title</value></data>
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
<data name="report.col.users_groups" xml:space="preserve"><value>Users/Groups</value></data>
<data name="report.col.simplified" xml:space="preserve"><value>Simplified</value></data>
<data name="report.col.risk" xml:space="preserve"><value>Risk</value></data>
<data name="report.col.library_folder" xml:space="preserve"><value>Library / Folder</value></data>
<data name="report.col.last_modified" xml:space="preserve"><value>Last Modified</value></data>
<data name="report.col.name" xml:space="preserve"><value>Name</value></data>
<data name="report.col.library" xml:space="preserve"><value>Library</value></data>
<data name="report.col.path" xml:space="preserve"><value>Path</value></data>
<data name="report.col.size" xml:space="preserve"><value>Size</value></data>
<data name="report.col.created" xml:space="preserve"><value>Created</value></data>
<data name="report.col.modified" xml:space="preserve"><value>Modified</value></data>
<data name="report.col.created_by" xml:space="preserve"><value>Created By</value></data>
<data name="report.col.modified_by" xml:space="preserve"><value>Modified By</value></data>
<data name="report.col.file_name" xml:space="preserve"><value>File Name</value></data>
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
<data name="report.col.file_type" xml:space="preserve"><value>File Type</value></data>
<data name="report.col.file_count" xml:space="preserve"><value>File Count</value></data>
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
<data name="report.col.size_bytes" xml:space="preserve"><value>Size (bytes)</value></data>
<data name="report.text.accesses" xml:space="preserve"><value>accesses</value></data>
<data name="report.text.access_es" xml:space="preserve"><value>access(es)</value></data>
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>duplicate group(s) found.</value></data>
<data name="report.text.results_parens" xml:space="preserve"><value>result(s)</value></data>
<data name="report.text.of" xml:space="preserve"><value>of</value></data>
<data name="report.text.shown" xml:space="preserve"><value>shown</value></data>
<data name="report.text.generated" xml:space="preserve"><value>Generated</value></data>
<data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data>
<data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data>
<data name="report.text.link" xml:space="preserve"><value>Link</value></data>
<data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data>
<data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data>
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
<!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
<data name="sitepicker.type" xml:space="preserve"><value>Type:</value></data>
<data name="sitepicker.type.all" xml:space="preserve"><value>All</value></data>
<data name="sitepicker.type.team" xml:space="preserve"><value>Team sites (MS Teams)</value></data>
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classic</value></data>
<data name="sitepicker.type.other" xml:space="preserve"><value>Other</value></data>
<data name="sitepicker.size" xml:space="preserve"><value>Size (MB):</value></data>
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
<data name="sitepicker.col.title" xml:space="preserve"><value>Title</value></data>
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
<data name="sitepicker.col.size" xml:space="preserve"><value>Size</value></data>
<data name="sitepicker.btn.load" xml:space="preserve"><value>Load Sites</value></data>
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Select All</value></data>
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Deselect All</value></data>
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Cancel</value></data>
<data name="sitepicker.status.loading" xml:space="preserve"><value>Loading sites...</value></data>
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites loaded.</value></data>
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites shown.</value></data>
<data name="sitepicker.status.error" xml:space="preserve"><value>Error: {0}</value></data>
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Team site</value></data>
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classic</value></data>
<data name="sitepicker.kind.other" xml:space="preserve"><value>Other</value></data>
<!-- Common UI -->
<data name="common.valid" xml:space="preserve"><value>Valid</value></data>
<data name="common.errors" xml:space="preserve"><value>Errors</value></data>
<data name="common.close" xml:space="preserve"><value>Close</value></data>
<data name="common.new_folder" xml:space="preserve"><value>+ New Folder</value></data>
<data name="common.guest" xml:space="preserve"><value>Guest</value></data>
<!-- InputDialog -->
<data name="input.title" xml:space="preserve"><value>Input</value></data>
<!-- ProfileManagementDialog -->
<data name="profmgmt.title" xml:space="preserve"><value>Manage Profiles</value></data>
<data name="profmgmt.group" xml:space="preserve"><value>Profiles</value></data>
<!-- Duplicates columns -->
<data name="duplicates.col.group" xml:space="preserve"><value>Group</value></data>
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
<!-- Folder structure levels -->
<data name="folderstruct.col.level1" xml:space="preserve"><value>Level 1</value></data>
<data name="folderstruct.col.level2" xml:space="preserve"><value>Level 2</value></data>
<data name="folderstruct.col.level3" xml:space="preserve"><value>Level 3</value></data>
<data name="folderstruct.col.level4" xml:space="preserve"><value>Level 4</value></data>
<!-- Permissions extra columns -->
<data name="perm.col.unique_perms" xml:space="preserve"><value>Unique Perms</value></data>
<data name="perm.col.permission_levels" xml:space="preserve"><value>Permission Levels</value></data>
<data name="perm.col.principal_type" xml:space="preserve"><value>Principal Type</value></data>
<!-- Storage summary labels -->
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Total Size: </value></data>
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Version Size: </value></data>
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Files: </value></data>
<!-- Templates columns -->
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
<data name="templates.col.captured" xml:space="preserve"><value>Captured</value></data>
<!-- Transfer view -->
<data name="transfer.text.files_selected" xml:space="preserve"><value> file(s) selected</value></data>
<data name="transfer.chk.include_source" xml:space="preserve"><value>Include source folder at destination</value></data>
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder.</value></data>
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copy folder contents</value></data>
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>When on (default), transfer files inside the folder. When off, only the folder is created at the destination.</value></data>
<!-- Shared ViewModel errors and statuses -->
<data name="err.no_tenant" xml:space="preserve"><value>No tenant connected.</value></data>
<data name="err.no_tenant_connected" xml:space="preserve"><value>No tenant selected. Please connect to a tenant first.</value></data>
<data name="err.no_profile_selected" xml:space="preserve"><value>No tenant profile selected. Please connect first.</value></data>
<data name="err.no_sites_selected" xml:space="preserve"><value>Select at least one site from the toolbar.</value></data>
<data name="err.no_users_selected" xml:space="preserve"><value>Add at least one user to audit.</value></data>
<data name="err.no_valid_rows" xml:space="preserve"><value>No valid rows to process. Import a CSV first.</value></data>
<data name="err.template_name_required" xml:space="preserve"><value>Template name is required.</value></data>
<data name="err.site_title_required" xml:space="preserve"><value>New site title is required.</value></data>
<data name="err.site_alias_required" xml:space="preserve"><value>New site alias is required.</value></data>
<data name="err.transfer_source_required" xml:space="preserve"><value>Source site and library must be selected.</value></data>
<data name="err.transfer_dest_required" xml:space="preserve"><value>Destination site and library must be selected.</value></data>
<data name="err.library_title_required" xml:space="preserve"><value>Library title is required.</value></data>
<!-- Templates status -->
<data name="templates.status.capturing" xml:space="preserve"><value>Capturing template...</value></data>
<data name="templates.status.success" xml:space="preserve"><value>Template captured successfully.</value></data>
<data name="templates.status.capture_failed" xml:space="preserve"><value>Capture failed: {0}</value></data>
<data name="templates.status.applying" xml:space="preserve"><value>Applying template...</value></data>
<data name="templates.status.applied" xml:space="preserve"><value>Template applied. Site created at: {0}</value></data>
<data name="templates.status.apply_failed" xml:space="preserve"><value>Apply failed: {0}</value></data>
<!-- UI text -->
<data name="audit.searching" xml:space="preserve"><value>Searching...</value></data>
<!-- Report text -->
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
</root> </root>
+4 -1
View File
@@ -8,6 +8,9 @@
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]}"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
MinWidth="900" MinHeight="600" Height="700" Width="1100"> MinWidth="900" MinHeight="600" Height="700" Width="1100">
<DockPanel> <DockPanel>
<!-- Toolbar --> <!-- Toolbar -->
@@ -28,7 +31,7 @@
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" />
<TextBlock Text="{Binding GlobalSitesSelectedLabel}" <TextBlock Text="{Binding GlobalSitesSelectedLabel}"
VerticalAlignment="Center" Margin="6,0,0,0" VerticalAlignment="Center" Margin="6,0,0,0"
Foreground="Gray" /> Foreground="{DynamicResource TextMutedBrush}" />
</ToolBar> </ToolBar>
<!-- StatusBar: tenant name | operation status text | progress % --> <!-- StatusBar: tenant name | operation status text | progress % -->
@@ -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)
{ {
@@ -7,29 +7,72 @@ public static class BulkOperationRunner
/// <summary> /// <summary>
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking, /// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
/// and cancellation support. OperationCanceledException propagates immediately. /// and cancellation support. OperationCanceledException propagates immediately.
///
/// Progress is reported AFTER each item completes (success or failure), so the bar
/// reflects actual work done rather than work queued. A final "Complete" report
/// guarantees 100% when the total was determinate.
///
/// Set <paramref name="maxConcurrency"/> > 1 to run items in parallel. Callers must
/// ensure processItem is safe to invoke concurrently (e.g. each invocation uses its
/// own CSOM ClientContext — a shared CSOM context is NOT thread-safe).
/// </summary> /// </summary>
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>( public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
IReadOnlyList<TItem> items, IReadOnlyList<TItem> items,
Func<TItem, int, CancellationToken, Task> processItem, Func<TItem, int, CancellationToken, Task> processItem,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct,
int maxConcurrency = 1)
{ {
var results = new List<BulkItemResult<TItem>>(); if (items.Count == 0)
for (int i = 0; i < items.Count; i++) {
progress.Report(new OperationProgress(0, 0, "Nothing to do."));
return new BulkOperationSummary<TItem>(Array.Empty<BulkItemResult<TItem>>());
}
progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}..."));
var results = new BulkItemResult<TItem>[items.Count];
int completed = 0;
async Task RunOne(int i, CancellationToken token)
{ {
ct.ThrowIfCancellationRequested();
progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}..."));
try try
{ {
await processItem(items[i], i, ct); await processItem(items[i], i, token);
results.Add(BulkItemResult<TItem>.Success(items[i])); results[i] = BulkItemResult<TItem>.Success(items[i]);
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
results.Add(BulkItemResult<TItem>.Failed(items[i], ex.Message)); results[i] = BulkItemResult<TItem>.Failed(items[i], ex.Message);
}
finally
{
int done = Interlocked.Increment(ref completed);
progress.Report(new OperationProgress(done, items.Count,
$"Processed {done}/{items.Count}"));
} }
} }
if (maxConcurrency <= 1)
{
for (int i = 0; i < items.Count; i++)
{
ct.ThrowIfCancellationRequested();
await RunOne(i, ct);
}
}
else
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = maxConcurrency,
CancellationToken = ct
};
await Parallel.ForEachAsync(Enumerable.Range(0, items.Count), options,
async (i, token) => await RunOne(i, token));
}
progress.Report(new OperationProgress(items.Count, items.Count, "Complete.")); progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
return new BulkOperationSummary<TItem>(results); return new BulkOperationSummary<TItem>(results);
} }
+90 -14
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))
@@ -102,10 +124,25 @@ public class DuplicatesService : IDuplicatesService
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults); .FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break; if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows) foreach (var rawRow in table.ResultRows)
{ {
var dict = row.Cast<System.Collections.DictionaryEntry>() // CSOM has returned ResultRows as either Hashtable or
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty); // Dictionary<string,object> across versions — accept both.
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic)
{
dict = generic;
}
else if (rawRow is System.Collections.IDictionary legacy)
{
dict = new Dictionary<string, object>();
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
else
{
continue;
}
string path = GetStr(dict, "Path"); string path = GetStr(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase)) if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
@@ -132,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
}); });
} }
@@ -156,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();
@@ -172,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>
""" """
}; };
@@ -200,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;
@@ -217,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
}); });
} }
} }
@@ -246,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,20 +2,40 @@ 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;
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;
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("Error"); csv.WriteField(TL["report.col.error"]);
csv.WriteField("Timestamp"); csv.WriteField(TL["report.col.timestamp"]);
csv.NextRecord(); csv.NextRecord();
foreach (var item in failedItems.Where(r => !r.IsSuccess)) foreach (var item in failedItems.Where(r => !r.IsSuccess))
@@ -29,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);
} }
} }
@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class CsvExportService public class CsvExportService
{ {
private const string Header = private static string BuildHeader()
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\""; {
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
}
/// <summary> /// <summary>
/// Builds a CSV string from the supplied permission entries. /// Builds a CSV string from the supplied permission entries.
@@ -20,7 +24,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<PermissionEntry> entries) public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine(Header); sb.AppendLine(BuildHeader());
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows // Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
var merged = entries var merged = entries
@@ -55,14 +59,17 @@ 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>
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns. /// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
/// </summary> /// </summary>
private const string SimplifiedHeader = private static string BuildSimplifiedHeader()
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\""; {
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
}
/// <summary> /// <summary>
/// Builds a CSV string from simplified permission entries. /// Builds a CSV string from simplified permission entries.
@@ -72,7 +79,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries) public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine(SimplifiedHeader); sb.AppendLine(BuildSimplifiedHeader());
var merged = entries var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough)) .GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
@@ -109,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;
}
}
@@ -0,0 +1,112 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row;
/// the Group column ties copies together and a Copies column gives the group size.
/// Header row is built at write-time so culture switches are honoured.
/// </summary>
public class DuplicatesCsvExportService
{
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string filePath,
CancellationToken ct)
{
var csv = BuildCsv(groups);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>
/// Writes one or more CSVs depending on <paramref name="splitMode"/>.
/// Single → <paramref name="basePath"/> as-is. BySite → one file per site,
/// filenames derived from <paramref name="basePath"/> with a site suffix.
/// </summary>
public Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
groups, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
internal static IEnumerable<(string Label, IReadOnlyList<DuplicateGroup> Partition)> PartitionBySite(
IReadOnlyList<DuplicateGroup> groups)
{
return groups
.GroupBy(g =>
{
var first = g.Items.FirstOrDefault();
return (Url: first?.SiteUrl ?? string.Empty, Title: first?.SiteTitle ?? string.Empty);
})
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.Url, g.Key.Title),
Partition: (IReadOnlyList<DuplicateGroup>)g.ToList()));
}
/// <summary>
/// Builds the CSV payload. Emits a header summary (group count, generated
/// timestamp), then one row per duplicate item with its group index and
/// group size. CSV fields are escaped via <see cref="CsvSanitizer.Escape"/>.
/// </summary>
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary
sb.AppendLine($"\"{T["report.title.duplicates_short"]}\"");
sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine(string.Join(",", new[]
{
Csv(T["report.col.number"]),
Csv(T["report.col.group"]),
Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
Csv(T["report.col.name"]),
Csv(T["report.col.library"]),
Csv(T["report.col.path"]),
Csv(T["report.col.size_bytes"]),
Csv(T["report.col.created"]),
Csv(T["report.col.modified"]),
}));
foreach (var g in groups)
{
int i = 0;
foreach (var item in g.Items)
{
i++;
sb.AppendLine(string.Join(",", new[]
{
Csv(i.ToString()),
Csv(g.Name),
Csv(g.Items.Count.ToString()),
Csv(item.SiteTitle),
Csv(item.Name),
Csv(item.Library),
Csv(item.Path),
Csv(item.SizeBytes?.ToString() ?? string.Empty),
Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty),
Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty),
}));
}
}
return sb.ToString();
}
private static string Csv(string value) => CsvSanitizer.Escape(value);
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.Text; using System.Text;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -10,17 +11,23 @@ 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 sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.duplicates"]}</title>");
sb.AppendLine(""" sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Duplicate Detection Report</title>
<style> <style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; } body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; } h1 { color: #0078d4; }
@@ -54,11 +61,9 @@ public class DuplicatesHtmlExportService
<body> <body>
"""); """);
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine(""" sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
<h1>Duplicate Detection Report</h1>
""");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>"); sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} {T["report.text.duplicate_groups_found"]}</p>");
for (int i = 0; i < groups.Count; i++) for (int i = 0; i < groups.Count; i++)
{ {
@@ -70,19 +75,19 @@ public class DuplicatesHtmlExportService
<div class="group-card"> <div class="group-card">
<div class="group-header" onclick="toggleGroup({i})"> <div class="group-header" onclick="toggleGroup({i})">
<span class="group-name">{H(g.Name)}</span> <span class="group-name">{H(g.Name)}</span>
<span class="badge {badgeClass}">{count} copies</span> <span class="badge {badgeClass}">{count} {T["report.text.copies"]}</span>
</div> </div>
<div class="group-body" id="gb-{i}"> <div class="group-body" id="gb-{i}">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>#</th> <th>{T["report.col.number"]}</th>
<th>Name</th> <th>{T["report.col.name"]}</th>
<th>Library</th> <th>{T["report.col.library"]}</th>
<th>Path</th> <th>{T["report.col.path"]}</th>
<th>Size</th> <th>{T["report.col.size"]}</th>
<th>Created</th> <th>{T["report.col.created"]}</th>
<th>Modified</th> <th>{T["report.col.modified"]}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -116,18 +121,59 @@ public class DuplicatesHtmlExportService
"""); """);
} }
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
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);
}
@@ -1,102 +1,50 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
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)
{ {
// Compute stats var T = TranslationSource.Instance;
var totalEntries = entries.Count; var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count(); entries.Count,
var distinctUsers = entries entries.Select(e => e.PermissionLevels),
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)) entries.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>SharePoint Permissions Report</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* Type badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
/* Unique/Inherited badges */
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
/* User pills */
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>"); sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report</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\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Granted Through</th>"); sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>"); sb.AppendLine("<tbody>");
@@ -105,99 +53,236 @@ a:hover { text-decoration: underline; }
{ {
var typeCss = ObjectTypeCss(entry.ObjectType); var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited"; var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
// Build user pills: zip UserLogins and Users (both semicolon-delimited) var (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\">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\">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> /// <summary>
/// Writes the HTML report to the specified file path using UTF-8 without BOM. /// Builds a self-contained HTML string from simplified permission entries.
/// Adds a risk-level summary card strip plus two columns (Simplified,
/// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
/// </summary> /// </summary>
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct, public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count,
entries.Select(e => e.PermissionLevels),
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
var sb = new StringBuilder();
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var s in summaries)
{
var (bg, text, border) = RiskLevelColors(s.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
AppendFilterInput(sb);
AppendTableOpen(sb);
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.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("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
colSpan: 9, grpMemIdx: ref grpMemIdx);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pills}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
AppendTableClose(sb);
AppendInlineJs(sb);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null, 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 inline CSS background and text color for a risk level.</summary> /// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var html = BuildHtml(entries, branding, groupMembers);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>
/// Split-aware write for permission entries.
/// Single → one file. BySite + SeparateFiles → one file per site.
/// BySite + SingleTabbed → one file with per-site iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
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 private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
{ {
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"), RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
@@ -206,228 +291,4 @@ function toggleGroup(id) {
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"), RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB") _ => ("#F3F4F6", "#374151", "#E5E7EB")
}; };
/// <summary>
/// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report (Simplified)</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(0,0,0,.03); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var summary in summaries)
{
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the simplified HTML report to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
private static string ObjectTypeCss(string t) => t switch
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
} }
@@ -0,0 +1,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
}
@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -10,12 +11,17 @@ 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 sb = new StringBuilder(); var sb = new StringBuilder();
// Header // Header
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)"); sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}");
foreach (var r in results) foreach (var r in results)
{ {
@@ -33,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!;
@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -11,17 +12,23 @@ 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 sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.search"]}</title>");
sb.AppendLine(""" sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint File Search Results</title>
<style> <style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; } body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; } h1 { color: #0078d4; }
@@ -45,27 +52,27 @@ public class SearchHtmlExportService
<body> <body>
"""); """);
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine(""" sb.AppendLine($"""
<h1>File Search Results</h1> <h1>{T["report.title.search_short"]}</h1>
<div class="toolbar"> <div class="toolbar">
<label for="filterInput">Filter:</label> <label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" /> <input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span> <span id="resultCount"></span>
</div> </div>
"""); """);
sb.AppendLine(""" sb.AppendLine($"""
<table id="resultsTable"> <table id="resultsTable">
<thead> <thead>
<tr> <tr>
<th onclick="sortTable(0)">File Name</th> <th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
<th onclick="sortTable(1)">Extension</th> <th onclick="sortTable(1)">{T["report.col.extension"]}</th>
<th onclick="sortTable(2)">Path</th> <th onclick="sortTable(2)">{T["report.col.path"]}</th>
<th onclick="sortTable(3)">Created</th> <th onclick="sortTable(3)">{T["report.col.created"]}</th>
<th onclick="sortTable(4)">Created By</th> <th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
<th onclick="sortTable(5)">Modified</th> <th onclick="sortTable(5)">{T["report.col.modified"]}</th>
<th onclick="sortTable(6)">Modified By</th> <th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
<th class="num" onclick="sortTable(7)">Size</th> <th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -93,7 +100,7 @@ public class SearchHtmlExportService
sb.AppendLine(" </tbody>\n</table>"); sb.AppendLine(" </tbody>\n</table>");
int count = results.Count; int count = results.Count;
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$""" sb.AppendLine($$"""
<script> <script>
@@ -126,10 +133,10 @@ public class SearchHtmlExportService
rows[i].className = match ? '' : 'hidden'; rows[i].className = match ? '' : 'hidden';
if (match) visible++; if (match) visible++;
} }
document.getElementById('resultCount').innerText = q ? (visible + ' of {{count:N0}} shown') : ''; document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
} }
window.onload = function() { window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)'; document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
}; };
</script> </script>
</body></html> </body></html>
@@ -138,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);
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO; using System.IO;
using System.Text; using System.Text;
@@ -10,12 +11,18 @@ 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 sb = new StringBuilder(); var sb = new StringBuilder();
// Header // Header
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified"); sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes) foreach (var node in nodes)
{ {
@@ -33,10 +40,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>
@@ -44,10 +52,11 @@ public class StorageCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics) public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{ {
var T = TranslationSource.Instance;
var sb = new StringBuilder(); var sb = new StringBuilder();
// Library details // Library details
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified"); sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", sb.AppendLine(string.Join(",",
@@ -65,10 +74,10 @@ public class StorageCsvExportService
if (fileTypeMetrics.Count > 0) if (fileTypeMetrics.Count > 0)
{ {
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("File Type,Size (MB),File Count"); sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
foreach (var m in fileTypeMetrics) foreach (var m in fileTypeMetrics)
{ {
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension; string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString())); sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
} }
} }
@@ -76,10 +85,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 ───────────────────────────────────────────────────────────────
@@ -87,12 +141,6 @@ 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);
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
} }
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO; using System.IO;
using System.Text; using System.Text;
@@ -13,18 +14,25 @@ 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;
_togIdx = 0; _togIdx = 0;
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine(""" sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style> <style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; } body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; } h1 { color: #0078d4; }
@@ -50,9 +58,7 @@ public class StorageHtmlExportService
<body> <body>
"""); """);
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine(""" sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
<h1>SharePoint Storage Metrics</h1>
""");
// Summary cards // Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList(); var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
@@ -62,22 +68,22 @@ public class StorageHtmlExportService
sb.AppendLine($""" sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap"> <div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div> <div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div> <div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div> <div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
</div> </div>
"""); """);
sb.AppendLine(""" sb.AppendLine($"""
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Library / Folder</th> <th>{T["report.col.library_folder"]}</th>
<th>Site</th> <th>{T["report.col.site"]}</th>
<th class="num">Files</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">Total Size</th> <th class="num">{T["report.stat.total_size"]}</th>
<th class="num">Version Size</th> <th class="num">{T["report.stat.version_size"]}</th>
<th>Last Modified</th> <th>{T["report.col.last_modified"]}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -93,7 +99,7 @@ public class StorageHtmlExportService
</table> </table>
"""); """);
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
return sb.ToString(); return sb.ToString();
@@ -104,16 +110,17 @@ public class StorageHtmlExportService
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
{ {
var T = TranslationSource.Instance;
_togIdx = 0; _togIdx = 0;
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine(""" sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style> <style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; } body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; } h1 { color: #0078d4; }
@@ -150,9 +157,7 @@ public class StorageHtmlExportService
<body> <body>
"""); """);
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine(""" sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
<h1>SharePoint Storage Metrics</h1>
""");
// ── Summary cards ── // ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList(); var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
@@ -161,10 +166,10 @@ public class StorageHtmlExportService
long fileTotal = rootNodes.Sum(n => n.TotalFileCount); long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
sb.AppendLine("<div class=\"stats\">"); sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// ── File type chart section ── // ── File type chart section ──
@@ -175,7 +180,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>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" };
@@ -185,13 +190,13 @@ public class StorageHtmlExportService
{ {
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0; double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length]; string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension; string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
sb.AppendLine($""" sb.AppendLine($"""
<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++;
@@ -201,17 +206,17 @@ public class StorageHtmlExportService
} }
// ── Storage table ── // ── Storage table ──
sb.AppendLine("<h2>Library Details</h2>"); sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
sb.AppendLine(""" sb.AppendLine($"""
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Library / Folder</th> <th>{T["report.col.library_folder"]}</th>
<th>Site</th> <th>{T["report.col.site"]}</th>
<th class="num">Files</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">Total Size</th> <th class="num">{T["report.stat.total_size"]}</th>
<th class="num">Version Size</th> <th class="num">{T["report.stat.version_size"]}</th>
<th>Last Modified</th> <th>{T["report.col.last_modified"]}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -227,24 +232,68 @@ public class StorageHtmlExportService
</table> </table>
"""); """);
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
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)
@@ -2,6 +2,7 @@ using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -11,8 +12,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class UserAccessCsvExportService public class UserAccessCsvExportService
{ {
private const string DataHeader = private static string BuildDataHeader()
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\""; {
var T = TranslationSource.Instance;
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
}
/// <summary> /// <summary>
/// Builds a CSV string for a single user's access entries. /// Builds a CSV string for a single user's access entries.
@@ -20,22 +24,23 @@ public class UserAccessCsvExportService
/// </summary> /// </summary>
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries) public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
{ {
var T = TranslationSource.Instance;
var sb = new StringBuilder(); var sb = new StringBuilder();
// Summary section // Summary section
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count(); var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege); var highPrivCount = entries.Count(e => e.IsHighPrivilege);
sb.AppendLine($"\"User Access Audit Report\""); sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\""); sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\""); sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"Sites\",\"{sitesCount}\""); sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\""); sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\""); sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); // Blank line separating summary from data sb.AppendLine(); // Blank line separating summary from data
// Data rows // Data rows
sb.AppendLine(DataHeader); sb.AppendLine(BuildDataHeader());
foreach (var entry in entries) foreach (var entry in entries)
{ {
sb.AppendLine(string.Join(",", new[] sb.AppendLine(string.Join(",", new[]
@@ -82,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);
} }
} }
@@ -99,20 +165,21 @@ public class UserAccessCsvExportService
CancellationToken ct, CancellationToken ct,
bool mergePermissions = false) bool mergePermissions = false)
{ {
var T = TranslationSource.Instance;
if (mergePermissions) if (mergePermissions)
{ {
var consolidated = PermissionConsolidator.Consolidate(entries); var consolidated = PermissionConsolidator.Consolidate(entries);
var sb = new StringBuilder(); var sb = new StringBuilder();
// Summary section // Summary section
sb.AppendLine("\"User Access Audit Report (Consolidated)\""); sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
sb.AppendLine($"\"Users Audited\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\""); sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"Total Entries\",\"{consolidated.Count}\""); sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\""); sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); sb.AppendLine();
// Header // Header
sb.AppendLine("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\""); sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
// Data rows // Data rows
foreach (var entry in consolidated) foreach (var entry in consolidated)
@@ -136,14 +203,14 @@ public class UserAccessCsvExportService
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var fullHeader = "\"User\",\"User Login\"," + DataHeader; var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
// Summary // Summary
var users = entries.Select(e => e.UserLogin).Distinct().ToList(); var users = entries.Select(e => e.UserLogin).Distinct().ToList();
sb.AppendLine($"\"User Access Audit Report\""); sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"Users Audited\",\"{users.Count}\""); sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\""); sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\""); sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(fullHeader); sb.AppendLine(fullHeader);
@@ -163,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)
{ {
@@ -2,6 +2,7 @@ using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -18,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)
@@ -26,6 +91,8 @@ public class UserAccessHtmlExportService
return BuildConsolidatedHtml(consolidated, entries, branding); return BuildConsolidatedHtml(consolidated, entries, branding);
} }
var T = TranslationSource.Instance;
// Compute stats // Compute stats
var totalAccesses = entries.Count; var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count(); var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -41,7 +108,7 @@ public class UserAccessHtmlExportService
sb.AppendLine("<head>"); sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">"); sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"); sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>"); sb.AppendLine($"<title>{T["report.title.user_access"]}</title>");
sb.AppendLine("<style>"); sb.AppendLine("<style>");
sb.AppendLine(@" sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -98,15 +165,15 @@ a:hover { text-decoration: underline; }
// ── BODY ─────────────────────────────────────────────────────────────── // ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>"); sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>"); sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
// Stats cards // Stats cards
sb.AppendLine("<div class=\"stats\">"); sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Per-user summary cards // Per-user summary cards
@@ -123,34 +190,34 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card"; var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">"); sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>"); sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>"); sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>"); sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>"); sb.AppendLine(" </div>");
} }
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// View toggle buttons // View toggle buttons
sb.AppendLine("<div class=\"view-toggle\">"); sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">By User</button>"); sb.AppendLine($" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>"); sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Filter input // Filter input
sb.AppendLine("<div class=\"filter-wrap\">"); sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />"); sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// ── BY-USER VIEW ─────────────────────────────────────────────────────── // ── BY-USER VIEW ───────────────────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">"); sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">"); sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>"); sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>"); sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>"); sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>"); sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>"); sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>"); sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">"); sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -161,10 +228,10 @@ a:hover { text-decoration: underline; }
var uName = HtmlEncode(ug.First().UserDisplayName); var uName = HtmlEncode(ug.First().UserDisplayName);
var uIsExt = ug.First().IsExternalUser; var uIsExt = ug.First().IsExternalUser;
var uCount = ug.Count(); var uCount = ug.Count();
var guestBadge = uIsExt ? " <span class=\"guest-badge\">Guest</span>" : ""; var guestBadge = uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">"); sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} access(es)</td>"); sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
foreach (var entry in ug) foreach (var entry in ug)
@@ -173,10 +240,14 @@ a:hover { text-decoration: underline; }
var accessBadge = AccessTypeBadge(entry.AccessType); var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : ""; var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">"); sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>"); sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>"); sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>"); sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>"); sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
@@ -192,12 +263,12 @@ a:hover { text-decoration: underline; }
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">"); sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
sb.AppendLine("<table id=\"tbl-site\">"); sb.AppendLine("<table id=\"tbl-site\">");
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>"); sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>"); sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>"); sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>"); sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>"); sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>"); sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-site\">"); sb.AppendLine("<tbody id=\"tbody-site\">");
@@ -210,7 +281,7 @@ a:hover { text-decoration: underline; }
var sCount = sg.Count(); var sCount = sg.Count();
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">"); sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} access(es)</td>"); sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
foreach (var entry in sg) foreach (var entry in sg)
@@ -218,12 +289,16 @@ a:hover { text-decoration: underline; }
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : ""; var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType); var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : ""; var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var guestBadge = entry.IsExternalUser ? " <span class=\"guest-badge\">Guest</span>" : ""; var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">"); sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>"); sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>"); sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>"); sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
@@ -333,7 +408,7 @@ function sortTable(view, col) {
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null) 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>
@@ -345,6 +420,8 @@ function sortTable(view, col) {
IReadOnlyList<UserAccessEntry> entries, IReadOnlyList<UserAccessEntry> entries,
ReportBranding? branding) ReportBranding? branding)
{ {
var T = TranslationSource.Instance;
// Stats computed from the original flat list for accurate counts // Stats computed from the original flat list for accurate counts
var totalAccesses = entries.Count; var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count(); var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -360,7 +437,7 @@ function sortTable(view, col) {
sb.AppendLine("<head>"); sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">"); sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"); sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>"); sb.AppendLine($"<title>{T["report.title.user_access_consolidated"]}</title>");
sb.AppendLine("<style>"); sb.AppendLine("<style>");
sb.AppendLine(@" sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -417,15 +494,15 @@ a:hover { text-decoration: underline; }
// ── BODY ─────────────────────────────────────────────────────────────── // ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>"); sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>"); sb.AppendLine($"<h1>{T["report.title.user_access_consolidated"]}</h1>");
// Stats cards // Stats cards
sb.AppendLine("<div class=\"stats\">"); sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Per-user summary cards (from original flat entries) // Per-user summary cards (from original flat entries)
@@ -442,32 +519,32 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card"; var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">"); sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>"); sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>"); sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>"); sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>"); sb.AppendLine(" </div>");
} }
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// View toggle — only By User (By Site is suppressed for consolidated view) // View toggle — only By User (By Site is suppressed for consolidated view)
sb.AppendLine("<div class=\"view-toggle\">"); sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\">By User</button>"); sb.AppendLine($" <button id=\"btn-user\" class=\"active\">{T["report.view.by_user"]}</button>");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Filter input // Filter input
sb.AppendLine("<div class=\"filter-wrap\">"); sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />"); sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// ── CONSOLIDATED BY-USER TABLE ──────────────────────────────────────── // ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">"); sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">"); sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>User</th>"); sb.AppendLine($" <th>{T["report.col.user"]}</th>");
sb.AppendLine(" <th>Permission Level</th>"); sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
sb.AppendLine(" <th>Access Type</th>"); sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
sb.AppendLine(" <th>Granted Through</th>"); sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
sb.AppendLine(" <th>Sites</th>"); sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">"); sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -486,10 +563,10 @@ a:hover { text-decoration: underline; }
var cuName = HtmlEncode(cug.First().UserDisplayName); var cuName = HtmlEncode(cug.First().UserDisplayName);
var cuIsExt = cug.First().IsExternalUser; var cuIsExt = cug.First().IsExternalUser;
var cuCount = cug.Count(); var cuCount = cug.Count();
var guestBadge = cuIsExt ? " <span class=\"guest-badge\">Guest</span>" : ""; var guestBadge = cuIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">"); sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} permission(s)</td>"); sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} {T["report.text.permissions_parens"]}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
foreach (var entry in cug) foreach (var entry in cug)
@@ -508,7 +585,7 @@ a:hover { text-decoration: underline; }
{ {
// Single location — inline site title + object title // Single location — inline site title + object title
var loc0 = entry.Locations[0]; var loc0 = entry.Locations[0];
var locLabel = string.IsNullOrEmpty(loc0.ObjectTitle) var locLabel = IsRedundantObjectTitle(loc0.SiteTitle, loc0.ObjectTitle)
? HtmlEncode(loc0.SiteTitle) ? HtmlEncode(loc0.SiteTitle)
: $"{HtmlEncode(loc0.SiteTitle)} &rsaquo; {HtmlEncode(loc0.ObjectTitle)}"; : $"{HtmlEncode(loc0.SiteTitle)} &rsaquo; {HtmlEncode(loc0.ObjectTitle)}";
sb.AppendLine($" <td>{locLabel}</td>"); sb.AppendLine($" <td>{locLabel}</td>");
@@ -518,13 +595,13 @@ 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
foreach (var loc in entry.Locations) foreach (var loc in entry.Locations)
{ {
var subLabel = string.IsNullOrEmpty(loc.ObjectTitle) var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle)
? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>" ? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>"
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> &rsaquo; {HtmlEncode(loc.ObjectTitle)}"; : $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> &rsaquo; {HtmlEncode(loc.ObjectTitle)}";
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">"); sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
@@ -591,13 +668,31 @@ function toggleGroup(id) {
} }
/// <summary>Returns a colored badge span for the given access type.</summary> /// <summary>Returns a colored badge span for the given access type.</summary>
private static string AccessTypeBadge(AccessType accessType) => accessType switch private static string AccessTypeBadge(AccessType accessType)
{ {
AccessType.Direct => "<span class=\"badge access-direct\">Direct</span>", var T = TranslationSource.Instance;
AccessType.Group => "<span class=\"badge access-group\">Group</span>", return accessType switch
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>", {
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>" AccessType.Direct => $"<span class=\"badge access-direct\">{T["report.badge.direct"]}</span>",
}; AccessType.Group => $"<span class=\"badge access-group\">{T["report.badge.group"]}</span>",
AccessType.Inherited => $"<span class=\"badge access-inherited\">{T["report.badge.inherited"]}</span>",
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
};
}
/// <summary>
/// Returns true when the ObjectTitle adds no information beyond the SiteTitle:
/// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate
/// of the other. Used to collapse "All Company &rsaquo; All Company" to "All Company".
/// </summary>
private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle)
{
if (string.IsNullOrWhiteSpace(objectTitle)) return true;
return string.Equals(
(siteTitle ?? string.Empty).Trim(),
objectTitle.Trim(),
StringComparison.OrdinalIgnoreCase);
}
/// <summary>Minimal HTML encoding for text content and attribute values.</summary> /// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value) private static string HtmlEncode(string value)
+167 -45
View File
@@ -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,
@@ -15,19 +30,53 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
// 1. Enumerate files from source // 1. Enumerate files from source (unless contents are suppressed).
progress.Report(new OperationProgress(0, 0, "Enumerating source files...")); IReadOnlyList<string> files;
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct); if (job.CopyFolderContents)
{
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
}
else
{
files = Array.Empty<string>();
}
if (files.Count == 0) // When CopyFolderContents is off, the job is folder-only: ensure the
// destination folder is created below (IncludeSourceFolder branch) and
// return without iterating any files.
if (files.Count == 0 && !job.IncludeSourceFolder)
{ {
progress.Report(new OperationProgress(0, 0, "No files found to transfer.")); progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>()); return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
} }
// 2. Build source and destination base paths // 2. Build source and destination base paths. Resolve library roots via
var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath); // CSOM — constructing from title breaks for localized libraries whose
var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath); // URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
// causing "Access denied" when CSOM tries to touch a non-existent path.
var srcBasePath = await ResolveLibraryPathAsync(
sourceCtx, job.SourceLibrary, job.SourceFolderPath, progress, ct);
var dstBasePath = await ResolveLibraryPathAsync(
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
// When IncludeSourceFolder is set, recreate the source folder name under
// destination so dest/srcFolderName/... mirrors the source tree. When
// no SourceFolderPath is set, fall back to the source library name.
// Also pre-create the folder itself — per-file EnsureFolder only fires
// for nested paths, so flat files at the root of the source folder
// would otherwise copy into a missing parent and fail.
if (job.IncludeSourceFolder)
{
var srcFolderName = !string.IsNullOrEmpty(job.SourceFolderPath)
? Path.GetFileName(job.SourceFolderPath.TrimEnd('/'))
: job.SourceLibrary;
if (!string.IsNullOrEmpty(srcFolderName))
{
dstBasePath = $"{dstBasePath}/{srcFolderName}";
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct);
}
}
// 3. Transfer each file using BulkOperationRunner // 3. Transfer each file using BulkOperationRunner
return await BulkOperationRunner.RunAsync( return await BulkOperationRunner.RunAsync(
@@ -68,8 +117,14 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl); // MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl); // not server-relative paths. Passing "/sites/..." silently fails or
// returns no error yet copies nothing — especially across site
// collections. Prefix with the owning site's scheme+host.
var srcAbs = ToAbsoluteUrl(sourceCtx, srcFileUrl);
var dstAbs = ToAbsoluteUrl(destCtx, dstFileUrl);
var srcPath = ResourcePath.FromDecodedUrl(srcAbs);
var dstPath = ResourcePath.FromDecodedUrl(dstAbs);
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite; bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
var options = new MoveCopyOptions var options = new MoveCopyOptions
@@ -109,41 +164,66 @@ public class FileTransferService : IFileTransferService
ctx.Load(rootFolder, f => f.ServerRelativeUrl); ctx.Load(rootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/'); var libraryRoot = rootFolder.ServerRelativeUrl.TrimEnd('/');
// Explicit per-file selection overrides folder enumeration. Paths are
// library-relative (e.g. "SubFolder/file.docx") and get resolved to
// full server-relative URLs here.
if (job.SelectedFilePaths.Count > 0)
{
return job.SelectedFilePaths
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => $"{libraryRoot}/{p.TrimStart('/')}")
.ToList();
}
var baseFolderUrl = libraryRoot;
if (!string.IsNullOrEmpty(job.SourceFolderPath)) 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(
@@ -152,28 +232,70 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
// Already there?
try try
{ {
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl); var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
ctx.Load(folder, f => f.Exists); ctx.Load(existing, f => f.Exists);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (folder.Exists) return; if (existing.Exists) return;
} }
catch { /* folder doesn't exist, create it */ } catch { /* not present — fall through to creation */ }
// Create folder using Folders.Add which creates intermediate folders // Walk the path, creating each missing segment. `Web.Folders.Add(url)` is
ctx.Web.Folders.Add(folderServerRelativeUrl); // ambiguous across CSOM versions (some treat the arg as relative to Web,
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); // others server-relative), which produces bogus paths + "Access denied".
// Resolve the parent explicitly and add only the leaf name instead.
int slash = folderServerRelativeUrl.LastIndexOf('/');
if (slash <= 0) return;
var parentUrl = folderServerRelativeUrl.Substring(0, slash);
var leafName = folderServerRelativeUrl.Substring(slash + 1);
if (string.IsNullOrEmpty(leafName)) return;
// Recurse to guarantee the parent exists first.
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
var parent = ctx.Web.GetFolderByServerRelativeUrl(parentUrl);
parent.Folders.Add(leafName);
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (Exception ex)
{
Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}",
parentUrl, leafName, ex.Message);
throw;
}
} }
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath) private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
{ {
// Extract site-relative URL from context URL if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return pathOrUrl;
var uri = new Uri(ctx.Url); var uri = new Uri(ctx.Url);
var siteRelative = uri.AbsolutePath.TrimEnd('/'); return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
var basePath = $"{siteRelative}/{library}"; }
if (!string.IsNullOrEmpty(folderPath))
basePath = $"{basePath}/{folderPath.TrimStart('/')}"; private static async Task<string> ResolveLibraryPathAsync(
ClientContext ctx,
string libraryTitle,
string relativeFolderPath,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var basePath = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
if (!string.IsNullOrEmpty(relativeFolderPath))
basePath = $"{basePath}/{relativeFolderPath.TrimStart('/')}";
return basePath; return basePath;
} }
} }
@@ -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,
@@ -27,5 +27,6 @@ public interface IUserAccessAuditService
IReadOnlyList<SiteInfo> sites, IReadOnlyList<SiteInfo> sites,
ScanOptions options, ScanOptions options,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct); CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
} }
@@ -7,6 +7,17 @@ public class OwnershipElevationService : IOwnershipElevationService
{ {
public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct) public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct)
{ {
// Tenant.SetSiteAdmin requires a real claims/UPN login; an empty string
// makes the server raise "Cannot convert Org ID to Claims" and abort.
// When the caller doesn't specify a user, fall back to the signed-in
// admin (the owner of tenantAdminCtx).
if (string.IsNullOrWhiteSpace(loginName))
{
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
await tenantAdminCtx.ExecuteQueryAsync();
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
}
var tenant = new Tenant(tenantAdminCtx); var tenant = new Tenant(tenantAdminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true); tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
await tenantAdminCtx.ExecuteQueryAsync(); await tenantAdminCtx.ExecuteQueryAsync();
@@ -1,4 +1,5 @@
using Microsoft.SharePoint.Client; using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
@@ -10,6 +11,21 @@ namespace SharepointToolbox.Services;
/// </summary> /// </summary>
public class PermissionsService : IPermissionsService public class PermissionsService : IPermissionsService
{ {
/// <summary>
/// Detects the SharePoint server error raised when a RoleAssignment member
/// refers to a user that no longer resolves (orphaned Azure AD account).
/// Message surfaces in the user's locale — match on language-agnostic tokens.
/// </summary>
private static bool IsClaimsResolutionError(ServerException ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("Claims", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Revendications", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Org ID", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("ID org", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
}
// Port of PS lines 1914-1926: system lists excluded from permission reporting // Port of PS lines 1914-1926: system lists excluded from permission reporting
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
{ {
@@ -122,7 +138,17 @@ public class PermissionsService : IPermissionsService
u => u.Title, u => u.Title,
u => u.LoginName, u => u.LoginName,
u => u.IsSiteAdmin)); u => u.IsSiteAdmin));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped site collection admins for {Url} — orphaned user: {Error}",
ctx.Web.Url, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
var admins = ctx.Web.SiteUsers var admins = ctx.Web.SiteUsers
.Where(u => u.IsSiteAdmin) .Where(u => u.IsSiteAdmin)
@@ -280,7 +306,23 @@ public class PermissionsService : IPermissionsService
ra => ra.Member.LoginName, ra => ra.Member.LoginName,
ra => ra.Member.PrincipalType, ra => ra.Member.PrincipalType,
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name))); ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// Orphaned AD users in RoleAssignments cause the server to throw
// "Cannot convert Org ID user to Claims user" during claim resolution.
// That kills the whole batch — skip this object so the scan continues.
// Only swallow the claims-resolution signature; real access-denied errors
// must bubble up so callers (e.g. PermissionsViewModel auto-elevation)
// can react to them.
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped {Type} '{Title}' ({Url}) — orphaned user in permissions: {Error}",
objectType, title, url, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
// Skip inherited objects when IncludeInherited=false // Skip inherited objects when IncludeInherited=false
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments) if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
+18 -3
View File
@@ -62,10 +62,25 @@ public class SearchService : ISearchService
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults); .FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break; if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows) foreach (var rawRow in table.ResultRows)
{ {
var dict = row.Cast<System.Collections.DictionaryEntry>() // CSOM has returned ResultRows as either Hashtable or
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty); // Dictionary<string,object> across versions — accept both.
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic)
{
dict = generic;
}
else if (rawRow is System.Collections.IDictionary legacy)
{
dict = new Dictionary<string, object>();
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
else
{
continue;
}
// Skip SharePoint version history paths // Skip SharePoint version history paths
string path = Str(dict, "Path"); string path = Str(dict, "Path");
@@ -43,4 +43,14 @@ public class SettingsService
settings.AutoTakeOwnership = enabled; settings.AutoTakeOwnership = enabled;
await _repository.SaveAsync(settings); await _repository.SaveAsync(settings);
} }
public async Task SetThemeAsync(string mode)
{
if (mode is not ("System" or "Light" or "Dark"))
throw new ArgumentException($"Unsupported theme '{mode}'. Supported: System, Light, Dark.", nameof(mode));
var settings = await _repository.LoadAsync();
settings.Theme = mode;
await _repository.SaveAsync(settings);
}
} }
@@ -45,10 +45,37 @@ public class SharePointGroupResolver : ISharePointGroupResolver
GraphServiceClient? graphClient = null; GraphServiceClient? graphClient = null;
// Preload the web's SiteGroups catalog once, so we can skip missing
// groups without triggering a server round-trip per name (which fills
// logs with "Could not resolve SP group" warnings for groups that
// live on other sites or were renamed/deleted).
var groupTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
foreach (var g in ctx.Web.SiteGroups)
groupTitles.Add(g.Title);
}
catch (Exception ex)
{
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
}
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase)) foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (!groupTitles.Contains(groupName))
{
// Group not on this web — likely scoped to another site in a
// multi-site scan. Keep quiet: log at Debug, return empty.
Log.Debug("SP group '{Group}' not present on {Url}; skipping.",
groupName, ctx.Url);
result[groupName] = Array.Empty<ResolvedMember>();
continue;
}
try try
{ {
var group = ctx.Web.SiteGroups.GetByName(groupName); var group = ctx.Web.SiteGroups.GetByName(groupName);
@@ -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
});
} }
} }
} }
+109 -42
View File
@@ -11,6 +11,13 @@ namespace SharepointToolbox.Services;
/// </summary> /// </summary>
public class StorageService : IStorageService public class StorageService : IStorageService
{ {
/// <summary>
/// Collects per-library and per-folder storage metrics for a single
/// SharePoint site. Depth and indentation are controlled via
/// <paramref name="options"/>; libraries flagged <c>Hidden</c> are skipped.
/// Traversal is breadth-first and leans on <see cref="SharePointPaginationHelper"/>
/// so libraries above the 5,000-item threshold remain scannable.
/// </summary>
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync( public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
StorageScanOptions options, StorageScanOptions options,
@@ -54,7 +61,7 @@ public class StorageService : IStorageService
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, progress, ct);
} }
@@ -65,6 +72,11 @@ public class StorageService : IStorageService
return result; return result;
} }
/// <summary>
/// Aggregates file counts and total sizes by extension across every
/// non-hidden document library on the site. Extensions are normalised to
/// lowercase; files without an extension roll up into a single bucket.
/// </summary>
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
@@ -96,24 +108,19 @@ 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 // Paginated CAML without a WHERE clause — WHERE on non-indexed fields
// Paginate with 500 items per batch to avoid list view threshold issues // (FSObjType) throws list-view threshold on libraries > 5,000 items.
// Filter files client-side via FSObjType.
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' /> <FieldRef Name='File_x0020_Size' />
</ViewFields> </ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit> <RowLimit Paged='TRUE'>5000</RowLimit>
</View>" </View>"
}; };
@@ -124,12 +131,15 @@ 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["FSObjType"],
i => i["FileLeafRef"], i => i["FileLeafRef"],
i => i["File_x0020_Size"])); i => i["File_x0020_Size"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var item in items) foreach (var item in items)
{ {
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty; string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0"; string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0";
@@ -137,7 +147,6 @@ public class StorageService : IStorageService
fileSize = 0; fileSize = 0;
string ext = Path.GetExtension(fileName).ToLowerInvariant(); string ext = Path.GetExtension(fileName).ToLowerInvariant();
// ext is "" for extensionless files, ".docx" etc. for others
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,7 +154,6 @@ 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);
@@ -198,21 +206,31 @@ public class StorageService : IStorageService
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase); var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup); BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Capture original TotalSizeBytes before reset — StorageMetrics.TotalSize
// includes version overhead, which cannot be rederived from a file scan
// (File_x0020_Size is the current stream size only).
var originalTotals = new Dictionary<StorageNode, long>();
CaptureTotals(libNode, originalTotals);
// Reset all nodes in this tree to zero before accumulating // Reset all nodes in this tree to zero before accumulating
ResetNodeCounts(libNode); ResetNodeCounts(libNode);
// Enumerate all files with their folder path // Paginated CAML without WHERE (filter folders client-side via FSObjType).
// SMTotalSize = per-file total including all versions (version-aware).
// SMTotalFileStreamSize = current stream only. File_x0020_Size is a fallback
// when SMTotalSize is unavailable (older tenants / custom fields stripped).
var query = new CamlQuery var query = new CamlQuery
{ {
ViewXml = @"<View Scope='RecursiveAll'> ViewXml = @"<View Scope='RecursiveAll'>
<Query><Where> <Query></Query>
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
</Where></Query>
<ViewFields> <ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' /> <FieldRef Name='FileDirRef' />
<FieldRef Name='File_x0020_Size' /> <FieldRef Name='File_x0020_Size' />
<FieldRef Name='SMTotalSize' />
<FieldRef Name='SMTotalFileStreamSize' />
</ViewFields> </ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit> <RowLimit Paged='TRUE'>5000</RowLimit>
</View>" </View>"
}; };
@@ -223,29 +241,38 @@ 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["FSObjType"],
i => i["FileDirRef"], i => i["FileDirRef"],
i => i["File_x0020_Size"])); i => i["File_x0020_Size"],
i => i["SMTotalSize"],
i => i["SMTotalFileStreamSize"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var item in items) foreach (var item in items)
{ {
long size = 0; if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
if (long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long s))
size = s; long streamSize = ParseLong(item["File_x0020_Size"]);
long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize"));
long smTotal = ParseLong(SafeGet(item, "SMTotalSize"));
// Prefer SM fields when present; fall back to File_x0020_Size otherwise.
if (smStream > 0) streamSize = smStream;
long totalSize = smTotal > 0 ? smTotal : streamSize;
string fileDirRef = item["FileDirRef"]?.ToString() ?? ""; string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
// Always count toward the library root // Always count toward the library root
libNode.TotalSizeBytes += size; libNode.TotalSizeBytes += totalSize;
libNode.FileStreamSizeBytes += size; libNode.FileStreamSizeBytes += streamSize;
libNode.TotalFileCount++; libNode.TotalFileCount++;
// Also count toward the most specific matching subfolder // Also count toward the most specific matching subfolder
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup); var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
if (matchedFolder != null && matchedFolder != libNode) if (matchedFolder != null && matchedFolder != libNode)
{ {
matchedFolder.TotalSizeBytes += size; matchedFolder.TotalSizeBytes += totalSize;
matchedFolder.FileStreamSizeBytes += size; matchedFolder.FileStreamSizeBytes += streamSize;
matchedFolder.TotalFileCount++; matchedFolder.TotalFileCount++;
} }
} }
@@ -253,9 +280,37 @@ public class StorageService : IStorageService
query.ListItemCollectionPosition = items.ListItemCollectionPosition; query.ListItemCollectionPosition = items.ListItemCollectionPosition;
} }
while (items.ListItemCollectionPosition != null); while (items.ListItemCollectionPosition != null);
// Restore original TotalSizeBytes where it exceeded the recomputed value.
// Preserves StorageMetrics.TotalSize for nodes whose original metrics were
// valid but SMTotalSize was missing on individual files.
foreach (var kv in originalTotals)
{
if (kv.Value > kv.Key.TotalSizeBytes)
kv.Key.TotalSizeBytes = kv.Value;
}
} }
} }
private static long ParseLong(object? value)
{
if (value == null) return 0;
return long.TryParse(value.ToString(), out long n) ? n : 0;
}
private static object? SafeGet(ListItem item, string fieldName)
{
try { return item[fieldName]; }
catch { return null; }
}
private static void CaptureTotals(StorageNode node, Dictionary<StorageNode, long> map)
{
map[node] = node.TotalSizeBytes;
foreach (var child in node.Children)
CaptureTotals(child, map);
}
private static bool HasZeroChild(StorageNode node) private static bool HasZeroChild(StorageNode node)
{ {
foreach (var child in node.Children) foreach (var child in node.Children)
@@ -349,6 +404,7 @@ 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,
@@ -361,31 +417,42 @@ public class StorageService : IStorageService
if (currentDepth > maxDepth) return; if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load direct child folders of this folder // Enumerate direct child folders via paginated CAML scoped to the parent.
Folder parentFolder = ctx.Web.GetFolderByServerRelativeUrl(parentServerRelativeUrl); // Folder.Folders lazy loading hits the list-view threshold on libraries
ctx.Load(parentFolder, // > 5,000 items; a paged CAML query with no WHERE bypasses it.
f => f.Folders.Include( var subfolders = new List<(string Name, string ServerRelativeUrl)>();
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; // folders only
string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
// Skip SharePoint system folders
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("_", StringComparison.Ordinal))
continue;
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, 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, progress, ct);
} }
+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(
+135
View File
@@ -0,0 +1,135 @@
using System.Windows;
using Microsoft.Win32;
using Microsoft.Extensions.Logging;
namespace SharepointToolbox.Services;
public enum ThemeMode { System, Light, Dark }
/// <summary>
/// Swaps the merged palette dictionary at runtime so all DynamicResource brush lookups retint live.
/// "System" mode reads HKCU AppsUseLightTheme (0 = dark, 1 = light) and subscribes to system theme changes.
/// </summary>
public class ThemeManager
{
private const string LightPaletteSource = "pack://application:,,,/Themes/LightPalette.xaml";
private const string DarkPaletteSource = "pack://application:,,,/Themes/DarkPalette.xaml";
private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
private readonly ILogger<ThemeManager> _logger;
private ThemeMode _mode = ThemeMode.System;
private bool _systemSubscribed;
public event EventHandler? ThemeChanged;
public ThemeMode Mode => _mode;
public bool IsDarkActive { get; private set; }
public ThemeManager(ILogger<ThemeManager> logger)
{
_logger = logger;
}
public void ApplyMode(ThemeMode mode)
{
_mode = mode;
bool dark = ResolveDark(mode);
ApplyPalette(dark);
EnsureSystemSubscription(mode);
}
public void ApplyFromString(string? value)
{
var mode = (value ?? "System") switch
{
"Light" => ThemeMode.Light,
"Dark" => ThemeMode.Dark,
_ => ThemeMode.System,
};
ApplyMode(mode);
}
private bool ResolveDark(ThemeMode mode) => mode switch
{
ThemeMode.Light => false,
ThemeMode.Dark => true,
_ => ReadSystemPrefersDark(),
};
private bool ReadSystemPrefersDark()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
if (key?.GetValue("AppsUseLightTheme") is int v)
return v == 0;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read system theme preference, defaulting to light");
}
return false;
}
private void ApplyPalette(bool dark)
{
var app = Application.Current;
if (app is null) return;
var newPalette = new ResourceDictionary
{
Source = new Uri(dark ? DarkPaletteSource : LightPaletteSource, UriKind.Absolute)
};
var dicts = app.Resources.MergedDictionaries;
int replaced = -1;
for (int i = 0; i < dicts.Count; i++)
{
var src = dicts[i].Source?.OriginalString ?? string.Empty;
if (src.EndsWith("LightPalette.xaml", StringComparison.OrdinalIgnoreCase) ||
src.EndsWith("DarkPalette.xaml", StringComparison.OrdinalIgnoreCase))
{
replaced = i;
break;
}
}
if (replaced >= 0)
dicts[replaced] = newPalette;
else
dicts.Insert(0, newPalette);
IsDarkActive = dark;
ThemeChanged?.Invoke(this, EventArgs.Empty);
}
private void EnsureSystemSubscription(ThemeMode mode)
{
if (mode == ThemeMode.System && !_systemSubscribed)
{
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
_systemSubscribed = true;
}
else if (mode != ThemeMode.System && _systemSubscribed)
{
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
_systemSubscribed = false;
}
}
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
if (_mode != ThemeMode.System) return;
var app = Application.Current;
if (app is null) return;
app.Dispatcher.BeginInvoke(new Action(() =>
{
bool dark = ReadSystemPrefersDark();
if (dark != IsDarkActive)
ApplyPalette(dark);
}));
}
}
@@ -29,7 +29,8 @@ public class UserAccessAuditService : IUserAccessAuditService
IReadOnlyList<SiteInfo> sites, IReadOnlyList<SiteInfo> sites,
ScanOptions options, ScanOptions options,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
{ {
// Normalize target logins for case-insensitive matching. // Normalize target logins for case-insensitive matching.
// Users may be identified by email ("alice@contoso.com") or full claim // Users may be identified by email ("alice@contoso.com") or full claim
@@ -59,10 +60,21 @@ public class UserAccessAuditService : IUserAccessAuditService
}; };
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct); var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct); IReadOnlyList<PermissionEntry> permEntries;
try
{
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
{
var elevated = await onAccessDenied(site.Url, ct);
if (!elevated)
throw;
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
}
var userEntries = TransformEntries(permEntries, targets, site); allEntries.AddRange(TransformEntries(permEntries, targets, site));
allEntries.AddRange(userEntries);
} }
progress.Report(new OperationProgress(sites.Count, sites.Count, progress.Report(new OperationProgress(sites.Count, sites.Count,
+27
View File
@@ -0,0 +1,27 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Dark palette -->
<SolidColorBrush x:Key="AppBgBrush" Color="#15181F" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E2230" />
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#272C3B" />
<SolidColorBrush x:Key="SurfaceAltBrushAlt" Color="#222636" />
<SolidColorBrush x:Key="BorderSoftBrush" Color="#323849" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#3F475B" />
<SolidColorBrush x:Key="TextBrush" Color="#E7EAF1" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#9AA3B2" />
<SolidColorBrush x:Key="AccentBrush" Color="#60A5FA" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#3B82F6" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#1E3A5F" />
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" />
<SolidColorBrush x:Key="DangerBrush" Color="#F87171" />
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399" />
<!-- Forced-dark text for elements painted with hardcoded light pastel backgrounds (risk tiles, colored rows). -->
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#4A5366" />
<SolidColorBrush x:Key="TooltipBgBrush" Color="#0B1220" />
<SolidColorBrush x:Key="TooltipFgBrush" Color="#E7EAF1" />
</ResourceDictionary>
@@ -0,0 +1,26 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Light palette -->
<SolidColorBrush x:Key="AppBgBrush" Color="#F6F7FB" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F3F4F8" />
<SolidColorBrush x:Key="SurfaceAltBrushAlt" Color="#FAFAFC" />
<SolidColorBrush x:Key="BorderSoftBrush" Color="#E3E6EC" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CED2D9" />
<SolidColorBrush x:Key="TextBrush" Color="#1F2430" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#5B6472" />
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1D4ED8" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#1E40AF" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#E8F0FE" />
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
<SolidColorBrush x:Key="SuccessBrush" Color="#047857" />
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#DBE7FF" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" />
<SolidColorBrush x:Key="TooltipBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="TooltipFgBrush" Color="#FFFFFF" />
</ResourceDictionary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
using System.ComponentModel;
using System.Globalization;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Views.Dialogs;
namespace SharepointToolbox.ViewModels.Dialogs;
/// <summary>
/// Headless logic for <see cref="SitePickerDialog"/>. Loads sites via
/// <see cref="ISiteListService"/> and applies filter/sort in memory so the
/// dialog's code-behind stays a thin shim and the logic is unit-testable
/// without a WPF host.
/// </summary>
public class SitePickerDialogLogic
{
private readonly ISiteListService _siteListService;
private readonly TenantProfile _profile;
public SitePickerDialogLogic(ISiteListService siteListService, TenantProfile profile)
{
_siteListService = siteListService;
_profile = profile;
}
/// <summary>
/// Loads all accessible sites for the tenant profile and wraps them in
/// <see cref="SitePickerItem"/> so the dialog can bind checkboxes.
/// </summary>
public async Task<IReadOnlyList<SitePickerItem>> LoadAsync(
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var sites = await _siteListService.GetSitesAsync(_profile, progress, ct);
return sites
.Select(s => new SitePickerItem(s.Url, s.Title, s.StorageUsedMb, s.StorageQuotaMb, s.Template))
.ToList();
}
/// <summary>
/// Filters items by free-text (title/url substring), storage-size range,
/// and site kind. Empty or zero-range parameters become no-ops.
/// </summary>
public static IEnumerable<SitePickerItem> ApplyFilter(
IEnumerable<SitePickerItem> items,
string text,
long minMb,
long maxMb,
string kindFilter)
{
var result = items;
if (!string.IsNullOrEmpty(text))
{
result = result.Where(i =>
i.Url.Contains(text, StringComparison.OrdinalIgnoreCase) ||
i.Title.Contains(text, StringComparison.OrdinalIgnoreCase));
}
result = result.Where(i => i.StorageUsedMb >= minMb && i.StorageUsedMb <= maxMb);
if (!string.IsNullOrEmpty(kindFilter) && kindFilter != "All")
result = result.Where(i => i.Kind.ToString() == kindFilter);
return result;
}
/// <summary>
/// Stable sort by a named column and direction. Unknown column names
/// return the input sequence unchanged.
/// </summary>
public static IEnumerable<SitePickerItem> ApplySort(
IEnumerable<SitePickerItem> items,
string column,
ListSortDirection direction)
{
var asc = direction == ListSortDirection.Ascending;
return column switch
{
"Title" => asc ? items.OrderBy(i => i.Title) : items.OrderByDescending(i => i.Title),
"Url" => asc ? items.OrderBy(i => i.Url) : items.OrderByDescending(i => i.Url),
"Kind" => asc ? items.OrderBy(i => i.KindDisplay) : items.OrderByDescending(i => i.KindDisplay),
"StorageUsedMb" => asc
? items.OrderBy(i => i.StorageUsedMb)
: items.OrderByDescending(i => i.StorageUsedMb),
"IsSelected" => asc
? items.OrderBy(i => i.IsSelected)
: items.OrderByDescending(i => i.IsSelected),
_ => items
};
}
/// <summary>
/// Lenient long parse: whitespace-only or unparseable input yields
/// <paramref name="fallback"/> instead of throwing.
/// </summary>
public static long ParseLongOrDefault(string text, long fallback)
{
if (string.IsNullOrWhiteSpace(text)) return fallback;
return long.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)
? v
: fallback;
}
}
@@ -23,6 +23,9 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient
[ObservableProperty] [ObservableProperty]
private int _progressValue; private int _progressValue;
[ObservableProperty]
private bool _isIndeterminate;
/// <summary> /// <summary>
/// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage. /// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage.
/// Derived VMs check this in RunOperationAsync before falling back to per-tab SiteUrl. /// Derived VMs check this in RunOperationAsync before falling back to per-tab SiteUrl.
@@ -46,24 +49,44 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient
IsRunning = true; IsRunning = true;
StatusMessage = string.Empty; StatusMessage = string.Empty;
ProgressValue = 0; ProgressValue = 0;
IsIndeterminate = false;
try try
{ {
var progress = new Progress<OperationProgress>(p => var progress = new Progress<OperationProgress>(p =>
{ {
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0; // Indeterminate reports (throttle waits, inner scan steps) must not
// reset the determinate bar to 0%; only update the status message
// and flip the bar into marquee mode. The next determinate report
// restores % and clears the marquee flag.
if (p.IsIndeterminate)
{
IsIndeterminate = true;
}
else
{
IsIndeterminate = false;
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
}
StatusMessage = p.Message; StatusMessage = p.Message;
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p)); WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
}); });
await RunOperationAsync(_cts.Token, progress); await RunOperationAsync(_cts.Token, progress);
// Success path: replace any lingering "Scanning X…" with a neutral
// completion marker so stale in-progress labels don't stick around.
StatusMessage = TranslationSource.Instance["status.complete"];
ProgressValue = 100;
IsIndeterminate = false;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
StatusMessage = TranslationSource.Instance["status.cancelled"]; StatusMessage = TranslationSource.Instance["status.cancelled"];
IsIndeterminate = false;
_logger.LogInformation("Operation cancelled by user."); _logger.LogInformation("Operation cancelled by user.");
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}"; StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
IsIndeterminate = false;
_logger.LogError(ex, "Operation failed."); _logger.LogError(ex, "Operation failed.");
} }
finally finally
@@ -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.");
} }
} }
@@ -306,21 +356,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 +376,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 +411,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;
@@ -31,6 +32,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
private readonly IDuplicatesService _duplicatesService; private readonly IDuplicatesService _duplicatesService;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly DuplicatesHtmlExportService _htmlExportService; private readonly DuplicatesHtmlExportService _htmlExportService;
private readonly DuplicatesCsvExportService _csvExportService;
private readonly IBrandingService _brandingService; private readonly IBrandingService _brandingService;
private readonly ILogger<FeatureViewModelBase> _logger; private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile; private TenantProfile? _currentProfile;
@@ -46,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
{ {
@@ -55,16 +65,19 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_results = value; _results = value;
OnPropertyChanged(); OnPropertyChanged();
ExportHtmlCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
} }
} }
public IAsyncRelayCommand ExportHtmlCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; }
public IAsyncRelayCommand ExportCsvCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile; public TenantProfile? CurrentProfile => _currentProfile;
public DuplicatesViewModel( public DuplicatesViewModel(
IDuplicatesService duplicatesService, IDuplicatesService duplicatesService,
ISessionManager sessionManager, ISessionManager sessionManager,
DuplicatesHtmlExportService htmlExportService, DuplicatesHtmlExportService htmlExportService,
DuplicatesCsvExportService csvExportService,
IBrandingService brandingService, IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger) ILogger<FeatureViewModelBase> logger)
: base(logger) : base(logger)
@@ -72,24 +85,26 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_duplicatesService = duplicatesService; _duplicatesService = duplicatesService;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_htmlExportService = htmlExportService; _htmlExportService = htmlExportService;
_csvExportService = csvExportService;
_brandingService = brandingService; _brandingService = brandingService;
_logger = logger; _logger = logger;
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
} }
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress) protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{ {
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;
} }
@@ -152,6 +167,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_lastGroups = Array.Empty<DuplicateGroup>(); _lastGroups = Array.Empty<DuplicateGroup>();
OnPropertyChanged(nameof(CurrentProfile)); OnPropertyChanged(nameof(CurrentProfile));
ExportHtmlCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
} }
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
@@ -179,9 +195,28 @@ 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."); }
} }
private async Task ExportCsvAsync()
{
if (_lastGroups.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export duplicates report to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "duplicates_report"
};
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
}
} }
@@ -103,15 +103,16 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress) 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;
} }
@@ -308,6 +317,32 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// Derives the tenant admin URL from a standard tenant URL. /// Derives the tenant admin URL from a standard tenant URL.
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com /// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
/// </summary> /// </summary>
/// <summary>
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
/// E.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/Reports → https://t.sharepoint.com/sites/hr
/// Falls back to scheme+host for root-collection URLs.
/// </summary>
internal static string DeriveSiteCollectionUrl(string objectUrl)
{
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
return objectUrl.TrimEnd('/');
var baseUrl = $"{uri.Scheme}://{uri.Host}";
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
// Managed paths: /sites/<name> or /teams/<name>
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return $"{baseUrl}/{segments[0]}/{segments[1]}";
}
// Root site collection
return baseUrl;
}
internal static string DeriveAdminUrl(string tenantUrl) internal static string DeriveAdminUrl(string tenantUrl)
{ {
var uri = new Uri(tenantUrl.TrimEnd('/')); var uri = new Uri(tenantUrl.TrimEnd('/'));
@@ -374,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)
@@ -408,36 +443,64 @@ public partial class PermissionsViewModel : FeatureViewModelBase
} }
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null; IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
if (_groupResolver != null && Results.Count > 0) if (_groupResolver != null && Results.Count > 0 && _currentProfile != null)
{ {
var groupNames = Results // SharePoint groups live per site collection. Bucket each group
// by the site it was observed on, then resolve against that
// site's context. Using the root tenant ctx for a group that
// lives on a sub-site makes CSOM fail with "Group not found".
var groupsBySite = Results
.Where(r => r.PrincipalType == "SharePointGroup") .Where(r => r.PrincipalType == "SharePointGroup")
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries)) .SelectMany(r => r.Users
.Select(n => n.Trim()) .Split(';', StringSplitOptions.RemoveEmptyEntries)
.Where(n => n.Length > 0) .Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim())))
.Distinct(StringComparer.OrdinalIgnoreCase) .Where(x => x.GroupName.Length > 0)
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (groupNames.Count > 0 && _currentProfile != null) if (groupsBySite.Count > 0)
{ {
try var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
StringComparer.OrdinalIgnoreCase);
foreach (var bucket in groupsBySite)
{ {
var ctx = await _sessionManager.GetOrCreateContextAsync( var distinctNames = bucket
_currentProfile, CancellationToken.None); .Select(x => x.GroupName)
groupMembers = await _groupResolver.ResolveGroupsAsync( .Distinct(StringComparer.OrdinalIgnoreCase)
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None); .ToList();
}
catch (Exception ex) try
{ {
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion."); var siteProfile = new TenantProfile
{
TenantUrl = bucket.Key,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(
siteProfile, CancellationToken.None);
var resolved = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None);
foreach (var kv in resolved)
merged[kv.Key] = kv.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Group resolution failed for {Site} — continuing without member expansion.",
bucket.Key);
}
} }
groupMembers = merged;
} }
} }
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;
} }
@@ -12,6 +12,7 @@ public partial class SettingsViewModel : FeatureViewModelBase
{ {
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
private readonly IBrandingService _brandingService; private readonly IBrandingService _brandingService;
private readonly ThemeManager _themeManager;
private string _selectedLanguage = "en"; private string _selectedLanguage = "en";
public string SelectedLanguage public string SelectedLanguage
@@ -39,6 +40,19 @@ public partial class SettingsViewModel : FeatureViewModelBase
} }
} }
private string _selectedTheme = "System";
public string SelectedTheme
{
get => _selectedTheme;
set
{
if (_selectedTheme == value) return;
_selectedTheme = value;
OnPropertyChanged();
_ = ApplyThemeAsync(value);
}
}
private bool _autoTakeOwnership; private bool _autoTakeOwnership;
public bool AutoTakeOwnership public bool AutoTakeOwnership
{ {
@@ -63,11 +77,12 @@ public partial class SettingsViewModel : FeatureViewModelBase
public IAsyncRelayCommand BrowseMspLogoCommand { get; } public IAsyncRelayCommand BrowseMspLogoCommand { get; }
public IAsyncRelayCommand ClearMspLogoCommand { get; } public IAsyncRelayCommand ClearMspLogoCommand { get; }
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger<FeatureViewModelBase> logger) public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ThemeManager themeManager, ILogger<FeatureViewModelBase> logger)
: base(logger) : base(logger)
{ {
_settingsService = settingsService; _settingsService = settingsService;
_brandingService = brandingService; _brandingService = brandingService;
_themeManager = themeManager;
BrowseFolderCommand = new RelayCommand(BrowseFolder); BrowseFolderCommand = new RelayCommand(BrowseFolder);
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync); BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync); ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
@@ -79,14 +94,29 @@ public partial class SettingsViewModel : FeatureViewModelBase
_selectedLanguage = settings.Lang; _selectedLanguage = settings.Lang;
_dataFolder = settings.DataFolder; _dataFolder = settings.DataFolder;
_autoTakeOwnership = settings.AutoTakeOwnership; _autoTakeOwnership = settings.AutoTakeOwnership;
_selectedTheme = settings.Theme;
OnPropertyChanged(nameof(SelectedLanguage)); OnPropertyChanged(nameof(SelectedLanguage));
OnPropertyChanged(nameof(DataFolder)); OnPropertyChanged(nameof(DataFolder));
OnPropertyChanged(nameof(AutoTakeOwnership)); OnPropertyChanged(nameof(AutoTakeOwnership));
OnPropertyChanged(nameof(SelectedTheme));
var mspLogo = await _brandingService.GetMspLogoAsync(); var mspLogo = await _brandingService.GetMspLogoAsync();
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null; MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
} }
private async Task ApplyThemeAsync(string mode)
{
try
{
_themeManager.ApplyFromString(mode);
await _settingsService.SetThemeAsync(mode);
}
catch (Exception ex)
{
StatusMessage = ex.Message;
}
}
private async Task ApplyLanguageAsync(string code) private async Task ApplyLanguageAsync(string code)
{ {
try try
@@ -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;
@@ -22,6 +23,9 @@ public partial class StorageViewModel : FeatureViewModelBase
private readonly StorageCsvExportService _csvExportService; private readonly StorageCsvExportService _csvExportService;
private readonly StorageHtmlExportService _htmlExportService; private readonly StorageHtmlExportService _htmlExportService;
private readonly IBrandingService? _brandingService; private readonly IBrandingService? _brandingService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ThemeManager? _themeManager;
private readonly ILogger<FeatureViewModelBase> _logger; private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile; private TenantProfile? _currentProfile;
@@ -37,6 +41,14 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDonutChart = true; private bool _isDonutChart = 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;
private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new(); private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
public ObservableCollection<FileTypeMetric> FileTypeMetrics public ObservableCollection<FileTypeMetric> FileTypeMetrics
{ {
@@ -79,6 +91,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;
@@ -136,7 +157,10 @@ public partial class StorageViewModel : FeatureViewModelBase
StorageCsvExportService csvExportService, StorageCsvExportService csvExportService,
StorageHtmlExportService htmlExportService, StorageHtmlExportService htmlExportService,
IBrandingService brandingService, IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger) ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null,
ThemeManager? themeManager = null)
: base(logger) : base(logger)
{ {
_storageService = storageService; _storageService = storageService;
@@ -144,10 +168,17 @@ public partial class StorageViewModel : FeatureViewModelBase
_csvExportService = csvExportService; _csvExportService = csvExportService;
_htmlExportService = htmlExportService; _htmlExportService = htmlExportService;
_brandingService = brandingService; _brandingService = brandingService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_themeManager = themeManager;
_logger = logger; _logger = logger;
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)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
} }
/// <summary>Test constructor — omits export services.</summary> /// <summary>Test constructor — omits export services.</summary>
@@ -173,14 +204,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;
} }
@@ -194,6 +225,8 @@ public partial class StorageViewModel : FeatureViewModelBase
var allNodes = new List<StorageNode>(); var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>(); var allTypeMetrics = new List<FileTypeMetric>();
var autoOwnership = await IsAutoTakeOwnershipEnabled();
int i = 0; int i = 0;
foreach (var url in nonEmpty) foreach (var url in nonEmpty)
{ {
@@ -207,9 +240,30 @@ public partial class StorageViewModel : FeatureViewModelBase
ClientId = _currentProfile.ClientId, ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name Name = _currentProfile.Name
}; };
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct); var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct); IReadOnlyList<StorageNode> nodes;
try
{
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership)
{
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url);
var adminProfile = new TenantProfile
{
TenantUrl = adminUrl,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
}
// Backfill any libraries where StorageMetrics returned zeros // Backfill any libraries where StorageMetrics returned zeros
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct); await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
@@ -258,6 +312,24 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportHtmlCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged();
} }
private async Task<bool> IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress) internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
@@ -278,7 +350,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)
@@ -309,7 +381,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)
@@ -324,11 +396,25 @@ public partial class StorageViewModel : FeatureViewModelBase
UpdateChartSeries(); UpdateChartSeries();
} }
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
private void ApplyChartThemeColors()
{
LegendTextPaint.Color = ChartFgColor;
LegendBackgroundPaint.Color = ChartSurfaceColor;
TooltipTextPaint.Color = ChartFgColor;
TooltipBackgroundPaint.Color = ChartSurfaceColor;
}
private void UpdateChartSeries() 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>();
@@ -361,6 +447,7 @@ public partial class StorageViewModel : FeatureViewModelBase
HoverPushout = 8, HoverPushout = 8,
MaxRadialColumnWidth = 60, MaxRadialColumnWidth = 60,
DataLabelsFormatter = _ => m.DisplayLabel, DataLabelsFormatter = _ => m.DisplayLabel,
DataLabelsPaint = new SolidColorPaint(ChartFgColor),
ToolTipLabelFormatter = _ => ToolTipLabelFormatter = _ =>
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)", $"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
IsVisibleAtLegend = true, IsVisibleAtLegend = true,
@@ -379,7 +466,8 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
int idx = (int)point.Index; int idx = (int)point.Index;
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : ""; return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
} },
DataLabelsPaint = new SolidColorPaint(ChartFgColor)
} }
}; };
@@ -388,7 +476,10 @@ public partial class StorageViewModel : FeatureViewModelBase
new Axis new Axis
{ {
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(), Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
LabelsRotation = -45 LabelsRotation = -45,
LabelsPaint = new SolidColorPaint(ChartFgColor),
TicksPaint = new SolidColorPaint(ChartFgColor),
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
} }
}; };
@@ -396,7 +487,10 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
new Axis new Axis
{ {
Labeler = value => FormatBytes((long)value) Labeler = value => FormatBytes((long)value),
LabelsPaint = new SolidColorPaint(ChartFgColor),
TicksPaint = new SolidColorPaint(ChartFgColor),
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
} }
}; };
} }
@@ -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;
@@ -15,6 +16,8 @@ public partial class TransferViewModel : FeatureViewModelBase
private readonly IFileTransferService _transferService; private readonly IFileTransferService _transferService;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly BulkResultCsvExportService _exportService; private readonly BulkResultCsvExportService _exportService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ILogger<FeatureViewModelBase> _logger; private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile; private TenantProfile? _currentProfile;
private bool _hasLocalSourceSiteOverride; private bool _hasLocalSourceSiteOverride;
@@ -32,6 +35,17 @@ public partial class TransferViewModel : FeatureViewModelBase
// Transfer options // Transfer options
[ObservableProperty] private TransferMode _transferMode = TransferMode.Copy; [ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
[ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip; [ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip;
[ObservableProperty] private bool _includeSourceFolder;
[ObservableProperty] private bool _copyFolderContents = true;
/// <summary>
/// Library-relative file paths the user checked in the source picker.
/// When non-empty, only these files are transferred — folder recursion is skipped.
/// </summary>
public List<string> SelectedFilePaths { get; } = new();
/// <summary>Count of per-file selections, for display in the view.</summary>
public int SelectedFileCount => SelectedFilePaths.Count;
// Results // Results
[ObservableProperty] private string _resultSummary = string.Empty; [ObservableProperty] private string _resultSummary = string.Empty;
@@ -51,12 +65,16 @@ public partial class TransferViewModel : FeatureViewModelBase
IFileTransferService transferService, IFileTransferService transferService,
ISessionManager sessionManager, ISessionManager sessionManager,
BulkResultCsvExportService exportService, BulkResultCsvExportService exportService,
ILogger<FeatureViewModelBase> logger) ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null)
: base(logger) : base(logger)
{ {
_transferService = transferService; _transferService = transferService;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_exportService = exportService; _exportService = exportService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_logger = logger; _logger = logger;
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures); ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
@@ -84,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)";
@@ -108,6 +127,9 @@ public partial class TransferViewModel : FeatureViewModelBase
DestinationFolderPath = DestFolderPath, DestinationFolderPath = DestFolderPath,
Mode = TransferMode, Mode = TransferMode,
ConflictPolicy = ConflictPolicy, ConflictPolicy = ConflictPolicy,
SelectedFilePaths = SelectedFilePaths.ToList(),
IncludeSourceFolder = IncludeSourceFolder,
CopyFolderContents = CopyFolderContents,
}; };
// Build per-site profiles so SessionManager can resolve contexts // Build per-site profiles so SessionManager can resolve contexts
@@ -127,7 +149,33 @@ public partial class TransferViewModel : FeatureViewModelBase
var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct); var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct); var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct); var autoOwnership = await IsAutoTakeOwnershipEnabled();
try
{
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException ex)
when (_ownershipService != null && autoOwnership)
{
_logger.LogWarning(ex, "Transfer hit access denied — auto-elevating on source and destination.");
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? SourceSiteUrl);
var adminProfile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = adminUrl,
ClientId = _currentProfile.ClientId,
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, SourceSiteUrl, string.Empty, ct);
await _ownershipService.ElevateAsync(adminCtx, DestSiteUrl, string.Empty, ct);
// Retry with fresh contexts so the new admin membership is honoured.
srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
}
// Update UI on dispatcher // Update UI on dispatcher
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
@@ -182,6 +230,34 @@ public partial class TransferViewModel : FeatureViewModelBase
DestFolderPath = string.Empty; DestFolderPath = string.Empty;
ResultSummary = string.Empty; ResultSummary = string.Empty;
HasFailures = false; HasFailures = false;
SelectedFilePaths.Clear();
OnPropertyChanged(nameof(SelectedFileCount));
_lastResult = null; _lastResult = null;
} }
/// <summary>Replaces the current per-file selection and notifies the view.</summary>
public void SetSelectedFiles(IEnumerable<string> libraryRelativePaths)
{
SelectedFilePaths.Clear();
SelectedFilePaths.AddRange(libraryRelativePaths);
OnPropertyChanged(nameof(SelectedFileCount));
}
private async Task<bool> IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
} }
@@ -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;
@@ -27,6 +28,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
private readonly UserAccessHtmlExportService? _htmlExportService; private readonly UserAccessHtmlExportService? _htmlExportService;
private readonly IBrandingService? _brandingService; private readonly IBrandingService? _brandingService;
private readonly IGraphUserDirectoryService? _graphUserDirectoryService; private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ILogger<FeatureViewModelBase> _logger; private readonly ILogger<FeatureViewModelBase> _logger;
// ── People picker debounce ────────────────────────────────────────────── // ── People picker debounce ──────────────────────────────────────────────
@@ -105,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 ─────────────────────────────────────────
@@ -163,7 +179,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
UserAccessHtmlExportService htmlExportService, UserAccessHtmlExportService htmlExportService,
IBrandingService brandingService, IBrandingService brandingService,
IGraphUserDirectoryService graphUserDirectoryService, IGraphUserDirectoryService graphUserDirectoryService,
ILogger<FeatureViewModelBase> logger) ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null)
: base(logger) : base(logger)
{ {
_auditService = auditService; _auditService = auditService;
@@ -173,6 +191,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
_htmlExportService = htmlExportService; _htmlExportService = htmlExportService;
_brandingService = brandingService; _brandingService = brandingService;
_graphUserDirectoryService = graphUserDirectoryService; _graphUserDirectoryService = graphUserDirectoryService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_logger = logger; _logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
@@ -248,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;
} }
@@ -269,10 +289,39 @@ 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;
} }
var autoOwnership = await IsAutoTakeOwnershipEnabled();
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null;
if (_ownershipService != null && autoOwnership)
{
onAccessDenied = async (siteUrl, token) =>
{
try
{
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", siteUrl);
var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? siteUrl);
var adminProfile = new TenantProfile
{
TenantUrl = adminUrl,
ClientId = _currentProfile?.ClientId ?? string.Empty,
Name = _currentProfile?.Name ?? string.Empty
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, token);
await _ownershipService.ElevateAsync(adminCtx, siteUrl, string.Empty, token);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Auto-elevation failed for {Url}", siteUrl);
return false;
}
};
}
var entries = await _auditService.AuditUsersAsync( var entries = await _auditService.AuditUsersAsync(
_sessionManager, _sessionManager,
_currentProfile, _currentProfile,
@@ -280,7 +329,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
effectiveSites, effectiveSites,
scanOptions, scanOptions,
progress, progress,
ct); ct,
onAccessDenied);
// Update Results on the UI thread — clear + repopulate (not replace) // Update Results on the UI thread — clear + repopulate (not replace)
// so the CollectionViewSource bound to ResultsView stays connected. // so the CollectionViewSource bound to ResultsView stays connected.
@@ -307,6 +357,26 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportHtmlCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged();
} }
// ── Auto-ownership helpers ───────────────────────────────────────────────
private async Task<bool> IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
// ── Tenant switching ───────────────────────────────────────────────────── // ── Tenant switching ─────────────────────────────────────────────────────
protected override void OnTenantSwitched(TenantProfile profile) protected override void OnTenantSwitched(TenantProfile profile)
@@ -402,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;
} }
@@ -496,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)
@@ -527,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,26 @@
<UserControl x:Class="SharepointToolbox.Views.Common.Spinner"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="20" Height="20">
<Grid RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<RotateTransform x:Name="Rot" Angle="0" />
</Grid.RenderTransform>
<Grid.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="Rot"
Storyboard.TargetProperty="Angle"
From="0" To="360" Duration="0:0:1"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Grid.Triggers>
<Ellipse Stroke="{DynamicResource BorderSoftBrush}" StrokeThickness="3" />
<Ellipse Stroke="{DynamicResource AccentBrush}" StrokeThickness="3"
StrokeDashArray="3 3" StrokeDashCap="Round" />
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace SharepointToolbox.Views.Common;
public partial class Spinner : UserControl
{
public Spinner()
{
InitializeComponent();
}
}
@@ -4,6 +4,9 @@
xmlns:loc="clr-namespace:SharepointToolbox.Localization" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}"
Width="450" Height="220" WindowStartupLocation="CenterOwner" Width="450" Height="220" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="NoResize"> ResizeMode="NoResize">
<Grid Margin="20"> <Grid Margin="20">
<Grid.RowDefinitions> <Grid.RowDefinitions>
@@ -3,13 +3,24 @@
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="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.title]}"
Width="400" Height="500" WindowStartupLocation="CenterOwner" Width="520" Height="560" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="CanResizeWithGrip"> ResizeMode="CanResizeWithGrip">
<DockPanel Margin="10"> <DockPanel Margin="10">
<!-- Status --> <!-- Status -->
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,10" <TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,10"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.loading]}" /> Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.loading]}" />
<!-- Action bar: new folder (destination mode only) -->
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
Margin="0,0,0,6" Visibility="Collapsed">
<Button x:Name="NewFolderButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
</StackPanel>
<!-- Buttons --> <!-- Buttons -->
<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">
@@ -8,13 +8,38 @@ namespace SharepointToolbox.Views.Dialogs;
public partial class FolderBrowserDialog : Window public partial class FolderBrowserDialog : Window
{ {
private readonly ClientContext _ctx; private readonly ClientContext _ctx;
private readonly bool _allowFileSelection;
private readonly bool _allowFolderCreation;
public string SelectedLibrary { get; private set; } = string.Empty; public string SelectedLibrary { get; private set; } = string.Empty;
public string SelectedFolderPath { get; private set; } = string.Empty; public string SelectedFolderPath { get; private set; } = string.Empty;
public FolderBrowserDialog(ClientContext ctx) /// <summary>
/// Library-relative file paths checked by the user. Only populated when
/// <paramref name="allowFileSelection"/> was true. Empty if the user picked
/// a folder node instead.
/// </summary>
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
private readonly List<CheckBox> _fileCheckboxes = new();
private readonly List<TreeViewItem> _expandedNodes = new();
/// <summary>
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
/// to show individual files (with sizes) and allow ticking them for targeted
/// transfer. Set <paramref name="allowFolderCreation"/> to expose a "New
/// Folder" button that creates a folder under the selected node.
/// </summary>
public FolderBrowserDialog(ClientContext ctx,
bool allowFileSelection = false,
bool allowFolderCreation = false)
{ {
InitializeComponent(); InitializeComponent();
_ctx = ctx; _ctx = ctx;
_allowFileSelection = allowFileSelection;
_allowFolderCreation = allowFolderCreation;
if (allowFolderCreation)
ActionBar.Visibility = Visibility.Visible;
Loaded += OnLoaded; Loaded += OnLoaded;
} }
@@ -22,24 +47,19 @@ public partial class FolderBrowserDialog : Window
{ {
try try
{ {
// Load libraries
var web = _ctx.Web; var web = _ctx.Web;
var lists = _ctx.LoadQuery(web.Lists var lists = _ctx.LoadQuery(web.Lists
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.RootFolder) .Include(l => l.Title, l => l.Hidden, l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl)
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)); .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary));
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);
foreach (var list in lists) foreach (var list in lists)
{ {
var libNode = new TreeViewItem var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
{ var libNode = MakeFolderNode(list.Title,
Header = list.Title, new FolderNodeInfo(list.Title, string.Empty, rootUrl));
Tag = new FolderNodeInfo(list.Title, string.Empty),
};
// Add dummy child for expand arrow
libNode.Items.Add(new TreeViewItem { Header = "Loading..." });
libNode.Expanded += LibNode_Expanded;
FolderTree.Items.Add(libNode); FolderTree.Items.Add(libNode);
} }
@@ -51,52 +71,146 @@ public partial class FolderBrowserDialog : Window
} }
} }
private async void LibNode_Expanded(object sender, RoutedEventArgs e) private TreeViewItem MakeFolderNode(string name, FolderNodeInfo info)
{
var node = new TreeViewItem
{
Header = name,
Tag = info,
};
// Placeholder child so the expand arrow appears.
node.Items.Add(new TreeViewItem { Header = "Loading..." });
node.Expanded += FolderNode_Expanded;
_expandedNodes.Add(node);
return node;
}
private async void FolderNode_Expanded(object sender, RoutedEventArgs e)
{ {
if (sender is not TreeViewItem node || node.Tag is not FolderNodeInfo info) if (sender is not TreeViewItem node || node.Tag is not FolderNodeInfo info)
return; return;
// Only load children once // Only load children once.
if (node.Items.Count == 1 && node.Items[0] is TreeViewItem dummy && dummy.Header?.ToString() == "Loading...") if (!(node.Items.Count == 1
&& node.Items[0] is TreeViewItem dummy
&& dummy.Header?.ToString() == "Loading..."))
return;
node.Items.Clear();
try
{ {
node.Items.Clear(); var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
try _ctx.Load(folder, f => f.StorageMetrics.TotalSize,
f => f.StorageMetrics.TotalFileCount);
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
_ctx.Load(list, l => l.Title);
var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
// Annotate the parent node header with total metrics now that they loaded.
node.Header = FormatFolderHeader(info.LibraryTitle == info.RelativePath || string.IsNullOrEmpty(info.RelativePath)
? (string)node.Header!
: System.IO.Path.GetFileName(info.RelativePath),
folder.StorageMetrics.TotalFileCount,
folder.StorageMetrics.TotalSize);
// Enumerate direct children via paginated CAML — 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))
{ {
var folderUrl = string.IsNullOrEmpty(info.FolderPath) var fsType = item["FSObjType"]?.ToString();
? GetLibraryRootUrl(info.LibraryTitle) var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
: info.FolderPath; var fileRef = item["FileRef"]?.ToString() ?? string.Empty;
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl); if (fsType == "1")
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
foreach (var subFolder in folder.Folders)
{ {
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms") if (name.StartsWith("_") || name == "Forms") continue;
continue; subFolders.Add((name, fileRef));
}
var childNode = new TreeViewItem else if (fsType == "0" && _allowFileSelection)
{ {
Header = subFolder.Name, long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long size);
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl), filesInFolder.Add((name, size, fileRef));
};
childNode.Items.Add(new TreeViewItem { Header = "Loading..." });
childNode.Expanded += LibNode_Expanded;
node.Items.Add(childNode);
} }
} }
catch (Exception ex)
// 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++)
{ {
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" }); var (name, url) = subFolders[i];
var childRelative = string.IsNullOrEmpty(info.RelativePath)
? name
: $"{info.RelativePath}/{name}";
var childInfo = new FolderNodeInfo(info.LibraryTitle, childRelative, url);
var childNode = MakeFolderNode(
FormatFolderHeader(name,
metricFolders[i].StorageMetrics.TotalFileCount,
metricFolders[i].StorageMetrics.TotalSize),
childInfo);
node.Items.Add(childNode);
} }
// Files under this folder — only shown when selection is enabled.
if (_allowFileSelection)
{
foreach (var (fileName, fileSize, _) in filesInFolder)
{
var fileRel = string.IsNullOrEmpty(info.RelativePath)
? fileName
: $"{info.RelativePath}/{fileName}";
var cb = new CheckBox
{
Content = $"{fileName} ({FormatSize(fileSize)})",
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
Margin = new Thickness(4, 2, 0, 2),
};
cb.Checked += FileCheckbox_Toggled;
cb.Unchecked += FileCheckbox_Toggled;
_fileCheckboxes.Add(cb);
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
node.Items.Add(fileItem);
}
}
}
catch (Exception ex)
{
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
} }
} }
private string GetLibraryRootUrl(string libraryTitle) private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
{ {
var uri = new Uri(_ctx.Url); if (fileCount <= 0) return name;
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}"; return $"{name} ({fileCount} files, {FormatSize(totalBytes)})";
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "0 B";
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";
} }
private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
@@ -104,13 +218,83 @@ public partial class FolderBrowserDialog : Window
if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info) if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info)
{ {
SelectedLibrary = info.LibraryTitle; SelectedLibrary = info.LibraryTitle;
SelectedFolderPath = info.FolderPath; SelectedFolderPath = info.RelativePath;
SelectButton.IsEnabled = true; SelectButton.IsEnabled = true;
NewFolderButton.IsEnabled = _allowFolderCreation;
}
else
{
// File nodes have CheckBox headers, not FolderNodeInfo tags.
NewFolderButton.IsEnabled = false;
}
}
private void FileCheckbox_Toggled(object sender, RoutedEventArgs e)
{
// Enable "Select" as soon as any file is checked — user can confirm
// purely via file selection without also picking a folder node.
if (_fileCheckboxes.Any(c => c.IsChecked == true))
SelectButton.IsEnabled = true;
}
private async void NewFolder_Click(object sender, RoutedEventArgs e)
{
if (FolderTree.SelectedItem is not TreeViewItem node ||
node.Tag is not FolderNodeInfo info)
return;
var dlg = new InputDialog("New folder name:", string.Empty)
{
Owner = this
};
if (dlg.ShowDialog() != true) return;
var folderName = dlg.ResponseText.Trim();
if (string.IsNullOrEmpty(folderName)) return;
try
{
var parent = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
var created = parent.Folders.Add(folderName);
_ctx.Load(created, f => f.ServerRelativeUrl, f => f.Name);
var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
var childRelative = string.IsNullOrEmpty(info.RelativePath)
? created.Name
: $"{info.RelativePath}/{created.Name}";
var childInfo = new FolderNodeInfo(
info.LibraryTitle, childRelative, created.ServerRelativeUrl);
var childNode = MakeFolderNode(created.Name, childInfo);
// Expand the parent so the fresh folder is visible immediately.
node.IsExpanded = true;
node.Items.Add(childNode);
StatusText.Text = $"Created: {created.ServerRelativeUrl}";
}
catch (Exception ex)
{
StatusText.Text = $"Error: {ex.Message}";
} }
} }
private void Select_Click(object sender, RoutedEventArgs e) private void Select_Click(object sender, RoutedEventArgs e)
{ {
// Harvest checked files (library-relative paths).
SelectedFilePaths = _fileCheckboxes
.Where(c => c.IsChecked == true && c.Tag is FileNodeInfo)
.Select(c => ((FileNodeInfo)c.Tag!).RelativePath)
.ToList();
// If files were picked but no folder node was selected, borrow the
// library from the first file so the caller still has a valid target.
if (SelectedFilePaths.Count > 0 && string.IsNullOrEmpty(SelectedLibrary))
{
var firstTag = (FileNodeInfo)_fileCheckboxes
.First(c => c.IsChecked == true && c.Tag is FileNodeInfo).Tag!;
SelectedLibrary = firstTag.LibraryTitle;
SelectedFolderPath = string.Empty;
}
DialogResult = true; DialogResult = true;
Close(); Close();
} }
@@ -121,5 +305,21 @@ public partial class FolderBrowserDialog : Window
Close(); Close();
} }
private record FolderNodeInfo(string LibraryTitle, string FolderPath); 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 FileNodeInfo(string LibraryTitle, string RelativePath);
} }
@@ -0,0 +1,24 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
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=[input.title]}"
Width="340" Height="140"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="NoResize">
<DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,10,0,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" />
<Button Content="OK" Width="70" IsDefault="True"
Click="Ok_Click" />
</StackPanel>
<TextBlock x:Name="PromptText" DockPanel.Dock="Top" Margin="0,0,0,6" />
<TextBox x:Name="ResponseBox" VerticalContentAlignment="Center" Padding="4" />
</DockPanel>
</Window>
@@ -0,0 +1,28 @@
using System.Windows;
namespace SharepointToolbox.Views.Dialogs;
public partial class InputDialog : Window
{
public string ResponseText => ResponseBox.Text;
public InputDialog(string prompt, string initialValue)
{
InitializeComponent();
PromptText.Text = prompt;
ResponseBox.Text = initialValue ?? string.Empty;
Loaded += (_, _) => { ResponseBox.Focus(); ResponseBox.SelectAll(); };
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
Close();
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
@@ -2,8 +2,12 @@
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}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="NoResize"> ResizeMode="NoResize">
<Window.Resources> <Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" /> <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
@@ -19,7 +23,7 @@
</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}"
@@ -50,14 +54,14 @@
<TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="2" Grid.Column="1" Margin="0,2" /> Grid.Row="2" Grid.Column="1" Margin="0,2" />
<TextBlock Grid.Row="3" Grid.Column="1" Margin="0,2,0,0" <TextBlock Grid.Row="3" Grid.Column="1" Margin="0,2,0,0"
FontSize="11" FontStyle="Italic" Foreground="#666666" TextWrapping="Wrap" FontSize="11" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap"
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>
<!-- Client Logo --> <!-- Client Logo -->
<StackPanel Grid.Row="3" Margin="0,8,0,8"> <StackPanel Grid.Row="3" 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="#DDDDDD" 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">
<Grid> <Grid>
<Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}" <Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
@@ -65,7 +69,7 @@
Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" /> Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}"
VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center"
Foreground="#999999" FontStyle="Italic"> Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" /> <Setter Property="Visibility" Value="Visible" />
@@ -87,7 +91,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}"
Command="{Binding AutoPullClientLogoCommand}" Width="130" /> Command="{Binding AutoPullClientLogoCommand}" Width="130" />
</StackPanel> </StackPanel>
<TextBlock Text="{Binding ValidationMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0" <TextBlock Text="{Binding ValidationMessage}" Foreground="{DynamicResource DangerBrush}" FontSize="11" Margin="0,4,0,0"
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}" /> Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel> </StackPanel>
@@ -107,11 +111,11 @@
<!-- Status text --> <!-- Status text -->
<TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0" <TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0"
Foreground="#006600" Foreground="{DynamicResource SuccessBrush}"
Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" /> Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" />
<!-- Fallback instructions panel --> <!-- Fallback instructions panel -->
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0" <Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0"
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}"> Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}">
<StackPanel> <StackPanel>
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step1]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step1]}"
@@ -134,11 +138,12 @@
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}" Width="60" Margin="4,0" /> Command="{Binding AddCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.rename]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
Command="{Binding RenameCommand}" Width="60" Margin="4,0" /> Command="{Binding SaveCommand}" MinWidth="80" Padding="6,0" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" /> Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
<Button Content="Close" Width="60" Margin="4,0" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
Width="60" Margin="4,0"
Click="CloseButton_Click" IsCancel="True" /> Click="CloseButton_Click" IsCancel="True" />
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -1,30 +1,73 @@
<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}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
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="#CCCCCC"> 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}"
@@ -32,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="#555555" 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";
} }
} }
@@ -1,10 +1,13 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView" <UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10"> <DockPanel Margin="10">
<!-- Options Panel (Left) --> <!-- Options Panel (Left) -->
<StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"> <ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" /> Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
@@ -19,8 +22,8 @@
Command="{Binding CancelCommand}" Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}" <common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" /> <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
@@ -29,6 +32,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}" /> Command="{Binding ExportFailedCommand}" />
</StackPanel> </StackPanel>
</ScrollViewer>
<!-- Preview DataGrid (Right) --> <!-- Preview DataGrid (Right) -->
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False" <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
@@ -44,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>
@@ -1,9 +1,12 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView" <UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10"> <DockPanel Margin="10">
<StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"> <ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" /> Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
@@ -18,8 +21,8 @@
Command="{Binding CancelCommand}" Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}" <common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" /> <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
@@ -28,6 +31,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}" /> Command="{Binding ExportFailedCommand}" />
</StackPanel> </StackPanel>
</ScrollViewer>
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False" <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
IsReadOnly="True" CanUserSortColumns="True"> IsReadOnly="True" CanUserSortColumns="True">
@@ -42,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]}"
@@ -51,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>
@@ -18,7 +18,7 @@
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8"> <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
<StackPanel Margin="4"> <StackPanel Margin="4">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,0,0,6" /> TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,6" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}"
IsChecked="{Binding MatchSize}" Margin="0,2" /> IsChecked="{Binding MatchSize}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}"
@@ -44,28 +44,51 @@
<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" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.open.results]}" <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]}"
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" /> Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<!-- Results DataGrid --> <!-- Results DataGrid -->
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False" <DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8"> VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="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="*" /> <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>
@@ -1,9 +1,12 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView" <UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10"> <DockPanel Margin="10">
<StackPanel DockPanel.Dock="Left" Width="280" Margin="0,0,10,0"> <ScrollViewer DockPanel.Dock="Left" Width="280" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- Library input --> <!-- Library input -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
Margin="0,0,0,3" /> Margin="0,0,0,3" />
@@ -23,14 +26,15 @@
Command="{Binding CancelCommand}" Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}" <common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" /> <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}" /> Command="{Binding ExportFailedCommand}" />
</StackPanel> </StackPanel>
</ScrollViewer>
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False" <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
IsReadOnly="True" CanUserSortColumns="True"> IsReadOnly="True" CanUserSortColumns="True">
@@ -45,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>
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common"
xmlns:models="clr-namespace:SharepointToolbox.Core.Models" xmlns:models="clr-namespace:SharepointToolbox.Core.Models"
xmlns:converters="clr-namespace:SharepointToolbox.Core.Converters"> xmlns:converters="clr-namespace:SharepointToolbox.Core.Converters">
@@ -21,7 +22,9 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Left panel: Scan configuration --> <!-- Left panel: Scan configuration -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8"> <ScrollViewer Grid.Column="0" Grid.Row="0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<DockPanel Margin="8">
<!-- Scan Options GroupBox --> <!-- Scan Options GroupBox -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}" <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
@@ -65,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>
@@ -108,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="*" />
@@ -125,6 +138,7 @@
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</ScrollViewer>
<!-- Right panel: Summary + Results --> <!-- Right panel: Summary + Results -->
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,8"> <Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,8">
@@ -153,7 +167,8 @@
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border CornerRadius="6" Padding="14,10" Margin="0,0,10,4" MinWidth="140"> <Border CornerRadius="6" Padding="14,10" Margin="0,0,10,4" MinWidth="140"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<Border.Style> <Border.Style>
<Style TargetType="Border"> <Style TargetType="Border">
<Setter Property="Background" Value="#F3F4F6" /> <Setter Property="Background" Value="#F3F4F6" />
@@ -182,7 +197,7 @@
</Style> </Style>
</Border.Style> </Border.Style>
<StackPanel> <StackPanel>
<TextBlock Text="{Binding Count}" FontSize="22" FontWeight="Bold" /> <TextBlock Text="{Binding Count}" FontSize="22" FontWeight="Bold" Foreground="#1F2430" />
<TextBlock Text="{Binding Label}" FontSize="11" Foreground="#555" /> <TextBlock Text="{Binding Label}" FontSize="11" Foreground="#555" />
<TextBlock FontSize="10" Foreground="#888" Margin="0,2,0,0"> <TextBlock FontSize="10" Foreground="#888" Margin="0,2,0,0">
<Run Text="{Binding DistinctUsers, Mode=OneWay}" /> <Run Text="{Binding DistinctUsers, Mode=OneWay}" />
@@ -222,19 +237,24 @@
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}"> <DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}">
<Setter Property="Background" Value="#FEF2F2" /> <Setter Property="Background" Value="#FEF2F2" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}"> <DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}">
<Setter Property="Background" Value="#FFFBEB" /> <Setter Property="Background" Value="#FFFBEB" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}"> <DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}">
<Setter Property="Background" Value="#ECFDF5" /> <Setter Property="Background" Value="#ECFDF5" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}"> <DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}">
<Setter Property="Background" Value="#EFF6FF" /> <Setter Property="Background" Value="#EFF6FF" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger> </DataTrigger>
<!-- Phase 18: auto-elevated rows get amber background + tooltip --> <!-- Phase 18: auto-elevated rows get amber background + tooltip -->
<DataTrigger Binding="{Binding WasAutoElevated}" Value="True"> <DataTrigger Binding="{Binding WasAutoElevated}" Value="True">
<Setter Property="Background" Value="#FFF9E6" /> <Setter Property="Background" Value="#FFF9E6" />
<Setter Property="Foreground" Value="#1F2430" />
<Setter Property="ToolTip" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[permissions.elevated.tooltip]}" /> <Setter Property="ToolTip" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[permissions.elevated.tooltip]}" />
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
@@ -261,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}"
@@ -277,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>
@@ -286,9 +315,8 @@
<!-- Bottom: status bar spanning both columns --> <!-- Bottom: status bar spanning both columns -->
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1"> <StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
<StatusBarItem> <StatusBarItem>
<ProgressBar Width="150" Height="14" <common:Spinner Width="14" Height="14"
Value="{Binding ProgressValue}" Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
Minimum="0" Maximum="100" />
</StatusBarItem> </StatusBarItem>
<StatusBarItem Content="{Binding StatusMessage}" /> <StatusBarItem Content="{Binding StatusMessage}" />
</StatusBar> </StatusBar>
+1 -1
View File
@@ -72,7 +72,7 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
+21 -4
View File
@@ -2,6 +2,7 @@
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">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16"> <StackPanel Margin="16">
<!-- Language --> <!-- Language -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.language]}" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.language]}" />
@@ -16,6 +17,21 @@
<Separator Margin="0,12" /> <Separator Margin="0,12" />
<!-- Theme -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme]}" />
<ComboBox Width="200" HorizontalAlignment="Left"
SelectedValue="{Binding SelectedTheme}"
SelectedValuePath="Tag">
<ComboBoxItem Tag="System"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme.system]}" />
<ComboBoxItem Tag="Light"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme.light]}" />
<ComboBoxItem Tag="Dark"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme.dark]}" />
</ComboBox>
<Separator Margin="0,12" />
<!-- Data folder --> <!-- Data folder -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.folder]}" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.folder]}" />
<DockPanel> <DockPanel>
@@ -29,7 +45,7 @@
<!-- MSP Logo --> <!-- MSP Logo -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.title]}" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.title]}" />
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4" <Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
HorizontalAlignment="Left" MinWidth="200" MinHeight="60" Margin="0,4,0,0"> HorizontalAlignment="Left" MinWidth="200" MinHeight="60" Margin="0,4,0,0">
<Grid> <Grid>
<Image Source="{Binding MspLogoPreview, Converter={StaticResource Base64ToImageConverter}}" <Image Source="{Binding MspLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
@@ -37,7 +53,7 @@
Visibility="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" /> Visibility="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.nopreview]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.nopreview]}"
VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center"
Foreground="#999999" FontStyle="Italic"> Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" /> <Setter Property="Visibility" Value="Visible" />
@@ -65,9 +81,10 @@
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.auto]}" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.auto]}"
Margin="0,4,0,0" /> Margin="0,4,0,0" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.description]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.description]}"
Foreground="#666666" FontSize="11" TextWrapping="Wrap" Margin="20,4,0,0" /> Foreground="{DynamicResource TextMutedBrush}" FontSize="11" TextWrapping="Wrap" Margin="20,4,0,0" />
<TextBlock Text="{Binding StatusMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0" <TextBlock Text="{Binding StatusMessage}" Foreground="{DynamicResource DangerBrush}" FontSize="11" Margin="0,4,0,0"
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" /> Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel> </StackPanel>
</ScrollViewer>
</UserControl> </UserControl>
+37 -21
View File
@@ -28,7 +28,7 @@
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}" <CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth}" Margin="0,2" /> IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#888" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
Margin="0,6,0,0" /> Margin="0,6,0,0" />
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -45,6 +45,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" />
@@ -68,7 +78,7 @@
<!-- Status --> <!-- Status -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
FontSize="11" Foreground="#555" Margin="0,4" /> FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -82,7 +92,7 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Summary bar --> <!-- Summary bar -->
<Border Grid.Row="0" Background="#F0F7FF" CornerRadius="4" Padding="12,8" Margin="0,0,0,6"> <Border Grid.Row="0" Background="{DynamicResource AccentSoftBrush}" CornerRadius="4" Padding="12,8" Margin="0,0,0,6">
<Border.Style> <Border.Style>
<Style TargetType="Border"> <Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" /> <Setter Property="Visibility" Value="Collapsed" />
@@ -94,18 +104,18 @@
</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">
<Run Text="Files: " FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" />
<Run Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</TextBlock> </StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -147,11 +157,11 @@
<!-- Splitter between DataGrid and Chart --> <!-- Splitter between DataGrid and Chart -->
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch" <GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
Background="#DDD" ResizeDirection="Rows" /> Background="{DynamicResource BorderSoftBrush}" ResizeDirection="Rows" />
<!-- Chart panel --> <!-- Chart panel -->
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4" <Border Grid.Row="3" BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" CornerRadius="4"
Padding="8" Background="White"> Padding="8" Background="{DynamicResource SurfaceBrush}">
<Grid> <Grid>
<!-- Chart title --> <!-- Chart title -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.title]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.title]}"
@@ -160,7 +170,7 @@
<!-- No data placeholder --> <!-- No data placeholder -->
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="#888" FontSize="12" Foreground="{DynamicResource TextMutedBrush}" FontSize="12"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.nodata]}"> Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.nodata]}">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
@@ -192,7 +202,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 +228,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)
{ {
@@ -4,7 +4,9 @@
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<DockPanel Margin="10"> <DockPanel Margin="10">
<!-- Left panel: Capture and Apply --> <!-- Left panel: Capture and Apply -->
<StackPanel DockPanel.Dock="Left" Width="320" Margin="0,0,10,0"> <ScrollViewer DockPanel.Dock="Left" Width="320" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- Capture Section --> <!-- Capture Section -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}" <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
Margin="0,0,0,10"> Margin="0,0,0,10">
@@ -52,6 +54,7 @@
<!-- Progress --> <!-- Progress -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,5,0,0" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,5,0,0" />
</StackPanel> </StackPanel>
</ScrollViewer>
<!-- Right panel: Template list --> <!-- Right panel: Template list -->
<DockPanel> <DockPanel>
@@ -68,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>
+24 -7
View File
@@ -1,10 +1,13 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView" <UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10"> <DockPanel Margin="10">
<!-- Options Panel (Left) --> <!-- Options Panel (Left) -->
<StackPanel DockPanel.Dock="Left" Width="340" Margin="0,0,10,0"> <ScrollViewer DockPanel.Dock="Left" Width="340" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- Source --> <!-- Source -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}" <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
Margin="0,0,0,10"> Margin="0,0,0,10">
@@ -14,7 +17,21 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
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="Gray" /> <TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
<StackPanel Orientation="Horizontal">
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
Text="{Binding SelectedFileCount, Mode=OneWay}" />
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
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}"
Margin="0,6,0,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}"
IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -27,7 +44,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
Click="BrowseDest_Click" Margin="0,0,0,5" /> Click="BrowseDest_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" /> <TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DestFolderPath}" Foreground="Gray" /> <TextBlock Text="{Binding DestFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -62,9 +79,8 @@
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Progress --> <!-- Progress -->
<ProgressBar Height="20" Margin="0,10,0,5" <common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Value="{Binding ProgressValue}" Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
<!-- Results --> <!-- Results -->
@@ -74,6 +90,7 @@
Command="{Binding ExportFailedCommand}" Command="{Binding ExportFailedCommand}"
Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel> </StackPanel>
</ScrollViewer>
<!-- Right panel placeholder for future enhancements --> <!-- Right panel placeholder for future enhancements -->
<Border /> <Border />
@@ -53,11 +53,15 @@ public partial class TransferView : UserControl
ClientId = _viewModel.CurrentProfile.ClientId, ClientId = _viewModel.CurrentProfile.ClientId,
}; };
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None); var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) }; var folderBrowser = new FolderBrowserDialog(ctx, allowFileSelection: true)
{
Owner = Window.GetWindow(this)
};
if (folderBrowser.ShowDialog() == true) if (folderBrowser.ShowDialog() == true)
{ {
_viewModel.SourceLibrary = folderBrowser.SelectedLibrary; _viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
_viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath; _viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
_viewModel.SetSelectedFiles(folderBrowser.SelectedFilePaths);
} }
} }
@@ -81,7 +85,10 @@ public partial class TransferView : UserControl
ClientId = _viewModel.CurrentProfile.ClientId, ClientId = _viewModel.CurrentProfile.ClientId,
}; };
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None); var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) }; var folderBrowser = new FolderBrowserDialog(ctx, allowFolderCreation: true)
{
Owner = Window.GetWindow(this)
};
if (folderBrowser.ShowDialog() == true) if (folderBrowser.ShowDialog() == true)
{ {
_viewModel.DestLibrary = folderBrowser.SelectedLibrary; _viewModel.DestLibrary = folderBrowser.SelectedLibrary;
@@ -1,7 +1,8 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.UserAccessAuditView" <UserControl x:Class="SharepointToolbox.Views.Tabs.UserAccessAuditView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"> xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="290" /> <ColumnDefinition Width="290" />
@@ -13,7 +14,9 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Left panel --> <!-- Left panel -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8"> <ScrollViewer Grid.Column="0" Grid.Row="0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<DockPanel Margin="8">
<!-- Mode toggle --> <!-- Mode toggle -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
@@ -41,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="Gray" 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" />
@@ -115,9 +118,9 @@
<!-- Status row: load status + user count --> <!-- Status row: load status + user count -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding DirectoryLoadStatus}" FontStyle="Italic" Foreground="Gray" FontSize="10" <TextBlock Text="{Binding DirectoryLoadStatus}" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10"
Margin="0,0,8,0" /> Margin="0,0,8,0" />
<TextBlock FontSize="10" Foreground="Gray"> <TextBlock FontSize="10" Foreground="{DynamicResource TextMutedBrush}">
<TextBlock.Text> <TextBlock.Text>
<MultiBinding StringFormat="{}{0} {1}"> <MultiBinding StringFormat="{}{0} {1}">
<Binding Path="DirectoryUserCount" /> <Binding Path="DirectoryUserCount" />
@@ -130,7 +133,7 @@
<!-- Hint text --> <!-- Hint text -->
<TextBlock DockPanel.Dock="Bottom" <TextBlock DockPanel.Dock="Bottom"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.hint.doubleclick]}" Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.hint.doubleclick]}"
FontStyle="Italic" Foreground="Gray" FontSize="10" Margin="0,4,0,0" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10" Margin="0,4,0,0"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<!-- Directory DataGrid --> <!-- Directory DataGrid -->
@@ -142,17 +145,17 @@
CanUserSortColumns="True" CanUserSortColumns="True"
SelectionMode="Single" SelectionUnit="FullRow" SelectionMode="Single" SelectionUnit="FullRow"
HeadersVisibility="Column" GridLinesVisibility="Horizontal" HeadersVisibility="Column" GridLinesVisibility="Horizontal"
BorderThickness="1" BorderBrush="#DDDDDD"> 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">
@@ -181,19 +184,20 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1" <Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
CornerRadius="4" Padding="6,2" Margin="0,1"> CornerRadius="4" Padding="6,2" Margin="0,1"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<DockPanel> <DockPanel>
<Button Content="x" DockPanel.Dock="Right" Padding="4,0" <Button Content="x" DockPanel.Dock="Right" Padding="4,0"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}" Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" /> CommandParameter="{Binding}" />
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" /> <TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" Foreground="#1F2430" />
</DockPanel> </DockPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" /> <TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10" />
</StackPanel> </StackPanel>
<!-- Scan Options (always visible) --> <!-- Scan Options (always visible) -->
@@ -232,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="*" />
@@ -247,6 +262,7 @@
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</ScrollViewer>
<!-- Right panel --> <!-- Right panel -->
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,0"> <Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,0">
@@ -258,37 +274,40 @@
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8"> <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1" <Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
CornerRadius="4" Padding="12,6" Margin="0,0,8,0"> CornerRadius="4" Padding="12,6" Margin="0,0,8,0"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding TotalAccessCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" /> <TextBlock Text="{Binding TotalAccessCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" Foreground="#1F2430" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.total]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.total]}"
FontSize="10" Foreground="Gray" HorizontalAlignment="Center" /> FontSize="10" Foreground="#5B6472" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Border> </Border>
<Border Background="#EAFAF1" BorderBrush="#27AE60" BorderThickness="1" <Border Background="#EAFAF1" BorderBrush="#27AE60" BorderThickness="1"
CornerRadius="4" Padding="12,6" Margin="0,0,8,0"> CornerRadius="4" Padding="12,6" Margin="0,0,8,0"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding SitesCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" /> <TextBlock Text="{Binding SitesCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" Foreground="#1F2430" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.sites]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.sites]}"
FontSize="10" Foreground="Gray" HorizontalAlignment="Center" /> FontSize="10" Foreground="#5B6472" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Border> </Border>
<Border Background="#FDEDEC" BorderBrush="#E74C3C" BorderThickness="1" <Border Background="#FDEDEC" BorderBrush="#E74C3C" BorderThickness="1"
CornerRadius="4" Padding="12,6"> CornerRadius="4" Padding="12,6"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding HighPrivilegeCount}" FontSize="20" FontWeight="Bold" <TextBlock Text="{Binding HighPrivilegeCount}" FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center" Foreground="#C0392B" /> HorizontalAlignment="Center" Foreground="#C0392B" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.highPriv]}" <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.highPriv]}"
FontSize="10" Foreground="Gray" HorizontalAlignment="Center" /> FontSize="10" Foreground="#5B6472" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4"> <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4">
<TextBox Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged}" Width="200" Margin="0,0,8,0" /> <TextBox Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged}" Width="200" Margin="0,0,8,0" />
<ToggleButton IsChecked="{Binding IsGroupByUser}" Padding="8,3"> <ToggleButton IsChecked="{Binding IsGroupByUser}">
<ToggleButton.Style> <ToggleButton.Style>
<Style TargetType="ToggleButton"> <Style TargetType="ToggleButton" BasedOn="{StaticResource ThemeToggleButtonStyle}">
<Setter Property="Content" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.toggle.bySite]}" /> <Setter Property="Content" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.toggle.bySite]}" />
<Style.Triggers> <Style.Triggers>
<Trigger Property="IsChecked" Value="True"> <Trigger Property="IsChecked" Value="True">
@@ -308,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" />
@@ -327,14 +349,14 @@
<DataTemplate> <DataTemplate>
<StackPanel Orientation="Horizontal" Margin="4,2"> <StackPanel Orientation="Horizontal" Margin="4,2">
<TextBlock Text="{Binding Name}" FontWeight="Bold" Margin="0,0,8,0" /> <TextBlock Text="{Binding Name}" FontWeight="Bold" Margin="0,0,8,0" />
<TextBlock Text="{Binding ItemCount, StringFormat=({0})}" Foreground="Gray" /> <TextBlock Text="{Binding ItemCount, StringFormat=({0})}" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</GroupStyle.HeaderTemplate> </GroupStyle.HeaderTemplate>
</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">
@@ -351,20 +373,24 @@
</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">
<TextBlock Text="&#x26A0;" Foreground="#E74C3C" Margin="0,0,4,0" <TextBlock Text="&#x26A0;" Foreground="{DynamicResource DangerBrush}" Margin="0,0,4,0"
FontSize="12" VerticalAlignment="Center"> FontSize="12" VerticalAlignment="Center">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
@@ -382,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>
@@ -406,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>
@@ -415,7 +442,8 @@
<!-- Status bar --> <!-- Status bar -->
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1"> <StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
<StatusBarItem> <StatusBarItem>
<ProgressBar Width="150" Height="14" Value="{Binding ProgressValue}" Minimum="0" Maximum="100" /> <common:Spinner Width="14" Height="14"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
</StatusBarItem> </StatusBarItem>
<StatusBarItem Content="{Binding StatusMessage}" /> <StatusBarItem Content="{Binding StatusMessage}" />
</StatusBar> </StatusBar>