feat(16-01): implement consolidated CSV export path and wire ViewModel call site

- Added mergePermissions=false optional parameter to WriteSingleFileAsync
- Added early-return consolidated branch using PermissionConsolidator.Consolidate
- Consolidated CSV uses distinct header with Locations and LocationCount columns
- Locations column is semicolon-separated site titles for multi-location rows
- Existing non-consolidated code path is completely unchanged
- UserAccessAuditViewModel.ExportCsvAsync now passes MergePermissions to service
This commit is contained in:
Dev
2026-04-09 12:33:54 +02:00
parent 4f7a6e3faa
commit 28714fbebc
2 changed files with 43 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -89,11 +90,50 @@ public class UserAccessCsvExportService
/// <summary> /// <summary>
/// Writes all entries to a single CSV file (alternative for single-file export). /// Writes all entries to a single CSV file (alternative for single-file export).
/// Used when the ViewModel export command picks a single file path. /// Used when the ViewModel export command picks a single file path.
/// When <paramref name="mergePermissions"/> is true, entries are consolidated using
/// <see cref="PermissionConsolidator"/> and written in a compact multi-location format.
/// </summary> /// </summary>
public async Task WriteSingleFileAsync( public async Task WriteSingleFileAsync(
IReadOnlyList<UserAccessEntry> entries, IReadOnlyList<UserAccessEntry> entries,
string filePath, string filePath,
CancellationToken ct) CancellationToken ct,
bool mergePermissions = false)
{
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();
// Header
sb.AppendLine("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
{
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
sb.AppendLine(string.Join(",", new[]
{
$"\"{entry.UserDisplayName}\"",
$"\"{entry.UserLogin}\"",
$"\"{entry.PermissionLevel}\"",
$"\"{entry.AccessType}\"",
$"\"{entry.GrantedThrough}\"",
$"\"{locations}\"",
$"\"{entry.LocationCount}\""
}));
}
await File.WriteAllTextAsync(filePath, sb.ToString(), new UTF8Encoding(false), ct);
return;
}
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var fullHeader = "\"User\",\"User Login\"," + DataHeader; var fullHeader = "\"User\",\"User Login\"," + DataHeader;
@@ -126,6 +166,7 @@ public class UserAccessCsvExportService
await File.WriteAllTextAsync(filePath, sb.ToString(), await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
} }
}
/// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary> /// <summary>RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes.</summary>
private static string Csv(string value) private static string Csv(string value)

View File

@@ -496,7 +496,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return; if (dialog.ShowDialog() != true) return;
try try
{ {
await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None); await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions);
OpenFile(dialog.FileName); OpenFile(dialog.FileName);
} }
catch (Exception ex) catch (Exception ex)