Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/03-storage/03-05-PLAN.md
Dev 655bb79a99
All checks were successful
Release zip package / release (push) Successful in 10s
chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:15:14 +02:00

19 KiB

phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan title status wave depends_on files_modified autonomous requirements must_haves
03 05 Search and Duplicate Export Services — CSV, Sortable HTML, and Grouped HTML pending 3
03-04
SharepointToolbox/Services/Export/SearchCsvExportService.cs
SharepointToolbox/Services/Export/SearchHtmlExportService.cs
SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
true
SRCH-03
SRCH-04
DUPL-03
truths artifacts key_links
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
path provides exports
SharepointToolbox/Services/Export/SearchCsvExportService.cs CSV exporter for SearchResult list (SRCH-03)
SearchCsvExportService
path provides exports
SharepointToolbox/Services/Export/SearchHtmlExportService.cs Sortable/filterable HTML exporter for SearchResult list (SRCH-04)
SearchHtmlExportService
path provides exports
SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs Grouped HTML exporter for DuplicateGroup list (DUPL-03)
DuplicatesHtmlExportService
from to via pattern
SearchHtmlExportService.cs sortTable JS inline script sort
from to via pattern
DuplicatesHtmlExportService.cs group card HTML per-DuplicateGroup rendering 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).

// SharepointToolbox/Services/Export/SearchCsvExportService.cs
using SharepointToolbox.Core.Models;
using System.Text;

namespace SharepointToolbox.Services.Export;

/// <summary>
/// Exports SearchResult list to a UTF-8 BOM CSV file.
/// Header matches the column order in SearchHtmlExportService for consistency.
/// </summary>
public class SearchCsvExportService
{
    public string BuildCsv(IReadOnlyList<SearchResult> 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<SearchResult> 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!;
}
// SharepointToolbox/Services/Export/SearchHtmlExportService.cs
using SharepointToolbox.Core.Models;
using System.Text;

namespace SharepointToolbox.Services.Export;

/// <summary>
/// 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.
/// </summary>
public class SearchHtmlExportService
{
    public string BuildHtml(IReadOnlyList<SearchResult> results)
    {
        var sb = new StringBuilder();

        sb.AppendLine("""
            <!DOCTYPE html>
            <html lang="en">
            <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>SharePoint File Search Results</title>
            <style>
              body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
              h1 { color: #0078d4; }
              .toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
              .toolbar label { font-weight: 600; }
              #filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
              #resultCount { font-size: 12px; color: #666; }
              table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
              th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
                   font-weight: 600; user-select: none; white-space: nowrap; }
              th:hover { background: #106ebe; }
              th.sorted-asc::after  { content: ' ▲'; font-size: 10px; }
              th.sorted-desc::after { content: ' ▼'; font-size: 10px; }
              td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
              tr:hover td { background: #f0f7ff; }
              tr.hidden { display: none; }
              .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
              .generated { font-size: 11px; color: #888; margin-top: 12px; }
            </style>
            </head>
            <body>
            <h1>File Search Results</h1>
            <div class="toolbar">
              <label for="filterInput">Filter:</label>
              <input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
              <span id="resultCount"></span>
            </div>
            """);

        sb.AppendLine("""
            <table id="resultsTable">
              <thead>
                <tr>
                  <th onclick="sortTable(0)">File Name</th>
                  <th onclick="sortTable(1)">Extension</th>
                  <th onclick="sortTable(2)">Path</th>
                  <th onclick="sortTable(3)">Created</th>
                  <th onclick="sortTable(4)">Created By</th>
                  <th onclick="sortTable(5)">Modified</th>
                  <th onclick="sortTable(6)">Modified By</th>
                  <th class="num" onclick="sortTable(7)">Size</th>
                </tr>
              </thead>
              <tbody>
            """);

        foreach (var r in results)
        {
            string fileName = System.IO.Path.GetFileName(r.Path);
            if (string.IsNullOrEmpty(fileName)) fileName = r.Title;

            sb.AppendLine($"""
                  <tr>
                    <td>{H(fileName)}</td>
                    <td>{H(r.FileExtension)}</td>
                    <td><a href="{H(r.Path)}" target="_blank">{H(r.Path)}</a></td>
                    <td>{(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
                    <td>{H(r.Author)}</td>
                    <td>{(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
                    <td>{H(r.ModifiedBy)}</td>
                    <td class="num" data-sort="{r.SizeBytes}">{FormatSize(r.SizeBytes)}</td>
                  </tr>
                """);
        }

        sb.AppendLine("  </tbody>\n</table>");

        // Inline sort + filter JS
        sb.AppendLine($$"""
            <p class="generated">Generated: {{DateTime.Now:yyyy-MM-dd HH:mm}} — {{results.Count:N0}} result(s)</p>
            <script>
            var sortDir = {};
            function sortTable(col) {
              var tbl = document.getElementById('resultsTable');
              var tbody = tbl.tBodies[0];
              var rows = Array.from(tbody.rows);
              var asc = sortDir[col] !== 'asc';
              sortDir[col] = asc ? 'asc' : 'desc';
              rows.sort(function(a, b) {
                var av = a.cells[col].dataset.sort || a.cells[col].innerText;
                var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
                var an = parseFloat(av), bn = parseFloat(bv);
                if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
                return asc ? av.localeCompare(bv) : bv.localeCompare(av);
              });
              rows.forEach(function(r) { tbody.appendChild(r); });
              var ths = tbl.tHead.rows[0].cells;
              for (var i = 0; i < ths.length; i++) {
                ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
              }
            }
            function filterTable() {
              var q = document.getElementById('filterInput').value.toLowerCase();
              var rows = document.getElementById('resultsTable').tBodies[0].rows;
              var visible = 0;
              for (var i = 0; i < rows.length; i++) {
                var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
                rows[i].className = match ? '' : 'hidden';
                if (match) visible++;
              }
              document.getElementById('resultCount').innerText = q ? (visible + ' of {{results.Count:N0}} shown') : '';
            }
            window.onload = function() {
              document.getElementById('resultCount').innerText = '{{results.Count:N0}} result(s)';
            };
            </script>
            </body></html>
            """);

        return sb.ToString();
    }

    public async Task WriteAsync(IReadOnlyList<SearchResult> 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:

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.

// SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
using SharepointToolbox.Core.Models;
using System.Text;

namespace SharepointToolbox.Services.Export;

/// <summary>
/// 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.
/// </summary>
public class DuplicatesHtmlExportService
{
    public string BuildHtml(IReadOnlyList<DuplicateGroup> groups)
    {
        var sb = new StringBuilder();

        sb.AppendLine("""
            <!DOCTYPE html>
            <html lang="en">
            <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>SharePoint Duplicate Detection Report</title>
            <style>
              body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
              h1 { color: #0078d4; }
              .summary { margin-bottom: 16px; font-size: 12px; color: #444; }
              .group-card { background: #fff; border: 1px solid #ddd; border-radius: 6px;
                            margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); overflow: hidden; }
              .group-header { background: #0078d4; color: #fff; padding: 8px 14px;
                              display: flex; align-items: center; justify-content: space-between;
                              cursor: pointer; user-select: none; }
              .group-header:hover { background: #106ebe; }
              .group-name { font-weight: 600; font-size: 14px; }
              .badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
                       font-size: 11px; font-weight: 700; }
              .badge-dup  { background: #e53935; color: #fff; }
              .group-body { padding: 0; }
              table { width: 100%; border-collapse: collapse; }
              th { background: #f0f7ff; color: #333; padding: 6px 12px; text-align: left;
                   font-weight: 600; border-bottom: 1px solid #ddd; font-size: 12px; }
              td { padding: 5px 12px; border-bottom: 1px solid #eee; font-size: 12px; word-break: break-all; }
              tr:last-child td { border-bottom: none; }
              .collapsed { display: none; }
              .generated { font-size: 11px; color: #888; margin-top: 16px; }
            </style>
            <script>
              function toggleGroup(id) {
                var body = document.getElementById('gb-' + id);
                if (body) body.classList.toggle('collapsed');
              }
            </script>
            </head>
            <body>
            <h1>Duplicate Detection Report</h1>
            """);

        sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");

        for (int i = 0; i < groups.Count; i++)
        {
            var g = groups[i];
            int count = g.Items.Count;
            string badgeClass = "badge-dup";

            sb.AppendLine($"""
                <div class="group-card">
                  <div class="group-header" onclick="toggleGroup({i})">
                    <span class="group-name">{H(g.Name)}</span>
                    <span class="badge {badgeClass}">{count} copies</span>
                  </div>
                  <div class="group-body" id="gb-{i}">
                    <table>
                      <thead>
                        <tr>
                          <th>#</th>
                          <th>Library</th>
                          <th>Path</th>
                          <th>Size</th>
                          <th>Created</th>
                          <th>Modified</th>
                        </tr>
                      </thead>
                      <tbody>
                """);

            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($"""
                          <tr>
                            <td>{j + 1}</td>
                            <td>{H(item.Library)}</td>
                            <td><a href="{H(item.Path)}" target="_blank">{H(item.Path)}</a></td>
                            <td>{size}</td>
                            <td>{created}</td>
                            <td>{modified}</td>
                          </tr>
                    """);
            }

            sb.AppendLine("""
                      </tbody>
                    </table>
                  </div>
                </div>
                """);
        }

        sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
        sb.AppendLine("</body></html>");

        return sb.ToString();
    }

    public async Task WriteAsync(IReadOnlyList<DuplicateGroup> 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:

dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesHtmlExportServiceTests" -x

Expected: 3 tests pass

Verification

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