chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,41 @@ namespace SharepointToolbox.Services;
|
||||
/// Manages Azure AD app registration and removal using the Microsoft Graph API.
|
||||
/// All operations use <see cref="GraphClientFactory"/> for token acquisition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>GraphServiceClient lifecycle: a fresh client is created per public call
|
||||
/// (<see cref="IsGlobalAdminAsync"/>, <see cref="RegisterAsync"/>,
|
||||
/// <see cref="RollbackAsync"/>, <see cref="RemoveAsync"/>). This is intentional —
|
||||
/// each call may use different scopes (RegistrationScopes vs. default) and target
|
||||
/// a different tenant, so a cached per-service instance would bind the wrong
|
||||
/// authority. The factory itself caches the underlying MSAL PCA and token cache,
|
||||
/// so client construction is cheap (no network hit when tokens are valid).</para>
|
||||
/// <para>Do not cache a GraphServiceClient at call sites — always go through
|
||||
/// <see cref="GraphClientFactory"/> so tenant pinning and scope selection stay
|
||||
/// correct.</para>
|
||||
/// </remarks>
|
||||
public class AppRegistrationService : IAppRegistrationService
|
||||
{
|
||||
// Entra built-in directory role template IDs are global constants shared across all tenants.
|
||||
// GlobalAdminTemplateId: "Global Administrator" directoryRoleTemplate.
|
||||
// See https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator
|
||||
private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10";
|
||||
|
||||
// First-party Microsoft service appIds (constant across tenants).
|
||||
private const string GraphAppId = "00000003-0000-0000-c000-000000000000";
|
||||
private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000";
|
||||
|
||||
// Explicit scopes for the registration flow. The bootstrap client
|
||||
// (Microsoft Graph Command Line Tools) does not pre-consent these, so
|
||||
// requesting `.default` returns a token without them → POST /applications
|
||||
// fails with 403 even for a Global Admin. Requesting them explicitly
|
||||
// triggers the admin-consent prompt on first use.
|
||||
private static readonly string[] RegistrationScopes = new[]
|
||||
{
|
||||
"https://graph.microsoft.com/Application.ReadWrite.All",
|
||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All",
|
||||
"https://graph.microsoft.com/Directory.Read.All",
|
||||
};
|
||||
|
||||
private readonly AppGraphClientFactory _graphFactory;
|
||||
private readonly AppMsalClientFactory _msalFactory;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
@@ -35,38 +64,33 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct)
|
||||
public async Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
|
||||
var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req =>
|
||||
{
|
||||
req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')";
|
||||
}, ct);
|
||||
// No $filter: isof() on directoryObject requires advanced query params
|
||||
// (ConsistencyLevel: eventual + $count=true) and fails with 400 otherwise.
|
||||
// The user's membership list is small; filtering client-side is fine.
|
||||
var tenantId = ResolveTenantId(tenantUrl);
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, ct);
|
||||
var memberships = await graphClient.Me.TransitiveMemberOf.GetAsync(cancellationToken: ct);
|
||||
|
||||
return roles?.Value?
|
||||
.OfType<DirectoryRole>()
|
||||
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
|
||||
StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId);
|
||||
return false;
|
||||
}
|
||||
return memberships?.Value?
|
||||
.OfType<DirectoryRole>()
|
||||
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
|
||||
StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AppRegistrationResult> RegisterAsync(
|
||||
string clientId,
|
||||
string tenantUrl,
|
||||
string tenantDisplayName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantUrl);
|
||||
Application? createdApp = null;
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
|
||||
|
||||
// Step 1: Create Application object
|
||||
var appRequest = new Application
|
||||
@@ -78,7 +102,10 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
{
|
||||
RedirectUris = new List<string>
|
||||
{
|
||||
"https://login.microsoftonline.com/common/oauth2/nativeclient"
|
||||
// Loopback URI for MSAL desktop default (any port accepted by Entra).
|
||||
"http://localhost",
|
||||
// Legacy native-client URI for embedded WebView fallback.
|
||||
"https://login.microsoftonline.com/common/oauth2/nativeclient",
|
||||
}
|
||||
},
|
||||
RequiredResourceAccess = BuildRequiredResourceAccess()
|
||||
@@ -131,34 +158,45 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
_logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId);
|
||||
return AppRegistrationResult.Success(createdApp.AppId!);
|
||||
}
|
||||
catch (Microsoft.Graph.Models.ODataErrors.ODataError odataEx)
|
||||
when (odataEx.ResponseStatusCode == 401 || odataEx.ResponseStatusCode == 403)
|
||||
{
|
||||
_logger.LogWarning(odataEx,
|
||||
"RegisterAsync refused by Graph (status {Status}) — user lacks role/consent. Surfacing fallback.",
|
||||
odataEx.ResponseStatusCode);
|
||||
await RollbackAsync(createdApp, clientId, tenantId, ct);
|
||||
return AppRegistrationResult.FallbackRequired();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RegisterAsync failed. Attempting rollback.");
|
||||
|
||||
if (createdApp?.Id is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
|
||||
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
|
||||
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
|
||||
}
|
||||
catch (Exception rollbackEx)
|
||||
{
|
||||
_logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id);
|
||||
}
|
||||
}
|
||||
|
||||
await RollbackAsync(createdApp, clientId, tenantId, ct);
|
||||
return AppRegistrationResult.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RollbackAsync(Application? createdApp, string clientId, string tenantId, CancellationToken ct)
|
||||
{
|
||||
if (createdApp?.Id is null) return;
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
|
||||
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
|
||||
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
|
||||
}
|
||||
catch (Exception rollbackEx)
|
||||
{
|
||||
_logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RemoveAsync(string clientId, string appId, CancellationToken ct)
|
||||
public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
|
||||
var tenantId = ResolveTenantId(tenantUrl);
|
||||
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
|
||||
await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct);
|
||||
_logger.LogInformation("Removed Application appId={AppId}", appId);
|
||||
}
|
||||
@@ -168,6 +206,32 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a tenant identifier (domain) from a SharePoint tenant URL so MSAL
|
||||
/// can pin the authority to the correct tenant. Examples:
|
||||
/// https://contoso.sharepoint.com → contoso.onmicrosoft.com
|
||||
/// https://contoso-admin.sharepoint.com → contoso.onmicrosoft.com
|
||||
/// Throws <see cref="ArgumentException"/> when the URL is not a recognisable
|
||||
/// SharePoint URL — falling back to /common would silently route registration
|
||||
/// to the signed-in user's home tenant, which is the bug this guards against.
|
||||
/// </summary>
|
||||
internal static string ResolveTenantId(string tenantUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
|
||||
throw new ArgumentException($"Invalid tenant URL: '{tenantUrl}'", nameof(tenantUrl));
|
||||
|
||||
var host = uri.Host;
|
||||
var firstDot = host.IndexOf('.');
|
||||
if (firstDot <= 0)
|
||||
throw new ArgumentException($"Cannot derive tenant from host '{host}'", nameof(tenantUrl));
|
||||
|
||||
var prefix = host.Substring(0, firstDot);
|
||||
if (prefix.EndsWith("-admin", StringComparison.OrdinalIgnoreCase))
|
||||
prefix = prefix.Substring(0, prefix.Length - "-admin".Length);
|
||||
|
||||
return $"{prefix}.onmicrosoft.com";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ClearMsalSessionAsync(string clientId, string tenantUrl)
|
||||
{
|
||||
|
||||
@@ -7,29 +7,72 @@ public static class BulkOperationRunner
|
||||
/// <summary>
|
||||
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
|
||||
/// and cancellation support. OperationCanceledException propagates immediately.
|
||||
///
|
||||
/// Progress is reported AFTER each item completes (success or failure), so the bar
|
||||
/// reflects actual work done rather than work queued. A final "Complete" report
|
||||
/// guarantees 100% when the total was determinate.
|
||||
///
|
||||
/// Set <paramref name="maxConcurrency"/> > 1 to run items in parallel. Callers must
|
||||
/// ensure processItem is safe to invoke concurrently (e.g. each invocation uses its
|
||||
/// own CSOM ClientContext — a shared CSOM context is NOT thread-safe).
|
||||
/// </summary>
|
||||
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
|
||||
IReadOnlyList<TItem> items,
|
||||
Func<TItem, int, CancellationToken, Task> processItem,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
int maxConcurrency = 1)
|
||||
{
|
||||
var results = new List<BulkItemResult<TItem>>();
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
if (items.Count == 0)
|
||||
{
|
||||
progress.Report(new OperationProgress(0, 0, "Nothing to do."));
|
||||
return new BulkOperationSummary<TItem>(Array.Empty<BulkItemResult<TItem>>());
|
||||
}
|
||||
|
||||
progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}..."));
|
||||
|
||||
var results = new BulkItemResult<TItem>[items.Count];
|
||||
int completed = 0;
|
||||
|
||||
async Task RunOne(int i, CancellationToken token)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}..."));
|
||||
try
|
||||
{
|
||||
await processItem(items[i], i, ct);
|
||||
results.Add(BulkItemResult<TItem>.Success(items[i]));
|
||||
await processItem(items[i], i, token);
|
||||
results[i] = BulkItemResult<TItem>.Success(items[i]);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(BulkItemResult<TItem>.Failed(items[i], ex.Message));
|
||||
results[i] = BulkItemResult<TItem>.Failed(items[i], ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
int done = Interlocked.Increment(ref completed);
|
||||
progress.Report(new OperationProgress(done, items.Count,
|
||||
$"Processed {done}/{items.Count}"));
|
||||
}
|
||||
}
|
||||
|
||||
if (maxConcurrency <= 1)
|
||||
{
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await RunOne(i, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = maxConcurrency,
|
||||
CancellationToken = ct
|
||||
};
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, items.Count), options,
|
||||
async (i, token) => await RunOne(i, token));
|
||||
}
|
||||
|
||||
progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
|
||||
return new BulkOperationSummary<TItem>(results);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.SharePoint.Client;
|
||||
using Microsoft.SharePoint.Client.Search.Query;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
@@ -13,9 +15,27 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
public class DuplicatesService : IDuplicatesService
|
||||
{
|
||||
// SharePoint Search REST API caps RowLimit at 500 per request; larger values are silently clamped.
|
||||
private const int BatchSize = 500;
|
||||
|
||||
// SharePoint Search hard ceiling — StartRow > 50,000 returns an error regardless of pagination state.
|
||||
// See https://learn.microsoft.com/sharepoint/dev/general-development/customizing-search-results-in-sharepoint
|
||||
private const int MaxStartRow = 50_000;
|
||||
|
||||
/// <summary>
|
||||
/// Scans a site for duplicate files or folders and groups matches by the
|
||||
/// composite key configured in <paramref name="options"/> (name plus any
|
||||
/// of size / created / modified / subfolder-count / file-count).
|
||||
/// File mode uses the SharePoint Search API — it is fast but capped at
|
||||
/// 50,000 rows (see <see cref="MaxStartRow"/>). Folder mode uses paginated
|
||||
/// CSOM CAML over every document library on the site. Groups with fewer
|
||||
/// than two items are dropped before return.
|
||||
/// </summary>
|
||||
/// <param name="ctx">Authenticated <see cref="ClientContext"/> for the target site.</param>
|
||||
/// <param name="options">Scope (Files/Folders), optional library filter, and match-key toggles.</param>
|
||||
/// <param name="progress">Receives row-count progress during collection.</param>
|
||||
/// <param name="ct">Cancellation token — honoured between paged requests.</param>
|
||||
/// <returns>Duplicate groups ordered by descending size, then name.</returns>
|
||||
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
|
||||
ClientContext ctx,
|
||||
DuplicateScanOptions options,
|
||||
@@ -70,6 +90,8 @@ public class DuplicatesService : IDuplicatesService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct);
|
||||
|
||||
// KQL: all documents, optionally scoped to a library
|
||||
var kqlParts = new List<string> { "ContentType:Document" };
|
||||
if (!string.IsNullOrEmpty(options.Library))
|
||||
@@ -102,10 +124,25 @@ public class DuplicatesService : IDuplicatesService
|
||||
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
||||
if (table == null || table.RowCount == 0) break;
|
||||
|
||||
foreach (System.Collections.Hashtable row in table.ResultRows)
|
||||
foreach (var rawRow in table.ResultRows)
|
||||
{
|
||||
var dict = row.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
|
||||
// CSOM has returned ResultRows as either Hashtable or
|
||||
// Dictionary<string,object> across versions — accept both.
|
||||
IDictionary<string, object> dict;
|
||||
if (rawRow is IDictionary<string, object> generic)
|
||||
{
|
||||
dict = generic;
|
||||
}
|
||||
else if (rawRow is System.Collections.IDictionary legacy)
|
||||
{
|
||||
dict = new Dictionary<string, object>();
|
||||
foreach (System.Collections.DictionaryEntry e in legacy)
|
||||
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string path = GetStr(dict, "Path");
|
||||
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -132,7 +169,9 @@ public class DuplicatesService : IDuplicatesService
|
||||
Library = library,
|
||||
SizeBytes = size,
|
||||
Created = created,
|
||||
Modified = modified
|
||||
Modified = modified,
|
||||
SiteUrl = siteUrl,
|
||||
SiteTitle = siteTitle
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,10 +195,16 @@ public class DuplicatesService : IDuplicatesService
|
||||
{
|
||||
// Load all document libraries on the site
|
||||
ctx.Load(ctx.Web,
|
||||
w => w.Title,
|
||||
w => w.Lists.Include(
|
||||
l => l.Title, l => l.Hidden, l => l.BaseType));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var siteUrl = ctx.Url;
|
||||
var siteTitle = string.IsNullOrWhiteSpace(ctx.Web.Title)
|
||||
? ReportSplitHelper.DeriveSiteLabel(siteUrl)
|
||||
: ctx.Web.Title;
|
||||
|
||||
var libs = ctx.Web.Lists
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToList();
|
||||
@@ -172,19 +217,15 @@ public class DuplicatesService : IDuplicatesService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// No WHERE clause — a WHERE on non-indexed fields (FSObjType) throws the
|
||||
// list-view threshold on libraries > 5,000 items even with pagination.
|
||||
// Filter for folders client-side via FileSystemObjectType below.
|
||||
var camlQuery = new CamlQuery
|
||||
{
|
||||
ViewXml = """
|
||||
<View Scope='RecursiveAll'>
|
||||
<Query>
|
||||
<Where>
|
||||
<Eq>
|
||||
<FieldRef Name='FSObjType' />
|
||||
<Value Type='Integer'>1</Value>
|
||||
</Eq>
|
||||
</Where>
|
||||
</Query>
|
||||
<RowLimit>2000</RowLimit>
|
||||
<Query></Query>
|
||||
<RowLimit Paged='TRUE'>5000</RowLimit>
|
||||
</View>
|
||||
"""
|
||||
};
|
||||
@@ -200,6 +241,8 @@ public class DuplicatesService : IDuplicatesService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
|
||||
|
||||
var fv = item.FieldValues;
|
||||
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
|
||||
@@ -217,7 +260,9 @@ public class DuplicatesService : IDuplicatesService
|
||||
FolderCount = subCount,
|
||||
FileCount = fileCount,
|
||||
Created = created,
|
||||
Modified = modified
|
||||
Modified = modified,
|
||||
SiteUrl = siteUrl,
|
||||
SiteTitle = siteTitle
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -246,6 +291,37 @@ public class DuplicatesService : IDuplicatesService
|
||||
private static DateTime? ParseDate(string s) =>
|
||||
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
|
||||
|
||||
private static async Task<(string Url, string Title)> LoadSiteIdentityAsync(
|
||||
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
ctx.Load(ctx.Web, w => w.Title);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort — fall back to URL-derived label
|
||||
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: failed to load Web.Title: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
var url = ctx.Url ?? string.Empty;
|
||||
string title;
|
||||
try { title = ctx.Web.Title; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: Web.Title getter threw: {ex.GetType().Name}: {ex.Message}");
|
||||
title = string.Empty;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
title = ReportSplitHelper.DeriveSiteLabel(url);
|
||||
return (url, title);
|
||||
}
|
||||
|
||||
private static string ExtractLibraryFromPath(string path, string siteUrl)
|
||||
{
|
||||
// Extract first path segment after the site URL as library name
|
||||
|
||||
@@ -2,20 +2,40 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports the failed subset of a <see cref="BulkOperationSummary{T}"/> run
|
||||
/// to CSV. CsvHelper is used so the <typeparamref name="T"/> payload's
|
||||
/// properties become columns automatically, plus one error-message and one
|
||||
/// timestamp column appended at the end.
|
||||
/// </summary>
|
||||
public class BulkResultCsvExportService
|
||||
{
|
||||
private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture)
|
||||
{
|
||||
// Prevent CSV formula injection: prefix =, +, -, @, tab, CR with single quote
|
||||
InjectionOptions = InjectionOptions.Escape,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV containing only items whose <see cref="BulkItemResult{T}.IsSuccess"/>
|
||||
/// is <c>false</c>. Columns: every public property of <typeparamref name="T"/>
|
||||
/// followed by Error and Timestamp (ISO-8601).
|
||||
/// </summary>
|
||||
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
|
||||
{
|
||||
var TL = TranslationSource.Instance;
|
||||
using var writer = new StringWriter();
|
||||
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
|
||||
using var csv = new CsvWriter(writer, CsvConfig);
|
||||
|
||||
csv.WriteHeader<T>();
|
||||
csv.WriteField("Error");
|
||||
csv.WriteField("Timestamp");
|
||||
csv.WriteField(TL["report.col.error"]);
|
||||
csv.WriteField(TL["report.col.timestamp"]);
|
||||
csv.NextRecord();
|
||||
|
||||
foreach (var item in failedItems.Where(r => !r.IsSuccess))
|
||||
@@ -29,12 +49,13 @@ public class BulkResultCsvExportService
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the failed-items CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||
public async Task WriteFailedItemsCsvAsync<T>(
|
||||
IReadOnlyList<BulkItemResult<T>> failedItems,
|
||||
string filePath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var content = BuildFailedItemsCsv(failedItems);
|
||||
await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class CsvExportService
|
||||
{
|
||||
private const string Header =
|
||||
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
||||
private static string BuildHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV string from the supplied permission entries.
|
||||
@@ -20,7 +24,7 @@ public class CsvExportService
|
||||
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(Header);
|
||||
sb.AppendLine(BuildHeader());
|
||||
|
||||
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
|
||||
var merged = entries
|
||||
@@ -55,14 +59,17 @@ public class CsvExportService
|
||||
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(entries);
|
||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
|
||||
/// </summary>
|
||||
private const string SimplifiedHeader =
|
||||
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
|
||||
private static string BuildSimplifiedHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV string from simplified permission entries.
|
||||
@@ -72,7 +79,7 @@ public class CsvExportService
|
||||
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(SimplifiedHeader);
|
||||
sb.AppendLine(BuildSimplifiedHeader());
|
||||
|
||||
var merged = entries
|
||||
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
|
||||
@@ -109,13 +116,57 @@ public class CsvExportService
|
||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(entries);
|
||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
|
||||
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
|
||||
private static string Csv(string value)
|
||||
/// <summary>
|
||||
/// Writes permission entries with optional per-site partitioning.
|
||||
/// Single → writes one file at <paramref name="basePath"/>.
|
||||
/// BySite → one file per site-collection URL, suffixed on the base path.
|
||||
/// </summary>
|
||||
public Task WriteAsync(
|
||||
IReadOnlyList<PermissionEntry> entries,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
CancellationToken ct)
|
||||
=> ReportSplitHelper.WritePartitionedAsync(
|
||||
entries, basePath, splitMode,
|
||||
PartitionBySite,
|
||||
(part, path, c) => WriteAsync(part, path, c),
|
||||
ct);
|
||||
|
||||
/// <summary>Simplified-entry split variant.</summary>
|
||||
public Task WriteAsync(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
CancellationToken ct)
|
||||
=> ReportSplitHelper.WritePartitionedAsync(
|
||||
entries, basePath, splitMode,
|
||||
PartitionBySite,
|
||||
(part, path, c) => WriteAsync(part, path, c),
|
||||
ct);
|
||||
|
||||
internal static IEnumerable<(string Label, IReadOnlyList<PermissionEntry> Partition)> PartitionBySite(
|
||||
IReadOnlyList<PermissionEntry> entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
return entries
|
||||
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => (
|
||||
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
|
||||
Partition: (IReadOnlyList<PermissionEntry>)g.ToList()));
|
||||
}
|
||||
|
||||
internal static IEnumerable<(string Label, IReadOnlyList<SimplifiedPermissionEntry> Partition)> PartitionBySite(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||
{
|
||||
return entries
|
||||
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => (
|
||||
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
|
||||
Partition: (IReadOnlyList<SimplifiedPermissionEntry>)g.ToList()));
|
||||
}
|
||||
|
||||
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
|
||||
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// CSV field sanitization. Adds RFC 4180 quoting plus formula-injection
|
||||
/// protection: Excel and other spreadsheet apps treat cells starting with
|
||||
/// '=', '+', '-', '@', tab, or CR as formulas. Prefixing with a single
|
||||
/// quote neutralizes the formula while remaining readable.
|
||||
/// </summary>
|
||||
internal static class CsvSanitizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Escapes a value for inclusion in a CSV row. Always wraps in double
|
||||
/// quotes. Doubles internal quotes per RFC 4180. Prepends an apostrophe
|
||||
/// when the value begins with a character a spreadsheet would evaluate.
|
||||
/// </summary>
|
||||
public static string Escape(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
||||
var safe = NeutralizeFormulaPrefix(value).Replace("\"", "\"\"");
|
||||
return $"\"{safe}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal quoting variant: only wraps in quotes when the value contains
|
||||
/// a delimiter, quote, or newline. Still guards against formula injection.
|
||||
/// </summary>
|
||||
public static string EscapeMinimal(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
var safe = NeutralizeFormulaPrefix(value);
|
||||
if (safe.Contains(',') || safe.Contains('"') || safe.Contains('\n') || safe.Contains('\r'))
|
||||
return $"\"{safe.Replace("\"", "\"\"")}\"";
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static string NeutralizeFormulaPrefix(string value)
|
||||
{
|
||||
if (value.Length == 0) return value;
|
||||
char first = value[0];
|
||||
if (first == '=' || first == '+' || first == '-' || first == '@'
|
||||
|| first == '\t' || first == '\r')
|
||||
{
|
||||
return "'" + value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row;
|
||||
/// the Group column ties copies together and a Copies column gives the group size.
|
||||
/// Header row is built at write-time so culture switches are honoured.
|
||||
/// </summary>
|
||||
public class DuplicatesCsvExportService
|
||||
{
|
||||
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<DuplicateGroup> groups,
|
||||
string filePath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(groups);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one or more CSVs depending on <paramref name="splitMode"/>.
|
||||
/// Single → <paramref name="basePath"/> as-is. BySite → one file per site,
|
||||
/// filenames derived from <paramref name="basePath"/> with a site suffix.
|
||||
/// </summary>
|
||||
public Task WriteAsync(
|
||||
IReadOnlyList<DuplicateGroup> groups,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
CancellationToken ct)
|
||||
=> ReportSplitHelper.WritePartitionedAsync(
|
||||
groups, basePath, splitMode,
|
||||
PartitionBySite,
|
||||
(part, path, c) => WriteAsync(part, path, c),
|
||||
ct);
|
||||
|
||||
internal static IEnumerable<(string Label, IReadOnlyList<DuplicateGroup> Partition)> PartitionBySite(
|
||||
IReadOnlyList<DuplicateGroup> groups)
|
||||
{
|
||||
return groups
|
||||
.GroupBy(g =>
|
||||
{
|
||||
var first = g.Items.FirstOrDefault();
|
||||
return (Url: first?.SiteUrl ?? string.Empty, Title: first?.SiteTitle ?? string.Empty);
|
||||
})
|
||||
.Select(g => (
|
||||
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.Url, g.Key.Title),
|
||||
Partition: (IReadOnlyList<DuplicateGroup>)g.ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the CSV payload. Emits a header summary (group count, generated
|
||||
/// timestamp), then one row per duplicate item with its group index and
|
||||
/// group size. CSV fields are escaped via <see cref="CsvSanitizer.Escape"/>.
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Summary
|
||||
sb.AppendLine($"\"{T["report.title.duplicates_short"]}\"");
|
||||
sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\"");
|
||||
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine();
|
||||
|
||||
// Header
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
{
|
||||
Csv(T["report.col.number"]),
|
||||
Csv(T["report.col.group"]),
|
||||
Csv(T["report.text.copies"]),
|
||||
Csv(T["report.col.site"]),
|
||||
Csv(T["report.col.name"]),
|
||||
Csv(T["report.col.library"]),
|
||||
Csv(T["report.col.path"]),
|
||||
Csv(T["report.col.size_bytes"]),
|
||||
Csv(T["report.col.created"]),
|
||||
Csv(T["report.col.modified"]),
|
||||
}));
|
||||
|
||||
foreach (var g in groups)
|
||||
{
|
||||
int i = 0;
|
||||
foreach (var item in g.Items)
|
||||
{
|
||||
i++;
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
{
|
||||
Csv(i.ToString()),
|
||||
Csv(g.Name),
|
||||
Csv(g.Items.Count.ToString()),
|
||||
Csv(item.SiteTitle),
|
||||
Csv(item.Name),
|
||||
Csv(item.Library),
|
||||
Csv(item.Path),
|
||||
Csv(item.SizeBytes?.ToString() ?? string.Empty),
|
||||
Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty),
|
||||
Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using System.Text;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
@@ -10,17 +11,23 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class DuplicatesHtmlExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a self-contained HTML string rendering one collapsible card per
|
||||
/// <see cref="DuplicateGroup"/>. The document ships with inline CSS and a
|
||||
/// tiny JS toggle so no external assets are needed.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.duplicates"]}</title>");
|
||||
sb.AppendLine("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SharePoint Duplicate Detection Report</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #0078d4; }
|
||||
@@ -54,11 +61,9 @@ public class DuplicatesHtmlExportService
|
||||
<body>
|
||||
""");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("""
|
||||
<h1>Duplicate Detection Report</h1>
|
||||
""");
|
||||
sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
|
||||
|
||||
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");
|
||||
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} {T["report.text.duplicate_groups_found"]}</p>");
|
||||
|
||||
for (int i = 0; i < groups.Count; i++)
|
||||
{
|
||||
@@ -70,19 +75,19 @@ public class DuplicatesHtmlExportService
|
||||
<div class="group-card">
|
||||
<div class="group-header" onclick="toggleGroup({i})">
|
||||
<span class="group-name">{H(g.Name)}</span>
|
||||
<span class="badge {badgeClass}">{count} copies</span>
|
||||
<span class="badge {badgeClass}">{count} {T["report.text.copies"]}</span>
|
||||
</div>
|
||||
<div class="group-body" id="gb-{i}">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Library</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>Modified</th>
|
||||
<th>{T["report.col.number"]}</th>
|
||||
<th>{T["report.col.name"]}</th>
|
||||
<th>{T["report.col.library"]}</th>
|
||||
<th>{T["report.col.path"]}</th>
|
||||
<th>{T["report.col.size"]}</th>
|
||||
<th>{T["report.col.created"]}</th>
|
||||
<th>{T["report.col.modified"]}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -116,18 +121,59 @@ public class DuplicatesHtmlExportService
|
||||
""");
|
||||
}
|
||||
|
||||
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the HTML report to the specified file path using UTF-8.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(groups, branding);
|
||||
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one or more HTML reports depending on <paramref name="splitMode"/> and
|
||||
/// <paramref name="layout"/>. Single → one file. BySite + SeparateFiles → one
|
||||
/// file per site. BySite + SingleTabbed → one file with per-site iframe tabs.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<DuplicateGroup> groups,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(groups, basePath, ct, branding);
|
||||
return;
|
||||
}
|
||||
|
||||
var partitions = DuplicatesCsvExportService.PartitionBySite(groups).ToList();
|
||||
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding)))
|
||||
.ToList();
|
||||
var T = TranslationSource.Instance;
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, T["report.title.duplicates_short"]);
|
||||
await System.IO.File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var partition in partitions)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, partition.Label);
|
||||
await WriteAsync(partition.Partition, path, ct, branding);
|
||||
}
|
||||
}
|
||||
|
||||
private static string H(string value) =>
|
||||
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Central file-write plumbing for export services so every CSV and HTML
|
||||
/// artefact gets a consistent encoding: CSV files are written with a UTF-8
|
||||
/// BOM (required for Excel to detect the encoding when opening a
|
||||
/// double-clicked .csv), HTML files are written without a BOM (some browsers
|
||||
/// and iframe <c>srcdoc</c> paths render the BOM as a visible character).
|
||||
/// Export services should call these helpers rather than constructing
|
||||
/// <see cref="UTF8Encoding"/> inline.
|
||||
/// </summary>
|
||||
internal static class ExportFileWriter
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true);
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
/// <summary>Writes <paramref name="csv"/> to <paramref name="filePath"/> as UTF-8 with BOM.</summary>
|
||||
public static Task WriteCsvAsync(string filePath, string csv, CancellationToken ct)
|
||||
=> File.WriteAllTextAsync(filePath, csv, Utf8WithBom, ct);
|
||||
|
||||
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
||||
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
||||
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
||||
}
|
||||
@@ -1,102 +1,50 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports permission entries to a self-contained interactive HTML report.
|
||||
/// Ports PowerShell Export-PermissionsToHTML functionality.
|
||||
/// No external CSS/JS dependencies — everything is inline.
|
||||
/// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
|
||||
/// No external CSS/JS dependencies — everything is inline so the file can be
|
||||
/// emailed or served from any static host. The standard and simplified
|
||||
/// variants share their document shell, stats cards, CSS, pill rendering, and
|
||||
/// inline script via <see cref="PermissionHtmlFragments"/>; this class only
|
||||
/// owns the table column sets and the simplified risk summary.
|
||||
/// </summary>
|
||||
public class HtmlExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a self-contained HTML string from the supplied permission entries.
|
||||
/// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills.
|
||||
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
|
||||
/// Builds a self-contained HTML string from the supplied permission
|
||||
/// entries. Standard report: columns are Object / Title / URL / Unique /
|
||||
/// Users / Permission / Granted Through. When
|
||||
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
|
||||
/// become expandable rows listing resolved members.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
|
||||
public string BuildHtml(
|
||||
IReadOnlyList<PermissionEntry> entries,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
// Compute stats
|
||||
var totalEntries = entries.Count;
|
||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
||||
var distinctUsers = entries
|
||||
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(u => u.Trim())
|
||||
.Where(u => u.Length > 0)
|
||||
.Distinct()
|
||||
.Count();
|
||||
var T = TranslationSource.Instance;
|
||||
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||
entries.Count,
|
||||
entries.Select(e => e.PermissionLevels),
|
||||
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// ── HTML HEAD ──────────────────────────────────────────────────────────
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine("<title>SharePoint Permissions Report</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(@"
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
||||
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
||||
.filter-wrap { padding: 0 24px 12px; }
|
||||
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
||||
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
/* Type badges */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
||||
.badge.site-coll { background: #dbeafe; color: #1e40af; }
|
||||
.badge.site { background: #dcfce7; color: #166534; }
|
||||
.badge.list { background: #fef9c3; color: #854d0e; }
|
||||
.badge.folder { background: #f3f4f6; color: #374151; }
|
||||
/* Unique/Inherited badges */
|
||||
.badge.unique { background: #dcfce7; color: #166534; }
|
||||
.badge.inherited { background: #f3f4f6; color: #374151; }
|
||||
/* User pills */
|
||||
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
||||
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
|
||||
.group-expandable { cursor: pointer; }
|
||||
.group-expandable:hover { opacity: 0.8; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
|
||||
// ── BODY ───────────────────────────────────────────────────────────────
|
||||
AppendHead(sb, T["report.title.permissions"], includeRiskCss: false);
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("<h1>SharePoint Permissions Report</h1>");
|
||||
|
||||
// Stats cards
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Filter input
|
||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
||||
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Table
|
||||
sb.AppendLine("<div class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"permTable\">");
|
||||
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
|
||||
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
||||
AppendFilterInput(sb);
|
||||
AppendTableOpen(sb);
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Granted Through</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody>");
|
||||
|
||||
@@ -105,99 +53,236 @@ a:hover { text-decoration: underline; }
|
||||
{
|
||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||
|
||||
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
|
||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var pillsBuilder = new StringBuilder();
|
||||
var memberSubRows = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < logins.Length; i++)
|
||||
{
|
||||
var login = logins[i].Trim();
|
||||
var name = i < names.Length ? names[i].Trim() : login;
|
||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isExpandableGroup = entry.PrincipalType == "SharePointGroup"
|
||||
&& groupMembers != null
|
||||
&& groupMembers.TryGetValue(name, out var members);
|
||||
|
||||
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</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\">members unavailable</em>";
|
||||
}
|
||||
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var pillCss = isExt ? "user-pill external-user" : "user-pill";
|
||||
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
||||
}
|
||||
}
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
|
||||
colSpan: 7, grpMemIdx: ref grpMemIdx);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
|
||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
||||
sb.AppendLine($" <td>{pills}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (memberSubRows.Length > 0)
|
||||
sb.Append(memberSubRows);
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
|
||||
sb.AppendLine("</tbody>");
|
||||
sb.AppendLine("</table>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Inline JS
|
||||
sb.AppendLine("<script>");
|
||||
sb.AppendLine(@"function filterTable() {
|
||||
var input = document.getElementById('filter').value.toLowerCase();
|
||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
||||
rows.forEach(function(row) {
|
||||
if (row.hasAttribute('data-group')) return;
|
||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function toggleGroup(id) {
|
||||
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
||||
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
||||
}");
|
||||
sb.AppendLine("</script>");
|
||||
AppendTableClose(sb);
|
||||
AppendInlineJs(sb);
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the HTML report to the specified file path using UTF-8 without BOM.
|
||||
/// Builds a self-contained HTML string from simplified permission entries.
|
||||
/// Adds a risk-level summary card strip plus two columns (Simplified,
|
||||
/// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
|
||||
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct,
|
||||
public string BuildHtml(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||
entries.Count,
|
||||
entries.Select(e => e.PermissionLevels),
|
||||
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
|
||||
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
||||
|
||||
sb.AppendLine("<div class=\"risk-cards\">");
|
||||
foreach (var s in summaries)
|
||||
{
|
||||
var (bg, text, border) = RiskLevelColors(s.RiskLevel);
|
||||
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
|
||||
sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
|
||||
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
|
||||
sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
AppendFilterInput(sb);
|
||||
AppendTableOpen(sb);
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody>");
|
||||
|
||||
int grpMemIdx = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||
colSpan: 9, grpMemIdx: ref grpMemIdx);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pills}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
|
||||
AppendTableClose(sb);
|
||||
AppendInlineJs(sb);
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<PermissionEntry> entries,
|
||||
string filePath,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
/// <summary>Returns inline CSS background and text color for a risk level.</summary>
|
||||
/// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
string filePath,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split-aware write for permission entries.
|
||||
/// Single → one file. BySite + SeparateFiles → one file per site.
|
||||
/// BySite + SingleTabbed → one file with per-site iframe tabs.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<PermissionEntry> entries,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||
return;
|
||||
}
|
||||
|
||||
var partitions = CsvExportService.PartitionBySite(entries).ToList();
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.permissions"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (label, partEntries) in partitions)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||
return;
|
||||
}
|
||||
|
||||
var partitions = CsvExportService.PartitionBySite(entries).ToList();
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.permissions_simplified"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (label, partEntries) in partitions)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||
}
|
||||
}
|
||||
|
||||
private static (int total, int uniquePerms, int distinctUsers) ComputeStats(
|
||||
int totalEntries,
|
||||
IEnumerable<string> permissionLevels,
|
||||
IEnumerable<string> userLogins)
|
||||
{
|
||||
var uniquePermSets = permissionLevels.Distinct().Count();
|
||||
var distinctUsers = userLogins
|
||||
.Select(u => u.Trim())
|
||||
.Where(u => u.Length > 0)
|
||||
.Distinct()
|
||||
.Count();
|
||||
return (totalEntries, uniquePermSets, distinctUsers);
|
||||
}
|
||||
|
||||
private static void AppendTableOpen(StringBuilder sb)
|
||||
{
|
||||
sb.AppendLine("<div class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"permTable\">");
|
||||
}
|
||||
|
||||
private static void AppendTableClose(StringBuilder sb)
|
||||
{
|
||||
sb.AppendLine("</tbody>");
|
||||
sb.AppendLine("</table>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
/// <summary>Returns inline CSS background, text, and border colors for a risk level.</summary>
|
||||
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
|
||||
{
|
||||
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
|
||||
@@ -206,228 +291,4 @@ function toggleGroup(id) {
|
||||
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
|
||||
_ => ("#F3F4F6", "#374151", "#E5E7EB")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a self-contained HTML string from simplified permission entries.
|
||||
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
|
||||
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||
|
||||
var totalEntries = entries.Count;
|
||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
||||
var distinctUsers = entries
|
||||
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(u => u.Trim())
|
||||
.Where(u => u.Length > 0)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine("<title>SharePoint Permissions Report (Simplified)</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(@"
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
||||
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
||||
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
|
||||
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
|
||||
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
|
||||
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
|
||||
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
|
||||
.filter-wrap { padding: 0 24px 12px; }
|
||||
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
||||
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(0,0,0,.03); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
||||
.badge.site-coll { background: #dbeafe; color: #1e40af; }
|
||||
.badge.site { background: #dcfce7; color: #166534; }
|
||||
.badge.list { background: #fef9c3; color: #854d0e; }
|
||||
.badge.folder { background: #f3f4f6; color: #374151; }
|
||||
.badge.unique { background: #dcfce7; color: #166534; }
|
||||
.badge.inherited { background: #f3f4f6; color: #374151; }
|
||||
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
||||
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
||||
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
|
||||
.group-expandable { cursor: pointer; }
|
||||
.group-expandable:hover { opacity: 0.8; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
|
||||
|
||||
// Stats cards
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Risk-level summary cards
|
||||
sb.AppendLine("<div class=\"risk-cards\">");
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
|
||||
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
|
||||
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
|
||||
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
|
||||
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Filter input
|
||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
||||
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Table with simplified columns
|
||||
sb.AppendLine("<div class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"permTable\">");
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody>");
|
||||
|
||||
int grpMemIdx = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
|
||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||
|
||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var pillsBuilder = new StringBuilder();
|
||||
var memberSubRows = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < logins.Length; i++)
|
||||
{
|
||||
var login = logins[i].Trim();
|
||||
var name = i < names.Length ? names[i].Trim() : login;
|
||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
|
||||
&& groupMembers != null
|
||||
&& groupMembers.TryGetValue(name, out _);
|
||||
|
||||
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</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\">members unavailable</em>";
|
||||
}
|
||||
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var pillCss = isExt ? "user-pill external-user" : "user-pill";
|
||||
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (memberSubRows.Length > 0)
|
||||
sb.Append(memberSubRows);
|
||||
}
|
||||
|
||||
sb.AppendLine("</tbody>");
|
||||
sb.AppendLine("</table>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
sb.AppendLine("<script>");
|
||||
sb.AppendLine(@"function filterTable() {
|
||||
var input = document.getElementById('filter').value.toLowerCase();
|
||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
||||
rows.forEach(function(row) {
|
||||
if (row.hasAttribute('data-group')) return;
|
||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function toggleGroup(id) {
|
||||
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
||||
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
||||
}");
|
||||
sb.AppendLine("</script>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the simplified HTML report to the specified file path.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
||||
}
|
||||
|
||||
/// <summary>Returns the CSS class for the object-type badge.</summary>
|
||||
private static string ObjectTypeCss(string t) => t switch
|
||||
{
|
||||
"Site Collection" => "badge site-coll",
|
||||
"Site" => "badge site",
|
||||
"List" => "badge list",
|
||||
"Folder" => "badge folder",
|
||||
_ => "badge"
|
||||
};
|
||||
|
||||
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
|
||||
private static string HtmlEncode(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
return value
|
||||
.Replace("&", "&")
|
||||
.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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -10,12 +11,17 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class SearchCsvExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the CSV payload. Column order mirrors
|
||||
/// <see cref="SearchHtmlExportService.BuildHtml(IReadOnlyList{SearchResult}, ReportBranding?)"/>.
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<SearchResult> results)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)");
|
||||
sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}");
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
@@ -33,19 +39,14 @@ public class SearchCsvExportService
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(results);
|
||||
await System.IO.File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
return value;
|
||||
}
|
||||
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
||||
|
||||
private static string IfEmpty(string? value, string fallback = "")
|
||||
=> string.IsNullOrEmpty(value) ? fallback : value!;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -11,17 +12,23 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class SearchHtmlExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a self-contained HTML table with inline sort/filter scripts.
|
||||
/// Each <see cref="SearchResult"/> becomes one row; the document has no
|
||||
/// external dependencies.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.search"]}</title>");
|
||||
sb.AppendLine("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SharePoint File Search Results</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #0078d4; }
|
||||
@@ -45,27 +52,27 @@ public class SearchHtmlExportService
|
||||
<body>
|
||||
""");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("""
|
||||
<h1>File Search Results</h1>
|
||||
sb.AppendLine($"""
|
||||
<h1>{T["report.title.search_short"]}</h1>
|
||||
<div class="toolbar">
|
||||
<label for="filterInput">Filter:</label>
|
||||
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
|
||||
<label for="filterInput">{T["report.filter.label"]}</label>
|
||||
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
|
||||
<span id="resultCount"></span>
|
||||
</div>
|
||||
""");
|
||||
|
||||
sb.AppendLine("""
|
||||
sb.AppendLine($"""
|
||||
<table id="resultsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortTable(0)">File Name</th>
|
||||
<th onclick="sortTable(1)">Extension</th>
|
||||
<th onclick="sortTable(2)">Path</th>
|
||||
<th onclick="sortTable(3)">Created</th>
|
||||
<th onclick="sortTable(4)">Created By</th>
|
||||
<th onclick="sortTable(5)">Modified</th>
|
||||
<th onclick="sortTable(6)">Modified By</th>
|
||||
<th class="num" onclick="sortTable(7)">Size</th>
|
||||
<th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
|
||||
<th onclick="sortTable(1)">{T["report.col.extension"]}</th>
|
||||
<th onclick="sortTable(2)">{T["report.col.path"]}</th>
|
||||
<th onclick="sortTable(3)">{T["report.col.created"]}</th>
|
||||
<th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
|
||||
<th onclick="sortTable(5)">{T["report.col.modified"]}</th>
|
||||
<th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
|
||||
<th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -93,7 +100,7 @@ public class SearchHtmlExportService
|
||||
sb.AppendLine(" </tbody>\n</table>");
|
||||
|
||||
int count = results.Count;
|
||||
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>");
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
|
||||
|
||||
sb.AppendLine($$"""
|
||||
<script>
|
||||
@@ -126,10 +133,10 @@ public class SearchHtmlExportService
|
||||
rows[i].className = match ? '' : 'hidden';
|
||||
if (match) visible++;
|
||||
}
|
||||
document.getElementById('resultCount').innerText = q ? (visible + ' of {{count:N0}} shown') : '';
|
||||
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
|
||||
}
|
||||
window.onload = function() {
|
||||
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)';
|
||||
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
|
||||
};
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -138,6 +145,7 @@ public class SearchHtmlExportService
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the HTML report to <paramref name="filePath"/>.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(results, branding);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
@@ -10,12 +11,18 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class StorageCsvExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a single-section CSV: header row plus one row per
|
||||
/// <see cref="StorageNode"/> with library, site, file count, total size
|
||||
/// (MB), version size (MB), and last-modified date.
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
|
||||
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
@@ -33,10 +40,11 @@ public class StorageCsvExportService
|
||||
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)
|
||||
{
|
||||
var csv = BuildCsv(nodes);
|
||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,10 +52,11 @@ public class StorageCsvExportService
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Library details
|
||||
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
|
||||
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
@@ -65,10 +74,10 @@ public class StorageCsvExportService
|
||||
if (fileTypeMetrics.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("File Type,Size (MB),File Count");
|
||||
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
|
||||
foreach (var m in fileTypeMetrics)
|
||||
{
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
|
||||
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
||||
}
|
||||
}
|
||||
@@ -76,10 +85,55 @@ public class StorageCsvExportService
|
||||
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)
|
||||
{
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
@@ -87,12 +141,6 @@ public class StorageCsvExportService
|
||||
private static string FormatMb(long bytes)
|
||||
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
|
||||
|
||||
/// <summary>RFC 4180 CSV field quoting.</summary>
|
||||
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;
|
||||
}
|
||||
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
|
||||
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
@@ -13,18 +14,25 @@ public class StorageHtmlExportService
|
||||
{
|
||||
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)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
_togIdx = 0;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
|
||||
sb.AppendLine("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SharePoint Storage Metrics</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #0078d4; }
|
||||
@@ -50,9 +58,7 @@ public class StorageHtmlExportService
|
||||
<body>
|
||||
""");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("""
|
||||
<h1>SharePoint Storage Metrics</h1>
|
||||
""");
|
||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||
|
||||
// Summary cards
|
||||
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
@@ -62,22 +68,22 @@ public class StorageHtmlExportService
|
||||
|
||||
sb.AppendLine($"""
|
||||
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
|
||||
</div>
|
||||
""");
|
||||
|
||||
sb.AppendLine("""
|
||||
sb.AppendLine($"""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Library / Folder</th>
|
||||
<th>Site</th>
|
||||
<th class="num">Files</th>
|
||||
<th class="num">Total Size</th>
|
||||
<th class="num">Version Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th>{T["report.col.library_folder"]}</th>
|
||||
<th>{T["report.col.site"]}</th>
|
||||
<th class="num">{T["report.stat.files"]}</th>
|
||||
<th class="num">{T["report.stat.total_size"]}</th>
|
||||
<th class="num">{T["report.stat.version_size"]}</th>
|
||||
<th>{T["report.col.last_modified"]}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -93,7 +99,7 @@ public class StorageHtmlExportService
|
||||
</table>
|
||||
""");
|
||||
|
||||
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
|
||||
return sb.ToString();
|
||||
@@ -104,16 +110,17 @@ public class StorageHtmlExportService
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
_togIdx = 0;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
|
||||
sb.AppendLine("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SharePoint Storage Metrics</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #0078d4; }
|
||||
@@ -150,9 +157,7 @@ public class StorageHtmlExportService
|
||||
<body>
|
||||
""");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("""
|
||||
<h1>SharePoint Storage Metrics</h1>
|
||||
""");
|
||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||
|
||||
// ── Summary cards ──
|
||||
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
@@ -161,10 +166,10 @@ public class StorageHtmlExportService
|
||||
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
||||
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// ── File type chart section ──
|
||||
@@ -175,7 +180,7 @@ public class StorageHtmlExportService
|
||||
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
||||
|
||||
sb.AppendLine("<div class=\"chart-section\">");
|
||||
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
|
||||
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
|
||||
|
||||
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
||||
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
||||
@@ -185,13 +190,13 @@ public class StorageHtmlExportService
|
||||
{
|
||||
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
|
||||
string color = colors[idx % colors.Length];
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
|
||||
|
||||
sb.AppendLine($"""
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">{HtmlEncode(label)}</span>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
|
||||
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} files</span>
|
||||
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} {T["report.text.files_unit"]}</span>
|
||||
</div>
|
||||
""");
|
||||
idx++;
|
||||
@@ -201,17 +206,17 @@ public class StorageHtmlExportService
|
||||
}
|
||||
|
||||
// ── Storage table ──
|
||||
sb.AppendLine("<h2>Library Details</h2>");
|
||||
sb.AppendLine("""
|
||||
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
|
||||
sb.AppendLine($"""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Library / Folder</th>
|
||||
<th>Site</th>
|
||||
<th class="num">Files</th>
|
||||
<th class="num">Total Size</th>
|
||||
<th class="num">Version Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th>{T["report.col.library_folder"]}</th>
|
||||
<th>{T["report.col.site"]}</th>
|
||||
<th class="num">{T["report.stat.files"]}</th>
|
||||
<th class="num">{T["report.stat.total_size"]}</th>
|
||||
<th class="num">{T["report.stat.version_size"]}</th>
|
||||
<th>{T["report.col.last_modified"]}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -227,24 +232,68 @@ public class StorageHtmlExportService
|
||||
</table>
|
||||
""");
|
||||
|
||||
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(nodes, branding);
|
||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
}
|
||||
|
||||
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
}
|
||||
|
||||
/// <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 void RenderNode(StringBuilder sb, StorageNode node)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -11,8 +12,11 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class UserAccessCsvExportService
|
||||
{
|
||||
private const string DataHeader =
|
||||
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\"";
|
||||
private static string BuildDataHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV string for a single user's access entries.
|
||||
@@ -20,22 +24,23 @@ public class UserAccessCsvExportService
|
||||
/// </summary>
|
||||
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Summary section
|
||||
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
|
||||
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
|
||||
|
||||
sb.AppendLine($"\"User Access Audit Report\"");
|
||||
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
|
||||
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
|
||||
sb.AppendLine($"\"Sites\",\"{sitesCount}\"");
|
||||
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\"");
|
||||
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
|
||||
sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
|
||||
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
|
||||
sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
|
||||
sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
|
||||
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine(); // Blank line separating summary from data
|
||||
|
||||
// Data rows
|
||||
sb.AppendLine(DataHeader);
|
||||
sb.AppendLine(BuildDataHeader());
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
@@ -82,8 +87,69 @@ public class UserAccessCsvExportService
|
||||
var filePath = Path.Combine(directoryPath, fileName);
|
||||
|
||||
var csv = BuildCsv(displayName, userLogin, entries);
|
||||
await File.WriteAllTextAsync(filePath, csv,
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes all entries split per site. File naming: "{base}_{siteLabel}.csv".
|
||||
/// </summary>
|
||||
public async Task WriteBySiteAsync(
|
||||
IReadOnlyList<UserAccessEntry> allEntries,
|
||||
string basePath,
|
||||
CancellationToken ct,
|
||||
bool mergePermissions = false)
|
||||
{
|
||||
foreach (var group in allEntries.GroupBy(e => (e.SiteUrl, e.SiteTitle)))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var label = ReportSplitHelper.DeriveSiteLabel(group.Key.SiteUrl, group.Key.SiteTitle);
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split-aware export dispatcher.
|
||||
/// Single → one file at <paramref name="basePath"/>.
|
||||
/// BySite → one file per site. ByUser → one file per user.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<UserAccessEntry> allEntries,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
CancellationToken ct,
|
||||
bool mergePermissions = false)
|
||||
{
|
||||
switch (splitMode)
|
||||
{
|
||||
case ReportSplitMode.Single:
|
||||
await WriteSingleFileAsync(allEntries, basePath, ct, mergePermissions);
|
||||
break;
|
||||
case ReportSplitMode.BySite:
|
||||
await WriteBySiteAsync(allEntries, basePath, ct, mergePermissions);
|
||||
break;
|
||||
case ReportSplitMode.ByUser:
|
||||
await WriteByUserAsync(allEntries, basePath, ct, mergePermissions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one CSV per user using <paramref name="basePath"/> as a filename template.
|
||||
/// </summary>
|
||||
public async Task WriteByUserAsync(
|
||||
IReadOnlyList<UserAccessEntry> allEntries,
|
||||
string basePath,
|
||||
CancellationToken ct,
|
||||
bool mergePermissions = false)
|
||||
{
|
||||
foreach (var group in allEntries.GroupBy(e => e.UserLogin))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var label = ReportSplitHelper.SanitizeFileName(group.Key);
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,20 +165,21 @@ public class UserAccessCsvExportService
|
||||
CancellationToken ct,
|
||||
bool mergePermissions = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
if (mergePermissions)
|
||||
{
|
||||
var consolidated = PermissionConsolidator.Consolidate(entries);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Summary section
|
||||
sb.AppendLine("\"User Access Audit Report (Consolidated)\"");
|
||||
sb.AppendLine($"\"Users Audited\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
|
||||
sb.AppendLine($"\"Total Entries\",\"{consolidated.Count}\"");
|
||||
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
|
||||
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
|
||||
sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
|
||||
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"");
|
||||
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
|
||||
|
||||
// Data rows
|
||||
foreach (var entry in consolidated)
|
||||
@@ -136,14 +203,14 @@ public class UserAccessCsvExportService
|
||||
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var fullHeader = "\"User\",\"User Login\"," + DataHeader;
|
||||
var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
|
||||
|
||||
// Summary
|
||||
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
|
||||
sb.AppendLine($"\"User Access Audit Report\"");
|
||||
sb.AppendLine($"\"Users Audited\",\"{users.Count}\"");
|
||||
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
|
||||
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
|
||||
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
|
||||
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
|
||||
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine(fullHeader);
|
||||
@@ -163,17 +230,12 @@ public class UserAccessCsvExportService
|
||||
}));
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(filePath, sb.ToString(),
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, sb.ToString(), ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
|
||||
private static string Csv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
|
||||
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -18,6 +19,70 @@ public class UserAccessHtmlExportService
|
||||
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user
|
||||
/// report with an expandable Sites column instead of the dual by-user/by-site view.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Split-aware HTML export. Single → one file.
|
||||
/// BySite/ByUser + SeparateFiles → one file per site/user.
|
||||
/// BySite/ByUser + SingleTabbed → one file with per-partition iframe tabs.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<UserAccessEntry> entries,
|
||||
string basePath,
|
||||
ReportSplitMode splitMode,
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
bool mergePermissions = false,
|
||||
ReportBranding? branding = null)
|
||||
{
|
||||
if (splitMode == ReportSplitMode.Single)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, mergePermissions, branding);
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<(string Label, IReadOnlyList<UserAccessEntry> Entries)> partitions;
|
||||
if (splitMode == ReportSplitMode.BySite)
|
||||
{
|
||||
partitions = entries
|
||||
.GroupBy(e => (e.SiteUrl, e.SiteTitle))
|
||||
.Select(g => (
|
||||
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.SiteUrl, g.Key.SiteTitle),
|
||||
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
|
||||
}
|
||||
else // ByUser
|
||||
{
|
||||
partitions = entries
|
||||
.GroupBy(e => e.UserLogin)
|
||||
.Select(g => (
|
||||
Label: ReportSplitHelper.SanitizeFileName(g.Key),
|
||||
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
|
||||
}
|
||||
|
||||
var partList = partitions.ToList();
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partList
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Entries, mergePermissions, branding)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.user_access"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (label, partEntries) in partList)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, mergePermissions, branding);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the user-access HTML report. Default layout is a per-entry
|
||||
/// grouped-by-user table; when <paramref name="mergePermissions"/> is true
|
||||
/// entries are consolidated via <see cref="PermissionConsolidator"/> into
|
||||
/// a single-row-per-user format with a Locations column.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
|
||||
{
|
||||
if (mergePermissions)
|
||||
@@ -26,6 +91,8 @@ public class UserAccessHtmlExportService
|
||||
return BuildConsolidatedHtml(consolidated, entries, branding);
|
||||
}
|
||||
|
||||
var T = TranslationSource.Instance;
|
||||
|
||||
// Compute stats
|
||||
var totalAccesses = entries.Count;
|
||||
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
|
||||
@@ -41,7 +108,7 @@ public class UserAccessHtmlExportService
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine("<title>User Access Audit Report</title>");
|
||||
sb.AppendLine($"<title>{T["report.title.user_access"]}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(@"
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -98,15 +165,15 @@ a:hover { text-decoration: underline; }
|
||||
// ── BODY ───────────────────────────────────────────────────────────────
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("<h1>User Access Audit Report</h1>");
|
||||
sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
|
||||
|
||||
// Stats cards
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Per-user summary cards
|
||||
@@ -123,34 +190,34 @@ a:hover { text-decoration: underline; }
|
||||
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
|
||||
|
||||
sb.AppendLine($" <div class=\"{cardClass}\">");
|
||||
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses • {uSites} site(s){(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// View toggle buttons
|
||||
sb.AppendLine("<div class=\"view-toggle\">");
|
||||
sb.AppendLine(" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">By User</button>");
|
||||
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>");
|
||||
sb.AppendLine($" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
|
||||
sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Filter input
|
||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
||||
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
|
||||
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// ── BY-USER VIEW ───────────────────────────────────────────────────────
|
||||
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"tbl-user\">");
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody id=\"tbody-user\">");
|
||||
|
||||
@@ -161,10 +228,10 @@ a:hover { text-decoration: underline; }
|
||||
var uName = HtmlEncode(ug.First().UserDisplayName);
|
||||
var uIsExt = ug.First().IsExternalUser;
|
||||
var uCount = ug.Count();
|
||||
var guestBadge = uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
|
||||
var guestBadge = uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
|
||||
|
||||
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
||||
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} — {uCount} access(es)</td>");
|
||||
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} — {uCount} {T["report.text.access_es"]}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
|
||||
foreach (var entry in ug)
|
||||
@@ -173,10 +240,14 @@ a:hover { text-decoration: underline; }
|
||||
var accessBadge = AccessTypeBadge(entry.AccessType);
|
||||
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
||||
|
||||
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
|
||||
? "—"
|
||||
: HtmlEncode(entry.ObjectTitle);
|
||||
|
||||
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
|
||||
sb.AppendLine($" <td>{objectCell}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
@@ -192,12 +263,12 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
|
||||
sb.AppendLine("<table id=\"tbl-site\">");
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>");
|
||||
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
|
||||
sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody id=\"tbody-site\">");
|
||||
|
||||
@@ -210,7 +281,7 @@ a:hover { text-decoration: underline; }
|
||||
var sCount = sg.Count();
|
||||
|
||||
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
||||
sb.AppendLine($" <td colspan=\"6\">{siteTitle} — {sCount} access(es)</td>");
|
||||
sb.AppendLine($" <td colspan=\"6\">{siteTitle} — {sCount} {T["report.text.access_es"]}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
|
||||
foreach (var entry in sg)
|
||||
@@ -218,12 +289,16 @@ a:hover { text-decoration: underline; }
|
||||
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
|
||||
var accessBadge = AccessTypeBadge(entry.AccessType);
|
||||
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
||||
var guestBadge = entry.IsExternalUser ? " <span class=\"guest-badge\">Guest</span>" : "";
|
||||
var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
|
||||
|
||||
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
|
||||
? "—"
|
||||
: HtmlEncode(entry.ObjectTitle);
|
||||
|
||||
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
|
||||
sb.AppendLine($" <td>{objectCell}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
@@ -333,7 +408,7 @@ function sortTable(view, col) {
|
||||
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(entries, mergePermissions, branding);
|
||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -345,6 +420,8 @@ function sortTable(view, col) {
|
||||
IReadOnlyList<UserAccessEntry> entries,
|
||||
ReportBranding? branding)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
|
||||
// Stats computed from the original flat list for accurate counts
|
||||
var totalAccesses = entries.Count;
|
||||
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
|
||||
@@ -360,7 +437,7 @@ function sortTable(view, col) {
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine("<title>User Access Audit Report</title>");
|
||||
sb.AppendLine($"<title>{T["report.title.user_access_consolidated"]}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(@"
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -417,15 +494,15 @@ a:hover { text-decoration: underline; }
|
||||
// ── BODY ───────────────────────────────────────────────────────────────
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine("<h1>User Access Audit Report</h1>");
|
||||
sb.AppendLine($"<h1>{T["report.title.user_access_consolidated"]}</h1>");
|
||||
|
||||
// Stats cards
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Per-user summary cards (from original flat entries)
|
||||
@@ -442,32 +519,32 @@ a:hover { text-decoration: underline; }
|
||||
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
|
||||
|
||||
sb.AppendLine($" <div class=\"{cardClass}\">");
|
||||
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses • {uSites} site(s){(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// View toggle — only By User (By Site is suppressed for consolidated view)
|
||||
sb.AppendLine("<div class=\"view-toggle\">");
|
||||
sb.AppendLine(" <button id=\"btn-user\" class=\"active\">By User</button>");
|
||||
sb.AppendLine($" <button id=\"btn-user\" class=\"active\">{T["report.view.by_user"]}</button>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Filter input
|
||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
||||
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
|
||||
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
|
||||
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"tbl-user\">");
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine(" <th>User</th>");
|
||||
sb.AppendLine(" <th>Permission Level</th>");
|
||||
sb.AppendLine(" <th>Access Type</th>");
|
||||
sb.AppendLine(" <th>Granted Through</th>");
|
||||
sb.AppendLine(" <th>Sites</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.user"]}</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody id=\"tbody-user\">");
|
||||
|
||||
@@ -486,10 +563,10 @@ a:hover { text-decoration: underline; }
|
||||
var cuName = HtmlEncode(cug.First().UserDisplayName);
|
||||
var cuIsExt = cug.First().IsExternalUser;
|
||||
var cuCount = cug.Count();
|
||||
var guestBadge = cuIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
|
||||
var guestBadge = cuIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
|
||||
|
||||
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
||||
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} — {cuCount} permission(s)</td>");
|
||||
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} — {cuCount} {T["report.text.permissions_parens"]}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
|
||||
foreach (var entry in cug)
|
||||
@@ -508,7 +585,7 @@ a:hover { text-decoration: underline; }
|
||||
{
|
||||
// Single location — inline site title + object title
|
||||
var loc0 = entry.Locations[0];
|
||||
var locLabel = string.IsNullOrEmpty(loc0.ObjectTitle)
|
||||
var locLabel = IsRedundantObjectTitle(loc0.SiteTitle, loc0.ObjectTitle)
|
||||
? HtmlEncode(loc0.SiteTitle)
|
||||
: $"{HtmlEncode(loc0.SiteTitle)} › {HtmlEncode(loc0.ObjectTitle)}";
|
||||
sb.AppendLine($" <td>{locLabel}</td>");
|
||||
@@ -518,13 +595,13 @@ a:hover { text-decoration: underline; }
|
||||
{
|
||||
// Multiple locations — expandable badge
|
||||
var currentLocId = $"loc{locIdx++}";
|
||||
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} sites</span></td>");
|
||||
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} {TranslationSource.Instance["report.text.sites_unit"]}</span></td>");
|
||||
sb.AppendLine("</tr>");
|
||||
|
||||
// Hidden sub-rows — one per location
|
||||
foreach (var loc in entry.Locations)
|
||||
{
|
||||
var subLabel = string.IsNullOrEmpty(loc.ObjectTitle)
|
||||
var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle)
|
||||
? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>"
|
||||
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> › {HtmlEncode(loc.ObjectTitle)}";
|
||||
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
|
||||
@@ -591,13 +668,31 @@ function toggleGroup(id) {
|
||||
}
|
||||
|
||||
/// <summary>Returns a colored badge span for the given access type.</summary>
|
||||
private static string AccessTypeBadge(AccessType accessType) => accessType switch
|
||||
private static string AccessTypeBadge(AccessType accessType)
|
||||
{
|
||||
AccessType.Direct => "<span class=\"badge access-direct\">Direct</span>",
|
||||
AccessType.Group => "<span class=\"badge access-group\">Group</span>",
|
||||
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>",
|
||||
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
|
||||
};
|
||||
var T = TranslationSource.Instance;
|
||||
return accessType switch
|
||||
{
|
||||
AccessType.Direct => $"<span class=\"badge access-direct\">{T["report.badge.direct"]}</span>",
|
||||
AccessType.Group => $"<span class=\"badge access-group\">{T["report.badge.group"]}</span>",
|
||||
AccessType.Inherited => $"<span class=\"badge access-inherited\">{T["report.badge.inherited"]}</span>",
|
||||
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the ObjectTitle adds no information beyond the SiteTitle:
|
||||
/// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate
|
||||
/// of the other. Used to collapse "All Company › All Company" to "All Company".
|
||||
/// </summary>
|
||||
private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectTitle)) return true;
|
||||
return string.Equals(
|
||||
(siteTitle ?? string.Empty).Trim(),
|
||||
objectTitle.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
|
||||
private static string HtmlEncode(string value)
|
||||
|
||||
@@ -6,8 +6,23 @@ using SharepointToolbox.Core.Models;
|
||||
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
|
||||
/// (unless the job is folder-only), pre-creates destination folders, then
|
||||
/// copies or moves each file according to <see cref="TransferJob.Mode"/>
|
||||
/// and <see cref="TransferJob.ConflictPolicy"/>. Returns a per-item
|
||||
/// summary where failures are reported individually — the method does
|
||||
/// not abort on first error so partial transfers are recoverable.
|
||||
/// </summary>
|
||||
public async Task<BulkOperationSummary<string>> TransferAsync(
|
||||
ClientContext sourceCtx,
|
||||
ClientContext destCtx,
|
||||
@@ -15,19 +30,53 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Enumerate files from source
|
||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
||||
// 1. Enumerate files from source (unless contents are suppressed).
|
||||
IReadOnlyList<string> files;
|
||||
if (job.CopyFolderContents)
|
||||
{
|
||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||
files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
files = Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
// When CopyFolderContents is off, the job is folder-only: ensure the
|
||||
// destination folder is created below (IncludeSourceFolder branch) and
|
||||
// return without iterating any files.
|
||||
if (files.Count == 0 && !job.IncludeSourceFolder)
|
||||
{
|
||||
progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
|
||||
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
||||
}
|
||||
|
||||
// 2. Build source and destination base paths
|
||||
var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath);
|
||||
var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath);
|
||||
// 2. Build source and destination base paths. Resolve library roots via
|
||||
// CSOM — constructing from title breaks for localized libraries whose
|
||||
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
|
||||
// causing "Access denied" when CSOM tries to touch a non-existent path.
|
||||
var srcBasePath = await ResolveLibraryPathAsync(
|
||||
sourceCtx, job.SourceLibrary, job.SourceFolderPath, progress, ct);
|
||||
var dstBasePath = await ResolveLibraryPathAsync(
|
||||
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
|
||||
|
||||
// When IncludeSourceFolder is set, recreate the source folder name under
|
||||
// destination so dest/srcFolderName/... mirrors the source tree. When
|
||||
// no SourceFolderPath is set, fall back to the source library name.
|
||||
// Also pre-create the folder itself — per-file EnsureFolder only fires
|
||||
// for nested paths, so flat files at the root of the source folder
|
||||
// would otherwise copy into a missing parent and fail.
|
||||
if (job.IncludeSourceFolder)
|
||||
{
|
||||
var srcFolderName = !string.IsNullOrEmpty(job.SourceFolderPath)
|
||||
? Path.GetFileName(job.SourceFolderPath.TrimEnd('/'))
|
||||
: job.SourceLibrary;
|
||||
if (!string.IsNullOrEmpty(srcFolderName))
|
||||
{
|
||||
dstBasePath = $"{dstBasePath}/{srcFolderName}";
|
||||
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Transfer each file using BulkOperationRunner
|
||||
return await BulkOperationRunner.RunAsync(
|
||||
@@ -68,8 +117,14 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl);
|
||||
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl);
|
||||
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
|
||||
// not server-relative paths. Passing "/sites/..." silently fails or
|
||||
// returns no error yet copies nothing — especially across site
|
||||
// collections. Prefix with the owning site's scheme+host.
|
||||
var srcAbs = ToAbsoluteUrl(sourceCtx, srcFileUrl);
|
||||
var dstAbs = ToAbsoluteUrl(destCtx, dstFileUrl);
|
||||
var srcPath = ResourcePath.FromDecodedUrl(srcAbs);
|
||||
var dstPath = ResourcePath.FromDecodedUrl(dstAbs);
|
||||
|
||||
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
|
||||
var options = new MoveCopyOptions
|
||||
@@ -109,41 +164,66 @@ public class FileTransferService : IFileTransferService
|
||||
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
var libraryRoot = rootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
// Explicit per-file selection overrides folder enumeration. Paths are
|
||||
// library-relative (e.g. "SubFolder/file.docx") and get resolved to
|
||||
// full server-relative URLs here.
|
||||
if (job.SelectedFilePaths.Count > 0)
|
||||
{
|
||||
return job.SelectedFilePaths
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => $"{libraryRoot}/{p.TrimStart('/')}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var baseFolderUrl = libraryRoot;
|
||||
if (!string.IsNullOrEmpty(job.SourceFolderPath))
|
||||
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
||||
|
||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl);
|
||||
// Paginated recursive CAML query — Folder.Files / Folder.Folders lazy
|
||||
// loading hits the list-view threshold on libraries > 5,000 items.
|
||||
var files = new List<string>();
|
||||
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
|
||||
|
||||
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||
ctx, list, baseFolderUrl, recursive: true,
|
||||
viewFields: new[] { "FSObjType", "FileRef", "FileDirRef" },
|
||||
ct: ct))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (item["FSObjType"]?.ToString() != "0") continue; // files only
|
||||
|
||||
var fileRef = item["FileRef"]?.ToString();
|
||||
if (string.IsNullOrEmpty(fileRef)) continue;
|
||||
|
||||
// Skip files under SharePoint system folders (e.g. "Forms", "_*").
|
||||
var dir = item["FileDirRef"]?.ToString() ?? string.Empty;
|
||||
if (HasSystemFolderSegment(dir, baseFolderUrl)) continue;
|
||||
|
||||
files.Add(fileRef);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async Task CollectFilesRecursiveAsync(
|
||||
ClientContext ctx,
|
||||
Folder folder,
|
||||
List<string> files,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
private static bool HasSystemFolderSegment(string fileDirRef, string baseFolderUrl)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrEmpty(fileDirRef)) return false;
|
||||
var baseTrim = baseFolderUrl.TrimEnd('/');
|
||||
if (!fileDirRef.StartsWith(baseTrim, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl),
|
||||
f => f.Folders);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
var tail = fileDirRef.Substring(baseTrim.Length).Trim('/');
|
||||
if (string.IsNullOrEmpty(tail)) return false;
|
||||
|
||||
foreach (var file in folder.Files)
|
||||
foreach (var seg in tail.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
files.Add(file.ServerRelativeUrl);
|
||||
}
|
||||
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
// Skip system folders
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct);
|
||||
if (seg.StartsWith("_", StringComparison.Ordinal) ||
|
||||
seg.Equals("Forms", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task EnsureFolderAsync(
|
||||
@@ -152,28 +232,70 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
// Already there?
|
||||
try
|
||||
{
|
||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
||||
ctx.Load(folder, f => f.Exists);
|
||||
var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
||||
ctx.Load(existing, f => f.Exists);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
if (folder.Exists) return;
|
||||
if (existing.Exists) return;
|
||||
}
|
||||
catch { /* folder doesn't exist, create it */ }
|
||||
catch { /* not present — fall through to creation */ }
|
||||
|
||||
// Create folder using Folders.Add which creates intermediate folders
|
||||
ctx.Web.Folders.Add(folderServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
// Walk the path, creating each missing segment. `Web.Folders.Add(url)` is
|
||||
// ambiguous across CSOM versions (some treat the arg as relative to Web,
|
||||
// others server-relative), which produces bogus paths + "Access denied".
|
||||
// Resolve the parent explicitly and add only the leaf name instead.
|
||||
int slash = folderServerRelativeUrl.LastIndexOf('/');
|
||||
if (slash <= 0) return;
|
||||
|
||||
var parentUrl = folderServerRelativeUrl.Substring(0, slash);
|
||||
var leafName = folderServerRelativeUrl.Substring(slash + 1);
|
||||
if (string.IsNullOrEmpty(leafName)) return;
|
||||
|
||||
// Recurse to guarantee the parent exists first.
|
||||
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
|
||||
|
||||
var parent = ctx.Web.GetFolderByServerRelativeUrl(parentUrl);
|
||||
parent.Folders.Add(leafName);
|
||||
try
|
||||
{
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}",
|
||||
parentUrl, leafName, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath)
|
||||
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
|
||||
{
|
||||
// Extract site-relative URL from context URL
|
||||
if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return pathOrUrl;
|
||||
|
||||
var uri = new Uri(ctx.Url);
|
||||
var siteRelative = uri.AbsolutePath.TrimEnd('/');
|
||||
var basePath = $"{siteRelative}/{library}";
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
basePath = $"{basePath}/{folderPath.TrimStart('/')}";
|
||||
return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
|
||||
}
|
||||
|
||||
private static async Task<string> ResolveLibraryPathAsync(
|
||||
ClientContext ctx,
|
||||
string libraryTitle,
|
||||
string relativeFolderPath,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
|
||||
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var basePath = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
if (!string.IsNullOrEmpty(relativeFolderPath))
|
||||
basePath = $"{basePath}/{relativeFolderPath.TrimStart('/')}";
|
||||
return basePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,23 +9,25 @@ public interface IAppRegistrationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the currently-authenticated user has the Global Administrator
|
||||
/// directory role (checked via transitiveMemberOf for nested-group coverage).
|
||||
/// Returns false on any failure, including 403, rather than throwing.
|
||||
/// directory role in the target tenant (checked via transitiveMemberOf for
|
||||
/// nested-group coverage). Throws on Graph/network failure so the UI can
|
||||
/// distinguish a confirmed non-admin from a call that could not complete.
|
||||
/// </summary>
|
||||
Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct);
|
||||
Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants
|
||||
/// atomically. On any intermediate failure the Application is deleted before
|
||||
/// returning a Failure result (best-effort rollback).
|
||||
/// atomically in the tenant identified by <paramref name="tenantUrl"/>.
|
||||
/// On any intermediate failure the Application is deleted before returning
|
||||
/// a Failure result (best-effort rollback).
|
||||
/// </summary>
|
||||
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct);
|
||||
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantUrl, string tenantDisplayName, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the registered application by its appId.
|
||||
/// Deletes the registered application by its appId in the given tenant.
|
||||
/// Logs a warning on failure but does not throw.
|
||||
/// </summary>
|
||||
Task RemoveAsync(string clientId, string appId, CancellationToken ct);
|
||||
Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the live SessionManager context, evicts all in-memory MSAL accounts,
|
||||
|
||||
@@ -27,5 +27,6 @@ public interface IUserAccessAuditService
|
||||
IReadOnlyList<SiteInfo> sites,
|
||||
ScanOptions options,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct);
|
||||
CancellationToken ct,
|
||||
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ public class OwnershipElevationService : IOwnershipElevationService
|
||||
{
|
||||
public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct)
|
||||
{
|
||||
// Tenant.SetSiteAdmin requires a real claims/UPN login; an empty string
|
||||
// makes the server raise "Cannot convert Org ID to Claims" and abort.
|
||||
// When the caller doesn't specify a user, fall back to the signed-in
|
||||
// admin (the owner of tenantAdminCtx).
|
||||
if (string.IsNullOrWhiteSpace(loginName))
|
||||
{
|
||||
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
|
||||
await tenantAdminCtx.ExecuteQueryAsync();
|
||||
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
|
||||
}
|
||||
|
||||
var tenant = new Tenant(tenantAdminCtx);
|
||||
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
|
||||
await tenantAdminCtx.ExecuteQueryAsync();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.SharePoint.Client;
|
||||
using Serilog;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
@@ -10,6 +11,21 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
public class PermissionsService : IPermissionsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects the SharePoint server error raised when a RoleAssignment member
|
||||
/// refers to a user that no longer resolves (orphaned Azure AD account).
|
||||
/// Message surfaces in the user's locale — match on language-agnostic tokens.
|
||||
/// </summary>
|
||||
private static bool IsClaimsResolutionError(ServerException ex)
|
||||
{
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
return msg.Contains("Claims", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("Revendications", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("Org ID", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("ID org", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Port of PS lines 1914-1926: system lists excluded from permission reporting
|
||||
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -122,7 +138,17 @@ public class PermissionsService : IPermissionsService
|
||||
u => u.Title,
|
||||
u => u.LoginName,
|
||||
u => u.IsSiteAdmin));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
try
|
||||
{
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
}
|
||||
catch (ServerException ex) when (IsClaimsResolutionError(ex))
|
||||
{
|
||||
Log.Warning("Skipped site collection admins for {Url} — orphaned user: {Error}",
|
||||
ctx.Web.Url, ex.Message);
|
||||
return Enumerable.Empty<PermissionEntry>();
|
||||
}
|
||||
|
||||
var admins = ctx.Web.SiteUsers
|
||||
.Where(u => u.IsSiteAdmin)
|
||||
@@ -280,7 +306,23 @@ public class PermissionsService : IPermissionsService
|
||||
ra => ra.Member.LoginName,
|
||||
ra => ra.Member.PrincipalType,
|
||||
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
// Orphaned AD users in RoleAssignments cause the server to throw
|
||||
// "Cannot convert Org ID user to Claims user" during claim resolution.
|
||||
// That kills the whole batch — skip this object so the scan continues.
|
||||
// Only swallow the claims-resolution signature; real access-denied errors
|
||||
// must bubble up so callers (e.g. PermissionsViewModel auto-elevation)
|
||||
// can react to them.
|
||||
try
|
||||
{
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
}
|
||||
catch (ServerException ex) when (IsClaimsResolutionError(ex))
|
||||
{
|
||||
Log.Warning("Skipped {Type} '{Title}' ({Url}) — orphaned user in permissions: {Error}",
|
||||
objectType, title, url, ex.Message);
|
||||
return Enumerable.Empty<PermissionEntry>();
|
||||
}
|
||||
|
||||
// Skip inherited objects when IncludeInherited=false
|
||||
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
|
||||
|
||||
@@ -62,10 +62,25 @@ public class SearchService : ISearchService
|
||||
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
||||
if (table == null || table.RowCount == 0) break;
|
||||
|
||||
foreach (System.Collections.Hashtable row in table.ResultRows)
|
||||
foreach (var rawRow in table.ResultRows)
|
||||
{
|
||||
var dict = row.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
|
||||
// CSOM has returned ResultRows as either Hashtable or
|
||||
// Dictionary<string,object> across versions — accept both.
|
||||
IDictionary<string, object> dict;
|
||||
if (rawRow is IDictionary<string, object> generic)
|
||||
{
|
||||
dict = generic;
|
||||
}
|
||||
else if (rawRow is System.Collections.IDictionary legacy)
|
||||
{
|
||||
dict = new Dictionary<string, object>();
|
||||
foreach (System.Collections.DictionaryEntry e in legacy)
|
||||
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SharePoint version history paths
|
||||
string path = Str(dict, "Path");
|
||||
|
||||
@@ -43,4 +43,14 @@ public class SettingsService
|
||||
settings.AutoTakeOwnership = enabled;
|
||||
await _repository.SaveAsync(settings);
|
||||
}
|
||||
|
||||
public async Task SetThemeAsync(string mode)
|
||||
{
|
||||
if (mode is not ("System" or "Light" or "Dark"))
|
||||
throw new ArgumentException($"Unsupported theme '{mode}'. Supported: System, Light, Dark.", nameof(mode));
|
||||
|
||||
var settings = await _repository.LoadAsync();
|
||||
settings.Theme = mode;
|
||||
await _repository.SaveAsync(settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,37 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
|
||||
GraphServiceClient? graphClient = null;
|
||||
|
||||
// Preload the web's SiteGroups catalog once, so we can skip missing
|
||||
// groups without triggering a server round-trip per name (which fills
|
||||
// logs with "Could not resolve SP group" warnings for groups that
|
||||
// live on other sites or were renamed/deleted).
|
||||
var groupTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||
foreach (var g in ctx.Web.SiteGroups)
|
||||
groupTitles.Add(g.Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
|
||||
}
|
||||
|
||||
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!groupTitles.Contains(groupName))
|
||||
{
|
||||
// Group not on this web — likely scoped to another site in a
|
||||
// multi-site scan. Keep quiet: log at Debug, return empty.
|
||||
Log.Debug("SP group '{Group}' not present on {Url}; skipping.",
|
||||
groupName, ctx.Url);
|
||||
result[groupName] = Array.Empty<ResolvedMember>();
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var group = ctx.Web.SiteGroups.GetByName(groupName);
|
||||
|
||||
@@ -69,7 +69,12 @@ public class SiteListService : ISiteListService
|
||||
if (s.Status == "Active"
|
||||
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new SiteInfo(s.Url, s.Title));
|
||||
results.Add(new SiteInfo(s.Url, s.Title)
|
||||
{
|
||||
StorageUsedMb = s.StorageUsage,
|
||||
StorageQuotaMb = s.StorageMaximumLevel,
|
||||
Template = s.Template ?? string.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
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(
|
||||
ClientContext ctx,
|
||||
StorageScanOptions options,
|
||||
@@ -54,7 +61,7 @@ public class StorageService : IStorageService
|
||||
if (options.FolderDepth > 0)
|
||||
{
|
||||
await CollectSubfoldersAsync(
|
||||
ctx, lib.RootFolder.ServerRelativeUrl,
|
||||
ctx, lib, lib.RootFolder.ServerRelativeUrl,
|
||||
libNode, 1, options.FolderDepth,
|
||||
siteTitle, lib.Title, progress, ct);
|
||||
}
|
||||
@@ -65,6 +72,11 @@ public class StorageService : IStorageService
|
||||
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(
|
||||
ClientContext ctx,
|
||||
IProgress<OperationProgress> progress,
|
||||
@@ -96,24 +108,19 @@ public class StorageService : IStorageService
|
||||
progress.Report(new OperationProgress(libIdx, libs.Count,
|
||||
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
|
||||
|
||||
// Use CamlQuery to enumerate all files in the library
|
||||
// Paginate with 500 items per batch to avoid list view threshold issues
|
||||
// Paginated CAML without a WHERE clause — WHERE on non-indexed fields
|
||||
// (FSObjType) throws list-view threshold on libraries > 5,000 items.
|
||||
// Filter files client-side via FSObjType.
|
||||
var query = new CamlQuery
|
||||
{
|
||||
ViewXml = @"<View Scope='RecursiveAll'>
|
||||
<Query>
|
||||
<Where>
|
||||
<Eq>
|
||||
<FieldRef Name='FSObjType' />
|
||||
<Value Type='Integer'>0</Value>
|
||||
</Eq>
|
||||
</Where>
|
||||
</Query>
|
||||
<Query></Query>
|
||||
<ViewFields>
|
||||
<FieldRef Name='FSObjType' />
|
||||
<FieldRef Name='FileLeafRef' />
|
||||
<FieldRef Name='File_x0020_Size' />
|
||||
</ViewFields>
|
||||
<RowLimit Paged='TRUE'>500</RowLimit>
|
||||
<RowLimit Paged='TRUE'>5000</RowLimit>
|
||||
</View>"
|
||||
};
|
||||
|
||||
@@ -124,12 +131,15 @@ public class StorageService : IStorageService
|
||||
items = lib.GetItems(query);
|
||||
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
||||
ic => ic.Include(
|
||||
i => i["FSObjType"],
|
||||
i => i["FileLeafRef"],
|
||||
i => i["File_x0020_Size"]));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
|
||||
|
||||
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0";
|
||||
|
||||
@@ -137,7 +147,6 @@ public class StorageService : IStorageService
|
||||
fileSize = 0;
|
||||
|
||||
string ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
// ext is "" for extensionless files, ".docx" etc. for others
|
||||
|
||||
if (extensionMap.TryGetValue(ext, out var existing))
|
||||
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
|
||||
@@ -145,7 +154,6 @@ public class StorageService : IStorageService
|
||||
extensionMap[ext] = (fileSize, 1);
|
||||
}
|
||||
|
||||
// Move to next page
|
||||
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||
}
|
||||
while (items.ListItemCollectionPosition != null);
|
||||
@@ -198,21 +206,31 @@ public class StorageService : IStorageService
|
||||
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
|
||||
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
|
||||
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
|
||||
{
|
||||
ViewXml = @"<View Scope='RecursiveAll'>
|
||||
<Query><Where>
|
||||
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
|
||||
</Where></Query>
|
||||
<Query></Query>
|
||||
<ViewFields>
|
||||
<FieldRef Name='FSObjType' />
|
||||
<FieldRef Name='FileDirRef' />
|
||||
<FieldRef Name='File_x0020_Size' />
|
||||
<FieldRef Name='SMTotalSize' />
|
||||
<FieldRef Name='SMTotalFileStreamSize' />
|
||||
</ViewFields>
|
||||
<RowLimit Paged='TRUE'>500</RowLimit>
|
||||
<RowLimit Paged='TRUE'>5000</RowLimit>
|
||||
</View>"
|
||||
};
|
||||
|
||||
@@ -223,29 +241,38 @@ public class StorageService : IStorageService
|
||||
items = lib.GetItems(query);
|
||||
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
||||
ic => ic.Include(
|
||||
i => i["FSObjType"],
|
||||
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);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
long size = 0;
|
||||
if (long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long s))
|
||||
size = s;
|
||||
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
|
||||
|
||||
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() ?? "";
|
||||
|
||||
// Always count toward the library root
|
||||
libNode.TotalSizeBytes += size;
|
||||
libNode.FileStreamSizeBytes += size;
|
||||
libNode.TotalSizeBytes += totalSize;
|
||||
libNode.FileStreamSizeBytes += streamSize;
|
||||
libNode.TotalFileCount++;
|
||||
|
||||
// Also count toward the most specific matching subfolder
|
||||
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
|
||||
if (matchedFolder != null && matchedFolder != libNode)
|
||||
{
|
||||
matchedFolder.TotalSizeBytes += size;
|
||||
matchedFolder.FileStreamSizeBytes += size;
|
||||
matchedFolder.TotalSizeBytes += totalSize;
|
||||
matchedFolder.FileStreamSizeBytes += streamSize;
|
||||
matchedFolder.TotalFileCount++;
|
||||
}
|
||||
}
|
||||
@@ -253,9 +280,37 @@ public class StorageService : IStorageService
|
||||
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||
}
|
||||
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)
|
||||
{
|
||||
foreach (var child in node.Children)
|
||||
@@ -349,6 +404,7 @@ public class StorageService : IStorageService
|
||||
|
||||
private static async Task CollectSubfoldersAsync(
|
||||
ClientContext ctx,
|
||||
List list,
|
||||
string parentServerRelativeUrl,
|
||||
StorageNode parentNode,
|
||||
int currentDepth,
|
||||
@@ -361,31 +417,42 @@ public class StorageService : IStorageService
|
||||
if (currentDepth > maxDepth) return;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Load direct child folders of this folder
|
||||
Folder parentFolder = ctx.Web.GetFolderByServerRelativeUrl(parentServerRelativeUrl);
|
||||
ctx.Load(parentFolder,
|
||||
f => f.Folders.Include(
|
||||
sf => sf.Name,
|
||||
sf => sf.ServerRelativeUrl));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
// Enumerate direct child folders via paginated CAML scoped to the parent.
|
||||
// Folder.Folders lazy loading hits the list-view threshold on libraries
|
||||
// > 5,000 items; a paged CAML query with no WHERE bypasses it.
|
||||
var subfolders = new List<(string Name, string ServerRelativeUrl)>();
|
||||
|
||||
foreach (Folder subFolder in parentFolder.Folders)
|
||||
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||
ctx, list, parentServerRelativeUrl, recursive: false,
|
||||
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
|
||||
ct: ct))
|
||||
{
|
||||
if (item["FSObjType"]?.ToString() != "1") continue; // 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();
|
||||
|
||||
// Skip SharePoint system folders
|
||||
if (subFolder.Name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
|
||||
subFolder.Name.StartsWith("_", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var childNode = await LoadFolderNodeAsync(
|
||||
ctx, subFolder.ServerRelativeUrl, subFolder.Name,
|
||||
ctx, sub.ServerRelativeUrl, sub.Name,
|
||||
siteTitle, library, currentDepth, progress, ct);
|
||||
|
||||
if (currentDepth < maxDepth)
|
||||
{
|
||||
await CollectSubfoldersAsync(
|
||||
ctx, subFolder.ServerRelativeUrl, childNode,
|
||||
ctx, list, sub.ServerRelativeUrl, childNode,
|
||||
currentDepth + 1, maxDepth,
|
||||
siteTitle, library, progress, ct);
|
||||
}
|
||||
|
||||
@@ -93,8 +93,7 @@ public class TemplateService : ITemplateService
|
||||
{
|
||||
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
libInfo.Folders = await EnumerateFoldersRecursiveAsync(
|
||||
ctx, list.RootFolder, string.Empty, progress, ct);
|
||||
libInfo.Folders = await EnumerateLibraryFoldersAsync(ctx, list, ct);
|
||||
}
|
||||
|
||||
template.Libraries.Add(libInfo);
|
||||
@@ -293,39 +292,72 @@ public class TemplateService : ITemplateService
|
||||
return siteUrl;
|
||||
}
|
||||
|
||||
private async Task<List<TemplateFolderInfo>> EnumerateFoldersRecursiveAsync(
|
||||
/// <summary>
|
||||
/// Enumerates every folder in a library via one paginated CAML scan, then
|
||||
/// reconstructs the hierarchy from the server-relative paths. Replaces the
|
||||
/// former per-level Folder.Folders lazy loading, which hits the list-view
|
||||
/// threshold on libraries above 5,000 items.
|
||||
/// </summary>
|
||||
private static async Task<List<TemplateFolderInfo>> EnumerateLibraryFoldersAsync(
|
||||
ClientContext ctx,
|
||||
Folder parentFolder,
|
||||
string parentRelativePath,
|
||||
IProgress<OperationProgress> progress,
|
||||
List list,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = new List<TemplateFolderInfo>();
|
||||
|
||||
ctx.Load(parentFolder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
foreach (var subFolder in parentFolder.Folders)
|
||||
// Collect all folders flat: (relativePath, parentRelativePath).
|
||||
var folders = new List<(string Relative, string Parent)>();
|
||||
|
||||
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||
ctx, list, rootUrl, recursive: true,
|
||||
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "FileDirRef" },
|
||||
ct: ct))
|
||||
{
|
||||
// Skip system folders
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
if (item["FSObjType"]?.ToString() != "1") continue; // folders only
|
||||
|
||||
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
var fileRef = (item["FileRef"]?.ToString() ?? string.Empty).TrimEnd('/');
|
||||
var dirRef = (item["FileDirRef"]?.ToString() ?? string.Empty).TrimEnd('/');
|
||||
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(fileRef)) continue;
|
||||
if (name.StartsWith("_", StringComparison.Ordinal) ||
|
||||
name.Equals("Forms", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var relativePath = string.IsNullOrEmpty(parentRelativePath)
|
||||
? subFolder.Name
|
||||
: $"{parentRelativePath}/{subFolder.Name}";
|
||||
// Paths relative to the library root.
|
||||
var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
|
||||
? fileRef.Substring(rootUrl.Length).TrimStart('/')
|
||||
: name;
|
||||
var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
|
||||
? dirRef.Substring(rootUrl.Length).TrimStart('/')
|
||||
: string.Empty;
|
||||
|
||||
var folderInfo = new TemplateFolderInfo
|
||||
{
|
||||
Name = subFolder.Name,
|
||||
RelativePath = relativePath,
|
||||
Children = await EnumerateFoldersRecursiveAsync(ctx, subFolder, relativePath, progress, ct),
|
||||
};
|
||||
result.Add(folderInfo);
|
||||
folders.Add((rel, parentRel));
|
||||
}
|
||||
|
||||
return result;
|
||||
// Build tree keyed by relative path.
|
||||
var nodes = folders.ToDictionary(
|
||||
f => f.Relative,
|
||||
f => new TemplateFolderInfo
|
||||
{
|
||||
Name = System.IO.Path.GetFileName(f.Relative),
|
||||
RelativePath = f.Relative,
|
||||
Children = new List<TemplateFolderInfo>(),
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var roots = new List<TemplateFolderInfo>();
|
||||
foreach (var (rel, parent) in folders)
|
||||
{
|
||||
if (!nodes.TryGetValue(rel, out var node)) continue;
|
||||
if (!string.IsNullOrEmpty(parent) && nodes.TryGetValue(parent, out var p))
|
||||
p.Children.Add(node);
|
||||
else
|
||||
roots.Add(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static async Task CreateFoldersFromTemplateAsync(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
public enum ThemeMode { System, Light, Dark }
|
||||
|
||||
/// <summary>
|
||||
/// Swaps the merged palette dictionary at runtime so all DynamicResource brush lookups retint live.
|
||||
/// "System" mode reads HKCU AppsUseLightTheme (0 = dark, 1 = light) and subscribes to system theme changes.
|
||||
/// </summary>
|
||||
public class ThemeManager
|
||||
{
|
||||
private const string LightPaletteSource = "pack://application:,,,/Themes/LightPalette.xaml";
|
||||
private const string DarkPaletteSource = "pack://application:,,,/Themes/DarkPalette.xaml";
|
||||
private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
|
||||
private readonly ILogger<ThemeManager> _logger;
|
||||
private ThemeMode _mode = ThemeMode.System;
|
||||
private bool _systemSubscribed;
|
||||
|
||||
public event EventHandler? ThemeChanged;
|
||||
|
||||
public ThemeMode Mode => _mode;
|
||||
public bool IsDarkActive { get; private set; }
|
||||
|
||||
public ThemeManager(ILogger<ThemeManager> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void ApplyMode(ThemeMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
bool dark = ResolveDark(mode);
|
||||
ApplyPalette(dark);
|
||||
EnsureSystemSubscription(mode);
|
||||
}
|
||||
|
||||
public void ApplyFromString(string? value)
|
||||
{
|
||||
var mode = (value ?? "System") switch
|
||||
{
|
||||
"Light" => ThemeMode.Light,
|
||||
"Dark" => ThemeMode.Dark,
|
||||
_ => ThemeMode.System,
|
||||
};
|
||||
ApplyMode(mode);
|
||||
}
|
||||
|
||||
private bool ResolveDark(ThemeMode mode) => mode switch
|
||||
{
|
||||
ThemeMode.Light => false,
|
||||
ThemeMode.Dark => true,
|
||||
_ => ReadSystemPrefersDark(),
|
||||
};
|
||||
|
||||
private bool ReadSystemPrefersDark()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
|
||||
if (key?.GetValue("AppsUseLightTheme") is int v)
|
||||
return v == 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read system theme preference, defaulting to light");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyPalette(bool dark)
|
||||
{
|
||||
var app = Application.Current;
|
||||
if (app is null) return;
|
||||
|
||||
var newPalette = new ResourceDictionary
|
||||
{
|
||||
Source = new Uri(dark ? DarkPaletteSource : LightPaletteSource, UriKind.Absolute)
|
||||
};
|
||||
|
||||
var dicts = app.Resources.MergedDictionaries;
|
||||
int replaced = -1;
|
||||
for (int i = 0; i < dicts.Count; i++)
|
||||
{
|
||||
var src = dicts[i].Source?.OriginalString ?? string.Empty;
|
||||
if (src.EndsWith("LightPalette.xaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
src.EndsWith("DarkPalette.xaml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
replaced = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (replaced >= 0)
|
||||
dicts[replaced] = newPalette;
|
||||
else
|
||||
dicts.Insert(0, newPalette);
|
||||
|
||||
IsDarkActive = dark;
|
||||
ThemeChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void EnsureSystemSubscription(ThemeMode mode)
|
||||
{
|
||||
if (mode == ThemeMode.System && !_systemSubscribed)
|
||||
{
|
||||
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
|
||||
_systemSubscribed = true;
|
||||
}
|
||||
else if (mode != ThemeMode.System && _systemSubscribed)
|
||||
{
|
||||
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
|
||||
_systemSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||
{
|
||||
if (e.Category != UserPreferenceCategory.General) return;
|
||||
if (_mode != ThemeMode.System) return;
|
||||
|
||||
var app = Application.Current;
|
||||
if (app is null) return;
|
||||
|
||||
app.Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
bool dark = ReadSystemPrefersDark();
|
||||
if (dark != IsDarkActive)
|
||||
ApplyPalette(dark);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
IReadOnlyList<SiteInfo> sites,
|
||||
ScanOptions options,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
|
||||
{
|
||||
// Normalize target logins for case-insensitive matching.
|
||||
// Users may be identified by email ("alice@contoso.com") or full claim
|
||||
@@ -59,10 +60,21 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
};
|
||||
|
||||
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||
var permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
|
||||
IReadOnlyList<PermissionEntry> permEntries;
|
||||
try
|
||||
{
|
||||
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
|
||||
}
|
||||
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
|
||||
{
|
||||
var elevated = await onAccessDenied(site.Url, ct);
|
||||
if (!elevated)
|
||||
throw;
|
||||
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
|
||||
}
|
||||
|
||||
var userEntries = TransformEntries(permEntries, targets, site);
|
||||
allEntries.AddRange(userEntries);
|
||||
allEntries.AddRange(TransformEntries(permEntries, targets, site));
|
||||
}
|
||||
|
||||
progress.Report(new OperationProgress(sites.Count, sites.Count,
|
||||
|
||||
Reference in New Issue
Block a user