Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e5cfc506 | |||
| 23a638a10a | |||
| a48df65f2e | |||
| df179be2ed | |||
| efb3d2ad11 | |||
| 2c9dbe39d3 | |||
| b8c09655ac | |||
| 12dd1de9f2 |
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>()))
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ public partial class App : Application
|
|||||||
services.AddTransient<DuplicatesViewModel>();
|
services.AddTransient<DuplicatesViewModel>();
|
||||||
services.AddTransient<DuplicatesView>();
|
services.AddTransient<DuplicatesView>();
|
||||||
|
|
||||||
|
// Versions cleanup
|
||||||
|
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
|
||||||
|
services.AddTransient<VersionCleanupHtmlExportService>();
|
||||||
|
services.AddTransient<VersionCleanupViewModel>();
|
||||||
|
services.AddTransient<VersionCleanupView>();
|
||||||
|
|
||||||
// Phase 2: Permissions
|
// Phase 2: Permissions
|
||||||
services.AddTransient<IPermissionsService, PermissionsService>();
|
services.AddTransient<IPermissionsService, PermissionsService>();
|
||||||
services.AddTransient<ISiteListService, SiteListService>();
|
services.AddTransient<ISiteListService, SiteListService>();
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ namespace SharepointToolbox.Core.Helpers;
|
|||||||
|
|
||||||
public static class SharePointPaginationHelper
|
public static class SharePointPaginationHelper
|
||||||
{
|
{
|
||||||
|
// Max page size SharePoint honors with Paged='TRUE' (threshold bypass).
|
||||||
|
private const int DefaultRowLimit = 5000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold.
|
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold.
|
||||||
/// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination.
|
/// Uses CamlQuery with Paged='TRUE' RowLimit and ListItemCollectionPosition for pagination.
|
||||||
/// Never call ExecuteQuery directly on a list — always use this helper.
|
/// Never call ExecuteQuery directly on a list — always use this helper.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
|
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
|
||||||
@@ -18,7 +21,7 @@ public static class SharePointPaginationHelper
|
|||||||
[EnumeratorCancellation] CancellationToken ct = default)
|
[EnumeratorCancellation] CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
|
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
|
||||||
query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000);
|
query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit);
|
||||||
query.ListItemCollectionPosition = null;
|
query.ListItemCollectionPosition = null;
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -36,21 +39,75 @@ public static class SharePointPaginationHelper
|
|||||||
while (query.ListItemCollectionPosition != null);
|
while (query.ListItemCollectionPosition != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates items within a specific folder (direct children by default, or
|
||||||
|
/// recursive when <paramref name="recursive"/> is true). Uses paginated CAML
|
||||||
|
/// with no WHERE clause so it works on libraries above the 5,000-item threshold.
|
||||||
|
/// Callers filter by FSObjType client-side via the returned ListItem fields.
|
||||||
|
/// </summary>
|
||||||
|
public static async IAsyncEnumerable<ListItem> GetItemsInFolderAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
List list,
|
||||||
|
string folderServerRelativeUrl,
|
||||||
|
bool recursive,
|
||||||
|
string[]? viewFields = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var fields = viewFields ?? new[]
|
||||||
|
{
|
||||||
|
"FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size"
|
||||||
|
};
|
||||||
|
|
||||||
|
var viewFieldsXml = string.Join(string.Empty,
|
||||||
|
fields.Select(f => $"<FieldRef Name='{f}' />"));
|
||||||
|
|
||||||
|
var scope = recursive ? " Scope='RecursiveAll'" : string.Empty;
|
||||||
|
var viewXml =
|
||||||
|
$"<View{scope}>" +
|
||||||
|
"<Query></Query>" +
|
||||||
|
$"<ViewFields>{viewFieldsXml}</ViewFields>" +
|
||||||
|
$"<RowLimit Paged='TRUE'>{DefaultRowLimit}</RowLimit>" +
|
||||||
|
"</View>";
|
||||||
|
|
||||||
|
var query = new CamlQuery
|
||||||
|
{
|
||||||
|
ViewXml = viewXml,
|
||||||
|
FolderServerRelativeUrl = folderServerRelativeUrl,
|
||||||
|
ListItemCollectionPosition = null
|
||||||
|
};
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var items = list.GetItems(query);
|
||||||
|
ctx.Load(items);
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
yield return item;
|
||||||
|
|
||||||
|
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||||
|
}
|
||||||
|
while (query.ListItemCollectionPosition != null);
|
||||||
|
}
|
||||||
|
|
||||||
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
|
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
|
||||||
{
|
{
|
||||||
// Inject or replace RowLimit in existing CAML, or create minimal view
|
|
||||||
if (string.IsNullOrWhiteSpace(existingXml))
|
if (string.IsNullOrWhiteSpace(existingXml))
|
||||||
return $"<View><RowLimit>{rowLimit}</RowLimit></View>";
|
return $"<View><RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>";
|
||||||
|
|
||||||
// Simple replacement approach — adequate for Phase 1
|
// Replace any existing <RowLimit ...>n</RowLimit> with paged form.
|
||||||
if (existingXml.Contains("<RowLimit>", StringComparison.OrdinalIgnoreCase))
|
if (System.Text.RegularExpressions.Regex.IsMatch(
|
||||||
|
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||||
{
|
{
|
||||||
return System.Text.RegularExpressions.Regex.Replace(
|
return System.Text.RegularExpressions.Regex.Replace(
|
||||||
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
|
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
|
||||||
$"<RowLimit>{rowLimit}</RowLimit>",
|
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit>",
|
||||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
}
|
}
|
||||||
return existingXml.Replace("</View>", $"<RowLimit>{rowLimit}</RowLimit></View>",
|
return existingXml.Replace("</View>",
|
||||||
|
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>",
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,8 @@ public class DuplicateItem
|
|||||||
public DateTime? Modified { get; set; }
|
public DateTime? Modified { get; set; }
|
||||||
public int? FolderCount { get; set; }
|
public int? FolderCount { get; set; }
|
||||||
public int? FileCount { get; set; }
|
public int? FileCount { get; set; }
|
||||||
|
/// <summary>URL of the site the item was collected from.</summary>
|
||||||
|
public string SiteUrl { get; set; } = string.Empty;
|
||||||
|
/// <summary>Friendly site title; falls back to a derived label when unknown.</summary>
|
||||||
|
public string SiteTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public record VersionCleanupOptions(
|
||||||
|
IReadOnlyList<string> LibraryTitles,
|
||||||
|
int KeepLast,
|
||||||
|
bool KeepFirst)
|
||||||
|
{
|
||||||
|
public static VersionCleanupOptions Default => new(Array.Empty<string>(), 5, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class VersionCleanupResult
|
||||||
|
{
|
||||||
|
public string SiteUrl { get; init; } = string.Empty;
|
||||||
|
public string Library { get; init; } = string.Empty;
|
||||||
|
public string FileServerRelativeUrl { get; init; } = string.Empty;
|
||||||
|
public string FileName { get; init; } = string.Empty;
|
||||||
|
public int VersionsBefore { get; init; }
|
||||||
|
public int VersionsDeleted { get; init; }
|
||||||
|
public int VersionsRemaining { get; init; }
|
||||||
|
public long BytesFreed { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
}
|
||||||
@@ -15,17 +15,47 @@ public class GraphClientFactory
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA
|
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA
|
||||||
/// used for SharePoint auth, but with Graph scopes.
|
/// used for SharePoint auth, but with Graph scopes. Uses the /common authority
|
||||||
|
/// and the <c>.default</c> scope (whatever the client is pre-consented for).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
|
public Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
|
||||||
|
=> CreateClientAsync(clientId, tenantId: null, scopes: null, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a GraphServiceClient pinned to a specific tenant authority.
|
||||||
|
/// Pass the tenant domain (e.g. "contoso.onmicrosoft.com") or tenant GUID.
|
||||||
|
/// Null <paramref name="tenantId"/> falls back to /common.
|
||||||
|
/// </summary>
|
||||||
|
public Task<GraphServiceClient> CreateClientAsync(string clientId, string? tenantId, CancellationToken ct)
|
||||||
|
=> CreateClientAsync(clientId, tenantId, scopes: null, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a GraphServiceClient with explicit Graph delegated scopes.
|
||||||
|
/// Use when <c>.default</c> is insufficient — typically for admin actions that
|
||||||
|
/// need scopes not pre-consented on the bootstrap client (e.g. app registration
|
||||||
|
/// requires <c>Application.ReadWrite.All</c> and
|
||||||
|
/// <c>DelegatedPermissionGrant.ReadWrite.All</c>). Triggers an admin-consent
|
||||||
|
/// prompt on first use if the tenant has not yet consented.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(
|
||||||
|
string clientId,
|
||||||
|
string? tenantId,
|
||||||
|
string[]? scopes,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pca = await _msalFactory.GetOrCreateAsync(clientId);
|
var pca = await _msalFactory.GetOrCreateAsync(clientId);
|
||||||
|
|
||||||
|
// Always reuse a cached account when one exists — `WithTenantId` on the
|
||||||
|
// silent/interactive call redirects the authority, and MSAL stores
|
||||||
|
// refresh tokens per tenant. Skipping the cached account forces an
|
||||||
|
// interactive prompt on every Graph call (the bug that produced 4–5
|
||||||
|
// sign-in windows during app registration).
|
||||||
var accounts = await pca.GetAccountsAsync();
|
var accounts = await pca.GetAccountsAsync();
|
||||||
var account = accounts.FirstOrDefault();
|
var account = accounts.FirstOrDefault();
|
||||||
|
|
||||||
var graphScopes = new[] { "https://graph.microsoft.com/.default" };
|
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
|
||||||
|
|
||||||
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes);
|
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes, tenantId);
|
||||||
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
|
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
|
||||||
return new GraphServiceClient(authProvider);
|
return new GraphServiceClient(authProvider);
|
||||||
}
|
}
|
||||||
@@ -37,14 +67,16 @@ public class GraphClientFactory
|
|||||||
internal class MsalTokenProvider : IAccessTokenProvider
|
internal class MsalTokenProvider : IAccessTokenProvider
|
||||||
{
|
{
|
||||||
private readonly IPublicClientApplication _pca;
|
private readonly IPublicClientApplication _pca;
|
||||||
private readonly IAccount? _account;
|
private IAccount? _account;
|
||||||
private readonly string[] _scopes;
|
private readonly string[] _scopes;
|
||||||
|
private readonly string? _tenantId;
|
||||||
|
|
||||||
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes)
|
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes, string? tenantId = null)
|
||||||
{
|
{
|
||||||
_pca = pca;
|
_pca = pca;
|
||||||
_account = account;
|
_account = account;
|
||||||
_scopes = scopes;
|
_scopes = scopes;
|
||||||
|
_tenantId = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AllowedHostsValidator AllowedHostsValidator { get; } = new();
|
public AllowedHostsValidator AllowedHostsValidator { get; } = new();
|
||||||
@@ -53,19 +85,36 @@ internal class MsalTokenProvider : IAccessTokenProvider
|
|||||||
Uri uri,
|
Uri uri,
|
||||||
Dictionary<string, object>? additionalAuthenticationContext = null,
|
Dictionary<string, object>? additionalAuthenticationContext = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Refresh _account from PCA cache each call — interactive flows on a
|
||||||
|
// sibling token provider populate the cache, and we want the next
|
||||||
|
// request on this provider to use that account silently.
|
||||||
|
if (_account is null)
|
||||||
|
{
|
||||||
|
var accounts = await _pca.GetAccountsAsync();
|
||||||
|
_account = accounts.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_account is not null)
|
||||||
{
|
{
|
||||||
try
|
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
|
// fall through to interactive
|
||||||
var result = await _pca.AcquireTokenInteractive(_scopes)
|
|
||||||
.ExecuteAsync(cancellationToken);
|
|
||||||
return result.AccessToken;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var interactive = _pca.AcquireTokenInteractive(_scopes);
|
||||||
|
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
|
||||||
|
var interactiveResult = await interactive.ExecuteAsync(cancellationToken);
|
||||||
|
// Cache the account so subsequent calls on this provider go silent.
|
||||||
|
_account = interactiveResult.Account;
|
||||||
|
return interactiveResult.AccessToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,103 @@
|
|||||||
<data name="tab.duplicates" xml:space="preserve">
|
<data name="tab.duplicates" xml:space="preserve">
|
||||||
<value>Doublons</value>
|
<value>Doublons</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="tab.versions" xml:space="preserve">
|
||||||
|
<value>Versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.tab" xml:space="preserve">
|
||||||
|
<value>Nettoyage des versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.libs" xml:space="preserve">
|
||||||
|
<value>Bibliothèques</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.policy" xml:space="preserve">
|
||||||
|
<value>Politique de conservation</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.pickLibs" xml:space="preserve">
|
||||||
|
<value>Choisir des bibliothèques…</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.clearLibs" xml:space="preserve">
|
||||||
|
<value>Réinitialiser (toutes les bibliothèques)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.run" xml:space="preserve">
|
||||||
|
<value>Supprimer les anciennes versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.lbl.keepLast" xml:space="preserve">
|
||||||
|
<value>Conserver les dernières :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.keepFirst" xml:space="preserve">
|
||||||
|
<value>Conserver aussi la toute première version</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.confirm" xml:space="preserve">
|
||||||
|
<value>Demander confirmation avant l'exécution</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.note" xml:space="preserve">
|
||||||
|
<value>Seules les versions historiques sont supprimées. La version courante publiée est toujours conservée. L'action est irréversible.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.all" xml:space="preserve">
|
||||||
|
<value>Toutes les bibliothèques (aucun filtre)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.count" xml:space="preserve">
|
||||||
|
<value>{0} bibliothèque(s) sélectionnée(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm" xml:space="preserve">
|
||||||
|
<value>Supprimer les versions historiques en gardant les {0} dernières {1} ?
|
||||||
|
Cette action est irréversible.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm.keepFirst" xml:space="preserve">
|
||||||
|
<value>(plus la première version)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.err.keepLast" xml:space="preserve">
|
||||||
|
<value>« Conserver les dernières » doit être supérieur ou égal à 0.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.files" xml:space="preserve">
|
||||||
|
<value>Fichiers nettoyés :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.deleted" xml:space="preserve">
|
||||||
|
<value>Versions supprimées :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.freed" xml:space="preserve">
|
||||||
|
<value>Octets libérés :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.library" xml:space="preserve">
|
||||||
|
<value>Bibliothèque</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.file" xml:space="preserve">
|
||||||
|
<value>Fichier</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.before" xml:space="preserve">
|
||||||
|
<value>Avant</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.deleted" xml:space="preserve">
|
||||||
|
<value>Supprimées</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.remaining" xml:space="preserve">
|
||||||
|
<value>Restantes</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.freed" xml:space="preserve">
|
||||||
|
<value>Libérés</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.path" xml:space="preserve">
|
||||||
|
<value>Chemin</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.error" xml:space="preserve">
|
||||||
|
<value>Erreur</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.title" xml:space="preserve">
|
||||||
|
<value>Sélectionner les bibliothèques</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loading" xml:space="preserve">
|
||||||
|
<value>Chargement des bibliothèques…</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loaded" xml:space="preserve">
|
||||||
|
<value>{0} bibliothèques chargées.</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectAll" xml:space="preserve">
|
||||||
|
<value>Tout sélectionner</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectNone" xml:space="preserve">
|
||||||
|
<value>Tout désélectionner</value>
|
||||||
|
</data>
|
||||||
<data name="tab.templates" xml:space="preserve">
|
<data name="tab.templates" xml:space="preserve">
|
||||||
<value>Modèles</value>
|
<value>Modèles</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -142,12 +239,24 @@
|
|||||||
<data name="profile.add" xml:space="preserve">
|
<data name="profile.add" xml:space="preserve">
|
||||||
<value>Ajouter</value>
|
<value>Ajouter</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profile.rename" xml:space="preserve">
|
<data name="profile.save" xml:space="preserve">
|
||||||
<value>Renommer</value>
|
<value>Enregistrer</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profile.delete" xml:space="preserve">
|
<data name="profile.delete" xml:space="preserve">
|
||||||
<value>Supprimer</value>
|
<value>Supprimer</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="profile.add.tooltip" xml:space="preserve">
|
||||||
|
<value>Créer un nouveau profil à partir des valeurs ci-dessus.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.save.tooltip" xml:space="preserve">
|
||||||
|
<value>Enregistrer les modifications du profil sélectionné.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.delete.tooltip" xml:space="preserve">
|
||||||
|
<value>Supprimer le profil sélectionné.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.register.warning" xml:space="preserve">
|
||||||
|
<value>L'enregistrement de l'application peut nécessiter jusqu'à {0} connexions. Continuer ?</value>
|
||||||
|
</data>
|
||||||
<data name="status.ready" xml:space="preserve">
|
<data name="status.ready" xml:space="preserve">
|
||||||
<value>Prêt</value>
|
<value>Prêt</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -372,6 +481,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é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éparés</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.tabbed" xml:space="preserve">
|
||||||
|
<value>Fichier unique à onglets</value>
|
||||||
|
</data>
|
||||||
<data name="audit.summary.total" xml:space="preserve">
|
<data name="audit.summary.total" xml:space="preserve">
|
||||||
<value>Total des accès</value>
|
<value>Total des accès</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -462,6 +592,8 @@
|
|||||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
||||||
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
||||||
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
||||||
|
<data name="report.title.versions" xml:space="preserve"><value>Rapport de nettoyage des versions SharePoint</value></data>
|
||||||
|
<data name="report.title.versions_short" xml:space="preserve"><value>Rapport de nettoyage des versions</value></data>
|
||||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
||||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
||||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
||||||
@@ -515,6 +647,7 @@
|
|||||||
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
|
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
|
||||||
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
|
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
|
||||||
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
||||||
|
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
|
||||||
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
|
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
|
||||||
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
|
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
|
||||||
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
|
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
|
||||||
@@ -537,4 +670,95 @@
|
|||||||
<data name="report.text.high_priv" xml:space="preserve"><value>priv. élevé</value></data>
|
<data name="report.text.high_priv" xml:space="preserve"><value>priv. élevé</value></data>
|
||||||
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
|
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
|
||||||
<data name="report.section.library_details" xml:space="preserve"><value>Détails des bibliothèques</value></data>
|
<data name="report.section.library_details" xml:space="preserve"><value>Détails des bibliothèques</value></data>
|
||||||
|
<!-- Site picker dialog -->
|
||||||
|
<data name="sitepicker.title" xml:space="preserve"><value>Sélectionner les sites</value></data>
|
||||||
|
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre :</value></data>
|
||||||
|
<data name="sitepicker.type" xml:space="preserve"><value>Type :</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'é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) :</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électionner</value></data>
|
||||||
|
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Tout désé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és.</value></data>
|
||||||
|
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites affichés.</value></data>
|
||||||
|
<data name="sitepicker.status.error" xml:space="preserve"><value>Erreur : {0}</value></data>
|
||||||
|
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Site d'é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é</value></data>
|
||||||
|
<!-- InputDialog -->
|
||||||
|
<data name="input.title" xml:space="preserve"><value>Saisie</value></data>
|
||||||
|
<!-- ProfileManagementDialog -->
|
||||||
|
<data name="profmgmt.title" xml:space="preserve"><value>Gé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 : </value></data>
|
||||||
|
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Taille des versions : </value></data>
|
||||||
|
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Fichiers : </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é</value></data>
|
||||||
|
<!-- Transfer view -->
|
||||||
|
<data name="transfer.text.files_selected" xml:space="preserve"><value> fichier(s) sélectionné(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é, recrée le dossier source sous la destination. Sinon, dé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é (par défaut), transfère les fichiers du dossier. Sinon, seul le dossier est créé à la destination.</value></data>
|
||||||
|
<!-- Shared ViewModel errors and statuses -->
|
||||||
|
<data name="err.no_tenant" xml:space="preserve"><value>Aucun tenant connecté.</value></data>
|
||||||
|
<data name="err.no_tenant_connected" xml:space="preserve"><value>Aucun tenant sélectionné. Connectez-vous à un tenant d'abord.</value></data>
|
||||||
|
<data name="err.no_profile_selected" xml:space="preserve"><value>Aucun profil de tenant sélectionné. Connectez-vous d'abord.</value></data>
|
||||||
|
<data name="err.no_sites_selected" xml:space="preserve"><value>Sé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 à auditer.</value></data>
|
||||||
|
<data name="err.no_valid_rows" xml:space="preserve"><value>Aucune ligne valide à traiter. Importez un CSV d'abord.</value></data>
|
||||||
|
<data name="err.template_name_required" xml:space="preserve"><value>Le nom du modè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èque doivent être sélectionnés.</value></data>
|
||||||
|
<data name="err.transfer_dest_required" xml:space="preserve"><value>Le site de destination et la bibliothèque doivent être sélectionnés.</value></data>
|
||||||
|
<data name="err.library_title_required" xml:space="preserve"><value>Le titre de la bibliothèque est requis.</value></data>
|
||||||
|
<!-- Templates status -->
|
||||||
|
<data name="templates.status.capturing" xml:space="preserve"><value>Capture du modèle...</value></data>
|
||||||
|
<data name="templates.status.success" xml:space="preserve"><value>Modèle capturé avec succès.</value></data>
|
||||||
|
<data name="templates.status.capture_failed" xml:space="preserve"><value>Échec de la capture : {0}</value></data>
|
||||||
|
<data name="templates.status.applying" xml:space="preserve"><value>Application du modèle...</value></data>
|
||||||
|
<data name="templates.status.applied" xml:space="preserve"><value>Modèle appliqué. Site créé à : {0}</value></data>
|
||||||
|
<data name="templates.status.apply_failed" xml:space="preserve"><value>Échec de l'application : {0}</value></data>
|
||||||
|
<!-- UI text -->
|
||||||
|
<data name="audit.searching" xml:space="preserve"><value>Recherche en cours...</value></data>
|
||||||
|
<!-- Report text -->
|
||||||
|
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
|
||||||
|
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
|
||||||
|
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -82,6 +82,103 @@
|
|||||||
<data name="tab.duplicates" xml:space="preserve">
|
<data name="tab.duplicates" xml:space="preserve">
|
||||||
<value>Duplicates</value>
|
<value>Duplicates</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="tab.versions" xml:space="preserve">
|
||||||
|
<value>Versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.tab" xml:space="preserve">
|
||||||
|
<value>Version cleanup</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.libs" xml:space="preserve">
|
||||||
|
<value>Libraries</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.policy" xml:space="preserve">
|
||||||
|
<value>Retention policy</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.pickLibs" xml:space="preserve">
|
||||||
|
<value>Select libraries...</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.clearLibs" xml:space="preserve">
|
||||||
|
<value>Reset (all libraries)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.run" xml:space="preserve">
|
||||||
|
<value>Delete old versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.lbl.keepLast" xml:space="preserve">
|
||||||
|
<value>Keep last:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.keepFirst" xml:space="preserve">
|
||||||
|
<value>Also keep the very first version</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.confirm" xml:space="preserve">
|
||||||
|
<value>Ask for confirmation before running</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.note" xml:space="preserve">
|
||||||
|
<value>Only historical versions are removed. The current published version is always kept. The action cannot be undone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.all" xml:space="preserve">
|
||||||
|
<value>All libraries (no filter)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.count" xml:space="preserve">
|
||||||
|
<value>{0} library/libraries selected</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm" xml:space="preserve">
|
||||||
|
<value>Delete historical file versions, keeping the last {0} {1}?
|
||||||
|
This cannot be undone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm.keepFirst" xml:space="preserve">
|
||||||
|
<value>(plus the first version)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.err.keepLast" xml:space="preserve">
|
||||||
|
<value>"Keep last" must be 0 or greater.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.files" xml:space="preserve">
|
||||||
|
<value>Files trimmed:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.deleted" xml:space="preserve">
|
||||||
|
<value>Versions deleted:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.freed" xml:space="preserve">
|
||||||
|
<value>Bytes freed:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.library" xml:space="preserve">
|
||||||
|
<value>Library</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.file" xml:space="preserve">
|
||||||
|
<value>File</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.before" xml:space="preserve">
|
||||||
|
<value>Before</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.deleted" xml:space="preserve">
|
||||||
|
<value>Deleted</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.remaining" xml:space="preserve">
|
||||||
|
<value>Remaining</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.freed" xml:space="preserve">
|
||||||
|
<value>Freed</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.path" xml:space="preserve">
|
||||||
|
<value>Path</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.error" xml:space="preserve">
|
||||||
|
<value>Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.title" xml:space="preserve">
|
||||||
|
<value>Select libraries</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loading" xml:space="preserve">
|
||||||
|
<value>Loading libraries...</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loaded" xml:space="preserve">
|
||||||
|
<value>{0} libraries loaded.</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectAll" xml:space="preserve">
|
||||||
|
<value>Select all</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectNone" xml:space="preserve">
|
||||||
|
<value>Select none</value>
|
||||||
|
</data>
|
||||||
<data name="tab.templates" xml:space="preserve">
|
<data name="tab.templates" xml:space="preserve">
|
||||||
<value>Templates</value>
|
<value>Templates</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -142,12 +239,24 @@
|
|||||||
<data name="profile.add" xml:space="preserve">
|
<data name="profile.add" xml:space="preserve">
|
||||||
<value>Add</value>
|
<value>Add</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profile.rename" xml:space="preserve">
|
<data name="profile.save" xml:space="preserve">
|
||||||
<value>Rename</value>
|
<value>Save</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profile.delete" xml:space="preserve">
|
<data name="profile.delete" xml:space="preserve">
|
||||||
<value>Delete</value>
|
<value>Delete</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="profile.add.tooltip" xml:space="preserve">
|
||||||
|
<value>Create a new profile from the values entered above.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.save.tooltip" xml:space="preserve">
|
||||||
|
<value>Save changes to the selected profile.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.delete.tooltip" xml:space="preserve">
|
||||||
|
<value>Delete the selected profile.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.register.warning" xml:space="preserve">
|
||||||
|
<value>Registering an app may prompt you to sign in up to {0} times. Continue?</value>
|
||||||
|
</data>
|
||||||
<data name="status.ready" xml:space="preserve">
|
<data name="status.ready" xml:space="preserve">
|
||||||
<value>Ready</value>
|
<value>Ready</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -372,6 +481,27 @@
|
|||||||
<data name="audit.btn.exportHtml" xml:space="preserve">
|
<data name="audit.btn.exportHtml" xml:space="preserve">
|
||||||
<value>Export HTML</value>
|
<value>Export HTML</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="export.split.label" xml:space="preserve">
|
||||||
|
<value>Split</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.single" xml:space="preserve">
|
||||||
|
<value>Single file</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.bySite" xml:space="preserve">
|
||||||
|
<value>By site</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.byUser" xml:space="preserve">
|
||||||
|
<value>By user</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.label" xml:space="preserve">
|
||||||
|
<value>HTML layout</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.separate" xml:space="preserve">
|
||||||
|
<value>Separate files</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.tabbed" xml:space="preserve">
|
||||||
|
<value>Single tabbed file</value>
|
||||||
|
</data>
|
||||||
<data name="audit.summary.total" xml:space="preserve">
|
<data name="audit.summary.total" xml:space="preserve">
|
||||||
<value>Total Accesses</value>
|
<value>Total Accesses</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -462,6 +592,8 @@
|
|||||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
|
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
|
||||||
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
|
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
|
||||||
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
|
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
|
||||||
|
<data name="report.title.versions" xml:space="preserve"><value>SharePoint Version Cleanup Report</value></data>
|
||||||
|
<data name="report.title.versions_short" xml:space="preserve"><value>Version Cleanup Report</value></data>
|
||||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
|
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
|
||||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
|
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
|
||||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
|
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
|
||||||
@@ -515,6 +647,7 @@
|
|||||||
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
|
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
|
||||||
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
|
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
|
||||||
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
||||||
|
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
|
||||||
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
|
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
|
||||||
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
|
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
|
||||||
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
|
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
|
||||||
@@ -537,4 +670,95 @@
|
|||||||
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
|
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
|
||||||
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
|
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
|
||||||
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
|
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
|
||||||
|
<!-- Site picker dialog -->
|
||||||
|
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
|
||||||
|
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
|
||||||
|
<data name="sitepicker.type" xml:space="preserve"><value>Type:</value></data>
|
||||||
|
<data name="sitepicker.type.all" xml:space="preserve"><value>All</value></data>
|
||||||
|
<data name="sitepicker.type.team" xml:space="preserve"><value>Team sites (MS Teams)</value></data>
|
||||||
|
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
|
||||||
|
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classic</value></data>
|
||||||
|
<data name="sitepicker.type.other" xml:space="preserve"><value>Other</value></data>
|
||||||
|
<data name="sitepicker.size" xml:space="preserve"><value>Size (MB):</value></data>
|
||||||
|
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
|
||||||
|
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
|
||||||
|
<data name="sitepicker.col.title" xml:space="preserve"><value>Title</value></data>
|
||||||
|
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
|
||||||
|
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<data name="sitepicker.col.size" xml:space="preserve"><value>Size</value></data>
|
||||||
|
<data name="sitepicker.btn.load" xml:space="preserve"><value>Load Sites</value></data>
|
||||||
|
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Select All</value></data>
|
||||||
|
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Deselect All</value></data>
|
||||||
|
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
|
||||||
|
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Cancel</value></data>
|
||||||
|
<data name="sitepicker.status.loading" xml:space="preserve"><value>Loading sites...</value></data>
|
||||||
|
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites loaded.</value></data>
|
||||||
|
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites shown.</value></data>
|
||||||
|
<data name="sitepicker.status.error" xml:space="preserve"><value>Error: {0}</value></data>
|
||||||
|
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Team site</value></data>
|
||||||
|
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
|
||||||
|
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classic</value></data>
|
||||||
|
<data name="sitepicker.kind.other" xml:space="preserve"><value>Other</value></data>
|
||||||
|
<!-- Common UI -->
|
||||||
|
<data name="common.valid" xml:space="preserve"><value>Valid</value></data>
|
||||||
|
<data name="common.errors" xml:space="preserve"><value>Errors</value></data>
|
||||||
|
<data name="common.close" xml:space="preserve"><value>Close</value></data>
|
||||||
|
<data name="common.new_folder" xml:space="preserve"><value>+ New Folder</value></data>
|
||||||
|
<data name="common.guest" xml:space="preserve"><value>Guest</value></data>
|
||||||
|
<!-- InputDialog -->
|
||||||
|
<data name="input.title" xml:space="preserve"><value>Input</value></data>
|
||||||
|
<!-- ProfileManagementDialog -->
|
||||||
|
<data name="profmgmt.title" xml:space="preserve"><value>Manage Profiles</value></data>
|
||||||
|
<data name="profmgmt.group" xml:space="preserve"><value>Profiles</value></data>
|
||||||
|
<!-- Duplicates columns -->
|
||||||
|
<data name="duplicates.col.group" xml:space="preserve"><value>Group</value></data>
|
||||||
|
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
|
||||||
|
<!-- Folder structure levels -->
|
||||||
|
<data name="folderstruct.col.level1" xml:space="preserve"><value>Level 1</value></data>
|
||||||
|
<data name="folderstruct.col.level2" xml:space="preserve"><value>Level 2</value></data>
|
||||||
|
<data name="folderstruct.col.level3" xml:space="preserve"><value>Level 3</value></data>
|
||||||
|
<data name="folderstruct.col.level4" xml:space="preserve"><value>Level 4</value></data>
|
||||||
|
<!-- Permissions extra columns -->
|
||||||
|
<data name="perm.col.unique_perms" xml:space="preserve"><value>Unique Perms</value></data>
|
||||||
|
<data name="perm.col.permission_levels" xml:space="preserve"><value>Permission Levels</value></data>
|
||||||
|
<data name="perm.col.principal_type" xml:space="preserve"><value>Principal Type</value></data>
|
||||||
|
<!-- Storage summary labels -->
|
||||||
|
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Total Size: </value></data>
|
||||||
|
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Version Size: </value></data>
|
||||||
|
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Files: </value></data>
|
||||||
|
<!-- Templates columns -->
|
||||||
|
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
|
||||||
|
<data name="templates.col.captured" xml:space="preserve"><value>Captured</value></data>
|
||||||
|
<!-- Transfer view -->
|
||||||
|
<data name="transfer.text.files_selected" xml:space="preserve"><value> file(s) selected</value></data>
|
||||||
|
<data name="transfer.chk.include_source" xml:space="preserve"><value>Include source folder at destination</value></data>
|
||||||
|
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder.</value></data>
|
||||||
|
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copy folder contents</value></data>
|
||||||
|
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>When on (default), transfer files inside the folder. When off, only the folder is created at the destination.</value></data>
|
||||||
|
<!-- Shared ViewModel errors and statuses -->
|
||||||
|
<data name="err.no_tenant" xml:space="preserve"><value>No tenant connected.</value></data>
|
||||||
|
<data name="err.no_tenant_connected" xml:space="preserve"><value>No tenant selected. Please connect to a tenant first.</value></data>
|
||||||
|
<data name="err.no_profile_selected" xml:space="preserve"><value>No tenant profile selected. Please connect first.</value></data>
|
||||||
|
<data name="err.no_sites_selected" xml:space="preserve"><value>Select at least one site from the toolbar.</value></data>
|
||||||
|
<data name="err.no_users_selected" xml:space="preserve"><value>Add at least one user to audit.</value></data>
|
||||||
|
<data name="err.no_valid_rows" xml:space="preserve"><value>No valid rows to process. Import a CSV first.</value></data>
|
||||||
|
<data name="err.template_name_required" xml:space="preserve"><value>Template name is required.</value></data>
|
||||||
|
<data name="err.site_title_required" xml:space="preserve"><value>New site title is required.</value></data>
|
||||||
|
<data name="err.site_alias_required" xml:space="preserve"><value>New site alias is required.</value></data>
|
||||||
|
<data name="err.transfer_source_required" xml:space="preserve"><value>Source site and library must be selected.</value></data>
|
||||||
|
<data name="err.transfer_dest_required" xml:space="preserve"><value>Destination site and library must be selected.</value></data>
|
||||||
|
<data name="err.library_title_required" xml:space="preserve"><value>Library title is required.</value></data>
|
||||||
|
<!-- Templates status -->
|
||||||
|
<data name="templates.status.capturing" xml:space="preserve"><value>Capturing template...</value></data>
|
||||||
|
<data name="templates.status.success" xml:space="preserve"><value>Template captured successfully.</value></data>
|
||||||
|
<data name="templates.status.capture_failed" xml:space="preserve"><value>Capture failed: {0}</value></data>
|
||||||
|
<data name="templates.status.applying" xml:space="preserve"><value>Applying template...</value></data>
|
||||||
|
<data name="templates.status.applied" xml:space="preserve"><value>Template applied. Site created at: {0}</value></data>
|
||||||
|
<data name="templates.status.apply_failed" xml:space="preserve"><value>Apply failed: {0}</value></data>
|
||||||
|
<!-- UI text -->
|
||||||
|
<data name="audit.searching" xml:space="preserve"><value>Searching...</value></data>
|
||||||
|
<!-- Report text -->
|
||||||
|
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
|
||||||
|
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
|
||||||
|
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
|
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
|
||||||
SelectedItem="{Binding SelectedProfile}"
|
SelectedItem="{Binding SelectedProfile}"
|
||||||
DisplayMemberPath="Name" />
|
DisplayMemberPath="Name" />
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.connect]}"
|
|
||||||
Command="{Binding ConnectCommand}" />
|
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
|
||||||
Command="{Binding ManageProfilesCommand}" />
|
Command="{Binding ManageProfilesCommand}" />
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -63,6 +61,9 @@
|
|||||||
<TabItem x:Name="DuplicatesTabItem"
|
<TabItem x:Name="DuplicatesTabItem"
|
||||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
<TabItem x:Name="VersionsTabItem"
|
||||||
|
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.versions]}">
|
||||||
|
</TabItem>
|
||||||
<TabItem x:Name="TransferTabItem"
|
<TabItem x:Name="TransferTabItem"
|
||||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
|
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public partial class MainWindow : Window
|
|||||||
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
|
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
|
||||||
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
|
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
|
||||||
|
|
||||||
|
// Versions cleanup tab
|
||||||
|
VersionsTabItem.Content = serviceProvider.GetRequiredService<VersionCleanupView>();
|
||||||
|
|
||||||
// Phase 4: Replace stub tabs with DI-resolved Views
|
// Phase 4: Replace stub tabs with DI-resolved Views
|
||||||
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
|
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
|
||||||
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
|
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using Microsoft.SharePoint.Client;
|
using Microsoft.SharePoint.Client;
|
||||||
using Microsoft.SharePoint.Client.Search.Query;
|
using Microsoft.SharePoint.Client.Search.Query;
|
||||||
using SharepointToolbox.Core.Helpers;
|
using SharepointToolbox.Core.Helpers;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services;
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
@@ -13,9 +15,27 @@ namespace SharepointToolbox.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DuplicatesService : IDuplicatesService
|
public class DuplicatesService : IDuplicatesService
|
||||||
{
|
{
|
||||||
|
// SharePoint Search REST API caps RowLimit at 500 per request; larger values are silently clamped.
|
||||||
private const int BatchSize = 500;
|
private const int BatchSize = 500;
|
||||||
|
|
||||||
|
// SharePoint Search hard ceiling — StartRow > 50,000 returns an error regardless of pagination state.
|
||||||
|
// See https://learn.microsoft.com/sharepoint/dev/general-development/customizing-search-results-in-sharepoint
|
||||||
private const int MaxStartRow = 50_000;
|
private const int MaxStartRow = 50_000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans a site for duplicate files or folders and groups matches by the
|
||||||
|
/// composite key configured in <paramref name="options"/> (name plus any
|
||||||
|
/// of size / created / modified / subfolder-count / file-count).
|
||||||
|
/// File mode uses the SharePoint Search API — it is fast but capped at
|
||||||
|
/// 50,000 rows (see <see cref="MaxStartRow"/>). Folder mode uses paginated
|
||||||
|
/// CSOM CAML over every document library on the site. Groups with fewer
|
||||||
|
/// than two items are dropped before return.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ctx">Authenticated <see cref="ClientContext"/> for the target site.</param>
|
||||||
|
/// <param name="options">Scope (Files/Folders), optional library filter, and match-key toggles.</param>
|
||||||
|
/// <param name="progress">Receives row-count progress during collection.</param>
|
||||||
|
/// <param name="ct">Cancellation token — honoured between paged requests.</param>
|
||||||
|
/// <returns>Duplicate groups ordered by descending size, then name.</returns>
|
||||||
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
|
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
|
||||||
ClientContext ctx,
|
ClientContext ctx,
|
||||||
DuplicateScanOptions options,
|
DuplicateScanOptions options,
|
||||||
@@ -70,6 +90,8 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct);
|
||||||
|
|
||||||
// KQL: all documents, optionally scoped to a library
|
// KQL: all documents, optionally scoped to a library
|
||||||
var kqlParts = new List<string> { "ContentType:Document" };
|
var kqlParts = new List<string> { "ContentType:Document" };
|
||||||
if (!string.IsNullOrEmpty(options.Library))
|
if (!string.IsNullOrEmpty(options.Library))
|
||||||
@@ -147,7 +169,9 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
Library = library,
|
Library = library,
|
||||||
SizeBytes = size,
|
SizeBytes = size,
|
||||||
Created = created,
|
Created = created,
|
||||||
Modified = modified
|
Modified = modified,
|
||||||
|
SiteUrl = siteUrl,
|
||||||
|
SiteTitle = siteTitle
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +195,16 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
{
|
{
|
||||||
// Load all document libraries on the site
|
// Load all document libraries on the site
|
||||||
ctx.Load(ctx.Web,
|
ctx.Load(ctx.Web,
|
||||||
|
w => w.Title,
|
||||||
w => w.Lists.Include(
|
w => w.Lists.Include(
|
||||||
l => l.Title, l => l.Hidden, l => l.BaseType));
|
l => l.Title, l => l.Hidden, l => l.BaseType));
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
var siteUrl = ctx.Url;
|
||||||
|
var siteTitle = string.IsNullOrWhiteSpace(ctx.Web.Title)
|
||||||
|
? ReportSplitHelper.DeriveSiteLabel(siteUrl)
|
||||||
|
: ctx.Web.Title;
|
||||||
|
|
||||||
var libs = ctx.Web.Lists
|
var libs = ctx.Web.Lists
|
||||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -187,19 +217,15 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No WHERE clause — a WHERE on non-indexed fields (FSObjType) throws the
|
||||||
|
// list-view threshold on libraries > 5,000 items even with pagination.
|
||||||
|
// Filter for folders client-side via FileSystemObjectType below.
|
||||||
var camlQuery = new CamlQuery
|
var camlQuery = new CamlQuery
|
||||||
{
|
{
|
||||||
ViewXml = """
|
ViewXml = """
|
||||||
<View Scope='RecursiveAll'>
|
<View Scope='RecursiveAll'>
|
||||||
<Query>
|
<Query></Query>
|
||||||
<Where>
|
<RowLimit Paged='TRUE'>5000</RowLimit>
|
||||||
<Eq>
|
|
||||||
<FieldRef Name='FSObjType' />
|
|
||||||
<Value Type='Integer'>1</Value>
|
|
||||||
</Eq>
|
|
||||||
</Where>
|
|
||||||
</Query>
|
|
||||||
<RowLimit>2000</RowLimit>
|
|
||||||
</View>
|
</View>
|
||||||
"""
|
"""
|
||||||
};
|
};
|
||||||
@@ -215,6 +241,8 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
|
||||||
|
|
||||||
var fv = item.FieldValues;
|
var fv = item.FieldValues;
|
||||||
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
|
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||||
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
|
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
|
||||||
@@ -232,7 +260,9 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
FolderCount = subCount,
|
FolderCount = subCount,
|
||||||
FileCount = fileCount,
|
FileCount = fileCount,
|
||||||
Created = created,
|
Created = created,
|
||||||
Modified = modified
|
Modified = modified,
|
||||||
|
SiteUrl = siteUrl,
|
||||||
|
SiteTitle = siteTitle
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +291,37 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
private static DateTime? ParseDate(string s) =>
|
private static DateTime? ParseDate(string s) =>
|
||||||
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
|
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
|
||||||
|
|
||||||
|
private static async Task<(string Url, string Title)> LoadSiteIdentityAsync(
|
||||||
|
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ctx.Load(ctx.Web, w => w.Title);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Best-effort — fall back to URL-derived label
|
||||||
|
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: failed to load Web.Title: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = ctx.Url ?? string.Empty;
|
||||||
|
string title;
|
||||||
|
try { title = ctx.Web.Title; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: Web.Title getter threw: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
title = string.Empty;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
title = ReportSplitHelper.DeriveSiteLabel(url);
|
||||||
|
return (url, title);
|
||||||
|
}
|
||||||
|
|
||||||
private static string ExtractLibraryFromPath(string path, string siteUrl)
|
private static string ExtractLibraryFromPath(string path, string siteUrl)
|
||||||
{
|
{
|
||||||
// Extract first path segment after the site URL as library name
|
// Extract first path segment after the site URL as library name
|
||||||
|
|||||||
@@ -2,18 +2,36 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
|
using CsvHelper.Configuration;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Localization;
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports the failed subset of a <see cref="BulkOperationSummary{T}"/> run
|
||||||
|
/// to CSV. CsvHelper is used so the <typeparamref name="T"/> payload's
|
||||||
|
/// properties become columns automatically, plus one error-message and one
|
||||||
|
/// timestamp column appended at the end.
|
||||||
|
/// </summary>
|
||||||
public class BulkResultCsvExportService
|
public class BulkResultCsvExportService
|
||||||
{
|
{
|
||||||
|
private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
// Prevent CSV formula injection: prefix =, +, -, @, tab, CR with single quote
|
||||||
|
InjectionOptions = InjectionOptions.Escape,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a CSV containing only items whose <see cref="BulkItemResult{T}.IsSuccess"/>
|
||||||
|
/// is <c>false</c>. Columns: every public property of <typeparamref name="T"/>
|
||||||
|
/// followed by Error and Timestamp (ISO-8601).
|
||||||
|
/// </summary>
|
||||||
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
|
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
|
||||||
{
|
{
|
||||||
var TL = TranslationSource.Instance;
|
var TL = TranslationSource.Instance;
|
||||||
using var writer = new StringWriter();
|
using var writer = new StringWriter();
|
||||||
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
|
using var csv = new CsvWriter(writer, CsvConfig);
|
||||||
|
|
||||||
csv.WriteHeader<T>();
|
csv.WriteHeader<T>();
|
||||||
csv.WriteField(TL["report.col.error"]);
|
csv.WriteField(TL["report.col.error"]);
|
||||||
@@ -31,12 +49,13 @@ public class BulkResultCsvExportService
|
|||||||
return writer.ToString();
|
return writer.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the failed-items CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||||
public async Task WriteFailedItemsCsvAsync<T>(
|
public async Task WriteFailedItemsCsvAsync<T>(
|
||||||
IReadOnlyList<BulkItemResult<T>> failedItems,
|
IReadOnlyList<BulkItemResult<T>> failedItems,
|
||||||
string filePath,
|
string filePath,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var content = BuildFailedItemsCsv(failedItems);
|
var content = BuildFailedItemsCsv(failedItems);
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct);
|
await ExportFileWriter.WriteCsvAsync(filePath, content, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ public class CsvExportService
|
|||||||
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(entries);
|
var csv = BuildCsv(entries);
|
||||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -116,13 +116,57 @@ public class CsvExportService
|
|||||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(entries);
|
var csv = BuildCsv(entries);
|
||||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
|
/// <summary>
|
||||||
private static string Csv(string value)
|
/// Writes permission entries with optional per-site partitioning.
|
||||||
|
/// Single → writes one file at <paramref name="basePath"/>.
|
||||||
|
/// BySite → one file per site-collection URL, suffixed on the base path.
|
||||||
|
/// </summary>
|
||||||
|
public Task WriteAsync(
|
||||||
|
IReadOnlyList<PermissionEntry> entries,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> ReportSplitHelper.WritePartitionedAsync(
|
||||||
|
entries, basePath, splitMode,
|
||||||
|
PartitionBySite,
|
||||||
|
(part, path, c) => WriteAsync(part, path, c),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
/// <summary>Simplified-entry split variant.</summary>
|
||||||
|
public Task WriteAsync(
|
||||||
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> ReportSplitHelper.WritePartitionedAsync(
|
||||||
|
entries, basePath, splitMode,
|
||||||
|
PartitionBySite,
|
||||||
|
(part, path, c) => WriteAsync(part, path, c),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
internal static IEnumerable<(string Label, IReadOnlyList<PermissionEntry> Partition)> PartitionBySite(
|
||||||
|
IReadOnlyList<PermissionEntry> entries)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
return entries
|
||||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => (
|
||||||
|
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
|
||||||
|
Partition: (IReadOnlyList<PermissionEntry>)g.ToList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static IEnumerable<(string Label, IReadOnlyList<SimplifiedPermissionEntry> Partition)> PartitionBySite(
|
||||||
|
IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||||
|
{
|
||||||
|
return entries
|
||||||
|
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => (
|
||||||
|
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
|
||||||
|
Partition: (IReadOnlyList<SimplifiedPermissionEntry>)g.ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
|
||||||
|
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CSV field sanitization. Adds RFC 4180 quoting plus formula-injection
|
||||||
|
/// protection: Excel and other spreadsheet apps treat cells starting with
|
||||||
|
/// '=', '+', '-', '@', tab, or CR as formulas. Prefixing with a single
|
||||||
|
/// quote neutralizes the formula while remaining readable.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CsvSanitizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Escapes a value for inclusion in a CSV row. Always wraps in double
|
||||||
|
/// quotes. Doubles internal quotes per RFC 4180. Prepends an apostrophe
|
||||||
|
/// when the value begins with a character a spreadsheet would evaluate.
|
||||||
|
/// </summary>
|
||||||
|
public static string Escape(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return "\"\"";
|
||||||
|
var safe = NeutralizeFormulaPrefix(value).Replace("\"", "\"\"");
|
||||||
|
return $"\"{safe}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal quoting variant: only wraps in quotes when the value contains
|
||||||
|
/// a delimiter, quote, or newline. Still guards against formula injection.
|
||||||
|
/// </summary>
|
||||||
|
public static string EscapeMinimal(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||||
|
var safe = NeutralizeFormulaPrefix(value);
|
||||||
|
if (safe.Contains(',') || safe.Contains('"') || safe.Contains('\n') || safe.Contains('\r'))
|
||||||
|
return $"\"{safe.Replace("\"", "\"\"")}\"";
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NeutralizeFormulaPrefix(string value)
|
||||||
|
{
|
||||||
|
if (value.Length == 0) return value;
|
||||||
|
char first = value[0];
|
||||||
|
if (first == '=' || first == '+' || first == '-' || first == '@'
|
||||||
|
|| first == '\t' || first == '\r')
|
||||||
|
{
|
||||||
|
return "'" + value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,52 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DuplicatesCsvExportService
|
public class DuplicatesCsvExportService
|
||||||
{
|
{
|
||||||
|
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
|
||||||
public async Task WriteAsync(
|
public async Task WriteAsync(
|
||||||
IReadOnlyList<DuplicateGroup> groups,
|
IReadOnlyList<DuplicateGroup> groups,
|
||||||
string filePath,
|
string filePath,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var csv = BuildCsv(groups);
|
||||||
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes one or more CSVs depending on <paramref name="splitMode"/>.
|
||||||
|
/// Single → <paramref name="basePath"/> as-is. BySite → one file per site,
|
||||||
|
/// filenames derived from <paramref name="basePath"/> with a site suffix.
|
||||||
|
/// </summary>
|
||||||
|
public Task WriteAsync(
|
||||||
|
IReadOnlyList<DuplicateGroup> groups,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> ReportSplitHelper.WritePartitionedAsync(
|
||||||
|
groups, basePath, splitMode,
|
||||||
|
PartitionBySite,
|
||||||
|
(part, path, c) => WriteAsync(part, path, c),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
internal static IEnumerable<(string Label, IReadOnlyList<DuplicateGroup> Partition)> PartitionBySite(
|
||||||
|
IReadOnlyList<DuplicateGroup> groups)
|
||||||
|
{
|
||||||
|
return groups
|
||||||
|
.GroupBy(g =>
|
||||||
|
{
|
||||||
|
var first = g.Items.FirstOrDefault();
|
||||||
|
return (Url: first?.SiteUrl ?? string.Empty, Title: first?.SiteTitle ?? string.Empty);
|
||||||
|
})
|
||||||
|
.Select(g => (
|
||||||
|
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.Url, g.Key.Title),
|
||||||
|
Partition: (IReadOnlyList<DuplicateGroup>)g.ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the CSV payload. Emits a header summary (group count, generated
|
||||||
|
/// timestamp), then one row per duplicate item with its group index and
|
||||||
|
/// group size. CSV fields are escaped via <see cref="CsvSanitizer.Escape"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -30,8 +72,9 @@ public class DuplicatesCsvExportService
|
|||||||
sb.AppendLine(string.Join(",", new[]
|
sb.AppendLine(string.Join(",", new[]
|
||||||
{
|
{
|
||||||
Csv(T["report.col.number"]),
|
Csv(T["report.col.number"]),
|
||||||
Csv("Group"),
|
Csv(T["report.col.group"]),
|
||||||
Csv(T["report.text.copies"]),
|
Csv(T["report.text.copies"]),
|
||||||
|
Csv(T["report.col.site"]),
|
||||||
Csv(T["report.col.name"]),
|
Csv(T["report.col.name"]),
|
||||||
Csv(T["report.col.library"]),
|
Csv(T["report.col.library"]),
|
||||||
Csv(T["report.col.path"]),
|
Csv(T["report.col.path"]),
|
||||||
@@ -40,7 +83,6 @@ public class DuplicatesCsvExportService
|
|||||||
Csv(T["report.col.modified"]),
|
Csv(T["report.col.modified"]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Rows
|
|
||||||
foreach (var g in groups)
|
foreach (var g in groups)
|
||||||
{
|
{
|
||||||
int i = 0;
|
int i = 0;
|
||||||
@@ -52,6 +94,7 @@ public class DuplicatesCsvExportService
|
|||||||
Csv(i.ToString()),
|
Csv(i.ToString()),
|
||||||
Csv(g.Name),
|
Csv(g.Name),
|
||||||
Csv(g.Items.Count.ToString()),
|
Csv(g.Items.Count.ToString()),
|
||||||
|
Csv(item.SiteTitle),
|
||||||
Csv(item.Name),
|
Csv(item.Name),
|
||||||
Csv(item.Library),
|
Csv(item.Library),
|
||||||
Csv(item.Path),
|
Csv(item.Path),
|
||||||
@@ -62,13 +105,8 @@ public class DuplicatesCsvExportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await File.WriteAllTextAsync(filePath, sb.ToString(),
|
return sb.ToString();
|
||||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Csv(string value)
|
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
|
||||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DuplicatesHtmlExportService
|
public class DuplicatesHtmlExportService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a self-contained HTML string rendering one collapsible card per
|
||||||
|
/// <see cref="DuplicateGroup"/>. The document ships with inline CSS and a
|
||||||
|
/// tiny JS toggle so no external assets are needed.
|
||||||
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
@@ -122,12 +127,53 @@ public class DuplicatesHtmlExportService
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the HTML report to the specified file path using UTF-8.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(groups, branding);
|
var html = BuildHtml(groups, branding);
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes one or more HTML reports depending on <paramref name="splitMode"/> and
|
||||||
|
/// <paramref name="layout"/>. Single → one file. BySite + SeparateFiles → one
|
||||||
|
/// file per site. BySite + SingleTabbed → one file with per-site iframe tabs.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<DuplicateGroup> groups,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
HtmlSplitLayout layout,
|
||||||
|
CancellationToken ct,
|
||||||
|
ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
if (splitMode != ReportSplitMode.BySite)
|
||||||
|
{
|
||||||
|
await WriteAsync(groups, basePath, ct, branding);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var partitions = DuplicatesCsvExportService.PartitionBySite(groups).ToList();
|
||||||
|
|
||||||
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||||
|
{
|
||||||
|
var parts = partitions
|
||||||
|
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding)))
|
||||||
|
.ToList();
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, T["report.title.duplicates_short"]);
|
||||||
|
await System.IO.File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var partition in partitions)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, partition.Label);
|
||||||
|
await WriteAsync(partition.Partition, path, ct, branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string H(string value) =>
|
private static string H(string value) =>
|
||||||
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central file-write plumbing for export services so every CSV and HTML
|
||||||
|
/// artefact gets a consistent encoding: CSV files are written with a UTF-8
|
||||||
|
/// BOM (required for Excel to detect the encoding when opening a
|
||||||
|
/// double-clicked .csv), HTML files are written without a BOM (some browsers
|
||||||
|
/// and iframe <c>srcdoc</c> paths render the BOM as a visible character).
|
||||||
|
/// Export services should call these helpers rather than constructing
|
||||||
|
/// <see cref="UTF8Encoding"/> inline.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ExportFileWriter
|
||||||
|
{
|
||||||
|
private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true);
|
||||||
|
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
|
||||||
|
/// <summary>Writes <paramref name="csv"/> to <paramref name="filePath"/> as UTF-8 with BOM.</summary>
|
||||||
|
public static Task WriteCsvAsync(string filePath, string csv, CancellationToken ct)
|
||||||
|
=> File.WriteAllTextAsync(filePath, csv, Utf8WithBom, ct);
|
||||||
|
|
||||||
|
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
||||||
|
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
||||||
|
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
||||||
|
}
|
||||||
@@ -2,101 +2,47 @@ using System.IO;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Localization;
|
using SharepointToolbox.Localization;
|
||||||
|
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exports permission entries to a self-contained interactive HTML report.
|
/// Exports permission entries to a self-contained interactive HTML report.
|
||||||
/// Ports PowerShell Export-PermissionsToHTML functionality.
|
/// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
|
||||||
/// No external CSS/JS dependencies — everything is inline.
|
/// No external CSS/JS dependencies — everything is inline so the file can be
|
||||||
|
/// emailed or served from any static host. The standard and simplified
|
||||||
|
/// variants share their document shell, stats cards, CSS, pill rendering, and
|
||||||
|
/// inline script via <see cref="PermissionHtmlFragments"/>; this class only
|
||||||
|
/// owns the table column sets and the simplified risk summary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HtmlExportService
|
public class HtmlExportService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a self-contained HTML string from the supplied permission entries.
|
/// Builds a self-contained HTML string from the supplied permission
|
||||||
/// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills.
|
/// entries. Standard report: columns are Object / Title / URL / Unique /
|
||||||
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
|
/// Users / Permission / Granted Through. When
|
||||||
|
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
|
||||||
|
/// become expandable rows listing resolved members.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
|
public string BuildHtml(
|
||||||
|
IReadOnlyList<PermissionEntry> entries,
|
||||||
|
ReportBranding? branding = null,
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
// Compute stats
|
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||||
var totalEntries = entries.Count;
|
entries.Count,
|
||||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
entries.Select(e => e.PermissionLevels),
|
||||||
var distinctUsers = entries
|
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
|
||||||
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
.Select(u => u.Trim())
|
|
||||||
.Where(u => u.Length > 0)
|
|
||||||
.Distinct()
|
|
||||||
.Count();
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
AppendHead(sb, T["report.title.permissions"], includeRiskCss: false);
|
||||||
// ── HTML HEAD ──────────────────────────────────────────────────────────
|
|
||||||
sb.AppendLine("<!DOCTYPE html>");
|
|
||||||
sb.AppendLine("<html lang=\"en\">");
|
|
||||||
sb.AppendLine("<head>");
|
|
||||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
|
||||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
||||||
sb.AppendLine($"<title>{T["report.title.permissions"]}</title>");
|
|
||||||
sb.AppendLine("<style>");
|
|
||||||
sb.AppendLine(@"
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
||||||
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
|
||||||
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
|
||||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
||||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
|
||||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
|
||||||
.filter-wrap { padding: 0 24px 12px; }
|
|
||||||
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
|
||||||
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
||||||
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
|
|
||||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr:hover td { background: #fafafa; }
|
|
||||||
/* Type badges */
|
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
|
||||||
.badge.site-coll { background: #dbeafe; color: #1e40af; }
|
|
||||||
.badge.site { background: #dcfce7; color: #166534; }
|
|
||||||
.badge.list { background: #fef9c3; color: #854d0e; }
|
|
||||||
.badge.folder { background: #f3f4f6; color: #374151; }
|
|
||||||
/* Unique/Inherited badges */
|
|
||||||
.badge.unique { background: #dcfce7; color: #166534; }
|
|
||||||
.badge.inherited { background: #f3f4f6; color: #374151; }
|
|
||||||
/* User pills */
|
|
||||||
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
|
||||||
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
|
|
||||||
.group-expandable { cursor: pointer; }
|
|
||||||
.group-expandable:hover { opacity: 0.8; }
|
|
||||||
a { color: #2563eb; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
");
|
|
||||||
sb.AppendLine("</style>");
|
|
||||||
sb.AppendLine("</head>");
|
|
||||||
|
|
||||||
// ── BODY ───────────────────────────────────────────────────────────────
|
|
||||||
sb.AppendLine("<body>");
|
sb.AppendLine("<body>");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
|
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
|
||||||
|
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
||||||
// Stats cards
|
AppendFilterInput(sb);
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
AppendTableOpen(sb);
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
|
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
|
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Filter input
|
|
||||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
|
||||||
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Table
|
|
||||||
sb.AppendLine("<div class=\"table-wrap\">");
|
|
||||||
sb.AppendLine("<table id=\"permTable\">");
|
|
||||||
sb.AppendLine("<thead><tr>");
|
sb.AppendLine("<thead><tr>");
|
||||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
|
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
sb.AppendLine("</tr></thead>");
|
||||||
@@ -109,206 +55,68 @@ a:hover { text-decoration: underline; }
|
|||||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||||
|
|
||||||
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
|
var (pills, subRows) = BuildUserPillsCell(
|
||||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
|
||||||
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
colSpan: 7, grpMemIdx: ref grpMemIdx);
|
||||||
var pillsBuilder = new StringBuilder();
|
|
||||||
var memberSubRows = new StringBuilder();
|
|
||||||
|
|
||||||
for (int i = 0; i < logins.Length; i++)
|
|
||||||
{
|
|
||||||
var login = logins[i].Trim();
|
|
||||||
var name = i < names.Length ? names[i].Trim() : login;
|
|
||||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
bool isExpandableGroup = entry.PrincipalType == "SharePointGroup"
|
|
||||||
&& groupMembers != null
|
|
||||||
&& groupMembers.TryGetValue(name, out var members);
|
|
||||||
|
|
||||||
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
|
|
||||||
{
|
|
||||||
var grpId = $"grpmem{grpMemIdx}";
|
|
||||||
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
|
||||||
|
|
||||||
string memberContent;
|
|
||||||
if (resolvedMembers.Count > 0)
|
|
||||||
{
|
|
||||||
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
|
||||||
memberContent = string.Join(" • ", memberParts);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
|
||||||
}
|
|
||||||
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
|
||||||
grpMemIdx++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var pillCss = isExt ? "user-pill external-user" : "user-pill";
|
|
||||||
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine("<tr>");
|
sb.AppendLine("<tr>");
|
||||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
||||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
sb.AppendLine($" <td>{pills}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||||
sb.AppendLine("</tr>");
|
sb.AppendLine("</tr>");
|
||||||
if (memberSubRows.Length > 0)
|
if (subRows.Length > 0) sb.Append(subRows);
|
||||||
sb.Append(memberSubRows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine("</tbody>");
|
AppendTableClose(sb);
|
||||||
sb.AppendLine("</table>");
|
AppendInlineJs(sb);
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Inline JS
|
|
||||||
sb.AppendLine("<script>");
|
|
||||||
sb.AppendLine(@"function filterTable() {
|
|
||||||
var input = document.getElementById('filter').value.toLowerCase();
|
|
||||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
|
||||||
rows.forEach(function(row) {
|
|
||||||
if (row.hasAttribute('data-group')) return;
|
|
||||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleGroup(id) {
|
|
||||||
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
|
||||||
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
|
||||||
}");
|
|
||||||
sb.AppendLine("</script>");
|
|
||||||
sb.AppendLine("</body>");
|
sb.AppendLine("</body>");
|
||||||
sb.AppendLine("</html>");
|
sb.AppendLine("</html>");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes the HTML report to the specified file path using UTF-8 without BOM.
|
|
||||||
/// </summary>
|
|
||||||
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct,
|
|
||||||
ReportBranding? branding = null,
|
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
||||||
{
|
|
||||||
var html = BuildHtml(entries, branding, groupMembers);
|
|
||||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Returns inline CSS background and text color for a risk level.</summary>
|
|
||||||
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
|
|
||||||
{
|
|
||||||
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
|
|
||||||
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
|
|
||||||
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
|
|
||||||
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
|
|
||||||
_ => ("#F3F4F6", "#374151", "#E5E7EB")
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a self-contained HTML string from simplified permission entries.
|
/// Builds a self-contained HTML string from simplified permission entries.
|
||||||
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
|
/// Adds a risk-level summary card strip plus two columns (Simplified,
|
||||||
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
|
/// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
|
||||||
|
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
|
public string BuildHtml(
|
||||||
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||||
|
ReportBranding? branding = null,
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||||
|
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||||
var totalEntries = entries.Count;
|
entries.Count,
|
||||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
entries.Select(e => e.PermissionLevels),
|
||||||
var distinctUsers = entries
|
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
|
||||||
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
.Select(u => u.Trim())
|
|
||||||
.Where(u => u.Length > 0)
|
|
||||||
.Distinct()
|
|
||||||
.Count();
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
|
||||||
sb.AppendLine("<!DOCTYPE html>");
|
|
||||||
sb.AppendLine("<html lang=\"en\">");
|
|
||||||
sb.AppendLine("<head>");
|
|
||||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
|
||||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
||||||
sb.AppendLine($"<title>{T["report.title.permissions_simplified"]}</title>");
|
|
||||||
sb.AppendLine("<style>");
|
|
||||||
sb.AppendLine(@"
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
||||||
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
|
||||||
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
|
||||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
||||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
|
||||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
|
||||||
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
|
|
||||||
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
|
|
||||||
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
|
|
||||||
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
|
|
||||||
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
|
|
||||||
.filter-wrap { padding: 0 24px 12px; }
|
|
||||||
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
|
||||||
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
||||||
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
|
|
||||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr:hover td { background: rgba(0,0,0,.03); }
|
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
|
||||||
.badge.site-coll { background: #dbeafe; color: #1e40af; }
|
|
||||||
.badge.site { background: #dcfce7; color: #166534; }
|
|
||||||
.badge.list { background: #fef9c3; color: #854d0e; }
|
|
||||||
.badge.folder { background: #f3f4f6; color: #374151; }
|
|
||||||
.badge.unique { background: #dcfce7; color: #166534; }
|
|
||||||
.badge.inherited { background: #f3f4f6; color: #374151; }
|
|
||||||
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
|
||||||
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
|
||||||
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
|
|
||||||
.group-expandable { cursor: pointer; }
|
|
||||||
.group-expandable:hover { opacity: 0.8; }
|
|
||||||
a { color: #2563eb; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
");
|
|
||||||
sb.AppendLine("</style>");
|
|
||||||
sb.AppendLine("</head>");
|
|
||||||
|
|
||||||
sb.AppendLine("<body>");
|
sb.AppendLine("<body>");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
|
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
|
||||||
|
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
||||||
|
|
||||||
// Stats cards
|
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
|
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
|
|
||||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Risk-level summary cards
|
|
||||||
sb.AppendLine("<div class=\"risk-cards\">");
|
sb.AppendLine("<div class=\"risk-cards\">");
|
||||||
foreach (var summary in summaries)
|
foreach (var s in summaries)
|
||||||
{
|
{
|
||||||
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
|
var (bg, text, border) = RiskLevelColors(s.RiskLevel);
|
||||||
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
|
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
|
||||||
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
|
sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
|
||||||
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
|
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
|
||||||
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
|
sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
|
||||||
sb.AppendLine(" </div>");
|
sb.AppendLine(" </div>");
|
||||||
}
|
}
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Filter input
|
AppendFilterInput(sb);
|
||||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
AppendTableOpen(sb);
|
||||||
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Table with simplified columns
|
|
||||||
sb.AppendLine("<div class=\"table-wrap\">");
|
|
||||||
sb.AppendLine("<table id=\"permTable\">");
|
|
||||||
sb.AppendLine("<thead><tr>");
|
sb.AppendLine("<thead><tr>");
|
||||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
sb.AppendLine("</tr></thead>");
|
||||||
@@ -322,115 +130,165 @@ function toggleGroup(id) {
|
|||||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||||
|
|
||||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
var (pills, subRows) = BuildUserPillsCell(
|
||||||
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||||
var pillsBuilder = new StringBuilder();
|
colSpan: 9, grpMemIdx: ref grpMemIdx);
|
||||||
var memberSubRows = new StringBuilder();
|
|
||||||
|
|
||||||
for (int i = 0; i < logins.Length; i++)
|
|
||||||
{
|
|
||||||
var login = logins[i].Trim();
|
|
||||||
var name = i < names.Length ? names[i].Trim() : login;
|
|
||||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
|
|
||||||
&& groupMembers != null
|
|
||||||
&& groupMembers.TryGetValue(name, out _);
|
|
||||||
|
|
||||||
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
|
|
||||||
{
|
|
||||||
var grpId = $"grpmem{grpMemIdx}";
|
|
||||||
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
|
||||||
|
|
||||||
string memberContent;
|
|
||||||
if (resolvedMembers.Count > 0)
|
|
||||||
{
|
|
||||||
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
|
||||||
memberContent = string.Join(" • ", memberParts);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
|
||||||
}
|
|
||||||
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
|
||||||
grpMemIdx++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var pillCss = isExt ? "user-pill external-user" : "user-pill";
|
|
||||||
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine("<tr>");
|
sb.AppendLine("<tr>");
|
||||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
||||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
sb.AppendLine($" <td>{pills}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||||
sb.AppendLine("</tr>");
|
sb.AppendLine("</tr>");
|
||||||
if (memberSubRows.Length > 0)
|
if (subRows.Length > 0) sb.Append(subRows);
|
||||||
sb.Append(memberSubRows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine("</tbody>");
|
AppendTableClose(sb);
|
||||||
sb.AppendLine("</table>");
|
AppendInlineJs(sb);
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
sb.AppendLine("<script>");
|
|
||||||
sb.AppendLine(@"function filterTable() {
|
|
||||||
var input = document.getElementById('filter').value.toLowerCase();
|
|
||||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
|
||||||
rows.forEach(function(row) {
|
|
||||||
if (row.hasAttribute('data-group')) return;
|
|
||||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleGroup(id) {
|
|
||||||
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
|
||||||
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
|
||||||
}");
|
|
||||||
sb.AppendLine("</script>");
|
|
||||||
sb.AppendLine("</body>");
|
sb.AppendLine("</body>");
|
||||||
sb.AppendLine("</html>");
|
sb.AppendLine("</html>");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
|
||||||
/// Writes the simplified HTML report to the specified file path.
|
public async Task WriteAsync(
|
||||||
/// </summary>
|
IReadOnlyList<PermissionEntry> entries,
|
||||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
|
string filePath,
|
||||||
|
CancellationToken ct,
|
||||||
ReportBranding? branding = null,
|
ReportBranding? branding = null,
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(entries, branding, groupMembers);
|
var html = BuildHtml(entries, branding, groupMembers);
|
||||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns the CSS class for the object-type badge.</summary>
|
/// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
|
||||||
private static string ObjectTypeCss(string t) => t switch
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||||
|
string filePath,
|
||||||
|
CancellationToken ct,
|
||||||
|
ReportBranding? branding = null,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
"Site Collection" => "badge site-coll",
|
var html = BuildHtml(entries, branding, groupMembers);
|
||||||
"Site" => "badge site",
|
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||||
"List" => "badge list",
|
}
|
||||||
"Folder" => "badge folder",
|
|
||||||
_ => "badge"
|
/// <summary>
|
||||||
|
/// Split-aware write for permission entries.
|
||||||
|
/// Single → one file. BySite + SeparateFiles → one file per site.
|
||||||
|
/// BySite + SingleTabbed → one file with per-site iframe tabs.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<PermissionEntry> entries,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
HtmlSplitLayout layout,
|
||||||
|
CancellationToken ct,
|
||||||
|
ReportBranding? branding = null,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
|
{
|
||||||
|
if (splitMode != ReportSplitMode.BySite)
|
||||||
|
{
|
||||||
|
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var partitions = CsvExportService.PartitionBySite(entries).ToList();
|
||||||
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||||
|
{
|
||||||
|
var parts = partitions
|
||||||
|
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||||
|
.ToList();
|
||||||
|
var title = TranslationSource.Instance["report.title.permissions"];
|
||||||
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||||
|
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (label, partEntries) in partitions)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||||
|
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
HtmlSplitLayout layout,
|
||||||
|
CancellationToken ct,
|
||||||
|
ReportBranding? branding = null,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
|
{
|
||||||
|
if (splitMode != ReportSplitMode.BySite)
|
||||||
|
{
|
||||||
|
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var partitions = CsvExportService.PartitionBySite(entries).ToList();
|
||||||
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||||
|
{
|
||||||
|
var parts = partitions
|
||||||
|
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||||
|
.ToList();
|
||||||
|
var title = TranslationSource.Instance["report.title.permissions_simplified"];
|
||||||
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||||
|
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (label, partEntries) in partitions)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||||
|
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int total, int uniquePerms, int distinctUsers) ComputeStats(
|
||||||
|
int totalEntries,
|
||||||
|
IEnumerable<string> permissionLevels,
|
||||||
|
IEnumerable<string> userLogins)
|
||||||
|
{
|
||||||
|
var uniquePermSets = permissionLevels.Distinct().Count();
|
||||||
|
var distinctUsers = userLogins
|
||||||
|
.Select(u => u.Trim())
|
||||||
|
.Where(u => u.Length > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
return (totalEntries, uniquePermSets, distinctUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendTableOpen(StringBuilder sb)
|
||||||
|
{
|
||||||
|
sb.AppendLine("<div class=\"table-wrap\">");
|
||||||
|
sb.AppendLine("<table id=\"permTable\">");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendTableClose(StringBuilder sb)
|
||||||
|
{
|
||||||
|
sb.AppendLine("</tbody>");
|
||||||
|
sb.AppendLine("</table>");
|
||||||
|
sb.AppendLine("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns inline CSS background, text, and border colors for a risk level.</summary>
|
||||||
|
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
|
||||||
|
{
|
||||||
|
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
|
||||||
|
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
|
||||||
|
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
|
||||||
|
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
|
||||||
|
_ => ("#F3F4F6", "#374151", "#E5E7EB")
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
|
|
||||||
private static string HtmlEncode(string value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
|
||||||
return value
|
|
||||||
.Replace("&", "&")
|
|
||||||
.Replace("<", "<")
|
|
||||||
.Replace(">", ">")
|
|
||||||
.Replace("\"", """)
|
|
||||||
.Replace("'", "'");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <script> 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)} ▼</span>");
|
||||||
|
|
||||||
|
string memberContent;
|
||||||
|
if (resolved.Count > 0)
|
||||||
|
{
|
||||||
|
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||||
|
memberContent = string.Join(" • ", 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("&", "&")
|
||||||
|
.Replace("<", "<")
|
||||||
|
.Replace(">", ">")
|
||||||
|
.Replace("\"", """)
|
||||||
|
.Replace("'", "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <iframe srcdoc> 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
|
||||||
|
/// <iframe srcdoc="..."> 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("&", "&")
|
||||||
|
.Replace("\"", """);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>How a report export is partitioned.</summary>
|
||||||
|
public enum ReportSplitMode
|
||||||
|
{
|
||||||
|
Single,
|
||||||
|
BySite,
|
||||||
|
ByUser
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>When a report is split, how HTML output is laid out.</summary>
|
||||||
|
public enum HtmlSplitLayout
|
||||||
|
{
|
||||||
|
SeparateFiles,
|
||||||
|
SingleTabbed
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SearchCsvExportService
|
public class SearchCsvExportService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the CSV payload. Column order mirrors
|
||||||
|
/// <see cref="SearchHtmlExportService.BuildHtml(IReadOnlyList{SearchResult}, ReportBranding?)"/>.
|
||||||
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<SearchResult> results)
|
public string BuildCsv(IReadOnlyList<SearchResult> results)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
@@ -35,19 +39,14 @@ public class SearchCsvExportService
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(results);
|
var csv = BuildCsv(results);
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Csv(string value)
|
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
|
||||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
|
||||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string IfEmpty(string? value, string fallback = "")
|
private static string IfEmpty(string? value, string fallback = "")
|
||||||
=> string.IsNullOrEmpty(value) ? fallback : value!;
|
=> string.IsNullOrEmpty(value) ? fallback : value!;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SearchHtmlExportService
|
public class SearchHtmlExportService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a self-contained HTML table with inline sort/filter scripts.
|
||||||
|
/// Each <see cref="SearchResult"/> becomes one row; the document has no
|
||||||
|
/// external dependencies.
|
||||||
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
@@ -140,6 +145,7 @@ public class SearchHtmlExportService
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the HTML report to <paramref name="filePath"/>.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(results, branding);
|
var html = BuildHtml(results, branding);
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class StorageCsvExportService
|
public class StorageCsvExportService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a single-section CSV: header row plus one row per
|
||||||
|
/// <see cref="StorageNode"/> with library, site, file count, total size
|
||||||
|
/// (MB), version size (MB), and last-modified date.
|
||||||
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
@@ -35,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>
|
||||||
@@ -79,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 ───────────────────────────────────────────────────────────────
|
||||||
@@ -90,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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ public class StorageHtmlExportService
|
|||||||
{
|
{
|
||||||
private int _togIdx;
|
private int _togIdx;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a self-contained HTML report with one collapsible row per
|
||||||
|
/// library and indented child folders. Library-only variant — use the
|
||||||
|
/// overload that accepts <see cref="FileTypeMetric"/>s when a file-type
|
||||||
|
/// breakdown section is desired.
|
||||||
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
@@ -174,7 +180,7 @@ public class StorageHtmlExportService
|
|||||||
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
||||||
|
|
||||||
sb.AppendLine("<div class=\"chart-section\">");
|
sb.AppendLine("<div class=\"chart-section\">");
|
||||||
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
|
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
|
||||||
|
|
||||||
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
||||||
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
||||||
@@ -190,7 +196,7 @@ public class StorageHtmlExportService
|
|||||||
<div class="bar-row">
|
<div class="bar-row">
|
||||||
<span class="bar-label">{HtmlEncode(label)}</span>
|
<span class="bar-label">{HtmlEncode(label)}</span>
|
||||||
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
|
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
|
||||||
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} files</span>
|
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} {T["report.text.files_unit"]}</span>
|
||||||
</div>
|
</div>
|
||||||
""");
|
""");
|
||||||
idx++;
|
idx++;
|
||||||
@@ -232,18 +238,62 @@ public class StorageHtmlExportService
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(nodes, branding);
|
var html = BuildHtml(nodes, branding);
|
||||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
||||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split-aware HTML export for storage metrics.
|
||||||
|
/// Single → one file. BySite + SeparateFiles → one file per site.
|
||||||
|
/// BySite + SingleTabbed → one HTML with per-site iframe tabs. File-type
|
||||||
|
/// metrics are replicated across partitions because they are not
|
||||||
|
/// attributed per-site by the scanner.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<StorageNode> nodes,
|
||||||
|
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
HtmlSplitLayout layout,
|
||||||
|
CancellationToken ct,
|
||||||
|
ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
if (splitMode != ReportSplitMode.BySite)
|
||||||
|
{
|
||||||
|
await WriteAsync(nodes, fileTypeMetrics, basePath, ct, branding);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var partitions = StorageCsvExportService.PartitionBySite(nodes).ToList();
|
||||||
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||||
|
{
|
||||||
|
var parts = partitions
|
||||||
|
.Select(p => (p.Label, Html: BuildHtml(p.Partition, fileTypeMetrics, branding)))
|
||||||
|
.ToList();
|
||||||
|
var title = TranslationSource.Instance["report.title.storage"];
|
||||||
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||||
|
await File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (label, partNodes) in partitions)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||||
|
await WriteAsync(partNodes, fileTypeMetrics, path, ct, branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private rendering ────────────────────────────────────────────────────
|
// ── Private rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void RenderNode(StringBuilder sb, StorageNode node)
|
private void RenderNode(StringBuilder sb, StorageNode node)
|
||||||
|
|||||||
@@ -87,8 +87,69 @@ public class UserAccessCsvExportService
|
|||||||
var filePath = Path.Combine(directoryPath, fileName);
|
var filePath = Path.Combine(directoryPath, fileName);
|
||||||
|
|
||||||
var csv = BuildCsv(displayName, userLogin, entries);
|
var csv = BuildCsv(displayName, userLogin, entries);
|
||||||
await File.WriteAllTextAsync(filePath, csv,
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes all entries split per site. File naming: "{base}_{siteLabel}.csv".
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteBySiteAsync(
|
||||||
|
IReadOnlyList<UserAccessEntry> allEntries,
|
||||||
|
string basePath,
|
||||||
|
CancellationToken ct,
|
||||||
|
bool mergePermissions = false)
|
||||||
|
{
|
||||||
|
foreach (var group in allEntries.GroupBy(e => (e.SiteUrl, e.SiteTitle)))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var label = ReportSplitHelper.DeriveSiteLabel(group.Key.SiteUrl, group.Key.SiteTitle);
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||||
|
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split-aware export dispatcher.
|
||||||
|
/// Single → one file at <paramref name="basePath"/>.
|
||||||
|
/// BySite → one file per site. ByUser → one file per user.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<UserAccessEntry> allEntries,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
CancellationToken ct,
|
||||||
|
bool mergePermissions = false)
|
||||||
|
{
|
||||||
|
switch (splitMode)
|
||||||
|
{
|
||||||
|
case ReportSplitMode.Single:
|
||||||
|
await WriteSingleFileAsync(allEntries, basePath, ct, mergePermissions);
|
||||||
|
break;
|
||||||
|
case ReportSplitMode.BySite:
|
||||||
|
await WriteBySiteAsync(allEntries, basePath, ct, mergePermissions);
|
||||||
|
break;
|
||||||
|
case ReportSplitMode.ByUser:
|
||||||
|
await WriteByUserAsync(allEntries, basePath, ct, mergePermissions);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes one CSV per user using <paramref name="basePath"/> as a filename template.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteByUserAsync(
|
||||||
|
IReadOnlyList<UserAccessEntry> allEntries,
|
||||||
|
string basePath,
|
||||||
|
CancellationToken ct,
|
||||||
|
bool mergePermissions = false)
|
||||||
|
{
|
||||||
|
foreach (var group in allEntries.GroupBy(e => e.UserLogin))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var label = ReportSplitHelper.SanitizeFileName(group.Key);
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||||
|
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,17 +230,12 @@ public class UserAccessCsvExportService
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
await File.WriteAllTextAsync(filePath, sb.ToString(),
|
await ExportFileWriter.WriteCsvAsync(filePath, sb.ToString(), ct);
|
||||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
|
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
|
||||||
private static string Csv(string value)
|
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
|
||||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SanitizeFileName(string name)
|
private static string SanitizeFileName(string name)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,70 @@ public class UserAccessHtmlExportService
|
|||||||
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user
|
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user
|
||||||
/// report with an expandable Sites column instead of the dual by-user/by-site view.
|
/// report with an expandable Sites column instead of the dual by-user/by-site view.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Split-aware HTML export. Single → one file.
|
||||||
|
/// BySite/ByUser + SeparateFiles → one file per site/user.
|
||||||
|
/// BySite/ByUser + SingleTabbed → one file with per-partition iframe tabs.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(
|
||||||
|
IReadOnlyList<UserAccessEntry> entries,
|
||||||
|
string basePath,
|
||||||
|
ReportSplitMode splitMode,
|
||||||
|
HtmlSplitLayout layout,
|
||||||
|
CancellationToken ct,
|
||||||
|
bool mergePermissions = false,
|
||||||
|
ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
if (splitMode == ReportSplitMode.Single)
|
||||||
|
{
|
||||||
|
await WriteAsync(entries, basePath, ct, mergePermissions, branding);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<(string Label, IReadOnlyList<UserAccessEntry> Entries)> partitions;
|
||||||
|
if (splitMode == ReportSplitMode.BySite)
|
||||||
|
{
|
||||||
|
partitions = entries
|
||||||
|
.GroupBy(e => (e.SiteUrl, e.SiteTitle))
|
||||||
|
.Select(g => (
|
||||||
|
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.SiteUrl, g.Key.SiteTitle),
|
||||||
|
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
|
||||||
|
}
|
||||||
|
else // ByUser
|
||||||
|
{
|
||||||
|
partitions = entries
|
||||||
|
.GroupBy(e => e.UserLogin)
|
||||||
|
.Select(g => (
|
||||||
|
Label: ReportSplitHelper.SanitizeFileName(g.Key),
|
||||||
|
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
var partList = partitions.ToList();
|
||||||
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||||
|
{
|
||||||
|
var parts = partList
|
||||||
|
.Select(p => (p.Label, Html: BuildHtml(p.Entries, mergePermissions, branding)))
|
||||||
|
.ToList();
|
||||||
|
var title = TranslationSource.Instance["report.title.user_access"];
|
||||||
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||||
|
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (label, partEntries) in partList)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||||
|
await WriteAsync(partEntries, path, ct, mergePermissions, branding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the user-access HTML report. Default layout is a per-entry
|
||||||
|
/// grouped-by-user table; when <paramref name="mergePermissions"/> is true
|
||||||
|
/// entries are consolidated via <see cref="PermissionConsolidator"/> into
|
||||||
|
/// a single-row-per-user format with a Locations column.
|
||||||
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
if (mergePermissions)
|
if (mergePermissions)
|
||||||
@@ -344,7 +408,7 @@ function sortTable(view, col) {
|
|||||||
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(entries, mergePermissions, branding);
|
var html = BuildHtml(entries, mergePermissions, branding);
|
||||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -531,7 +595,7 @@ a:hover { text-decoration: underline; }
|
|||||||
{
|
{
|
||||||
// Multiple locations — expandable badge
|
// Multiple locations — expandable badge
|
||||||
var currentLocId = $"loc{locIdx++}";
|
var currentLocId = $"loc{locIdx++}";
|
||||||
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} sites</span></td>");
|
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} {TranslationSource.Instance["report.text.sites_unit"]}</span></td>");
|
||||||
sb.AppendLine("</tr>");
|
sb.AppendLine("</tr>");
|
||||||
|
|
||||||
// Hidden sub-rows — one per location
|
// Hidden sub-rows — one per location
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using System.Text;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports VersionCleanupResult list to a self-contained sortable/filterable HTML report.
|
||||||
|
/// Summary header shows totals (files trimmed, versions deleted, bytes freed); a single
|
||||||
|
/// table lists every processed file with sort/filter controls. No external assets.
|
||||||
|
/// </summary>
|
||||||
|
public class VersionCleanupHtmlExportService
|
||||||
|
{
|
||||||
|
public string BuildHtml(IReadOnlyList<VersionCleanupResult> results, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
long totalBytes = results.Sum(r => r.BytesFreed);
|
||||||
|
int totalDeleted = results.Sum(r => r.VersionsDeleted);
|
||||||
|
int totalFiles = results.Count(r => r.VersionsDeleted > 0);
|
||||||
|
|
||||||
|
sb.AppendLine("<!DOCTYPE html>");
|
||||||
|
sb.AppendLine("<html lang=\"en\">");
|
||||||
|
sb.AppendLine("<head>");
|
||||||
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||||
|
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||||
|
sb.AppendLine($"<title>{T["report.title.versions"]}</title>");
|
||||||
|
sb.AppendLine("""
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
|
h1 { color: #0078d4; }
|
||||||
|
.summary { display: flex; gap: 24px; margin: 12px 0 16px 0; padding: 12px 16px;
|
||||||
|
background: #e8f1fb; border-radius: 6px; }
|
||||||
|
.summary .item { display: flex; flex-direction: column; }
|
||||||
|
.summary .label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.summary .value { font-size: 18px; font-weight: 600; color: #0078d4; }
|
||||||
|
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.toolbar label { font-weight: 600; }
|
||||||
|
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
|
||||||
|
#resultCount { font-size: 12px; color: #666; }
|
||||||
|
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
||||||
|
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
|
||||||
|
font-weight: 600; user-select: none; white-space: nowrap; }
|
||||||
|
th:hover { background: #106ebe; }
|
||||||
|
th.sorted-asc::after { content: ' ▲'; font-size: 10px; }
|
||||||
|
th.sorted-desc::after { content: ' ▼'; font-size: 10px; }
|
||||||
|
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
|
||||||
|
tr:hover td { background: #f0f7ff; }
|
||||||
|
tr.hidden { display: none; }
|
||||||
|
tr.err td { background: #fff4f4; }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.err-cell { color: #b00020; }
|
||||||
|
.generated { font-size: 11px; color: #888; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
""");
|
||||||
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
|
sb.AppendLine($"<h1>{T["report.title.versions_short"]}</h1>");
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<div class="summary">
|
||||||
|
<div class="item"><span class="label">{T["versions.summary.files"]}</span><span class="value">{totalFiles:N0}</span></div>
|
||||||
|
<div class="item"><span class="label">{T["versions.summary.deleted"]}</span><span class="value">{totalDeleted:N0}</span></div>
|
||||||
|
<div class="item"><span class="label">{T["versions.summary.freed"]}</span><span class="value">{FormatSize(totalBytes)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<label for="filterInput">{T["report.filter.label"]}</label>
|
||||||
|
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
|
||||||
|
<span id="resultCount"></span>
|
||||||
|
</div>
|
||||||
|
""");
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<table id="resultsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th onclick="sortTable(0)">{T["report.col.site"]}</th>
|
||||||
|
<th onclick="sortTable(1)">{T["versions.col.library"]}</th>
|
||||||
|
<th onclick="sortTable(2)">{T["versions.col.file"]}</th>
|
||||||
|
<th onclick="sortTable(3)">{T["versions.col.path"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(4)">{T["versions.col.before"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(5)">{T["versions.col.deleted"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(6)">{T["versions.col.remaining"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(7)">{T["versions.col.freed"]}</th>
|
||||||
|
<th onclick="sortTable(8)">{T["versions.col.error"]}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
""");
|
||||||
|
|
||||||
|
foreach (var r in results)
|
||||||
|
{
|
||||||
|
string rowClass = string.IsNullOrEmpty(r.Error) ? string.Empty : " class=\"err\"";
|
||||||
|
string errCell = string.IsNullOrEmpty(r.Error)
|
||||||
|
? string.Empty
|
||||||
|
: $"<span class=\"err-cell\">{H(r.Error)}</span>";
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<tr{rowClass}>
|
||||||
|
<td>{H(r.SiteUrl)}</td>
|
||||||
|
<td>{H(r.Library)}</td>
|
||||||
|
<td>{H(r.FileName)}</td>
|
||||||
|
<td>{H(r.FileServerRelativeUrl)}</td>
|
||||||
|
<td class="num" data-sort="{r.VersionsBefore}">{r.VersionsBefore:N0}</td>
|
||||||
|
<td class="num" data-sort="{r.VersionsDeleted}">{r.VersionsDeleted:N0}</td>
|
||||||
|
<td class="num" data-sort="{r.VersionsRemaining}">{r.VersionsRemaining:N0}</td>
|
||||||
|
<td class="num" data-sort="{r.BytesFreed}">{FormatSize(r.BytesFreed)}</td>
|
||||||
|
<td>{errCell}</td>
|
||||||
|
</tr>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(" </tbody>\n</table>");
|
||||||
|
|
||||||
|
int count = results.Count;
|
||||||
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
|
||||||
|
|
||||||
|
sb.AppendLine($$"""
|
||||||
|
<script>
|
||||||
|
var sortDir = {};
|
||||||
|
function sortTable(col) {
|
||||||
|
var tbl = document.getElementById('resultsTable');
|
||||||
|
var tbody = tbl.tBodies[0];
|
||||||
|
var rows = Array.from(tbody.rows);
|
||||||
|
var asc = sortDir[col] !== 'asc';
|
||||||
|
sortDir[col] = asc ? 'asc' : 'desc';
|
||||||
|
rows.sort(function(a, b) {
|
||||||
|
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
|
||||||
|
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
|
||||||
|
var an = parseFloat(av), bn = parseFloat(bv);
|
||||||
|
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
|
||||||
|
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
});
|
||||||
|
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||||
|
var ths = tbl.tHead.rows[0].cells;
|
||||||
|
for (var i = 0; i < ths.length; i++) {
|
||||||
|
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function filterTable() {
|
||||||
|
var q = document.getElementById('filterInput').value.toLowerCase();
|
||||||
|
var rows = document.getElementById('resultsTable').tBodies[0].rows;
|
||||||
|
var visible = 0;
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
|
||||||
|
rows[i].className = (rows[i].className.indexOf('err') >= 0 ? 'err ' : '') + (match ? '' : 'hidden');
|
||||||
|
if (match) visible++;
|
||||||
|
}
|
||||||
|
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
|
||||||
|
}
|
||||||
|
window.onload = function() {
|
||||||
|
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
""");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the HTML report to <paramref name="filePath"/> as UTF-8.</summary>
|
||||||
|
public async Task WriteAsync(IReadOnlyList<VersionCleanupResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var html = BuildHtml(results, branding);
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string H(string value) =>
|
||||||
|
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||||
|
|
||||||
|
private static string FormatSize(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||||
|
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
|
||||||
|
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
|
||||||
|
return $"{bytes} B";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,23 @@ using SharepointToolbox.Core.Models;
|
|||||||
|
|
||||||
namespace SharepointToolbox.Services;
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates server-side file copy/move between two SharePoint libraries
|
||||||
|
/// (same or different tenants). Uses <see cref="MoveCopyUtil"/> for the
|
||||||
|
/// transfer itself so bytes never round-trip through the local machine.
|
||||||
|
/// Folder creation and enumeration are done via CSOM; all ambient retries
|
||||||
|
/// flow through <see cref="ExecuteQueryRetryHelper"/>.
|
||||||
|
/// </summary>
|
||||||
public class FileTransferService : IFileTransferService
|
public class FileTransferService : IFileTransferService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
|
||||||
|
/// (unless the job is folder-only), pre-creates destination folders, then
|
||||||
|
/// copies or moves each file according to <see cref="TransferJob.Mode"/>
|
||||||
|
/// and <see cref="TransferJob.ConflictPolicy"/>. Returns a per-item
|
||||||
|
/// summary where failures are reported individually — the method does
|
||||||
|
/// not abort on first error so partial transfers are recoverable.
|
||||||
|
/// </summary>
|
||||||
public async Task<BulkOperationSummary<string>> TransferAsync(
|
public async Task<BulkOperationSummary<string>> TransferAsync(
|
||||||
ClientContext sourceCtx,
|
ClientContext sourceCtx,
|
||||||
ClientContext destCtx,
|
ClientContext destCtx,
|
||||||
@@ -166,37 +181,49 @@ public class FileTransferService : IFileTransferService
|
|||||||
if (!string.IsNullOrEmpty(job.SourceFolderPath))
|
if (!string.IsNullOrEmpty(job.SourceFolderPath))
|
||||||
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
||||||
|
|
||||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl);
|
// Paginated recursive CAML query — Folder.Files / Folder.Folders lazy
|
||||||
|
// loading hits the list-view threshold on libraries > 5,000 items.
|
||||||
var files = new List<string>();
|
var files = new List<string>();
|
||||||
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public interface IVersionCleanupService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates document libraries (filtered by <see cref="VersionCleanupOptions.LibraryTitles"/>
|
||||||
|
/// when non-empty) and deletes historical file versions per file according to
|
||||||
|
/// <see cref="VersionCleanupOptions.KeepLast"/> and <see cref="VersionCleanupOptions.KeepFirst"/>.
|
||||||
|
/// The current published version is never touched. Returns one result row per file
|
||||||
|
/// where at least one version was inspected.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
VersionCleanupOptions options,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists non-hidden document libraries on the site. Used by the library picker
|
||||||
|
/// so callers can present a checkbox UI.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -69,7 +69,12 @@ public class SiteListService : ISiteListService
|
|||||||
if (s.Status == "Active"
|
if (s.Status == "Active"
|
||||||
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
results.Add(new SiteInfo(s.Url, s.Title));
|
results.Add(new SiteInfo(s.Url, s.Title)
|
||||||
|
{
|
||||||
|
StorageUsedMb = s.StorageUsage,
|
||||||
|
StorageQuotaMb = s.StorageMaximumLevel,
|
||||||
|
Template = s.Template ?? string.Empty
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ public class TemplateService : ITemplateService
|
|||||||
{
|
{
|
||||||
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
|
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
libInfo.Folders = await EnumerateFoldersRecursiveAsync(
|
libInfo.Folders = await EnumerateLibraryFoldersAsync(ctx, list, ct);
|
||||||
ctx, list.RootFolder, string.Empty, progress, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
template.Libraries.Add(libInfo);
|
template.Libraries.Add(libInfo);
|
||||||
@@ -293,39 +292,72 @@ public class TemplateService : ITemplateService
|
|||||||
return siteUrl;
|
return siteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<TemplateFolderInfo>> EnumerateFoldersRecursiveAsync(
|
/// <summary>
|
||||||
|
/// Enumerates every folder in a library via one paginated CAML scan, then
|
||||||
|
/// reconstructs the hierarchy from the server-relative paths. Replaces the
|
||||||
|
/// former per-level Folder.Folders lazy loading, which hits the list-view
|
||||||
|
/// threshold on libraries above 5,000 items.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<List<TemplateFolderInfo>> EnumerateLibraryFoldersAsync(
|
||||||
ClientContext ctx,
|
ClientContext ctx,
|
||||||
Folder parentFolder,
|
List list,
|
||||||
string parentRelativePath,
|
|
||||||
IProgress<OperationProgress> progress,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
var result = new List<TemplateFolderInfo>();
|
|
||||||
|
|
||||||
ctx.Load(parentFolder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
|
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
||||||
|
|
||||||
foreach (var subFolder in parentFolder.Folders)
|
// Collect all folders flat: (relativePath, parentRelativePath).
|
||||||
|
var folders = new List<(string Relative, string Parent)>();
|
||||||
|
|
||||||
|
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||||
|
ctx, list, rootUrl, recursive: true,
|
||||||
|
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "FileDirRef" },
|
||||||
|
ct: ct))
|
||||||
{
|
{
|
||||||
// Skip system folders
|
if (item["FSObjType"]?.ToString() != "1") continue; // folders only
|
||||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
|
||||||
|
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||||
|
var fileRef = (item["FileRef"]?.ToString() ?? string.Empty).TrimEnd('/');
|
||||||
|
var dirRef = (item["FileDirRef"]?.ToString() ?? string.Empty).TrimEnd('/');
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(fileRef)) continue;
|
||||||
|
if (name.StartsWith("_", StringComparison.Ordinal) ||
|
||||||
|
name.Equals("Forms", StringComparison.OrdinalIgnoreCase))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var relativePath = string.IsNullOrEmpty(parentRelativePath)
|
// Paths relative to the library root.
|
||||||
? subFolder.Name
|
var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
|
||||||
: $"{parentRelativePath}/{subFolder.Name}";
|
? fileRef.Substring(rootUrl.Length).TrimStart('/')
|
||||||
|
: name;
|
||||||
|
var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? dirRef.Substring(rootUrl.Length).TrimStart('/')
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
var folderInfo = new TemplateFolderInfo
|
folders.Add((rel, parentRel));
|
||||||
{
|
|
||||||
Name = subFolder.Name,
|
|
||||||
RelativePath = relativePath,
|
|
||||||
Children = await EnumerateFoldersRecursiveAsync(ctx, subFolder, relativePath, progress, ct),
|
|
||||||
};
|
|
||||||
result.Add(folderInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
// Build tree keyed by relative path.
|
||||||
|
var nodes = folders.ToDictionary(
|
||||||
|
f => f.Relative,
|
||||||
|
f => new TemplateFolderInfo
|
||||||
|
{
|
||||||
|
Name = System.IO.Path.GetFileName(f.Relative),
|
||||||
|
RelativePath = f.Relative,
|
||||||
|
Children = new List<TemplateFolderInfo>(),
|
||||||
|
},
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var roots = new List<TemplateFolderInfo>();
|
||||||
|
foreach (var (rel, parent) in folders)
|
||||||
|
{
|
||||||
|
if (!nodes.TryGetValue(rel, out var node)) continue;
|
||||||
|
if (!string.IsNullOrEmpty(parent) && nodes.TryGetValue(parent, out var p))
|
||||||
|
p.Children.Add(node);
|
||||||
|
else
|
||||||
|
roots.Add(node);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CreateFoldersFromTemplateAsync(
|
private static async Task CreateFoldersFromTemplateAsync(
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Core.Helpers;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public class VersionCleanupService : IVersionCleanupService
|
||||||
|
{
|
||||||
|
private readonly ILogger<VersionCleanupService> _logger;
|
||||||
|
|
||||||
|
public VersionCleanupService(ILogger<VersionCleanupService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
ctx.Load(ctx.Web,
|
||||||
|
w => w.Lists.Include(
|
||||||
|
l => l.Title,
|
||||||
|
l => l.Hidden,
|
||||||
|
l => l.BaseType));
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||||
|
return ctx.Web.Lists
|
||||||
|
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||||
|
.Select(l => l.Title)
|
||||||
|
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
VersionCleanupOptions options,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (options.KeepLast < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(options), "KeepLast must be >= 0.");
|
||||||
|
|
||||||
|
ctx.Load(ctx.Web, w => w.Url, w => w.ServerRelativeUrl,
|
||||||
|
w => w.Lists.Include(
|
||||||
|
l => l.Title,
|
||||||
|
l => l.Hidden,
|
||||||
|
l => l.BaseType,
|
||||||
|
l => l.RootFolder.ServerRelativeUrl));
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
var allLibs = ctx.Web.Lists
|
||||||
|
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var titleFilter = options.LibraryTitles?.Count > 0
|
||||||
|
? new HashSet<string>(options.LibraryTitles, StringComparer.OrdinalIgnoreCase)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var libs = titleFilter is null
|
||||||
|
? allLibs
|
||||||
|
: allLibs.Where(l => titleFilter.Contains(l.Title)).ToList();
|
||||||
|
|
||||||
|
var results = new List<VersionCleanupResult>();
|
||||||
|
var siteUrl = ctx.Web.Url;
|
||||||
|
|
||||||
|
int libIdx = 0;
|
||||||
|
foreach (var lib in libs)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
libIdx++;
|
||||||
|
progress.Report(new OperationProgress(libIdx, libs.Count,
|
||||||
|
$"Scanning versions: {lib.Title} ({libIdx}/{libs.Count})"));
|
||||||
|
|
||||||
|
// Enumerate files via paginated CAML so libs > 5,000 items work.
|
||||||
|
var files = new List<string>();
|
||||||
|
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||||
|
ctx, lib, lib.RootFolder.ServerRelativeUrl, recursive: true,
|
||||||
|
viewFields: new[] { "FSObjType", "FileRef" },
|
||||||
|
ct: ct))
|
||||||
|
{
|
||||||
|
if (item["FSObjType"]?.ToString() != "0") continue;
|
||||||
|
var fileRef = item["FileRef"]?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(fileRef))
|
||||||
|
files.Add(fileRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
int fileIdx = 0;
|
||||||
|
foreach (var fileRef in files)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
fileIdx++;
|
||||||
|
if (fileIdx % 25 == 0 || fileIdx == files.Count)
|
||||||
|
{
|
||||||
|
progress.Report(new OperationProgress(fileIdx, files.Count,
|
||||||
|
$"{lib.Title}: {fileIdx}/{files.Count} files"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await TrimFileVersionsAsync(
|
||||||
|
ctx, siteUrl, lib.Title, fileRef, options, progress, ct);
|
||||||
|
if (result is not null)
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<VersionCleanupResult?> TrimFileVersionsAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string siteUrl,
|
||||||
|
string libraryTitle,
|
||||||
|
string fileServerRelativeUrl,
|
||||||
|
VersionCleanupOptions options,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
|
||||||
|
ctx.Load(file, f => f.Name);
|
||||||
|
ctx.Load(file.Versions,
|
||||||
|
vs => vs.Include(
|
||||||
|
v => v.VersionLabel,
|
||||||
|
v => v.Created,
|
||||||
|
v => v.Size));
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
// file.Versions contains only HISTORICAL versions; the current published
|
||||||
|
// version lives on `file` itself and is never deletable here.
|
||||||
|
var versions = file.Versions.ToList();
|
||||||
|
int before = versions.Count;
|
||||||
|
if (before == 0) return null;
|
||||||
|
|
||||||
|
// Sort by Created ascending so [0] is the oldest historical version.
|
||||||
|
var ordered = versions
|
||||||
|
.OrderBy(v => v.Created)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Preserve set: the last N most recent + optionally the very first.
|
||||||
|
var keep = new HashSet<int>();
|
||||||
|
int keepLast = Math.Min(options.KeepLast, ordered.Count);
|
||||||
|
for (int i = ordered.Count - keepLast; i < ordered.Count; i++)
|
||||||
|
keep.Add(i);
|
||||||
|
if (options.KeepFirst && ordered.Count > 0)
|
||||||
|
keep.Add(0);
|
||||||
|
|
||||||
|
long bytesFreed = 0;
|
||||||
|
int deleted = 0;
|
||||||
|
for (int i = 0; i < ordered.Count; i++)
|
||||||
|
{
|
||||||
|
if (keep.Contains(i)) continue;
|
||||||
|
var v = ordered[i];
|
||||||
|
bytesFreed += v.Size;
|
||||||
|
v.DeleteObject();
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted == 0) return null;
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
return new VersionCleanupResult
|
||||||
|
{
|
||||||
|
SiteUrl = siteUrl,
|
||||||
|
Library = libraryTitle,
|
||||||
|
FileServerRelativeUrl = fileServerRelativeUrl,
|
||||||
|
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
|
||||||
|
VersionsBefore = before,
|
||||||
|
VersionsDeleted = deleted,
|
||||||
|
VersionsRemaining = before - deleted,
|
||||||
|
BytesFreed = bytesFreed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
|
||||||
|
return new VersionCleanupResult
|
||||||
|
{
|
||||||
|
SiteUrl = siteUrl,
|
||||||
|
Library = libraryTitle,
|
||||||
|
FileServerRelativeUrl = fileServerRelativeUrl,
|
||||||
|
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
|
||||||
|
Error = ex.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.Views.Dialogs;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headless logic for <see cref="SitePickerDialog"/>. Loads sites via
|
||||||
|
/// <see cref="ISiteListService"/> and applies filter/sort in memory so the
|
||||||
|
/// dialog's code-behind stays a thin shim and the logic is unit-testable
|
||||||
|
/// without a WPF host.
|
||||||
|
/// </summary>
|
||||||
|
public class SitePickerDialogLogic
|
||||||
|
{
|
||||||
|
private readonly ISiteListService _siteListService;
|
||||||
|
private readonly TenantProfile _profile;
|
||||||
|
|
||||||
|
public SitePickerDialogLogic(ISiteListService siteListService, TenantProfile profile)
|
||||||
|
{
|
||||||
|
_siteListService = siteListService;
|
||||||
|
_profile = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads all accessible sites for the tenant profile and wraps them in
|
||||||
|
/// <see cref="SitePickerItem"/> so the dialog can bind checkboxes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<SitePickerItem>> LoadAsync(
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sites = await _siteListService.GetSitesAsync(_profile, progress, ct);
|
||||||
|
return sites
|
||||||
|
.Select(s => new SitePickerItem(s.Url, s.Title, s.StorageUsedMb, s.StorageQuotaMb, s.Template))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters items by free-text (title/url substring), storage-size range,
|
||||||
|
/// and site kind. Empty or zero-range parameters become no-ops.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<SitePickerItem> ApplyFilter(
|
||||||
|
IEnumerable<SitePickerItem> items,
|
||||||
|
string text,
|
||||||
|
long minMb,
|
||||||
|
long maxMb,
|
||||||
|
string kindFilter)
|
||||||
|
{
|
||||||
|
var result = items;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
result = result.Where(i =>
|
||||||
|
i.Url.Contains(text, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
i.Title.Contains(text, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.Where(i => i.StorageUsedMb >= minMb && i.StorageUsedMb <= maxMb);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(kindFilter) && kindFilter != "All")
|
||||||
|
result = result.Where(i => i.Kind.ToString() == kindFilter);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable sort by a named column and direction. Unknown column names
|
||||||
|
/// return the input sequence unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<SitePickerItem> ApplySort(
|
||||||
|
IEnumerable<SitePickerItem> items,
|
||||||
|
string column,
|
||||||
|
ListSortDirection direction)
|
||||||
|
{
|
||||||
|
var asc = direction == ListSortDirection.Ascending;
|
||||||
|
return column switch
|
||||||
|
{
|
||||||
|
"Title" => asc ? items.OrderBy(i => i.Title) : items.OrderByDescending(i => i.Title),
|
||||||
|
"Url" => asc ? items.OrderBy(i => i.Url) : items.OrderByDescending(i => i.Url),
|
||||||
|
"Kind" => asc ? items.OrderBy(i => i.KindDisplay) : items.OrderByDescending(i => i.KindDisplay),
|
||||||
|
"StorageUsedMb" => asc
|
||||||
|
? items.OrderBy(i => i.StorageUsedMb)
|
||||||
|
: items.OrderByDescending(i => i.StorageUsedMb),
|
||||||
|
"IsSelected" => asc
|
||||||
|
? items.OrderBy(i => i.IsSelected)
|
||||||
|
: items.OrderByDescending(i => i.IsSelected),
|
||||||
|
_ => items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lenient long parse: whitespace-only or unparseable input yields
|
||||||
|
/// <paramref name="fallback"/> instead of throwing.
|
||||||
|
/// </summary>
|
||||||
|
public static long ParseLongOrDefault(string text, long fallback)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return fallback;
|
||||||
|
return long.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)
|
||||||
|
? v
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,6 @@ public partial class MainWindowViewModel : ObservableRecipient
|
|||||||
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
|
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
|
||||||
: Localization.TranslationSource.Instance["toolbar.globalSites.none"];
|
: Localization.TranslationSource.Instance["toolbar.globalSites.none"];
|
||||||
|
|
||||||
public IAsyncRelayCommand ConnectCommand { get; }
|
|
||||||
public IAsyncRelayCommand ClearSessionCommand { get; }
|
public IAsyncRelayCommand ClearSessionCommand { get; }
|
||||||
public RelayCommand ManageProfilesCommand { get; }
|
public RelayCommand ManageProfilesCommand { get; }
|
||||||
public RelayCommand OpenGlobalSitePickerCommand { get; }
|
public RelayCommand OpenGlobalSitePickerCommand { get; }
|
||||||
@@ -64,7 +63,6 @@ public partial class MainWindowViewModel : ObservableRecipient
|
|||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
|
|
||||||
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
|
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
|
||||||
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
|
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
|
||||||
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
|
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
|
||||||
@@ -96,7 +94,6 @@ public partial class MainWindowViewModel : ObservableRecipient
|
|||||||
{
|
{
|
||||||
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
|
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
|
||||||
}
|
}
|
||||||
ConnectCommand.NotifyCanExecuteChanged();
|
|
||||||
ClearSessionCommand.NotifyCanExecuteChanged();
|
ClearSessionCommand.NotifyCanExecuteChanged();
|
||||||
// Clear global site selection on tenant switch (sites belong to a tenant)
|
// Clear global site selection on tenant switch (sites belong to a tenant)
|
||||||
GlobalSelectedSites.Clear();
|
GlobalSelectedSites.Clear();
|
||||||
@@ -121,22 +118,6 @@ public partial class MainWindowViewModel : ObservableRecipient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ConnectAsync()
|
|
||||||
{
|
|
||||||
if (SelectedProfile == null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ConnectionStatus = "Connecting...";
|
|
||||||
await _sessionManager.GetOrCreateContextAsync(SelectedProfile, CancellationToken.None);
|
|
||||||
ConnectionStatus = SelectedProfile.Name;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ConnectionStatus = "Connection failed";
|
|
||||||
_logger.LogError(ex, "Failed to connect to tenant {TenantUrl}.", SelectedProfile.TenantUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ClearSessionAsync()
|
private async Task ClearSessionAsync()
|
||||||
{
|
{
|
||||||
if (SelectedProfile == null) return;
|
if (SelectedProfile == null) return;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
public ObservableCollection<TenantProfile> Profiles { get; } = new();
|
public ObservableCollection<TenantProfile> Profiles { get; } = new();
|
||||||
|
|
||||||
public IAsyncRelayCommand AddCommand { get; }
|
public IAsyncRelayCommand AddCommand { get; }
|
||||||
public IAsyncRelayCommand RenameCommand { get; }
|
public IAsyncRelayCommand SaveCommand { get; }
|
||||||
public IAsyncRelayCommand DeleteCommand { get; }
|
public IAsyncRelayCommand DeleteCommand { get; }
|
||||||
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
|
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
|
||||||
public IAsyncRelayCommand ClearClientLogoCommand { get; }
|
public IAsyncRelayCommand ClearClientLogoCommand { get; }
|
||||||
@@ -82,7 +82,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
_appRegistrationService = appRegistrationService;
|
_appRegistrationService = appRegistrationService;
|
||||||
|
|
||||||
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
|
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
|
||||||
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
|
SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave);
|
||||||
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
|
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
|
||||||
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
|
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
|
||||||
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
|
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
|
||||||
@@ -112,11 +112,24 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnSelectedProfileChanged(TenantProfile? value)
|
partial void OnSelectedProfileChanged(TenantProfile? value)
|
||||||
{
|
{
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
NewName = value.Name;
|
||||||
|
NewTenantUrl = value.TenantUrl;
|
||||||
|
NewClientId = value.ClientId ?? string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NewName = string.Empty;
|
||||||
|
NewTenantUrl = string.Empty;
|
||||||
|
NewClientId = string.Empty;
|
||||||
|
}
|
||||||
|
ValidationMessage = string.Empty;
|
||||||
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
|
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
|
||||||
BrowseClientLogoCommand.NotifyCanExecuteChanged();
|
BrowseClientLogoCommand.NotifyCanExecuteChanged();
|
||||||
ClearClientLogoCommand.NotifyCanExecuteChanged();
|
ClearClientLogoCommand.NotifyCanExecuteChanged();
|
||||||
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
|
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
|
||||||
RenameCommand.NotifyCanExecuteChanged();
|
SaveCommand.NotifyCanExecuteChanged();
|
||||||
DeleteCommand.NotifyCanExecuteChanged();
|
DeleteCommand.NotifyCanExecuteChanged();
|
||||||
OnPropertyChanged(nameof(HasRegisteredApp));
|
OnPropertyChanged(nameof(HasRegisteredApp));
|
||||||
RegisterAppCommand.NotifyCanExecuteChanged();
|
RegisterAppCommand.NotifyCanExecuteChanged();
|
||||||
@@ -135,17 +148,30 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
private void NotifyCommandsCanExecuteChanged()
|
private void NotifyCommandsCanExecuteChanged()
|
||||||
{
|
{
|
||||||
AddCommand.NotifyCanExecuteChanged();
|
AddCommand.NotifyCanExecuteChanged();
|
||||||
RenameCommand.NotifyCanExecuteChanged();
|
SaveCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanAdd()
|
private bool CanAdd()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
||||||
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
||||||
|
// Fields mirror the selected profile after selection; block Add so the user doesn't
|
||||||
|
// create a duplicate — they should use Save to update, or change the name to fork.
|
||||||
|
if (SelectedProfile != null &&
|
||||||
|
string.Equals(NewName.Trim(), SelectedProfile.Name, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
|
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanSave()
|
||||||
|
{
|
||||||
|
if (SelectedProfile == null) return false;
|
||||||
|
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
||||||
|
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task AddAsync()
|
private async Task AddAsync()
|
||||||
{
|
{
|
||||||
if (!CanAdd()) return;
|
if (!CanAdd()) return;
|
||||||
@@ -171,19 +197,43 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RenameAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return;
|
if (!CanSave()) return;
|
||||||
|
var target = SelectedProfile!;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim());
|
var newName = NewName.Trim();
|
||||||
SelectedProfile.Name = NewName.Trim();
|
var newUrl = NewTenantUrl.Trim();
|
||||||
NewName = string.Empty;
|
var newClientId = NewClientId?.Trim() ?? string.Empty;
|
||||||
|
var oldName = target.Name;
|
||||||
|
|
||||||
|
if (!string.Equals(oldName, newName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await _profileService.RenameProfileAsync(oldName, newName);
|
||||||
|
target.Name = newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.TenantUrl = newUrl;
|
||||||
|
target.ClientId = newClientId;
|
||||||
|
await _profileService.UpdateProfileAsync(target);
|
||||||
|
|
||||||
|
// Force ListBox to pick up the renamed entry (TenantProfile is a plain POCO,
|
||||||
|
// so mutating Name does not raise PropertyChanged).
|
||||||
|
var idx = Profiles.IndexOf(target);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
Profiles.RemoveAt(idx);
|
||||||
|
Profiles.Insert(idx, target);
|
||||||
|
SelectedProfile = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationMessage = string.Empty;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
ValidationMessage = ex.Message;
|
ValidationMessage = ex.Message;
|
||||||
_logger.LogError(ex, "Failed to rename profile.");
|
_logger.LogError(ex, "Failed to save profile.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,9 +346,25 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
private bool CanRemoveApp()
|
private bool CanRemoveApp()
|
||||||
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering;
|
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set by the view to display the pre-registration warning. Returns true if the
|
||||||
|
/// user accepts and registration should proceed.
|
||||||
|
/// </summary>
|
||||||
|
public Func<string, bool>? ConfirmRegisterApp { get; set; }
|
||||||
|
|
||||||
private async Task RegisterAppAsync(CancellationToken ct)
|
private async Task RegisterAppAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (SelectedProfile == null) return;
|
if (SelectedProfile == null) return;
|
||||||
|
|
||||||
|
// Auth caching reduces this to one prompt in the common case, but a fresh
|
||||||
|
// tenant or different admin account may still trigger up to two — warn so
|
||||||
|
// the user knows another window is expected after they sign in.
|
||||||
|
if (ConfirmRegisterApp != null)
|
||||||
|
{
|
||||||
|
var msg = string.Format(TranslationSource.Instance["profile.register.warning"], 2);
|
||||||
|
if (!ConfirmRegisterApp(msg)) return;
|
||||||
|
}
|
||||||
|
|
||||||
IsRegistering = true;
|
IsRegistering = true;
|
||||||
ShowFallbackInstructions = false;
|
ShowFallbackInstructions = false;
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
|
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
|
||||||
@@ -306,21 +372,17 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
// Use the profile's own ClientId if it has one; otherwise bootstrap with the
|
// Use the profile's own ClientId if it has one; otherwise bootstrap with the
|
||||||
// Microsoft Graph Command Line Tools public client so a first-time profile
|
// Microsoft Graph Command Line Tools public client so a first-time profile
|
||||||
// (name + URL only) can still perform the admin check and registration.
|
// (name + URL only) can still perform registration.
|
||||||
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
|
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
|
||||||
? BootstrapClientId
|
? BootstrapClientId
|
||||||
: SelectedProfile.ClientId;
|
: SelectedProfile.ClientId;
|
||||||
|
|
||||||
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(authClientId, ct);
|
// No preflight admin check: it used Global Admin as the criterion and
|
||||||
if (!isAdmin)
|
// rejected Application Admins / Cloud Application Admins who can also
|
||||||
{
|
// create apps. Let Entra enforce authorization via the POST itself —
|
||||||
ShowFallbackInstructions = true;
|
// any 401/403 returns FallbackRequired and triggers the tutorial.
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
|
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
|
||||||
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct);
|
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.TenantUrl, SelectedProfile.Name, ct);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -330,9 +392,18 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
|
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
|
||||||
SelectedProfile.ClientId = result.AppId!;
|
SelectedProfile.ClientId = result.AppId!;
|
||||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||||
|
// Reflect adopted ClientId in the bound TextBox. Without this the
|
||||||
|
// field stays blank and the next Save would overwrite the stored
|
||||||
|
// ClientId with an empty string.
|
||||||
|
NewClientId = SelectedProfile.ClientId;
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
|
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
|
||||||
OnPropertyChanged(nameof(HasRegisteredApp));
|
OnPropertyChanged(nameof(HasRegisteredApp));
|
||||||
}
|
}
|
||||||
|
else if (result.IsFallback)
|
||||||
|
{
|
||||||
|
ShowFallbackInstructions = true;
|
||||||
|
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"];
|
RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"];
|
||||||
@@ -356,7 +427,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
RegistrationStatus = TranslationSource.Instance["profile.remove.removing"];
|
RegistrationStatus = TranslationSource.Instance["profile.remove.removing"];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.AppId!, ct);
|
await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl, SelectedProfile.AppId!, ct);
|
||||||
await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl);
|
await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl);
|
||||||
SelectedProfile.AppId = null;
|
SelectedProfile.AppId = null;
|
||||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ public partial class BulkMembersViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
{
|
{
|
||||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
|
||||||
if (_validRows == null || _validRows.Count == 0)
|
if (_validRows == null || _validRows.Count == 0)
|
||||||
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
|
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
|
||||||
|
|
||||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||||
$"{_validRows.Count} members will be added");
|
$"{_validRows.Count} members will be added");
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ public partial class BulkSitesViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
{
|
{
|
||||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
|
||||||
if (_validRows == null || _validRows.Count == 0)
|
if (_validRows == null || _validRows.Count == 0)
|
||||||
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
|
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
|
||||||
|
|
||||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||||
$"{_validRows.Count} sites will be created");
|
$"{_validRows.Count} sites will be created");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.Services.Export;
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -47,6 +48,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
[ObservableProperty] private bool _includeSubsites;
|
[ObservableProperty] private bool _includeSubsites;
|
||||||
[ObservableProperty] private string _library = string.Empty;
|
[ObservableProperty] private string _library = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
||||||
|
[ObservableProperty] private int _splitModeIndex;
|
||||||
|
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||||
|
[ObservableProperty] private int _htmlLayoutIndex;
|
||||||
|
|
||||||
|
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||||
|
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||||
|
|
||||||
private ObservableCollection<DuplicateRow> _results = new();
|
private ObservableCollection<DuplicateRow> _results = new();
|
||||||
public ObservableCollection<DuplicateRow> Results
|
public ObservableCollection<DuplicateRow> Results
|
||||||
{
|
{
|
||||||
@@ -88,14 +97,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
if (_currentProfile == null)
|
if (_currentProfile == null)
|
||||||
{
|
{
|
||||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
if (urls.Count == 0)
|
if (urls.Count == 0)
|
||||||
{
|
{
|
||||||
StatusMessage = "Select at least one site from the toolbar.";
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +195,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
branding = new ReportBranding(mspLogo, clientLogo);
|
branding = new ReportBranding(mspLogo, clientLogo);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding);
|
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
|
||||||
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
|
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
|
||||||
@@ -205,7 +214,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
if (dialog.ShowDialog() != true) return;
|
if (dialog.ShowDialog() != true) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
|
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||||
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
|
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
|
||||||
|
|||||||
@@ -103,15 +103,16 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
{
|
{
|
||||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
var T = TranslationSource.Instance;
|
||||||
|
if (_currentProfile == null) throw new InvalidOperationException(T["err.no_tenant"]);
|
||||||
if (_validRows == null || _validRows.Count == 0)
|
if (_validRows == null || _validRows.Count == 0)
|
||||||
throw new InvalidOperationException("No valid rows. Import a CSV first.");
|
throw new InvalidOperationException(T["err.no_valid_rows"]);
|
||||||
if (string.IsNullOrWhiteSpace(LibraryTitle))
|
if (string.IsNullOrWhiteSpace(LibraryTitle))
|
||||||
throw new InvalidOperationException("Library title is required.");
|
throw new InvalidOperationException(T["err.library_title_required"]);
|
||||||
|
|
||||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
if (urls.Count == 0)
|
if (urls.Count == 0)
|
||||||
throw new InvalidOperationException("Select at least one site from the toolbar.");
|
throw new InvalidOperationException(T["err.no_sites_selected"]);
|
||||||
|
|
||||||
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
||||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using SharepointToolbox.Core.Messages;
|
using SharepointToolbox.Core.Messages;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.Core.Helpers;
|
using SharepointToolbox.Core.Helpers;
|
||||||
using SharepointToolbox.Services.Export;
|
using SharepointToolbox.Services.Export;
|
||||||
@@ -84,6 +85,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isDetailView = true;
|
private bool _isDetailView = true;
|
||||||
|
|
||||||
|
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
||||||
|
[ObservableProperty] private int _splitModeIndex;
|
||||||
|
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||||
|
[ObservableProperty] private int _htmlLayoutIndex;
|
||||||
|
|
||||||
|
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||||
|
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -214,7 +223,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
if (urls.Count == 0)
|
if (urls.Count == 0)
|
||||||
{
|
{
|
||||||
StatusMessage = "Select at least one site from the toolbar.";
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,9 +409,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||||
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
|
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||||
else
|
else
|
||||||
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -489,9 +498,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
|
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||||
else
|
else
|
||||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
|
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.Services.Export;
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -79,14 +80,14 @@ public partial class SearchViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
if (_currentProfile == null)
|
if (_currentProfile == null)
|
||||||
{
|
{
|
||||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
if (urls.Count == 0)
|
if (urls.Count == 0)
|
||||||
{
|
{
|
||||||
StatusMessage = "Select at least one site from the toolbar.";
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using LiveChartsCore.SkiaSharpView.Painting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.Services.Export;
|
using SharepointToolbox.Services.Export;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
@@ -40,6 +41,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
|
||||||
{
|
{
|
||||||
@@ -82,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;
|
||||||
@@ -158,6 +176,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
|
||||||
|
ApplyChartThemeColors();
|
||||||
if (_themeManager is not null)
|
if (_themeManager is not null)
|
||||||
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
|
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
|
||||||
}
|
}
|
||||||
@@ -185,14 +204,14 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
if (_currentProfile == null)
|
if (_currentProfile == null)
|
||||||
{
|
{
|
||||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
if (urls.Count == 0)
|
if (urls.Count == 0)
|
||||||
{
|
{
|
||||||
StatusMessage = "Select at least one site from the toolbar.";
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +350,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
if (dialog.ShowDialog() != true) return;
|
if (dialog.ShowDialog() != true) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
|
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -362,7 +381,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
branding = new ReportBranding(mspLogo, clientLogo);
|
branding = new ReportBranding(mspLogo, clientLogo);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding);
|
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -379,12 +398,23 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
|
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
|
||||||
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
|
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
|
||||||
|
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
|
||||||
|
|
||||||
|
private void ApplyChartThemeColors()
|
||||||
|
{
|
||||||
|
LegendTextPaint.Color = ChartFgColor;
|
||||||
|
LegendBackgroundPaint.Color = ChartSurfaceColor;
|
||||||
|
TooltipTextPaint.Color = ChartFgColor;
|
||||||
|
TooltipBackgroundPaint.Color = ChartSurfaceColor;
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateChartSeries()
|
private void UpdateChartSeries()
|
||||||
{
|
{
|
||||||
var metrics = FileTypeMetrics.ToList();
|
var metrics = FileTypeMetrics.ToList();
|
||||||
OnPropertyChanged(nameof(HasChartData));
|
OnPropertyChanged(nameof(HasChartData));
|
||||||
|
|
||||||
|
ApplyChartThemeColors();
|
||||||
|
|
||||||
if (metrics.Count == 0)
|
if (metrics.Count == 0)
|
||||||
{
|
{
|
||||||
PieChartSeries = Enumerable.Empty<ISeries>();
|
PieChartSeries = Enumerable.Empty<ISeries>();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Infrastructure.Persistence;
|
using SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
namespace SharepointToolbox.ViewModels.Tabs;
|
namespace SharepointToolbox.ViewModels.Tabs;
|
||||||
@@ -39,6 +40,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
// Apply options
|
// Apply options
|
||||||
[ObservableProperty] private string _newSiteTitle = string.Empty;
|
[ObservableProperty] private string _newSiteTitle = string.Empty;
|
||||||
[ObservableProperty] private string _newSiteAlias = string.Empty;
|
[ObservableProperty] private string _newSiteAlias = string.Empty;
|
||||||
|
private bool _aliasManuallyEdited;
|
||||||
|
|
||||||
public IAsyncRelayCommand CaptureCommand { get; }
|
public IAsyncRelayCommand CaptureCommand { get; }
|
||||||
public IAsyncRelayCommand ApplyCommand { get; }
|
public IAsyncRelayCommand ApplyCommand { get; }
|
||||||
@@ -78,19 +80,20 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
private async Task CaptureAsync()
|
private async Task CaptureAsync()
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
if (_currentProfile == null)
|
if (_currentProfile == null)
|
||||||
throw new InvalidOperationException("No tenant connected.");
|
throw new InvalidOperationException(T["err.no_tenant"]);
|
||||||
if (string.IsNullOrWhiteSpace(TemplateName))
|
if (string.IsNullOrWhiteSpace(TemplateName))
|
||||||
throw new InvalidOperationException("Template name is required.");
|
throw new InvalidOperationException(T["err.template_name_required"]);
|
||||||
|
|
||||||
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
|
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
|
||||||
if (string.IsNullOrWhiteSpace(captureSiteUrl))
|
if (string.IsNullOrWhiteSpace(captureSiteUrl))
|
||||||
throw new InvalidOperationException("Select at least one site from the toolbar.");
|
throw new InvalidOperationException(T["err.no_sites_selected"]);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
StatusMessage = "Capturing template...";
|
StatusMessage = T["templates.status.capturing"];
|
||||||
|
|
||||||
var profile = new TenantProfile
|
var profile = new TenantProfile
|
||||||
{
|
{
|
||||||
@@ -117,11 +120,11 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
|
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
|
||||||
|
|
||||||
await RefreshListAsync();
|
await RefreshListAsync();
|
||||||
StatusMessage = $"Template captured successfully.";
|
StatusMessage = T["templates.status.success"];
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Capture failed: {ex.Message}";
|
StatusMessage = string.Format(T["templates.status.capture_failed"], ex.Message);
|
||||||
Log.Error(ex, "Template capture failed");
|
Log.Error(ex, "Template capture failed");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -133,15 +136,23 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
private async Task ApplyAsync()
|
private async Task ApplyAsync()
|
||||||
{
|
{
|
||||||
if (_currentProfile == null || SelectedTemplate == null) return;
|
if (_currentProfile == null || SelectedTemplate == null) return;
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
if (string.IsNullOrWhiteSpace(NewSiteTitle))
|
if (string.IsNullOrWhiteSpace(NewSiteTitle))
|
||||||
throw new InvalidOperationException("New site title is required.");
|
throw new InvalidOperationException(T["err.site_title_required"]);
|
||||||
|
|
||||||
|
// Auto-fill alias from title if user left it blank
|
||||||
if (string.IsNullOrWhiteSpace(NewSiteAlias))
|
if (string.IsNullOrWhiteSpace(NewSiteAlias))
|
||||||
throw new InvalidOperationException("New site alias is required.");
|
{
|
||||||
|
var generated = GenerateAliasFromTitle(NewSiteTitle);
|
||||||
|
if (string.IsNullOrWhiteSpace(generated))
|
||||||
|
throw new InvalidOperationException(T["err.site_alias_required"]);
|
||||||
|
NewSiteAlias = generated;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
StatusMessage = $"Applying template...";
|
StatusMessage = T["templates.status.applying"];
|
||||||
|
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
|
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
|
||||||
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
|
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
|
||||||
@@ -150,12 +161,12 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
|
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
|
||||||
progress, CancellationToken.None);
|
progress, CancellationToken.None);
|
||||||
|
|
||||||
StatusMessage = $"Template applied. Site created at: {siteUrl}";
|
StatusMessage = string.Format(T["templates.status.applied"], siteUrl);
|
||||||
Log.Information("Template applied. New site: {Url}", siteUrl);
|
Log.Information("Template applied. New site: {Url}", siteUrl);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Apply failed: {ex.Message}";
|
StatusMessage = string.Format(T["templates.status.apply_failed"], ex.Message);
|
||||||
Log.Error(ex, "Template apply failed");
|
Log.Error(ex, "Template apply failed");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -204,6 +215,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
TemplateName = string.Empty;
|
TemplateName = string.Empty;
|
||||||
NewSiteTitle = string.Empty;
|
NewSiteTitle = string.Empty;
|
||||||
NewSiteAlias = string.Empty;
|
NewSiteAlias = string.Empty;
|
||||||
|
_aliasManuallyEdited = false;
|
||||||
StatusMessage = string.Empty;
|
StatusMessage = string.Empty;
|
||||||
|
|
||||||
_ = RefreshListAsync();
|
_ = RefreshListAsync();
|
||||||
@@ -215,4 +227,44 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
|||||||
RenameCommand.NotifyCanExecuteChanged();
|
RenameCommand.NotifyCanExecuteChanged();
|
||||||
DeleteCommand.NotifyCanExecuteChanged();
|
DeleteCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnNewSiteTitleChanged(string value)
|
||||||
|
{
|
||||||
|
if (_aliasManuallyEdited) return;
|
||||||
|
var generated = GenerateAliasFromTitle(value);
|
||||||
|
if (NewSiteAlias != generated)
|
||||||
|
{
|
||||||
|
// Bypass user-edit flag while we sync alias to title
|
||||||
|
_suppressAliasEditedFlag = true;
|
||||||
|
NewSiteAlias = generated;
|
||||||
|
_suppressAliasEditedFlag = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _suppressAliasEditedFlag;
|
||||||
|
|
||||||
|
partial void OnNewSiteAliasChanged(string value)
|
||||||
|
{
|
||||||
|
if (_suppressAliasEditedFlag) return;
|
||||||
|
_aliasManuallyEdited = !string.IsNullOrWhiteSpace(value)
|
||||||
|
&& value != GenerateAliasFromTitle(NewSiteTitle);
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) _aliasManuallyEdited = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GenerateAliasFromTitle(string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title)) return string.Empty;
|
||||||
|
|
||||||
|
var normalized = title.Normalize(System.Text.NormalizationForm.FormD);
|
||||||
|
var sb = new System.Text.StringBuilder(normalized.Length);
|
||||||
|
foreach (var c in normalized)
|
||||||
|
{
|
||||||
|
var cat = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (cat == System.Globalization.UnicodeCategory.NonSpacingMark) continue;
|
||||||
|
if (char.IsLetterOrDigit(c)) sb.Append(c);
|
||||||
|
else if (c == ' ' || c == '-' || c == '_') sb.Append('-');
|
||||||
|
}
|
||||||
|
var collapsed = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), "-+", "-");
|
||||||
|
return collapsed.Trim('-');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.Services.Export;
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -101,14 +102,15 @@ public partial class TransferViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
if (_currentProfile == null)
|
if (_currentProfile == null)
|
||||||
throw new InvalidOperationException("No tenant connected.");
|
throw new InvalidOperationException(T["err.no_tenant"]);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
|
if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
|
||||||
throw new InvalidOperationException("Source site and library must be selected.");
|
throw new InvalidOperationException(T["err.transfer_source_required"]);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
|
if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
|
||||||
throw new InvalidOperationException("Destination site and library must be selected.");
|
throw new InvalidOperationException(T["err.transfer_dest_required"]);
|
||||||
|
|
||||||
// Confirmation dialog
|
// Confirmation dialog
|
||||||
var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
|
var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.Services.Export;
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -107,6 +108,19 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _mergePermissions;
|
private bool _mergePermissions;
|
||||||
|
|
||||||
|
/// <summary>0 = Single file, 1 = Split by site, 2 = Split by user.</summary>
|
||||||
|
[ObservableProperty] private int _splitModeIndex;
|
||||||
|
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||||
|
[ObservableProperty] private int _htmlLayoutIndex;
|
||||||
|
|
||||||
|
private ReportSplitMode CurrentSplit => SplitModeIndex switch
|
||||||
|
{
|
||||||
|
1 => ReportSplitMode.BySite,
|
||||||
|
2 => ReportSplitMode.ByUser,
|
||||||
|
_ => ReportSplitMode.Single
|
||||||
|
};
|
||||||
|
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||||
|
|
||||||
private CancellationTokenSource? _directoryCts = null;
|
private CancellationTokenSource? _directoryCts = null;
|
||||||
|
|
||||||
// ── Computed summary properties ─────────────────────────────────────────
|
// ── Computed summary properties ─────────────────────────────────────────
|
||||||
@@ -254,13 +268,13 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
if (SelectedUsers.Count == 0)
|
if (SelectedUsers.Count == 0)
|
||||||
{
|
{
|
||||||
StatusMessage = "Add at least one user to audit.";
|
StatusMessage = TranslationSource.Instance["err.no_users_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GlobalSites.Count == 0)
|
if (GlobalSites.Count == 0)
|
||||||
{
|
{
|
||||||
StatusMessage = "Select at least one site from the toolbar.";
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +289,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
|
|
||||||
if (_currentProfile == null)
|
if (_currentProfile == null)
|
||||||
{
|
{
|
||||||
StatusMessage = "No tenant profile selected. Please connect first.";
|
StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +472,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
var clientId = _currentProfile?.ClientId;
|
var clientId = _currentProfile?.ClientId;
|
||||||
if (string.IsNullOrEmpty(clientId))
|
if (string.IsNullOrEmpty(clientId))
|
||||||
{
|
{
|
||||||
StatusMessage = "No tenant profile selected. Please connect first.";
|
StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +566,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
if (dialog.ShowDialog() != true) return;
|
if (dialog.ShowDialog() != true) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions);
|
await _csvExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None, MergePermissions);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -583,7 +597,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
branding = new ReportBranding(mspLogo, clientLogo);
|
branding = new ReportBranding(mspLogo, clientLogo);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding);
|
await _htmlExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, MergePermissions, branding);
|
||||||
OpenFile(dialog.FileName);
|
OpenFile(dialog.FileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.ViewModels.Tabs;
|
||||||
|
|
||||||
|
public partial class VersionCleanupViewModel : FeatureViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IVersionCleanupService _versionService;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly VersionCleanupHtmlExportService _htmlExportService;
|
||||||
|
private readonly IBrandingService _brandingService;
|
||||||
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
|
private TenantProfile? _currentProfile;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _keepLast = 5;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _keepFirstVersion;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _confirmDelete = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _selectedLibrariesLabel = string.Empty;
|
||||||
|
|
||||||
|
public ObservableCollection<string> SelectedLibraries { get; } = new();
|
||||||
|
|
||||||
|
public ObservableCollection<VersionCleanupResult> Results { get; } = new();
|
||||||
|
|
||||||
|
public long TotalBytesFreed => Results.Sum(r => r.BytesFreed);
|
||||||
|
public int TotalVersionsDeleted => Results.Sum(r => r.VersionsDeleted);
|
||||||
|
public int TotalFilesAffected => Results.Count(r => r.VersionsDeleted > 0);
|
||||||
|
public bool HasResults => Results.Count > 0;
|
||||||
|
|
||||||
|
public TenantProfile? CurrentProfile => _currentProfile;
|
||||||
|
|
||||||
|
/// <summary>Set by the view to invoke <see cref="LibraryPickerDialog"/> against the current site.</summary>
|
||||||
|
public Func<string, IReadOnlyCollection<string>, Task<IReadOnlyList<string>?>>? PickLibrariesAsync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set by the view to display a confirm dialog before destructive run.</summary>
|
||||||
|
public Func<string, bool>? ConfirmAction { get; set; }
|
||||||
|
|
||||||
|
public IAsyncRelayCommand SelectLibrariesCommand { get; }
|
||||||
|
public IRelayCommand ClearLibrariesCommand { get; }
|
||||||
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||||
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||||
|
|
||||||
|
public VersionCleanupViewModel(
|
||||||
|
IVersionCleanupService versionService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
VersionCleanupHtmlExportService htmlExportService,
|
||||||
|
IBrandingService brandingService,
|
||||||
|
ILogger<FeatureViewModelBase> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
_versionService = versionService;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_htmlExportService = htmlExportService;
|
||||||
|
_brandingService = brandingService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
SelectLibrariesCommand = new AsyncRelayCommand(SelectLibrariesAsync, CanPickLibraries);
|
||||||
|
ClearLibrariesCommand = new RelayCommand(ClearLibraries);
|
||||||
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, () => Results.Count > 0);
|
||||||
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, () => Results.Count > 0);
|
||||||
|
|
||||||
|
SelectedLibraries.CollectionChanged += (_, _) => UpdateSelectedLibrariesLabel();
|
||||||
|
Results.CollectionChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasResults));
|
||||||
|
OnPropertyChanged(nameof(TotalBytesFreed));
|
||||||
|
OnPropertyChanged(nameof(TotalVersionsDeleted));
|
||||||
|
OnPropertyChanged(nameof(TotalFilesAffected));
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
|
UpdateSelectedLibrariesLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnTenantSwitched(TenantProfile profile)
|
||||||
|
{
|
||||||
|
_currentProfile = profile;
|
||||||
|
SelectedLibraries.Clear();
|
||||||
|
Results.Clear();
|
||||||
|
OnPropertyChanged(nameof(CurrentProfile));
|
||||||
|
SelectLibrariesCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||||
|
{
|
||||||
|
// Site changes invalidate library list — clear so user re-picks.
|
||||||
|
SelectedLibraries.Clear();
|
||||||
|
SelectLibrariesCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanPickLibraries() => _currentProfile != null && GlobalSites.Count > 0;
|
||||||
|
|
||||||
|
private async Task SelectLibrariesAsync()
|
||||||
|
{
|
||||||
|
if (PickLibrariesAsync == null || _currentProfile == null) return;
|
||||||
|
var first = GlobalSites.FirstOrDefault();
|
||||||
|
if (first == null)
|
||||||
|
{
|
||||||
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var picked = await PickLibrariesAsync(first.Url, SelectedLibraries.ToArray());
|
||||||
|
if (picked == null) return;
|
||||||
|
SelectedLibraries.Clear();
|
||||||
|
foreach (var t in picked) SelectedLibraries.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearLibraries() => SelectedLibraries.Clear();
|
||||||
|
|
||||||
|
private void UpdateSelectedLibrariesLabel()
|
||||||
|
{
|
||||||
|
SelectedLibrariesLabel = SelectedLibraries.Count == 0
|
||||||
|
? TranslationSource.Instance["versions.libs.all"]
|
||||||
|
: string.Format(TranslationSource.Instance["versions.libs.count"], SelectedLibraries.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
|
{
|
||||||
|
if (_currentProfile == null)
|
||||||
|
{
|
||||||
|
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
|
if (urls.Count == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KeepLast < 0)
|
||||||
|
{
|
||||||
|
StatusMessage = TranslationSource.Instance["versions.err.keepLast"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConfirmDelete && ConfirmAction != null)
|
||||||
|
{
|
||||||
|
var msg = string.Format(
|
||||||
|
TranslationSource.Instance["versions.confirm"],
|
||||||
|
KeepLast,
|
||||||
|
KeepFirstVersion ? TranslationSource.Instance["versions.confirm.keepFirst"] : string.Empty);
|
||||||
|
if (!ConfirmAction(msg)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new VersionCleanupOptions(
|
||||||
|
SelectedLibraries.ToList(), KeepLast, KeepFirstVersion);
|
||||||
|
|
||||||
|
Results.Clear();
|
||||||
|
int siteIdx = 0;
|
||||||
|
foreach (var url in urls)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
siteIdx++;
|
||||||
|
progress.Report(new OperationProgress(siteIdx, urls.Count, $"Cleaning {url}..."));
|
||||||
|
|
||||||
|
var siteProfile = new TenantProfile
|
||||||
|
{
|
||||||
|
TenantUrl = url.TrimEnd('/'),
|
||||||
|
ClientId = _currentProfile.ClientId,
|
||||||
|
Name = _currentProfile.Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||||
|
var siteResults = await _versionService.DeleteOldVersionsAsync(ctx, options, progress, ct);
|
||||||
|
|
||||||
|
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||||
|
{
|
||||||
|
await dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
foreach (var r in siteResults) Results.Add(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var r in siteResults) Results.Add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportCsvAsync()
|
||||||
|
{
|
||||||
|
if (Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export version cleanup results",
|
||||||
|
Filter = "CSV files (*.csv)|*.csv",
|
||||||
|
DefaultExt = "csv",
|
||||||
|
FileName = "version_cleanup",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var w = new System.IO.StreamWriter(dialog.FileName);
|
||||||
|
await w.WriteLineAsync("Site,Library,File,Versions Before,Versions Deleted,Versions Remaining,Bytes Freed,Error");
|
||||||
|
foreach (var r in Results)
|
||||||
|
{
|
||||||
|
await w.WriteLineAsync(string.Join(",",
|
||||||
|
Csv(r.SiteUrl),
|
||||||
|
Csv(r.Library),
|
||||||
|
Csv(r.FileServerRelativeUrl),
|
||||||
|
r.VersionsBefore,
|
||||||
|
r.VersionsDeleted,
|
||||||
|
r.VersionsRemaining,
|
||||||
|
r.BytesFreed,
|
||||||
|
Csv(r.Error ?? string.Empty)));
|
||||||
|
}
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "Version cleanup CSV export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportHtmlAsync()
|
||||||
|
{
|
||||||
|
if (Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export version cleanup results to HTML",
|
||||||
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
||||||
|
DefaultExt = "html",
|
||||||
|
FileName = "version_cleanup",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||||
|
var clientLogo = _currentProfile?.ClientLogo;
|
||||||
|
var branding = new ReportBranding(mspLogo, clientLogo);
|
||||||
|
|
||||||
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "Version cleanup HTML export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Csv(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||||
|
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||||
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnKeepLastChanged(int value)
|
||||||
|
{
|
||||||
|
if (value < 0) KeepLast = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
<!-- Action bar: new folder (destination mode only) -->
|
<!-- Action bar: new folder (destination mode only) -->
|
||||||
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
|
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
|
||||||
Margin="0,0,0,6" Visibility="Collapsed">
|
Margin="0,0,0,6" Visibility="Collapsed">
|
||||||
<Button x:Name="NewFolderButton" Content="+ New Folder"
|
<Button x:Name="NewFolderButton"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
|
||||||
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
|
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public partial class FolderBrowserDialog : Window
|
|||||||
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
|
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
|
||||||
|
|
||||||
private readonly List<CheckBox> _fileCheckboxes = new();
|
private readonly List<CheckBox> _fileCheckboxes = new();
|
||||||
|
private readonly List<TreeViewItem> _expandedNodes = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
|
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
|
||||||
@@ -80,6 +81,7 @@ public partial class FolderBrowserDialog : Window
|
|||||||
// Placeholder child so the expand arrow appears.
|
// Placeholder child so the expand arrow appears.
|
||||||
node.Items.Add(new TreeViewItem { Header = "Loading..." });
|
node.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||||
node.Expanded += FolderNode_Expanded;
|
node.Expanded += FolderNode_Expanded;
|
||||||
|
_expandedNodes.Add(node);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +101,9 @@ public partial class FolderBrowserDialog : Window
|
|||||||
{
|
{
|
||||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
||||||
_ctx.Load(folder, f => f.StorageMetrics.TotalSize,
|
_ctx.Load(folder, f => f.StorageMetrics.TotalSize,
|
||||||
f => f.StorageMetrics.TotalFileCount,
|
f => f.StorageMetrics.TotalFileCount);
|
||||||
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl,
|
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
|
||||||
sf => sf.StorageMetrics.TotalSize,
|
_ctx.Load(list, l => l.Title);
|
||||||
sf => sf.StorageMetrics.TotalFileCount),
|
|
||||||
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
|
|
||||||
fi => fi.ServerRelativeUrl));
|
|
||||||
var progress = new Progress<Core.Models.OperationProgress>();
|
var progress = new Progress<Core.Models.OperationProgress>();
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||||
|
|
||||||
@@ -115,23 +114,56 @@ public partial class FolderBrowserDialog : Window
|
|||||||
folder.StorageMetrics.TotalFileCount,
|
folder.StorageMetrics.TotalFileCount,
|
||||||
folder.StorageMetrics.TotalSize);
|
folder.StorageMetrics.TotalSize);
|
||||||
|
|
||||||
// Child folders first
|
// Enumerate direct children via paginated CAML — Folder.Folders /
|
||||||
foreach (var subFolder in folder.Folders)
|
// Folder.Files lazy loading hits the list-view threshold on libraries
|
||||||
|
// above 5,000 items even when only a small folder is being expanded.
|
||||||
|
var subFolders = new List<(string Name, string Url)>();
|
||||||
|
var filesInFolder = new List<(string Name, long Length, string Url)>();
|
||||||
|
|
||||||
|
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||||
|
_ctx, list, info.ServerRelativeUrl, recursive: false,
|
||||||
|
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "File_x0020_Size" },
|
||||||
|
ct: CancellationToken.None))
|
||||||
{
|
{
|
||||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
var fsType = item["FSObjType"]?.ToString();
|
||||||
continue;
|
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||||
|
var fileRef = item["FileRef"]?.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
if (fsType == "1")
|
||||||
|
{
|
||||||
|
if (name.StartsWith("_") || name == "Forms") continue;
|
||||||
|
subFolders.Add((name, fileRef));
|
||||||
|
}
|
||||||
|
else if (fsType == "0" && _allowFileSelection)
|
||||||
|
{
|
||||||
|
long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long size);
|
||||||
|
filesInFolder.Add((name, size, fileRef));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-load StorageMetrics for each subfolder in one round-trip.
|
||||||
|
var metricFolders = subFolders
|
||||||
|
.Select(sf => _ctx.Web.GetFolderByServerRelativeUrl(sf.Url))
|
||||||
|
.ToList();
|
||||||
|
foreach (var mf in metricFolders)
|
||||||
|
_ctx.Load(mf, f => f.StorageMetrics.TotalSize,
|
||||||
|
f => f.StorageMetrics.TotalFileCount);
|
||||||
|
if (metricFolders.Count > 0)
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||||
|
|
||||||
|
for (int i = 0; i < subFolders.Count; i++)
|
||||||
|
{
|
||||||
|
var (name, url) = subFolders[i];
|
||||||
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||||
? subFolder.Name
|
? name
|
||||||
: $"{info.RelativePath}/{subFolder.Name}";
|
: $"{info.RelativePath}/{name}";
|
||||||
|
|
||||||
var childInfo = new FolderNodeInfo(
|
var childInfo = new FolderNodeInfo(info.LibraryTitle, childRelative, url);
|
||||||
info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
|
|
||||||
|
|
||||||
var childNode = MakeFolderNode(
|
var childNode = MakeFolderNode(
|
||||||
FormatFolderHeader(subFolder.Name,
|
FormatFolderHeader(name,
|
||||||
subFolder.StorageMetrics.TotalFileCount,
|
metricFolders[i].StorageMetrics.TotalFileCount,
|
||||||
subFolder.StorageMetrics.TotalSize),
|
metricFolders[i].StorageMetrics.TotalSize),
|
||||||
childInfo);
|
childInfo);
|
||||||
node.Items.Add(childNode);
|
node.Items.Add(childNode);
|
||||||
}
|
}
|
||||||
@@ -139,16 +171,15 @@ public partial class FolderBrowserDialog : Window
|
|||||||
// Files under this folder — only shown when selection is enabled.
|
// Files under this folder — only shown when selection is enabled.
|
||||||
if (_allowFileSelection)
|
if (_allowFileSelection)
|
||||||
{
|
{
|
||||||
foreach (var file in folder.Files)
|
foreach (var (fileName, fileSize, _) in filesInFolder)
|
||||||
{
|
{
|
||||||
// Library-relative path for the file (used by the transfer service)
|
|
||||||
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
||||||
? file.Name
|
? fileName
|
||||||
: $"{info.RelativePath}/{file.Name}";
|
: $"{info.RelativePath}/{fileName}";
|
||||||
|
|
||||||
var cb = new CheckBox
|
var cb = new CheckBox
|
||||||
{
|
{
|
||||||
Content = $"{file.Name} ({FormatSize(file.Length)})",
|
Content = $"{fileName} ({FormatSize(fileSize)})",
|
||||||
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
||||||
Margin = new Thickness(4, 2, 0, 2),
|
Margin = new Thickness(4, 2, 0, 2),
|
||||||
};
|
};
|
||||||
@@ -274,6 +305,21 @@ public partial class FolderBrowserDialog : Window
|
|||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
Loaded -= OnLoaded;
|
||||||
|
foreach (var node in _expandedNodes)
|
||||||
|
node.Expanded -= FolderNode_Expanded;
|
||||||
|
_expandedNodes.Clear();
|
||||||
|
foreach (var cb in _fileCheckboxes)
|
||||||
|
{
|
||||||
|
cb.Checked -= FileCheckbox_Toggled;
|
||||||
|
cb.Unchecked -= FileCheckbox_Toggled;
|
||||||
|
}
|
||||||
|
_fileCheckboxes.Clear();
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
|
||||||
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
|
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
|
||||||
private record FileNodeInfo(string LibraryTitle, string RelativePath);
|
private record FileNodeInfo(string LibraryTitle, string RelativePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
|
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Input"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
|
||||||
Width="340" Height="140"
|
Width="340" Height="140"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{DynamicResource AppBgBrush}"
|
Background="{DynamicResource AppBgBrush}"
|
||||||
@@ -11,7 +12,8 @@
|
|||||||
<DockPanel Margin="12">
|
<DockPanel Margin="12">
|
||||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||||
HorizontalAlignment="Right" Margin="0,10,0,0">
|
HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
<Button Content="Cancel" Width="70" IsCancel="True" Margin="0,0,8,0"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||||
|
Width="70" IsCancel="True" Margin="0,0,8,0"
|
||||||
Click="Cancel_Click" />
|
Click="Cancel_Click" />
|
||||||
<Button Content="OK" Width="70" IsDefault="True"
|
<Button Content="OK" Width="70" IsDefault="True"
|
||||||
Click="Ok_Click" />
|
Click="Ok_Click" />
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Window x:Class="SharepointToolbox.Views.Dialogs.LibraryPickerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.title]}"
|
||||||
|
Width="420" Height="520" WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource AppBgBrush}"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
ResizeMode="CanResizeWithGrip">
|
||||||
|
<DockPanel Margin="10">
|
||||||
|
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,8"
|
||||||
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.loading]}" />
|
||||||
|
|
||||||
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,6">
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.selectAll]}"
|
||||||
|
Click="SelectAll_Click" Margin="0,0,6,0" Padding="6,2" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.selectNone]}"
|
||||||
|
Click="SelectNone_Click" Padding="6,2" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.cancel]}"
|
||||||
|
Width="80" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click" />
|
||||||
|
<Button x:Name="OkButton"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.select]}"
|
||||||
|
Width="80" IsDefault="True" IsEnabled="False" Click="Ok_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl x:Name="LibrariesList">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||||
|
Margin="2,4" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class LibraryPickerDialog : Window
|
||||||
|
{
|
||||||
|
private readonly ClientContext _ctx;
|
||||||
|
private readonly IVersionCleanupService _libraryLister;
|
||||||
|
private readonly ObservableCollection<LibraryItem> _items = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> SelectedLibraryTitles { get; private set; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
public LibraryPickerDialog(
|
||||||
|
ClientContext ctx,
|
||||||
|
IVersionCleanupService libraryLister,
|
||||||
|
IReadOnlyCollection<string>? preselected = null)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_ctx = ctx;
|
||||||
|
_libraryLister = libraryLister;
|
||||||
|
LibrariesList.ItemsSource = _items;
|
||||||
|
Loaded += async (_, _) => await LoadAsync(preselected ?? Array.Empty<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync(IReadOnlyCollection<string> preselected)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var titles = await _libraryLister.ListLibraryTitlesAsync(_ctx, CancellationToken.None);
|
||||||
|
var preset = new HashSet<string>(preselected, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var t in titles)
|
||||||
|
{
|
||||||
|
var item = new LibraryItem { Title = t, IsSelected = preset.Contains(t) };
|
||||||
|
item.PropertyChanged += OnItemChanged;
|
||||||
|
_items.Add(item);
|
||||||
|
}
|
||||||
|
StatusText.Text = string.Format(
|
||||||
|
Localization.TranslationSource.Instance["librarypicker.loaded"],
|
||||||
|
_items.Count);
|
||||||
|
UpdateOkEnabled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText.Text = $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(LibraryItem.IsSelected)) UpdateOkEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateOkEnabled()
|
||||||
|
=> OkButton.IsEnabled = _items.Any(i => i.IsSelected);
|
||||||
|
|
||||||
|
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var i in _items) i.IsSelected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectNone_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var i in _items) i.IsSelected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Ok_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SelectedLibraryTitles = _items.Where(i => i.IsSelected).Select(i => i.Title).ToList();
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var i in _items) i.PropertyChanged -= OnItemChanged;
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LibraryItem : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isSelected;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSelected == value) return;
|
||||||
|
_isSelected = value;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
Title="Manage Profiles" Width="500" Height="750"
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profmgmt.title]}"
|
||||||
|
Width="500" Height="750"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
Background="{DynamicResource AppBgBrush}"
|
Background="{DynamicResource AppBgBrush}"
|
||||||
Foreground="{DynamicResource TextBrush}"
|
Foreground="{DynamicResource TextBrush}"
|
||||||
@@ -19,10 +20,11 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Profile list -->
|
<!-- Profile list -->
|
||||||
<Label Content="Profiles" Grid.Row="0" />
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profmgmt.group]}" Grid.Row="0" />
|
||||||
<ListBox Grid.Row="1" Margin="0,0,0,8"
|
<ListBox Grid.Row="1" Margin="0,0,0,8"
|
||||||
ItemsSource="{Binding Profiles}"
|
ItemsSource="{Binding Profiles}"
|
||||||
SelectedItem="{Binding SelectedProfile}"
|
SelectedItem="{Binding SelectedProfile}"
|
||||||
@@ -57,8 +59,31 @@
|
|||||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Profile CRUD buttons (placed under fields for natural flow) -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,4,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
|
||||||
|
Command="{Binding AddCommand}"
|
||||||
|
MinWidth="90" Padding="10,4" Margin="0,0,6,0"
|
||||||
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add.tooltip]}" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
|
||||||
|
Command="{Binding SaveCommand}"
|
||||||
|
MinWidth="90" Padding="10,4"
|
||||||
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save.tooltip]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
|
||||||
|
Command="{Binding DeleteCommand}"
|
||||||
|
MinWidth="90" Padding="10,4"
|
||||||
|
Foreground="{DynamicResource DangerBrush}"
|
||||||
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete.tooltip]}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Client Logo -->
|
<!-- Client Logo -->
|
||||||
<StackPanel Grid.Row="3" Margin="0,8,0,8">
|
<StackPanel Grid.Row="4" Margin="0,8,0,8">
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
|
||||||
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
|
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
|
||||||
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
||||||
@@ -95,7 +120,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- App Registration -->
|
<!-- App Registration -->
|
||||||
<StackPanel Grid.Row="4" Margin="0,8,0,8">
|
<StackPanel Grid.Row="5" Margin="0,8,0,8">
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
|
||||||
FontWeight="SemiBold" Padding="0,0,0,4"
|
FontWeight="SemiBold" Padding="0,0,0,4"
|
||||||
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
|
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
|
||||||
@@ -133,15 +158,10 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Close button -->
|
||||||
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
|
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
|
||||||
Command="{Binding AddCommand}" Width="60" Margin="4,0" />
|
MinWidth="80" Padding="10,4"
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.rename]}"
|
|
||||||
Command="{Binding RenameCommand}" Width="60" Margin="4,0" />
|
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
|
|
||||||
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
|
|
||||||
<Button Content="Close" Width="60" Margin="4,0"
|
|
||||||
Click="CloseButton_Click" IsCancel="True" />
|
Click="CloseButton_Click" IsCancel="True" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ public partial class ProfileManagementDialog : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
|
viewModel.ConfirmRegisterApp = msg =>
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
this, msg,
|
||||||
|
Localization.TranslationSource.Instance["profile.register"],
|
||||||
|
MessageBoxButton.OKCancel,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
return result == MessageBoxResult.OK;
|
||||||
|
};
|
||||||
Loaded += async (_, _) => await viewModel.LoadAsync();
|
Loaded += async (_, _) => await viewModel.LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<Window x:Class="SharepointToolbox.Views.Dialogs.SitePickerDialog"
|
<Window x:Class="SharepointToolbox.Views.Dialogs.SitePickerDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Select Sites" Width="600" Height="500"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.title]}"
|
||||||
|
Width="760" Height="560"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
Background="{DynamicResource AppBgBrush}"
|
Background="{DynamicResource AppBgBrush}"
|
||||||
Foreground="{DynamicResource TextBrush}"
|
Foreground="{DynamicResource TextBrush}"
|
||||||
@@ -9,25 +11,63 @@
|
|||||||
Loaded="Window_Loaded">
|
Loaded="Window_Loaded">
|
||||||
<Grid Margin="12">
|
<Grid Margin="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Filter row -->
|
<!-- Text filter row -->
|
||||||
<DockPanel Grid.Row="0" Margin="0,0,0,8">
|
<DockPanel Grid.Row="0" Margin="0,0,0,6">
|
||||||
<TextBlock Text="Filter:" VerticalAlignment="Center" Margin="0,0,8,0" />
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.filter]}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||||
<TextBox x:Name="FilterBox" TextChanged="FilterBox_TextChanged" />
|
<TextBox x:Name="FilterBox" TextChanged="FilterBox_TextChanged" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Size + type filter row -->
|
||||||
|
<DockPanel Grid.Row="1" Margin="0,0,0,8" LastChildFill="False">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type]}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||||
|
<ComboBox x:Name="TypeFilter" Width="170" SelectedIndex="0"
|
||||||
|
SelectionChanged="TypeFilter_SelectionChanged" Margin="0,0,12,0">
|
||||||
|
<ComboBoxItem Tag="All"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.all]}" />
|
||||||
|
<ComboBoxItem Tag="TeamSite"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.team]}" />
|
||||||
|
<ComboBoxItem Tag="CommunicationSite"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.communication]}" />
|
||||||
|
<ComboBoxItem Tag="Classic"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.classic]}" />
|
||||||
|
<ComboBoxItem Tag="Unknown"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.other]}" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size]}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size.min]}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,4,0"
|
||||||
|
Foreground="{DynamicResource TextMutedBrush}" />
|
||||||
|
<TextBox x:Name="MinSizeBox" Width="80" Margin="0,0,8,0"
|
||||||
|
TextChanged="SizeBox_TextChanged" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size.max]}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,4,0"
|
||||||
|
Foreground="{DynamicResource TextMutedBrush}" />
|
||||||
|
<TextBox x:Name="MaxSizeBox" Width="80"
|
||||||
|
TextChanged="SizeBox_TextChanged" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Site list with checkboxes -->
|
<!-- Site list with checkboxes -->
|
||||||
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8"
|
<ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8"
|
||||||
SelectionMode="Single"
|
SelectionMode="Single"
|
||||||
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
|
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
|
||||||
|
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="" Width="32">
|
<GridViewColumn Width="32">
|
||||||
|
<GridViewColumn.Header>
|
||||||
|
<GridViewColumnHeader Tag="IsSelected" Content="" />
|
||||||
|
</GridViewColumn.Header>
|
||||||
<GridViewColumn.CellTemplate>
|
<GridViewColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
@@ -35,28 +75,56 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</GridViewColumn.CellTemplate>
|
</GridViewColumn.CellTemplate>
|
||||||
</GridViewColumn>
|
</GridViewColumn>
|
||||||
<GridViewColumn Header="Title" Width="200" DisplayMemberBinding="{Binding Title}" />
|
<GridViewColumn Width="200" DisplayMemberBinding="{Binding Title}">
|
||||||
<GridViewColumn Header="URL" Width="320" DisplayMemberBinding="{Binding Url}" />
|
<GridViewColumn.Header>
|
||||||
|
<GridViewColumnHeader Tag="Title"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.title]}" />
|
||||||
|
</GridViewColumn.Header>
|
||||||
|
</GridViewColumn>
|
||||||
|
<GridViewColumn Width="280" DisplayMemberBinding="{Binding Url}">
|
||||||
|
<GridViewColumn.Header>
|
||||||
|
<GridViewColumnHeader Tag="Url"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.url]}" />
|
||||||
|
</GridViewColumn.Header>
|
||||||
|
</GridViewColumn>
|
||||||
|
<GridViewColumn Width="110" DisplayMemberBinding="{Binding KindDisplay}">
|
||||||
|
<GridViewColumn.Header>
|
||||||
|
<GridViewColumnHeader Tag="Kind"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.type]}" />
|
||||||
|
</GridViewColumn.Header>
|
||||||
|
</GridViewColumn>
|
||||||
|
<GridViewColumn Width="80" DisplayMemberBinding="{Binding SizeDisplay}">
|
||||||
|
<GridViewColumn.Header>
|
||||||
|
<GridViewColumnHeader Tag="StorageUsedMb"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.size]}" />
|
||||||
|
</GridViewColumn.Header>
|
||||||
|
</GridViewColumn>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
|
||||||
<!-- Status text -->
|
<!-- Status text -->
|
||||||
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
|
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
|
||||||
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
|
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
|
||||||
|
|
||||||
<!-- Button row -->
|
<!-- Button row -->
|
||||||
<DockPanel Grid.Row="3">
|
<DockPanel Grid.Row="4">
|
||||||
<Button x:Name="LoadButton" Content="Load Sites" Width="80" Margin="0,0,8,0"
|
<Button x:Name="LoadButton"
|
||||||
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.load]}"
|
||||||
|
Width="110" Margin="0,0,8,0"
|
||||||
Click="LoadButton_Click" />
|
Click="LoadButton_Click" />
|
||||||
<Button Content="Select All" Width="80" Margin="0,0,8,0"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.selectAll]}"
|
||||||
|
Width="120" Margin="0,0,8,0"
|
||||||
Click="SelectAll_Click" />
|
Click="SelectAll_Click" />
|
||||||
<Button Content="Deselect All" Width="80" Margin="0,0,8,0"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.deselectAll]}"
|
||||||
|
Width="140" Margin="0,0,8,0"
|
||||||
Click="DeselectAll_Click" />
|
Click="DeselectAll_Click" />
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
||||||
<Button Content="OK" Width="70" Margin="4,0" IsDefault="True"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.ok]}"
|
||||||
|
Width="80" Margin="4,0" IsDefault="True"
|
||||||
Click="OK_Click" />
|
Click="OK_Click" />
|
||||||
<Button Content="Cancel" Width="70" Margin="4,0" IsCancel="True" />
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.cancel]}"
|
||||||
|
Width="80" Margin="4,0" IsCancel="True" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,52 +1,61 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Globalization;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.ViewModels.Dialogs;
|
||||||
|
|
||||||
namespace SharepointToolbox.Views.Dialogs;
|
namespace SharepointToolbox.Views.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dialog for selecting multiple SharePoint sites.
|
/// Dialog for selecting multiple SharePoint sites.
|
||||||
/// Loads sites from ISiteListService, shows them in a filterable list with checkboxes.
|
/// Delegates loading and filter/sort logic to <see cref="SitePickerDialogLogic"/>
|
||||||
|
/// so the code-behind only handles WPF plumbing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class SitePickerDialog : Window
|
public partial class SitePickerDialog : Window
|
||||||
{
|
{
|
||||||
private readonly ISiteListService _siteListService;
|
private readonly SitePickerDialogLogic _logic;
|
||||||
private readonly TenantProfile _profile;
|
|
||||||
private List<SitePickerItem> _allItems = new();
|
private List<SitePickerItem> _allItems = new();
|
||||||
|
private string _sortColumn = "Url";
|
||||||
|
private ListSortDirection _sortDirection = ListSortDirection.Ascending;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the list of sites the user checked before clicking OK.
|
/// Returns the list of sites the user checked before clicking OK.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<SiteInfo> SelectedUrls =>
|
public IReadOnlyList<SiteInfo> SelectedUrls =>
|
||||||
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)).ToList();
|
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)
|
||||||
|
{
|
||||||
|
StorageUsedMb = i.StorageUsedMb,
|
||||||
|
StorageQuotaMb = i.StorageQuotaMb,
|
||||||
|
Template = i.Template
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
public SitePickerDialog(ISiteListService siteListService, TenantProfile profile)
|
public SitePickerDialog(ISiteListService siteListService, TenantProfile profile)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_siteListService = siteListService;
|
_logic = new SitePickerDialogLogic(siteListService, profile);
|
||||||
_profile = profile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync();
|
private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync();
|
||||||
|
|
||||||
private async Task LoadSitesAsync()
|
private async Task LoadSitesAsync()
|
||||||
{
|
{
|
||||||
StatusText.Text = "Loading sites...";
|
StatusText.Text = TranslationSource.Instance["sitepicker.status.loading"];
|
||||||
LoadButton.IsEnabled = false;
|
LoadButton.IsEnabled = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sites = await _siteListService.GetSitesAsync(
|
var items = await _logic.LoadAsync(
|
||||||
_profile,
|
|
||||||
new Progress<OperationProgress>(),
|
new Progress<OperationProgress>(),
|
||||||
System.Threading.CancellationToken.None);
|
System.Threading.CancellationToken.None);
|
||||||
|
_allItems = items.ToList();
|
||||||
_allItems = sites.Select(s => new SitePickerItem(s.Url, s.Title)).ToList();
|
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
StatusText.Text = $"{_allItems.Count} sites loaded.";
|
StatusText.Text = string.Format(
|
||||||
|
CultureInfo.CurrentUICulture,
|
||||||
|
TranslationSource.Instance["sitepicker.status.loaded"],
|
||||||
|
_allItems.Count);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
@@ -54,7 +63,10 @@ public partial class SitePickerDialog : Window
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusText.Text = $"Error: {ex.Message}";
|
StatusText.Text = string.Format(
|
||||||
|
CultureInfo.CurrentUICulture,
|
||||||
|
TranslationSource.Instance["sitepicker.status.error"],
|
||||||
|
ex.Message);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -64,25 +76,66 @@ public partial class SitePickerDialog : Window
|
|||||||
|
|
||||||
private void ApplyFilter()
|
private void ApplyFilter()
|
||||||
{
|
{
|
||||||
var filter = FilterBox.Text.Trim();
|
var text = FilterBox.Text.Trim();
|
||||||
SiteList.ItemsSource = string.IsNullOrEmpty(filter)
|
var minMb = SitePickerDialogLogic.ParseLongOrDefault(MinSizeBox.Text, 0);
|
||||||
? (IEnumerable<SitePickerItem>)_allItems
|
var maxMb = SitePickerDialogLogic.ParseLongOrDefault(MaxSizeBox.Text, long.MaxValue);
|
||||||
: _allItems.Where(i =>
|
var kindFilter = (TypeFilter.SelectedItem as ComboBoxItem)?.Tag as string ?? "All";
|
||||||
i.Url.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
i.Title.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
|
var filtered = SitePickerDialogLogic.ApplyFilter(_allItems, text, minMb, maxMb, kindFilter);
|
||||||
|
var sorted = SitePickerDialogLogic.ApplySort(filtered, _sortColumn, _sortDirection);
|
||||||
|
var list = sorted.ToList();
|
||||||
|
|
||||||
|
SiteList.ItemsSource = list;
|
||||||
|
if (_allItems.Count > 0)
|
||||||
|
StatusText.Text = string.Format(
|
||||||
|
CultureInfo.CurrentUICulture,
|
||||||
|
TranslationSource.Instance["sitepicker.status.shown"],
|
||||||
|
list.Count, _allItems.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SiteList_ColumnHeaderClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OriginalSource is not GridViewColumnHeader header) return;
|
||||||
|
if (header.Role == GridViewColumnHeaderRole.Padding) return;
|
||||||
|
if (header.Tag is not string column || string.IsNullOrEmpty(column)) return;
|
||||||
|
|
||||||
|
if (_sortColumn == column)
|
||||||
|
{
|
||||||
|
_sortDirection = _sortDirection == ListSortDirection.Ascending
|
||||||
|
? ListSortDirection.Descending
|
||||||
|
: ListSortDirection.Ascending;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_sortColumn = column;
|
||||||
|
_sortDirection = ListSortDirection.Ascending;
|
||||||
|
}
|
||||||
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
|
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
|
||||||
|
private void SizeBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
|
||||||
|
private void TypeFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!IsLoaded) return;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
foreach (var item in _allItems) item.IsSelected = true;
|
if (SiteList.ItemsSource is IEnumerable<SitePickerItem> visible)
|
||||||
|
{
|
||||||
|
foreach (var item in visible) item.IsSelected = true;
|
||||||
|
}
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DeselectAll_Click(object sender, RoutedEventArgs e)
|
private void DeselectAll_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
foreach (var item in _allItems) item.IsSelected = false;
|
if (SiteList.ItemsSource is IEnumerable<SitePickerItem> visible)
|
||||||
|
{
|
||||||
|
foreach (var item in visible) item.IsSelected = false;
|
||||||
|
}
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +158,13 @@ public class SitePickerItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string Url { get; init; } = string.Empty;
|
public string Url { get; init; } = string.Empty;
|
||||||
public string Title { get; init; } = string.Empty;
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public long StorageUsedMb { get; init; }
|
||||||
|
public long StorageQuotaMb { get; init; }
|
||||||
|
public string Template { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public SiteKind Kind => SiteKindHelper.FromTemplate(Template);
|
||||||
|
public string KindDisplay => SiteKindHelper.DisplayName(Kind);
|
||||||
|
public string SizeDisplay => FormatSize(StorageUsedMb);
|
||||||
|
|
||||||
public bool IsSelected
|
public bool IsSelected
|
||||||
{
|
{
|
||||||
@@ -119,9 +179,19 @@ public class SitePickerItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
public SitePickerItem(string url, string title)
|
public SitePickerItem(string url, string title, long storageUsedMb = 0, long storageQuotaMb = 0, string template = "")
|
||||||
{
|
{
|
||||||
Url = url;
|
Url = url;
|
||||||
Title = title;
|
Title = title;
|
||||||
|
StorageUsedMb = storageUsedMb;
|
||||||
|
StorageQuotaMb = storageQuotaMb;
|
||||||
|
Template = template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSize(long mb)
|
||||||
|
{
|
||||||
|
if (mb <= 0) return "—";
|
||||||
|
if (mb >= 1024) return $"{mb / 1024.0:F1} GB";
|
||||||
|
return $"{mb} MB";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,14 +48,15 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</DataGrid.RowStyle>
|
</DataGrid.RowStyle>
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.valid]}"
|
||||||
|
Binding="{Binding IsValid}" Width="50" />
|
||||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}"
|
||||||
Binding="{Binding Record.GroupName}" Width="*" />
|
Binding="{Binding Record.GroupName}" Width="*" />
|
||||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
|
||||||
Binding="{Binding Record.Email}" Width="*" />
|
Binding="{Binding Record.Email}" Width="*" />
|
||||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}"
|
||||||
Binding="{Binding Record.Role}" Width="80" />
|
Binding="{Binding Record.Role}" Width="80" />
|
||||||
<DataGridTextColumn Header="Errors"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.errors]}"
|
||||||
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
||||||
Width="*" />
|
Width="*" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</DataGrid.RowStyle>
|
</DataGrid.RowStyle>
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.valid]}"
|
||||||
|
Binding="{Binding IsValid}" Width="50" />
|
||||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}"
|
||||||
Binding="{Binding Record.Name}" Width="*" />
|
Binding="{Binding Record.Name}" Width="*" />
|
||||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}"
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
Binding="{Binding Record.Type}" Width="100" />
|
Binding="{Binding Record.Type}" Width="100" />
|
||||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}"
|
||||||
Binding="{Binding Record.Owners}" Width="*" />
|
Binding="{Binding Record.Owners}" Width="*" />
|
||||||
<DataGridTextColumn Header="Errors"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.errors]}"
|
||||||
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
||||||
Width="*" />
|
Width="*" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
|
|||||||
@@ -44,6 +44,17 @@
|
|||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||||
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,4,0,0" />
|
||||||
|
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="26" Margin="0,0,0,4">
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
|
||||||
|
</ComboBox>
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Margin="0,2,0,0" />
|
||||||
|
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="26" Margin="0,0,0,6">
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
|
||||||
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
|
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
|
||||||
@@ -60,17 +71,24 @@
|
|||||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||||
ColumnWidth="Auto">
|
ColumnWidth="Auto">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[duplicates.col.group]}"
|
||||||
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
|
Binding="{Binding GroupName}" Width="160" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[duplicates.col.copies]}"
|
||||||
|
Binding="{Binding GroupSize}" Width="60"
|
||||||
ElementStyle="{StaticResource RightAlignStyle}" />
|
ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="160" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.name]}"
|
||||||
<DataGridTextColumn Header="Library" Binding="{Binding Library}" Width="120" />
|
Binding="{Binding Name}" Width="160" />
|
||||||
<DataGridTextColumn Header="Size"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.library]}"
|
||||||
|
Binding="{Binding Library}" Width="120" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}"
|
||||||
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
|
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
|
||||||
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}"
|
||||||
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
|
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||||
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}"
|
||||||
|
Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}"
|
||||||
|
Binding="{Binding Path}" Width="400" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -49,12 +49,17 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</DataGrid.RowStyle>
|
</DataGrid.RowStyle>
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.valid]}"
|
||||||
<DataGridTextColumn Header="Level 1" Binding="{Binding Record.Level1}" Width="*" />
|
Binding="{Binding IsValid}" Width="50" />
|
||||||
<DataGridTextColumn Header="Level 2" Binding="{Binding Record.Level2}" Width="*" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level1]}"
|
||||||
<DataGridTextColumn Header="Level 3" Binding="{Binding Record.Level3}" Width="*" />
|
Binding="{Binding Record.Level1}" Width="*" />
|
||||||
<DataGridTextColumn Header="Level 4" Binding="{Binding Record.Level4}" Width="*" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level2]}"
|
||||||
<DataGridTextColumn Header="Errors"
|
Binding="{Binding Record.Level2}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level3]}"
|
||||||
|
Binding="{Binding Record.Level3}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.col.level4]}"
|
||||||
|
Binding="{Binding Record.Level4}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.errors]}"
|
||||||
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
||||||
Width="*" />
|
Width="*" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding IsSimplifiedMode}" Value="False">
|
<DataTrigger Binding="{Binding IsSimplifiedMode}" Value="False">
|
||||||
<Setter Property="Foreground" Value="Gray" />
|
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -111,6 +111,16 @@
|
|||||||
Command="{Binding CancelCommand}"
|
Command="{Binding CancelCommand}"
|
||||||
Margin="0,0,0,4" Padding="6,3" />
|
Margin="0,0,0,4" Padding="6,3" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" />
|
||||||
|
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
|
||||||
|
</ComboBox>
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Padding="0,2" />
|
||||||
|
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="24" Margin="0,0,0,6">
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
|
||||||
|
</ComboBox>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
@@ -271,15 +281,22 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="100" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.object_type]}"
|
||||||
<DataGridTextColumn Header="Title" Binding="{Binding Title}" Width="140" />
|
Binding="{Binding ObjectType}" Width="100" />
|
||||||
<DataGridTextColumn Header="URL" Binding="{Binding Url}" Width="200" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.title]}"
|
||||||
<DataGridTextColumn Header="Unique Perms" Binding="{Binding HasUniquePermissions}" Width="90" />
|
Binding="{Binding Title}" Width="140" />
|
||||||
<DataGridTextColumn Header="Users" Binding="{Binding Users}" Width="140" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.url]}"
|
||||||
<DataGridTextColumn Header="Permission Levels" Binding="{Binding PermissionLevels}" Width="140" />
|
Binding="{Binding Url}" Width="200" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.col.unique_perms]}"
|
||||||
|
Binding="{Binding HasUniquePermissions}" Width="90" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.users_groups]}"
|
||||||
|
Binding="{Binding Users}" Width="140" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.col.permission_levels]}"
|
||||||
|
Binding="{Binding PermissionLevels}" Width="140" />
|
||||||
|
|
||||||
<!-- Simplified Labels column (only visible in simplified mode) -->
|
<!-- Simplified Labels column (only visible in simplified mode) -->
|
||||||
<DataGridTextColumn Header="Simplified" Binding="{Binding SimplifiedLabels}" Width="200">
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.simplified]}"
|
||||||
|
Binding="{Binding SimplifiedLabels}" Width="200">
|
||||||
<DataGridTextColumn.Visibility>
|
<DataGridTextColumn.Visibility>
|
||||||
<Binding Path="DataContext.IsSimplifiedMode"
|
<Binding Path="DataContext.IsSimplifiedMode"
|
||||||
RelativeSource="{RelativeSource AncestorType=DataGrid}"
|
RelativeSource="{RelativeSource AncestorType=DataGrid}"
|
||||||
@@ -287,8 +304,10 @@
|
|||||||
</DataGridTextColumn.Visibility>
|
</DataGridTextColumn.Visibility>
|
||||||
</DataGridTextColumn>
|
</DataGridTextColumn>
|
||||||
|
|
||||||
<DataGridTextColumn Header="Granted Through" Binding="{Binding GrantedThrough}" Width="140" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.granted_through]}"
|
||||||
<DataGridTextColumn Header="Principal Type" Binding="{Binding PrincipalType}" Width="110" />
|
Binding="{Binding GrantedThrough}" Width="140" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.col.principal_type]}"
|
||||||
|
Binding="{Binding PrincipalType}" Width="110" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,10 +71,14 @@
|
|||||||
AutoGenerateColumns="False" IsReadOnly="True"
|
AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
SelectionMode="Single" CanUserSortColumns="True">
|
SelectionMode="Single" CanUserSortColumns="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.name]}"
|
||||||
<DataGridTextColumn Header="Type" Binding="{Binding SiteType}" Width="100" />
|
Binding="{Binding Name}" Width="*" />
|
||||||
<DataGridTextColumn Header="Source" Binding="{Binding SourceUrl}" Width="*" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.type]}"
|
||||||
<DataGridTextColumn Header="Captured" Binding="{Binding CapturedAt, StringFormat=yyyy-MM-dd HH:mm}" Width="140" />
|
Binding="{Binding SiteType}" Width="100" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.col.source]}"
|
||||||
|
Binding="{Binding SourceUrl}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.col.captured]}"
|
||||||
|
Binding="{Binding CapturedAt, StringFormat=yyyy-MM-dd HH:mm}" Width="140" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -18,18 +18,20 @@
|
|||||||
Click="BrowseSource_Click" Margin="0,0,0,5" />
|
Click="BrowseSource_Click" Margin="0,0,0,5" />
|
||||||
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
|
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
|
||||||
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
|
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
|
||||||
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Run Text="{Binding SelectedFileCount, Mode=OneWay}" />
|
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
|
||||||
<Run Text=" file(s) selected" />
|
Text="{Binding SelectedFileCount, Mode=OneWay}" />
|
||||||
</TextBlock>
|
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
|
||||||
<CheckBox Content="Include source folder at destination"
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.text.files_selected]}" />
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source]}"
|
||||||
IsChecked="{Binding IncludeSourceFolder}"
|
IsChecked="{Binding IncludeSourceFolder}"
|
||||||
Margin="0,6,0,0"
|
Margin="0,6,0,0"
|
||||||
ToolTip="When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder." />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.include_source.tooltip]}" />
|
||||||
<CheckBox Content="Copy folder contents"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents]}"
|
||||||
IsChecked="{Binding CopyFolderContents}"
|
IsChecked="{Binding CopyFolderContents}"
|
||||||
Margin="0,4,0,0"
|
Margin="0,4,0,0"
|
||||||
ToolTip="When on (default), transfer files inside the folder. When off, only the folder is created at the destination." />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</GroupBox.Style>
|
</GroupBox.Style>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
|
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
|
||||||
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
|
||||||
<TextBlock.Style>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
<Setter Property="Visibility" Value="Collapsed" />
|
<Setter Property="Visibility" Value="Collapsed" />
|
||||||
@@ -147,15 +147,15 @@
|
|||||||
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
|
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
|
||||||
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
|
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Name"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.name]}"
|
||||||
Binding="{Binding DisplayName}" Width="120" />
|
Binding="{Binding DisplayName}" Width="120" />
|
||||||
<DataGridTextColumn Header="Email"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
|
||||||
Binding="{Binding UserPrincipalName}" Width="140" />
|
Binding="{Binding UserPrincipalName}" Width="140" />
|
||||||
<DataGridTextColumn Header="Department"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.department]}"
|
||||||
Binding="{Binding Department}" Width="90" />
|
Binding="{Binding Department}" Width="90" />
|
||||||
<DataGridTextColumn Header="Job Title"
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.jobtitle]}"
|
||||||
Binding="{Binding JobTitle}" Width="90" />
|
Binding="{Binding JobTitle}" Width="90" />
|
||||||
<DataGridTemplateColumn Header="Type" Width="60">
|
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.type]}" Width="60">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding UserType}" VerticalAlignment="Center">
|
<TextBlock Text="{Binding UserType}" VerticalAlignment="Center">
|
||||||
@@ -236,6 +236,17 @@
|
|||||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||||
Command="{Binding CancelCommand}" Margin="0,0,0,0" Padding="6,3" />
|
Command="{Binding CancelCommand}" Margin="0,0,0,0" Padding="6,3" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Padding="0,2" />
|
||||||
|
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="24" Margin="0,0,0,4">
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.bySite]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.byUser]}" />
|
||||||
|
</ComboBox>
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.label]}" Padding="0,2" />
|
||||||
|
<ComboBox SelectedIndex="{Binding HtmlLayoutIndex}" Height="24" Margin="0,0,0,6">
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.separate]}" />
|
||||||
|
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
|
||||||
|
</ComboBox>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
@@ -316,12 +327,15 @@
|
|||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding AccessType}" Value="Direct">
|
<DataTrigger Binding="{Binding AccessType}" Value="Direct">
|
||||||
<Setter Property="Background" Value="#EBF5FB" />
|
<Setter Property="Background" Value="#EBF5FB" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding AccessType}" Value="Group">
|
<DataTrigger Binding="{Binding AccessType}" Value="Group">
|
||||||
<Setter Property="Background" Value="#EAFAF1" />
|
<Setter Property="Background" Value="#EAFAF1" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding AccessType}" Value="Inherited">
|
<DataTrigger Binding="{Binding AccessType}" Value="Inherited">
|
||||||
<Setter Property="Background" Value="#F4F6F6" />
|
<Setter Property="Background" Value="#F4F6F6" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding IsHighPrivilege}" Value="True">
|
<DataTrigger Binding="{Binding IsHighPrivilege}" Value="True">
|
||||||
<Setter Property="FontWeight" Value="Bold" />
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
@@ -342,7 +356,7 @@
|
|||||||
</GroupStyle>
|
</GroupStyle>
|
||||||
</DataGrid.GroupStyle>
|
</DataGrid.GroupStyle>
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTemplateColumn Header="User" Width="180">
|
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.user]}" Width="180">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
@@ -359,16 +373,20 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Border.Style>
|
</Border.Style>
|
||||||
<TextBlock Text="Guest" FontSize="10" Foreground="White" FontWeight="SemiBold" />
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.guest]}"
|
||||||
|
FontSize="10" Foreground="White" FontWeight="SemiBold" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
<DataGridTextColumn Header="Site" Binding="{Binding SiteTitle}" Width="120" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.site]}"
|
||||||
<DataGridTextColumn Header="Object" Binding="{Binding ObjectTitle}" Width="140" />
|
Binding="{Binding SiteTitle}" Width="120" />
|
||||||
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="90" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.object]}"
|
||||||
<DataGridTemplateColumn Header="Permission Level" Width="140">
|
Binding="{Binding ObjectTitle}" Width="140" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.object_type]}"
|
||||||
|
Binding="{Binding ObjectType}" Width="90" />
|
||||||
|
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.permission_level]}" Width="140">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
@@ -390,7 +408,7 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
<DataGridTemplateColumn Header="Access Type" Width="110">
|
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.access_type]}" Width="110">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock>
|
<TextBlock>
|
||||||
@@ -414,7 +432,8 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
<DataGridTextColumn Header="Granted Through" Binding="{Binding GrantedThrough}" Width="140" />
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.granted_through]}"
|
||||||
|
Binding="{Binding GrantedThrough}" Width="140" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
||||||
|
<StackPanel>
|
||||||
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.libs]}"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<StackPanel Margin="4">
|
||||||
|
<TextBlock Text="{Binding SelectedLibrariesLabel}" Margin="0,0,0,6"
|
||||||
|
Foreground="{DynamicResource TextMutedBrush}" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.pickLibs]}"
|
||||||
|
Command="{Binding SelectLibrariesCommand}" Height="26" Margin="0,0,0,4" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.clearLibs]}"
|
||||||
|
Command="{Binding ClearLibrariesCommand}" Height="26" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<StackPanel Margin="4">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,2">
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}"
|
||||||
|
VerticalAlignment="Center" Padding="0,0,4,0" />
|
||||||
|
<TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Width="50" Height="22" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}"
|
||||||
|
IsChecked="{Binding KeepFirstVersion}" Margin="0,4,0,2" />
|
||||||
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}"
|
||||||
|
IsChecked="{Binding ConfirmDelete}" Margin="0,4,0,2" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}"
|
||||||
|
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
|
||||||
|
Margin="0,6,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.run]}"
|
||||||
|
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||||
|
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
|
||||||
|
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
|
||||||
|
Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextMutedBrush}" Margin="0,6" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Grid Margin="4,8,8,8">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Background="{DynamicResource AccentSoftBrush}" CornerRadius="4"
|
||||||
|
Padding="12,8" Margin="0,0,0,6">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding HasResults}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.files]}"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding TotalFilesAffected, StringFormat=N0, Mode=OneWay}" Margin="4,0,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.deleted]}"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding TotalVersionsDeleted, StringFormat=N0, Mode=OneWay}" Margin="4,0,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.freed]}"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding TotalBytesFreed, Converter={StaticResource BytesConverter}, Mode=OneWay}"
|
||||||
|
Margin="4,0,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<DataGrid Grid.Row="1" ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.library]}"
|
||||||
|
Binding="{Binding Library}" Width="140" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.file]}"
|
||||||
|
Binding="{Binding FileName}" Width="200" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.before]}"
|
||||||
|
Binding="{Binding VersionsBefore, StringFormat=N0}" Width="80"
|
||||||
|
ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.deleted]}"
|
||||||
|
Binding="{Binding VersionsDeleted, StringFormat=N0}" Width="80"
|
||||||
|
ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.remaining]}"
|
||||||
|
Binding="{Binding VersionsRemaining, StringFormat=N0}" Width="90"
|
||||||
|
ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.freed]}"
|
||||||
|
Binding="{Binding BytesFreed, Converter={StaticResource BytesConverter}}" Width="100"
|
||||||
|
ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.path]}"
|
||||||
|
Binding="{Binding FileServerRelativeUrl}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.error]}"
|
||||||
|
Binding="{Binding Error}" Width="160" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.Views.Dialogs;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Tabs;
|
||||||
|
|
||||||
|
public partial class VersionCleanupView : UserControl
|
||||||
|
{
|
||||||
|
private readonly ViewModels.Tabs.VersionCleanupViewModel _viewModel;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IVersionCleanupService _versionService;
|
||||||
|
|
||||||
|
public VersionCleanupView(
|
||||||
|
ViewModels.Tabs.VersionCleanupViewModel viewModel,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IVersionCleanupService versionService)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_viewModel = viewModel;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_versionService = versionService;
|
||||||
|
DataContext = viewModel;
|
||||||
|
|
||||||
|
viewModel.PickLibrariesAsync = async (siteUrl, preselected) =>
|
||||||
|
{
|
||||||
|
if (viewModel.CurrentProfile == null) return null;
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
TenantUrl = siteUrl,
|
||||||
|
ClientId = viewModel.CurrentProfile.ClientId,
|
||||||
|
Name = viewModel.CurrentProfile.Name,
|
||||||
|
};
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
|
||||||
|
var dlg = new LibraryPickerDialog(ctx, _versionService, preselected)
|
||||||
|
{
|
||||||
|
Owner = Window.GetWindow(this)
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() != true) return null;
|
||||||
|
return dlg.SelectedLibraryTitles;
|
||||||
|
};
|
||||||
|
|
||||||
|
viewModel.ConfirmAction = msg =>
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
Window.GetWindow(this), msg,
|
||||||
|
Localization.TranslationSource.Instance["versions.tab"],
|
||||||
|
MessageBoxButton.OKCancel,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return result == MessageBoxResult.OK;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user