--- phase: 03 plan: 05 title: Search and Duplicate Export Services — CSV, Sortable HTML, and Grouped HTML status: pending wave: 3 depends_on: - 03-04 files_modified: - SharepointToolbox/Services/Export/SearchCsvExportService.cs - SharepointToolbox/Services/Export/SearchHtmlExportService.cs - SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs autonomous: true requirements: - SRCH-03 - SRCH-04 - DUPL-03 must_haves: truths: - "SearchCsvExportService.BuildCsv produces a UTF-8 BOM CSV with header: File Name, Extension, Path, Created, Created By, Modified, Modified By, Size (bytes)" - "SearchHtmlExportService.BuildHtml produces a self-contained HTML with sortable columns (click-to-sort JS) and a filter/search input" - "DuplicatesHtmlExportService.BuildHtml produces a self-contained HTML with one card per group, showing item paths, and an ok/diff badge indicating group size" - "SearchExportServiceTests: all 6 tests pass" - "DuplicatesHtmlExportServiceTests: all 3 tests pass" artifacts: - path: "SharepointToolbox/Services/Export/SearchCsvExportService.cs" provides: "CSV exporter for SearchResult list (SRCH-03)" exports: ["SearchCsvExportService"] - path: "SharepointToolbox/Services/Export/SearchHtmlExportService.cs" provides: "Sortable/filterable HTML exporter for SearchResult list (SRCH-04)" exports: ["SearchHtmlExportService"] - path: "SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs" provides: "Grouped HTML exporter for DuplicateGroup list (DUPL-03)" exports: ["DuplicatesHtmlExportService"] key_links: - from: "SearchHtmlExportService.cs" to: "sortTable JS" via: "inline script" pattern: "sort" - from: "DuplicatesHtmlExportService.cs" to: "group card HTML" via: "per-DuplicateGroup rendering" pattern: "group" --- # Plan 03-05: Search and Duplicate Export Services — CSV, Sortable HTML, and Grouped HTML ## Goal Replace the three stub export implementations created in Plan 03-01 with real ones. `SearchCsvExportService` produces a UTF-8 BOM CSV. `SearchHtmlExportService` ports the PS `Export-SearchToHTML` pattern (PS lines 2112-2233) with sortable columns and a live filter input. `DuplicatesHtmlExportService` ports the PS `Export-DuplicatesToHTML` pattern (PS lines 2235-2406) with grouped cards and ok/diff badges. ## Context Test files `SearchExportServiceTests.cs` and `DuplicatesHtmlExportServiceTests.cs` already exist from Plan 03-01 and currently fail because stubs return `string.Empty`. This plan makes them pass. All HTML exports are self-contained (no external CDN or CSS links) using the same `Segoe UI` font stack and `#0078d4` color palette established in Phase 2. ## Tasks ### Task 1: Implement SearchCsvExportService and SearchHtmlExportService **Files:** - `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` **Action:** Modify (replace stubs with full implementation) **Why:** SRCH-03 (CSV export) and SRCH-04 (sortable/filterable HTML export). ```csharp // SharepointToolbox/Services/Export/SearchCsvExportService.cs using SharepointToolbox.Core.Models; using System.Text; namespace SharepointToolbox.Services.Export; /// /// Exports SearchResult list to a UTF-8 BOM CSV file. /// Header matches the column order in SearchHtmlExportService for consistency. /// public class SearchCsvExportService { public string BuildCsv(IReadOnlyList results) { var sb = new StringBuilder(); // Header sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)"); foreach (var r in results) { sb.AppendLine(string.Join(",", Csv(IfEmpty(System.IO.Path.GetFileName(r.Path), r.Title)), Csv(r.FileExtension), Csv(r.Path), r.Created.HasValue ? Csv(r.Created.Value.ToString("yyyy-MM-dd")) : string.Empty, Csv(r.Author), r.LastModified.HasValue ? Csv(r.LastModified.Value.ToString("yyyy-MM-dd")) : string.Empty, Csv(r.ModifiedBy), r.SizeBytes.ToString())); } return sb.ToString(); } public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) { var csv = BuildCsv(results); await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); } private static string Csv(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) return $"\"{value.Replace("\"", "\"\"")}\""; return value; } private static string IfEmpty(string? value, string fallback = "") => string.IsNullOrEmpty(value) ? fallback : value!; } ``` ```csharp // SharepointToolbox/Services/Export/SearchHtmlExportService.cs using SharepointToolbox.Core.Models; using System.Text; namespace SharepointToolbox.Services.Export; /// /// Exports SearchResult list to a self-contained sortable/filterable HTML report. /// Port of PS Export-SearchToHTML (PS lines 2112-2233). /// Columns are sortable by clicking the header. A filter input narrows rows by text match. /// public class SearchHtmlExportService { public string BuildHtml(IReadOnlyList results) { var sb = new StringBuilder(); sb.AppendLine(""" SharePoint File Search Results

File Search Results

"""); sb.AppendLine(""" """); foreach (var r in results) { string fileName = System.IO.Path.GetFileName(r.Path); if (string.IsNullOrEmpty(fileName)) fileName = r.Title; sb.AppendLine($""" """); } sb.AppendLine(" \n
File Name Extension Path Created Created By Modified Modified By Size
{H(fileName)} {H(r.FileExtension)} {H(r.Path)} {(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)} {H(r.Author)} {(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)} {H(r.ModifiedBy)} {FormatSize(r.SizeBytes)}
"); // Inline sort + filter JS sb.AppendLine($$"""

Generated: {{DateTime.Now:yyyy-MM-dd HH:mm}} — {{results.Count:N0}} result(s)

"""); return sb.ToString(); } public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) { var html = BuildHtml(results); await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); } private static string H(string value) => System.Net.WebUtility.HtmlEncode(value ?? string.Empty); private static string FormatSize(long bytes) { if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB"; if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB"; if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB"; return $"{bytes} B"; } } ``` **Verification:** ```bash dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SearchExportServiceTests" -x ``` Expected: 6 tests pass ### Task 2: Implement DuplicatesHtmlExportService **File:** `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs` **Action:** Modify (replace stub with full implementation) **Why:** DUPL-03 — user can export duplicate report to HTML with grouped display and visual indicators. ```csharp // SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs using SharepointToolbox.Core.Models; using System.Text; namespace SharepointToolbox.Services.Export; /// /// Exports DuplicateGroup list to a self-contained HTML with collapsible group cards. /// Port of PS Export-DuplicatesToHTML (PS lines 2235-2406). /// Each group gets a card showing item count badge and a table of paths. /// public class DuplicatesHtmlExportService { public string BuildHtml(IReadOnlyList groups) { var sb = new StringBuilder(); sb.AppendLine(""" SharePoint Duplicate Detection Report

Duplicate Detection Report

"""); sb.AppendLine($"

{groups.Count:N0} duplicate group(s) found.

"); for (int i = 0; i < groups.Count; i++) { var g = groups[i]; int count = g.Items.Count; string badgeClass = "badge-dup"; sb.AppendLine($"""
{H(g.Name)} {count} copies
"""); for (int j = 0; j < g.Items.Count; j++) { var item = g.Items[j]; string size = item.SizeBytes.HasValue ? FormatSize(item.SizeBytes.Value) : string.Empty; string created = item.Created.HasValue ? item.Created.Value.ToString("yyyy-MM-dd") : string.Empty; string modified = item.Modified.HasValue ? item.Modified.Value.ToString("yyyy-MM-dd") : string.Empty; sb.AppendLine($""" """); } sb.AppendLine("""
# Library Path Size Created Modified
{j + 1} {H(item.Library)} {H(item.Path)} {size} {created} {modified}
"""); } sb.AppendLine($"

Generated: {DateTime.Now:yyyy-MM-dd HH:mm}

"); sb.AppendLine(""); return sb.ToString(); } public async Task WriteAsync(IReadOnlyList groups, string filePath, CancellationToken ct) { var html = BuildHtml(groups); await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); } private static string H(string value) => System.Net.WebUtility.HtmlEncode(value ?? string.Empty); private static string FormatSize(long bytes) { if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB"; if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB"; if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB"; return $"{bytes} B"; } } ``` **Verification:** ```bash dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesHtmlExportServiceTests" -x ``` Expected: 3 tests pass ## Verification ```bash dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SearchExportServiceTests|FullyQualifiedName~DuplicatesHtmlExportServiceTests" -x ``` Expected: 9 tests pass, 0 fail ## Commit Message feat(03-05): implement SearchCsvExportService, SearchHtmlExportService, DuplicatesHtmlExportService ## Output After completion, create `.planning/phases/03-storage/03-05-SUMMARY.md`