Compare commits

..

8 Commits

Author SHA1 Message Date
Dev b8c09655ac Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-24 10:50:19 +02:00
Dev 12dd1de9f2 chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:23:11 +02:00
Dev 8f30a60d2a Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-15 14:27:36 +02:00
kawa 6e05d26114 Update README.md 2026-04-15 14:27:31 +02:00
kawa a257fbba0a Update README.md 2026-04-15 11:17:51 +02:00
Dev b33c0769d4 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-15 11:16:24 +02:00
Dev fec5ae26e1 chore: clean repo for v2.0 publish
- Remove .planning/ (251 GSD planning files)
- Remove old PowerShell-era files (TODO.md, lang/, examples/)
- Remove accidentally tracked zip
- Rewrite README for .NET WPF app
- Update .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:16:09 +02:00
96 changed files with 9164 additions and 839 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;
+7
View File
@@ -4,6 +4,11 @@
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>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Themes/LightPalette.xaml" />
<ResourceDictionary Source="/Themes/ModernTheme.xaml" />
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" /> <BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<conv:IndentConverter x:Key="IndentConverter" /> <conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" /> <conv:BytesConverter x:Key="BytesConverter" />
@@ -14,6 +19,8 @@
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" /> <conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style> </Style>
</ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>
+27
View File
@@ -34,9 +34,34 @@ public partial class App : Application
.Build(); .Build();
host.Start(); host.Start();
// Apply persisted language before any UI is created so bindings resolve to the saved culture.
try
{
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(settings.Lang))
Localization.TranslationSource.Instance.CurrentCulture = new System.Globalization.CultureInfo(settings.Lang);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted language at startup");
}
App app = new(); App 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
@@ -88,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>();
@@ -112,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);
// 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(); var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault(); account = accounts.FirstOrDefault();
}
var graphScopes = new[] { "https://graph.microsoft.com/.default" }; var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes); var tokenProvider = new MsalTokenProvider(pca, account, graphScopes, tenantId);
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider); var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider); return new GraphServiceClient(authProvider);
} }
@@ -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;
} }
} }
+224 -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>
@@ -124,11 +136,14 @@
<data name="profile.clientid" xml:space="preserve"> <data name="profile.clientid" xml:space="preserve">
<value>ID client</value> <value>ID client</value>
</data> </data>
<data name="profile.clientid.hint" xml:space="preserve">
<value>Optionnel — laissez vide pour enregistrer l'application automatiquement</value>
</data>
<data name="profile.add" xml:space="preserve"> <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>
@@ -136,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>
@@ -354,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>
@@ -434,4 +473,187 @@
<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>
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
<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>
<<<<<<< HEAD
<!-- 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>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
</root> </root>
+224 -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>
@@ -124,11 +136,14 @@
<data name="profile.clientid" xml:space="preserve"> <data name="profile.clientid" xml:space="preserve">
<value>Client ID</value> <value>Client ID</value>
</data> </data>
<data name="profile.clientid.hint" xml:space="preserve">
<value>Optional — leave blank to register the app automatically</value>
</data>
<data name="profile.add" xml:space="preserve"> <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>
@@ -136,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>
@@ -354,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>
@@ -434,4 +473,187 @@
<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>
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
<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>
<<<<<<< HEAD
<!-- 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>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
</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,15 +158,29 @@ 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);
return AppRegistrationResult.Failure(ex.Message);
}
}
if (createdApp?.Id is not null) private async Task RollbackAsync(Application? createdApp, string clientId, string tenantId, CancellationToken ct)
{ {
if (createdApp?.Id is null) return;
try try
{ {
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct); await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id); _logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
} }
@@ -149,16 +190,13 @@ public class AppRegistrationService : IAppRegistrationService
} }
} }
return AppRegistrationResult.Failure(ex.Message);
}
}
/// <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,142 @@
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
{
<<<<<<< HEAD
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string filePath,
CancellationToken ct)
{
<<<<<<< HEAD
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)
{
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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"]),
<<<<<<< HEAD
Csv(T["report.col.group"]),
Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
=======
Csv("Group"),
Csv(T["report.text.copies"]),
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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"]),
}));
<<<<<<< HEAD
=======
// Rows
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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()),
<<<<<<< HEAD
Csv(item.SiteTitle),
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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),
}));
}
}
<<<<<<< HEAD
return sb.ToString();
}
private static string Csv(string value) => CsvSanitizer.Escape(value);
=======
await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
}
@@ -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,24 +1,53 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
<<<<<<< HEAD
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
/// <summary> /// <summary>
/// Exports permission entries to a self-contained interactive HTML report. /// Exports permission entries to a self-contained interactive HTML report.
/// Ports PowerShell Export-PermissionsToHTML functionality. /// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
/// No external CSS/JS dependencies — everything is inline. /// No external CSS/JS dependencies — everything is inline so the file can be
/// emailed or served from any static host. The standard and simplified
/// variants share their document shell, stats cards, CSS, pill rendering, and
/// inline script via <see cref="PermissionHtmlFragments"/>; this class only
/// owns the table column sets and the simplified risk summary.
/// </summary> /// </summary>
public class HtmlExportService public class HtmlExportService
{ {
/// <summary> /// <summary>
/// Builds a self-contained HTML string from the supplied permission entries. /// Builds a self-contained HTML string from the supplied permission
/// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills. /// entries. Standard report: columns are Object / Title / URL / Unique /
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable. /// Users / Permission / Granted Through. When
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
/// become expandable rows listing resolved members.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null, public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var T = TranslationSource.Instance;
<<<<<<< HEAD
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"], includeRiskCss: false);
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
AppendFilterInput(sb);
AppendTableOpen(sb);
=======
// Compute stats // Compute stats
var totalEntries = entries.Count; var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count(); var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
@@ -37,7 +66,7 @@ public class HtmlExportService
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>SharePoint Permissions Report</title>"); sb.AppendLine($"<title>{T["report.title.permissions"]}</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; }
@@ -78,25 +107,26 @@ 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>SharePoint Permissions Report</h1>"); sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
// Stats cards // Stats cards
sb.AppendLine("<div class=\"stats\">"); 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\">{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\">Unique Permission Sets</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\">Distinct Users/Groups</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>"); 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 permissions...\" onkeyup=\"filterTable()\" />"); sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Table // Table
sb.AppendLine("<div class=\"table-wrap\">"); sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">"); sb.AppendLine("<table id=\"permTable\">");
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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,8 +135,13 @@ 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"];
<<<<<<< HEAD
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx);
=======
// Build user pills: zip UserLogins and Users (both semicolon-delimited) // Build user pills: zip UserLogins and Users (both semicolon-delimited)
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -136,7 +171,7 @@ a:hover { text-decoration: underline; }
} }
else else
{ {
memberContent = "<em style=\"color:#888\">members unavailable</em>"; memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
} }
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>"); 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++; grpMemIdx++;
@@ -147,57 +182,232 @@ a:hover { text-decoration: underline; }
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>"); pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
} }
} }
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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,6 +416,8 @@ function toggleGroup(id) {
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"), RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB") _ => ("#F3F4F6", "#374151", "#E5E7EB")
}; };
<<<<<<< HEAD
=======
/// <summary> /// <summary>
/// Builds a self-contained HTML string from simplified permission entries. /// Builds a self-contained HTML string from simplified permission entries.
@@ -215,6 +427,7 @@ function toggleGroup(id) {
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null, public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries); var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count; var totalEntries = entries.Count;
@@ -233,7 +446,7 @@ function toggleGroup(id) {
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>SharePoint Permissions Report (Simplified)</title>"); sb.AppendLine($"<title>{T["report.title.permissions_simplified"]}</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; }
@@ -276,13 +489,13 @@ function toggleGroup(id) {
sb.AppendLine("<body>"); sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>"); sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
// Stats cards // Stats cards
sb.AppendLine("<div class=\"stats\">"); 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\">{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\">Unique Permission Sets</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\">Distinct Users/Groups</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>"); sb.AppendLine("</div>");
// Risk-level summary cards // Risk-level summary cards
@@ -300,14 +513,14 @@ function toggleGroup(id) {
// 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 permissions...\" onkeyup=\"filterTable()\" />"); sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>"); sb.AppendLine("</div>");
// Table with simplified columns // Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">"); sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">"); 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>Simplified</th><th>Risk</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.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>"); sb.AppendLine("<tbody>");
@@ -316,7 +529,7 @@ function toggleGroup(id) {
{ {
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"];
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel); var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -347,7 +560,7 @@ function toggleGroup(id) {
} }
else else
{ {
memberContent = "<em style=\"color:#888\">members unavailable</em>"; memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
} }
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>"); 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++; grpMemIdx++;
@@ -362,7 +575,7 @@ function toggleGroup(id) {
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>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
@@ -430,4 +643,5 @@ function toggleGroup(id) {
.Replace("\"", "&quot;") .Replace("\"", "&quot;")
.Replace("'", "&#39;"); .Replace("'", "&#39;");
} }
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
} }
@@ -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,11 @@ 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>"); <<<<<<< HEAD
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
=======
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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 +194,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 +210,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 +236,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>", {
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>" _ => $"<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).
IReadOnlyList<string> files;
if (job.CopyFolderContents)
{
progress.Report(new OperationProgress(0, 0, "Enumerating source files...")); progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct); 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);
return files;
}
private async Task CollectFilesRecursiveAsync( await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ClientContext ctx, ctx, list, baseFolderUrl, recursive: true,
Folder folder, viewFields: new[] { "FSObjType", "FileRef", "FileDirRef" },
List<string> files, ct: ct))
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl), if (item["FSObjType"]?.ToString() != "0") continue; // files only
f => f.Folders);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var file in folder.Files) var fileRef = item["FileRef"]?.ToString();
{ if (string.IsNullOrEmpty(fileRef)) continue;
files.Add(file.ServerRelativeUrl);
// Skip files under SharePoint system folders (e.g. "Forms", "_*").
var dir = item["FileDirRef"]?.ToString() ?? string.Empty;
if (HasSystemFolderSegment(dir, baseFolderUrl)) continue;
files.Add(fileRef);
} }
foreach (var subFolder in folder.Folders) return files;
{
// Skip system folders
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue;
await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct);
} }
private static bool HasSystemFolderSegment(string fileDirRef, string baseFolderUrl)
{
if (string.IsNullOrEmpty(fileDirRef)) return false;
var baseTrim = baseFolderUrl.TrimEnd('/');
if (!fileDirRef.StartsWith(baseTrim, StringComparison.OrdinalIgnoreCase))
return false;
var tail = fileDirRef.Substring(baseTrim.Length).Trim('/');
if (string.IsNullOrEmpty(tail)) return false;
foreach (var seg in tail.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
if (seg.StartsWith("_", StringComparison.Ordinal) ||
seg.Equals("Forms", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
} }
private async Task 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;
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath) var parentUrl = folderServerRelativeUrl.Substring(0, slash);
var leafName = folderServerRelativeUrl.Substring(slash + 1);
if (string.IsNullOrEmpty(leafName)) return;
// Recurse to guarantee the parent exists first.
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
var parent = ctx.Web.GetFolderByServerRelativeUrl(parentUrl);
parent.Folders.Add(leafName);
try
{ {
// Extract site-relative URL from context URL await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (Exception ex)
{
Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}",
parentUrl, leafName, ex.Message);
throw;
}
}
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
{
if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return pathOrUrl;
var uri = new Uri(ctx.Url); var 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));
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); 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)));
// 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); 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)
+3 -2
View File
@@ -24,8 +24,9 @@ public class ProfileService
!Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _)) !Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _))
throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile)); throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile));
if (string.IsNullOrWhiteSpace(profile.ClientId)) // ClientId is optional at creation time: the user can register the app from within
throw new ArgumentException("ClientId must not be empty.", nameof(profile)); // the tool, which will populate ClientId/AppId on the profile afterwards.
profile.ClientId ??= string.Empty;
var existing = (await _repository.LoadAsync()).ToList(); var existing = (await _repository.LoadAsync()).ToList();
existing.Add(profile); existing.Add(profile);
+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,8 +280,36 @@ 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)
{ {
@@ -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 =>
{ {
// 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; 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
@@ -19,6 +19,11 @@ public partial class ProfileManagementViewModel : ObservableObject
private readonly ILogger<ProfileManagementViewModel> _logger; private readonly ILogger<ProfileManagementViewModel> _logger;
private readonly IAppRegistrationService _appRegistrationService; private readonly IAppRegistrationService _appRegistrationService;
// Well-known public client (Microsoft Graph Command Line Tools) used as a bootstrap
// when a profile has no ClientId yet, so the user can sign in as admin and have the
// app registration created for them.
private const string BootstrapClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
[ObservableProperty] [ObservableProperty]
private TenantProfile? _selectedProfile; private TenantProfile? _selectedProfile;
@@ -55,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; }
@@ -77,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);
@@ -107,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();
@@ -130,14 +148,27 @@ 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;
if (string.IsNullOrWhiteSpace(NewClientId)) return false; // Fields mirror the selected profile after selection; block Add so the user doesn't
// create a duplicate — they should use Save to update, or change the name to fork.
if (SelectedProfile != null &&
string.Equals(NewName.Trim(), SelectedProfile.Name, StringComparison.Ordinal))
return false;
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
return true;
}
private bool CanSave()
{
if (SelectedProfile == null) return false;
if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
return true; return true;
} }
@@ -150,7 +181,7 @@ public partial class ProfileManagementViewModel : ObservableObject
{ {
Name = NewName.Trim(), Name = NewName.Trim(),
TenantUrl = NewTenantUrl.Trim(), TenantUrl = NewTenantUrl.Trim(),
ClientId = NewClientId.Trim() ClientId = NewClientId?.Trim() ?? string.Empty
}; };
await _profileService.AddProfileAsync(profile); await _profileService.AddProfileAsync(profile);
Profiles.Add(profile); Profiles.Add(profile);
@@ -166,19 +197,43 @@ public partial class ProfileManagementViewModel : ObservableObject
} }
} }
private async Task RenameAsync() private async Task SaveAsync()
{ {
if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return; if (!CanSave()) return;
var target = SelectedProfile!;
try 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.");
} }
} }
@@ -299,24 +354,40 @@ public partial class ProfileManagementViewModel : ObservableObject
RegistrationStatus = TranslationSource.Instance["profile.register.checking"]; RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
try try
{ {
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(SelectedProfile.ClientId, ct); // Use the profile's own ClientId if it has one; otherwise bootstrap with the
if (!isAdmin) // Microsoft Graph Command Line Tools public client so a first-time profile
{ // (name + URL only) can still perform registration.
ShowFallbackInstructions = true; var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"]; ? BootstrapClientId
return; : SelectedProfile.ClientId;
}
// No preflight admin check: it used Global Admin as the criterion and
// rejected Application Admins / Cloud Application Admins who can also
// create apps. Let Entra enforce authorization via the POST itself —
// any 401/403 returns FallbackRequired and triggers the tutorial.
RegistrationStatus = TranslationSource.Instance["profile.register.registering"]; RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
var result = await _appRegistrationService.RegisterAsync(SelectedProfile.ClientId, SelectedProfile.Name, ct); var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.TenantUrl, SelectedProfile.Name, ct);
if (result.IsSuccess) if (result.IsSuccess)
{ {
SelectedProfile.AppId = result.AppId; SelectedProfile.AppId = result.AppId;
// If the profile had no ClientId, adopt the freshly registered app's id
// so subsequent sign-ins use the profile's own app registration.
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
SelectedProfile.ClientId = result.AppId!;
await _profileService.UpdateProfileAsync(SelectedProfile); 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"];
@@ -340,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,32 @@ 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
{
<<<<<<< HEAD
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
=======
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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())))
.Where(x => x.GroupName.Length > 0)
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupsBySite.Count > 0)
{
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
StringComparer.OrdinalIgnoreCase);
foreach (var bucket in groupsBySite)
{
var distinctNames = bucket
.Select(x => x.GroupName)
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (groupNames.Count > 0 && _currentProfile != null)
{
try try
{ {
var siteProfile = new TenantProfile
{
TenantUrl = bucket.Key,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync( var ctx = await _sessionManager.GetOrCreateContextAsync(
_currentProfile, CancellationToken.None); siteProfile, CancellationToken.None);
groupMembers = await _groupResolver.ResolveGroupsAsync( var resolved = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None); ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None);
foreach (var kv in resolved)
merged[kv.Key] = kv.Value;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion."); _logger.LogWarning(ex,
"Group resolution failed for {Site} — continuing without member expansion.",
bucket.Key);
} }
} }
groupMembers = merged;
}
} }
if (IsSimplifiedMode && SimplifiedResults.Count > 0) 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,20 @@ 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);
<<<<<<< HEAD
ApplyChartThemeColors();
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
} }
/// <summary>Test constructor — omits export services.</summary> /// <summary>Test constructor — omits export services.</summary>
@@ -173,14 +207,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 +228,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 +243,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 +315,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 +353,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 +384,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 +399,28 @@ 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);
<<<<<<< HEAD
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;
}
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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 +453,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 +472,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 +482,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 +493,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);
var autoOwnership = await IsAutoTakeOwnershipEnabled();
try
{
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct); _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,28 @@
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">
<<<<<<< HEAD
<Button x:Name="NewFolderButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
=======
<Button x:Name="NewFolderButton" Content="+ New Folder"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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,41 @@ 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();
<<<<<<< HEAD
private readonly List<TreeViewItem> _expandedNodes = new();
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
/// <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 +50,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 +74,206 @@ 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;
<<<<<<< HEAD
_expandedNodes.Add(node);
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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(); node.Items.Clear();
try try
{ {
var folderUrl = string.IsNullOrEmpty(info.FolderPath) var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
? GetLibraryRootUrl(info.LibraryTitle) _ctx.Load(folder, f => f.StorageMetrics.TotalSize,
: info.FolderPath; <<<<<<< HEAD
f => f.StorageMetrics.TotalFileCount);
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl); var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl)); _ctx.Load(list, l => l.Title);
=======
f => f.StorageMetrics.TotalFileCount,
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl,
sf => sf.StorageMetrics.TotalSize,
sf => sf.StorageMetrics.TotalFileCount),
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
fi => fi.ServerRelativeUrl));
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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);
// 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);
<<<<<<< HEAD
// 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 fsType = item["FSObjType"]?.ToString();
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
var fileRef = item["FileRef"]?.ToString() ?? string.Empty;
if (fsType == "1")
{
if (name.StartsWith("_") || name == "Forms") continue;
subFolders.Add((name, fileRef));
}
else if (fsType == "0" && _allowFileSelection)
{
long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long size);
filesInFolder.Add((name, size, fileRef));
}
}
// Batch-load StorageMetrics for each subfolder in one round-trip.
var metricFolders = subFolders
.Select(sf => _ctx.Web.GetFolderByServerRelativeUrl(sf.Url))
.ToList();
foreach (var mf in metricFolders)
_ctx.Load(mf, f => f.StorageMetrics.TotalSize,
f => f.StorageMetrics.TotalFileCount);
if (metricFolders.Count > 0)
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
for (int i = 0; i < subFolders.Count; i++)
{
var (name, url) = subFolders[i];
var childRelative = string.IsNullOrEmpty(info.RelativePath)
? 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);
}
}
=======
// Child folders first
foreach (var subFolder in folder.Folders) foreach (var subFolder in folder.Folders)
{ {
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms") if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue; continue;
var childNode = new TreeViewItem var childRelative = string.IsNullOrEmpty(info.RelativePath)
{ ? subFolder.Name
Header = subFolder.Name, : $"{info.RelativePath}/{subFolder.Name}";
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl),
}; var childInfo = new FolderNodeInfo(
childNode.Items.Add(new TreeViewItem { Header = "Loading..." }); info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
childNode.Expanded += LibNode_Expanded;
var childNode = MakeFolderNode(
FormatFolderHeader(subFolder.Name,
subFolder.StorageMetrics.TotalFileCount,
subFolder.StorageMetrics.TotalSize),
childInfo);
node.Items.Add(childNode); node.Items.Add(childNode);
} }
// Files under this folder — only shown when selection is enabled.
if (_allowFileSelection)
{
foreach (var file in folder.Files)
{
// Library-relative path for the file (used by the transfer service)
var fileRel = string.IsNullOrEmpty(info.RelativePath)
? file.Name
: $"{info.RelativePath}/{file.Name}";
var cb = new CheckBox
{
Content = $"{file.Name} ({FormatSize(file.Length)})",
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);
}
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
} }
catch (Exception ex) catch (Exception ex)
{ {
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" }); node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
} }
} }
private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
{
if (fileCount <= 0) return name;
return $"{name} ({fileCount} files, {FormatSize(totalBytes)})";
} }
private string GetLibraryRootUrl(string libraryTitle) private static string FormatSize(long bytes)
{ {
var uri = new Uri(_ctx.Url); if (bytes <= 0) return "0 B";
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}"; 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 +281,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 +368,24 @@ public partial class FolderBrowserDialog : Window
Close(); Close();
} }
private record FolderNodeInfo(string LibraryTitle, string FolderPath); <<<<<<< HEAD
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);
}
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
private record FileNodeInfo(string LibraryTitle, string RelativePath);
} }
@@ -0,0 +1,32 @@
<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"
<<<<<<< HEAD
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
=======
Title="Input"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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">
<<<<<<< HEAD
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Width="70" IsCancel="True" Margin="0,0,8,0"
=======
<Button Content="Cancel" Width="70" IsCancel="True" Margin="0,0,8,0"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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}"
@@ -35,6 +39,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.name]}" <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.name]}"
Grid.Row="0" Grid.Column="0" /> Grid.Row="0" Grid.Column="0" />
@@ -48,12 +53,15 @@
Grid.Row="2" Grid.Column="0" /> Grid.Row="2" Grid.Column="0" />
<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"
FontSize="11" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap"
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}}"
@@ -61,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" />
@@ -83,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>
@@ -103,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]}"
@@ -130,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,77 @@
<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"> <<<<<<< HEAD
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
=======
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
<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 +79,60 @@
</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 -->
<<<<<<< HEAD
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
=======
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8" <TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
Foreground="#555555" FontSize="11" /> >>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
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,7 +22,7 @@
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" />
@@ -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,7 +21,7 @@
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" />
@@ -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,60 @@
<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]}" <<<<<<< HEAD
<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>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
<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}" />
<<<<<<< HEAD
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}"
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<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" />
=======
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" /> <DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" /> <DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" /> <DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" />
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
</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,7 +26,7 @@
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" />
@@ -31,6 +34,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">
@@ -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()
{
_tip = new System.Windows.Controls.ToolTip
{ {
Padding = new System.Windows.Thickness(8, 4, 8, 4), Padding = new System.Windows.Thickness(8, 4, 8, 4),
FontSize = 13, FontSize = 13,
Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(255, 255, 255)),
BorderBrush = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(200, 200, 200)),
BorderThickness = new System.Windows.Thickness(1), 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>
+38 -6
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,36 @@
<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}" />
<<<<<<< HEAD
<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]}" />
=======
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11">
<Run Text="{Binding SelectedFileCount, Mode=OneWay}" />
<Run Text=" file(s) selected" />
</TextBlock>
<CheckBox Content="Include source folder at destination"
IsChecked="{Binding IncludeSourceFolder}"
Margin="0,6,0,0"
ToolTip="When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder." />
<CheckBox Content="Copy folder contents"
IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0"
ToolTip="When on (default), transfer files inside the folder. When off, only the folder is created at the destination." />
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -27,7 +59,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,8 +94,7 @@
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" />
@@ -74,6 +105,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,11 @@
</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"> <<<<<<< HEAD
<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 Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" /> <Setter Property="Visibility" Value="Collapsed" />
@@ -115,9 +122,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 +137,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 +149,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 +188,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 +240,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 +266,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 +278,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 +331,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 +353,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 +377,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 +412,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 +436,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 +446,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>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>SharePoint Toolbox — Wiki</title>
<style>
:root{--bg:#0f172a;--panel:#1e293b;--text:#e2e8f0;--muted:#94a3b8;--accent:#38bdf8;--border:#334155;--code:#0b1220}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
.layout{display:grid;grid-template-columns:260px 1fr;min-height:100vh}
nav{background:var(--panel);border-right:1px solid var(--border);padding:24px 16px;position:sticky;top:0;height:100vh;overflow-y:auto}
nav h2{font-size:14px;text-transform:uppercase;color:var(--muted);letter-spacing:1px;margin:16px 0 8px}
nav a{display:block;color:var(--text);text-decoration:none;padding:6px 10px;border-radius:6px;font-size:14px}
nav a:hover{background:var(--bg);color:var(--accent)}
main{padding:40px 56px;max-width:960px}
h1{font-size:32px;border-bottom:2px solid var(--accent);padding-bottom:12px;margin-top:0}
h2{color:var(--accent);margin-top:40px;border-bottom:1px solid var(--border);padding-bottom:6px}
h3{color:#7dd3fc;margin-top:24px}
code{background:var(--code);padding:2px 6px;border-radius:4px;font-size:.9em;color:#fbbf24}
pre{background:var(--code);padding:16px;border-radius:8px;overflow-x:auto;border:1px solid var(--border)}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid var(--border);padding:8px 12px;text-align:left}
th{background:var(--panel);color:var(--accent)}
tr:nth-child(even) td{background:rgba(30,41,59,.5)}
.badge{display:inline-block;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:2px 10px;font-size:12px;color:var(--accent);margin:2px}
.hero{background:linear-gradient(135deg,#1e293b,#0f172a);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px}
.hero p{color:var(--muted);margin:8px 0 0}
section{scroll-margin-top:20px}
ul li{margin:4px 0}
@media(max-width:800px){.layout{grid-template-columns:1fr}nav{position:static;height:auto}main{padding:24px}}
</style>
</head>
<body>
<div class="layout">
<nav>
<h2>SP Toolbox</h2>
<a href="#apercu">Apercu</a>
<a href="#install">Installation</a>
<a href="#prerequis">Prerequis</a>
<a href="#connexion">Connexion &amp; profils</a>
<a href="#permissions">Rapport de permissions</a>
<a href="#stockage">Metriques de stockage</a>
<a href="#utilisateurs">Annuaire utilisateurs</a>
<a href="#recherche">Recherche de fichiers</a>
<a href="#doublons">Doublons</a>
<a href="#architecture">Architecture</a>
<a href="#dependances">Dependances</a>
<a href="#azure">Configuration Azure AD</a>
<a href="#depannage">Depannage</a>
</nav>
<main>
<div class="hero">
<h1 style="border:none;margin:0">SharePoint Toolbox — Wiki</h1>
<p>Application WPF (.NET 10) pour administrer, auditer et exporter des donnees depuis un tenant SharePoint Online.</p>
<div>
<span class="badge">.NET 10</span>
<span class="badge">WPF</span>
<span class="badge">MVVM</span>
<span class="badge">MSAL</span>
<span class="badge">Graph</span>
<span class="badge">PnP.Framework</span>
<span class="badge">EN / FR</span>
</div>
</div>
<section id="apercu">
<h2>Apercu</h2>
<p>SharePoint Toolbox centralise les taches recurrentes d'un administrateur SharePoint Online : audit des permissions, mesure du stockage, annuaire des utilisateurs, recherche de fichiers et detection de doublons. Tous les rapports peuvent etre exportes en CSV ou en HTML interactif avec un branding configurable.</p>
</section>
<section id="install">
<h2>Installation</h2>
<ol>
<li>Telecharger le dernier zip depuis la page Releases.</li>
<li>Extraire l'archive dans un dossier de votre choix.</li>
<li>Lancer <code>SharepointToolbox.exe</code>.</li>
</ol>
</section>
<section id="prerequis">
<h2>Prerequis</h2>
<ul>
<li>Windows 10 ou superieur</li>
<li>Runtime .NET 10 Desktop</li>
<li>Une Azure AD App Registration (Client ID) avec les permissions deleguees</li>
<li>Des identifiants ayant acces au tenant SharePoint cible</li>
</ul>
</section>
<section id="connexion">
<h2>Connexion &amp; profils</h2>
<ul>
<li>Saisir le <strong>Tenant URL</strong> et le <strong>Client ID</strong>.</li>
<li>Enregistrer des profils reutilisables (creation, renommage, suppression, rechargement).</li>
<li>Parcourir le tenant et cocher plusieurs sites depuis le selecteur.</li>
<li><strong>Enregistrement Azure AD</strong> automatique ou guide depuis le profil.</li>
<li>Branding multi-tenant avec logos client dans les exports.</li>
</ul>
</section>
<section id="permissions">
<h2>Rapport de permissions</h2>
<p>Audit complet des permissions sur un ou plusieurs sites.</p>
<ul>
<li>Scan des <strong>bibliotheques, listes et dossiers</strong> avec profondeur configurable.</li>
<li>Inclusion optionnelle des permissions heritees et des sous-sites.</li>
<li><strong>Mode consolidation</strong> : fusion des permissions identiques avec liste des sites/bibliotheques concernes.</li>
<li>Export en <strong>CSV</strong> ou en <strong>HTML</strong> (rapport interactif avec filtrage, tri et regroupement par utilisateur/site).</li>
</ul>
</section>
<section id="stockage">
<h2>Metriques de stockage</h2>
<p>Analyse de l'occupation du stockage SharePoint.</p>
<ul>
<li>Repartition par bibliotheque avec profondeur de dossiers configurable.</li>
<li>Metriques : taille totale, taille des versions, nombre d'elements, derniere modification.</li>
<li><strong>Visualisation 3D</strong> interactive du stockage.</li>
<li>Export en <strong>CSV</strong> ou en <strong>HTML</strong> (rapport avec graphiques de repartition).</li>
</ul>
</section>
<section id="utilisateurs">
<h2>Annuaire utilisateurs</h2>
<ul>
<li>Liste complete des utilisateurs du tenant via Microsoft Graph.</li>
<li>Filtrage et recherche textuelle.</li>
<li>Export en <strong>HTML</strong>.</li>
</ul>
</section>
<section id="recherche">
<h2>Recherche de fichiers</h2>
<p>Recherche avancee de fichiers a travers les bibliotheques d'un site, utilisant la <strong>Search API SharePoint (KQL)</strong> avec pagination automatique.</p>
<table>
<tr><th>Filtre</th><th>Description</th></tr>
<tr><td>Extension(s)</td><td>ex. <code>docx pdf xlsx</code></td></tr>
<tr><td>Nom / Regex</td><td>Expression reguliere sur le chemin du fichier</td></tr>
<tr><td>Cree apres / avant</td><td>Plage de dates de creation</td></tr>
<tr><td>Modifie apres / avant</td><td>Plage de dates de modification</td></tr>
<tr><td>Cree par</td><td>Nom ou email de l'auteur</td></tr>
<tr><td>Modifie par</td><td>Nom ou email du dernier editeur</td></tr>
<tr><td>Bibliotheque</td><td>Limite la recherche a un chemin relatif</td></tr>
</table>
</section>
<section id="doublons">
<h2>Doublons</h2>
<p>Detection de fichiers ou dossiers en double au sein d'un ou plusieurs sites.</p>
<ul>
<li><strong>Type de scan :</strong> Fichiers (Search API) ou Dossiers (enumeration CAML).</li>
<li><strong>Criteres de comparaison (combinables) :</strong> Nom, Taille, Date de creation, Date de modification, Nombre de sous-dossiers, Nombre de fichiers.</li>
<li>Export en <strong>CSV</strong> ou en <strong>HTML</strong> (cartes depliables avec mise en evidence des valeurs identiques/differentes).</li>
</ul>
</section>
<section id="architecture">
<h2>Architecture</h2>
<ul>
<li>Interface <strong>WPF</strong> avec pattern <strong>MVVM</strong> (generateurs CommunityToolkit.Mvvm).</li>
<li>Injection de dependances via <code>Microsoft.Extensions.Hosting</code>.</li>
<li>Authentification <strong>MSAL</strong> avec cache persistant et support broker WAM.</li>
<li><strong>Microsoft Graph SDK</strong> pour les operations tenant/utilisateurs.</li>
<li><strong>PnP.Framework</strong> (CSOM) pour les operations SharePoint.</li>
<li>Localisation complete <strong>EN/FR</strong> via fichiers <code>.resx</code>.</li>
<li>Branding configurable (logos MSP et client) dans les exports HTML.</li>
<li>Journalisation structuree via <strong>Serilog</strong> (sink fichier).</li>
</ul>
</section>
<section id="dependances">
<h2>Dependances</h2>
<table>
<tr><th>Paquet</th><th>Version</th><th>Role</th></tr>
<tr><td>CommunityToolkit.Mvvm</td><td>8.4.2</td><td>Generateurs MVVM</td></tr>
<tr><td>CsvHelper</td><td>33.1.0</td><td>Lecture/ecriture CSV</td></tr>
<tr><td>LiveChartsCore.SkiaSharpView.WPF</td><td>2.0.0-rc5.4</td><td>Graphiques / visualisation 3D</td></tr>
<tr><td>Microsoft.Extensions.Hosting</td><td>10.0.0</td><td>Host generique &amp; DI</td></tr>
<tr><td>Microsoft.Graph</td><td>5.74.0</td><td>SDK Graph (tenant/utilisateurs)</td></tr>
<tr><td>Microsoft.Identity.Client</td><td>4.83.3</td><td>Authentification MSAL</td></tr>
<tr><td>Microsoft.Identity.Client.Broker</td><td>4.82.1</td><td>Support broker WAM</td></tr>
<tr><td>Microsoft.Identity.Client.Extensions.Msal</td><td>4.83.3</td><td>Cache de tokens persistant</td></tr>
<tr><td>PnP.Framework</td><td>1.18.0</td><td>Operations SharePoint CSOM</td></tr>
<tr><td>Serilog</td><td>4.3.1</td><td>Journalisation structuree</td></tr>
<tr><td>Serilog.Extensions.Hosting</td><td>10.0.0</td><td>Integration host</td></tr>
<tr><td>Serilog.Sinks.File</td><td>7.0.0</td><td>Sink fichier</td></tr>
</table>
</section>
<section id="azure">
<h2>Configuration Azure AD</h2>
<p>L'application peut enregistrer l'app Azure AD automatiquement, ou vous pouvez la creer manuellement avec les permissions deleguees suivantes :</p>
<ul>
<li><code>Sites.FullControl.All</code> (SharePoint)</li>
<li><code>User.Read.All</code> (Microsoft Graph)</li>
<li><code>Directory.Read.All</code> (Microsoft Graph)</li>
</ul>
<p>L'URI de redirection doit etre definie sur la valeur par defaut MSAL public client (<code>http://localhost</code>) pour la connexion interactive.</p>
</section>
<section id="depannage">
<h2>Depannage</h2>
<ul>
<li><strong>Boucle de connexion / erreurs AADSTS :</strong> verifier le Client ID, le tenant URL et le consentement administrateur.</li>
<li><strong>Recherche vide :</strong> l'indexation SharePoint Search peut prendre du temps ; reessayer plus tard.</li>
<li><strong>Timeouts de scan de permissions :</strong> reduire la profondeur de dossiers ou scanner moins de sites a la fois.</li>
<li><strong>Logs :</strong> ecrits par Serilog dans le dossier local de l'application — les joindre en cas de probleme.</li>
</ul>
</section>
</main>
</div>
</body>
</html>