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("\"", "\"\"")}\"";
+ }
}