From 44913f807588505dc99d8f7f6b8bd8c2504d58bb Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 13:58:39 +0200 Subject: [PATCH] feat(02-04): implement CsvExportService with Merge-PermissionRows port - GroupBy (Users, PermissionLevels, GrantedThrough) to merge duplicate entries - Pipe-joins URLs and Titles for merged rows - RFC 4180 CSV escaping: all fields double-quoted, internal quotes doubled - WriteAsync uses UTF-8 with BOM for Excel compatibility - All 3 CsvExportServiceTests pass --- .../Services/Export/CsvExportService.cs | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/SharepointToolbox/Services/Export/CsvExportService.cs b/SharepointToolbox/Services/Export/CsvExportService.cs index f4397b2..ee033a2 100644 --- a/SharepointToolbox/Services/Export/CsvExportService.cs +++ b/SharepointToolbox/Services/Export/CsvExportService.cs @@ -1,18 +1,67 @@ +using System.IO; +using System.Text; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; /// /// Exports permission entries to CSV format. -/// Full implementation will be added in Plan 03. +/// Ports PowerShell Merge-PermissionRows + Export-Csv functionality. /// public class CsvExportService { - /// Builds a CSV string from the supplied permission entries. - public string BuildCsv(IReadOnlyList entries) => - throw new NotImplementedException("CsvExportService.BuildCsv — implemented in Plan 03"); + private const string Header = + "\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\""; - /// Writes the CSV output to a file. - public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) => - throw new NotImplementedException("CsvExportService.WriteAsync — implemented in Plan 03"); + /// + /// Builds a CSV string from the supplied permission entries. + /// Merges rows with identical (Users, PermissionLevels, GrantedThrough) by pipe-joining URLs and Titles. + /// + public string BuildCsv(IReadOnlyList entries) + { + var sb = new StringBuilder(); + sb.AppendLine(Header); + + // Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows + var merged = entries + .GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough)) + .Select(g => new + { + ObjectType = g.First().ObjectType, + Title = string.Join(" | ", g.Select(e => e.Title).Distinct()), + Url = string.Join(" | ", g.Select(e => e.Url).Distinct()), + HasUnique = g.First().HasUniquePermissions, + Users = g.Key.Users, + UserLogins = g.First().UserLogins, + PrincipalType = g.First().PrincipalType, + Permissions = g.Key.PermissionLevels, + GrantedThrough = g.Key.GrantedThrough + }); + + foreach (var row in merged) + sb.AppendLine(string.Join(",", new[] + { + Csv(row.ObjectType), Csv(row.Title), Csv(row.Url), + Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins), + Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough) + })); + + return sb.ToString(); + } + + /// + /// Writes the CSV to the specified file path using UTF-8 with BOM (for Excel compatibility). + /// + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) + { + var csv = BuildCsv(entries); + await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + } + + /// RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes. + private static string Csv(string value) + { + if (string.IsNullOrEmpty(value)) return "\"\""; + return $"\"{value.Replace("\"", "\"\"")}\""; + } }