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
This commit is contained in:
@@ -1,18 +1,67 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports permission entries to CSV format.
|
||||
/// Full implementation will be added in Plan 03.
|
||||
/// Ports PowerShell Merge-PermissionRows + Export-Csv functionality.
|
||||
/// </summary>
|
||||
public class CsvExportService
|
||||
{
|
||||
/// <summary>Builds a CSV string from the supplied permission entries.</summary>
|
||||
public string BuildCsv(IReadOnlyList<PermissionEntry> entries) =>
|
||||
throw new NotImplementedException("CsvExportService.BuildCsv — implemented in Plan 03");
|
||||
private const string Header =
|
||||
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
||||
|
||||
/// <summary>Writes the CSV output to a file.</summary>
|
||||
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct) =>
|
||||
throw new NotImplementedException("CsvExportService.WriteAsync — implemented in Plan 03");
|
||||
/// <summary>
|
||||
/// Builds a CSV string from the supplied permission entries.
|
||||
/// Merges rows with identical (Users, PermissionLevels, GrantedThrough) by pipe-joining URLs and Titles.
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<PermissionEntry> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the CSV to the specified file path using UTF-8 with BOM (for Excel compatibility).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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("\"", "\"\"")}\"";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user