From 28714fbebca373654f31fe33bd5fe6abd2a87b12 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 9 Apr 2026 12:33:54 +0200 Subject: [PATCH] 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 --- .../Export/UserAccessCsvExportService.cs | 43 ++++++++++++++++++- .../Tabs/UserAccessAuditViewModel.cs | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs b/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs index 6a4577d..7cc8c35 100644 --- a/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs +++ b/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text; +using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; @@ -89,12 +90,51 @@ public class UserAccessCsvExportService /// /// Writes all entries to a single CSV file (alternative for single-file export). /// Used when the ViewModel export command picks a single file path. + /// When is true, entries are consolidated using + /// and written in a compact multi-location format. /// public async Task WriteSingleFileAsync( IReadOnlyList entries, 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 fullHeader = "\"User\",\"User Login\"," + DataHeader; @@ -125,6 +165,7 @@ public class UserAccessCsvExportService await File.WriteAllTextAsync(filePath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + } } /// RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes. diff --git a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs index 14b24a8..ecac2a1 100644 --- a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs @@ -496,7 +496,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase if (dialog.ShowDialog() != true) return; try { - await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None); + await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions); OpenFile(dialog.FileName); } catch (Exception ex)