Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/03-storage/03-03-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

13 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 03 Storage Export Services — CSV and Collapsible-Tree HTML pending 2
03-02
SharepointToolbox/Services/Export/StorageCsvExportService.cs
SharepointToolbox/Services/Export/StorageHtmlExportService.cs
true
STOR-04
STOR-05
truths artifacts key_links
StorageCsvExportService.BuildCsv produces a UTF-8 BOM CSV with header: Library, Site, Files, Total Size (MB), Version Size (MB), Last Modified
StorageCsvExportService.BuildCsv includes one row per StorageNode (flattened, respects IndentLevel for Library name prefix)
StorageHtmlExportService.BuildHtml produces a self-contained HTML file with inline CSS and JS — no external dependencies
StorageHtmlExportService.BuildHtml includes toggle(i) JS and collapsible subfolder rows (sf-{i} IDs)
StorageCsvExportServiceTests: all 3 tests pass
StorageHtmlExportServiceTests: all 3 tests pass
path provides exports
SharepointToolbox/Services/Export/StorageCsvExportService.cs CSV exporter for StorageNode list (STOR-04)
StorageCsvExportService
path provides exports
SharepointToolbox/Services/Export/StorageHtmlExportService.cs Collapsible-tree HTML exporter for StorageNode list (STOR-05)
StorageHtmlExportService
from to via pattern
StorageCsvExportService.cs StorageNode.VersionSizeBytes computed property VersionSizeBytes
from to via pattern
StorageHtmlExportService.cs toggle(i) JS inline script toggle(

Plan 03-03: Storage Export Services — CSV and Collapsible-Tree HTML

Goal

Replace the stub implementations in StorageCsvExportService and StorageHtmlExportService with real implementations. The CSV export produces a flat UTF-8 BOM CSV compatible with Excel. The HTML export ports the PowerShell Export-StorageToHTML function (PS lines 1621-1780), producing a self-contained HTML file with a collapsible tree view driven by an inline toggle(i) JavaScript function.

Context

Plan 03-01 created stub BuildCsv/BuildHtml methods returning string.Empty. This plan fills them in. The test files StorageCsvExportServiceTests.cs and StorageHtmlExportServiceTests.cs already exist and define the expected output — they currently fail because of the stubs.

Pattern reference: Phase 2 CsvExportService uses UTF-8 BOM + RFC 4180 quoting. The same Csv() helper pattern is applied here. StorageHtmlExportService uses a _togIdx counter reset at the start of each BuildHtml call (per the PS pattern) to generate unique IDs for collapsible rows.

Tasks

Task 1: Implement StorageCsvExportService

File: SharepointToolbox/Services/Export/StorageCsvExportService.cs

Action: Modify (replace stub with full implementation)

Why: STOR-04 — user can export storage metrics to CSV.

using SharepointToolbox.Core.Models;
using System.Text;

namespace SharepointToolbox.Services.Export;

/// <summary>
/// Exports a flat list of StorageNode objects to a UTF-8 BOM CSV.
/// Compatible with Microsoft Excel (BOM signals UTF-8 encoding).
/// </summary>
public class StorageCsvExportService
{
    public string BuildCsv(IReadOnlyList<StorageNode> nodes)
    {
        var sb = new StringBuilder();

        // Header
        sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");

        foreach (var node in nodes)
        {
            sb.AppendLine(string.Join(",",
                Csv(node.Name),
                Csv(node.SiteTitle),
                node.TotalFileCount.ToString(),
                FormatMb(node.TotalSizeBytes),
                FormatMb(node.VersionSizeBytes),
                node.LastModified.HasValue
                    ? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
                    : string.Empty));
        }

        return sb.ToString();
    }

    public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
    {
        var csv = BuildCsv(nodes);
        // UTF-8 with BOM for Excel compatibility
        await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private static string FormatMb(long bytes)
        => (bytes / (1024.0 * 1024.0)).ToString("F2");

    /// <summary>RFC 4180 CSV field quoting.</summary>
    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;
    }
}

Verification:

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

Expected: 3 tests pass

Task 2: Implement StorageHtmlExportService

File: SharepointToolbox/Services/Export/StorageHtmlExportService.cs

Action: Modify (replace stub with full implementation)

Why: STOR-05 — user can export storage metrics to interactive HTML with collapsible tree view.

using SharepointToolbox.Core.Models;
using System.Text;

namespace SharepointToolbox.Services.Export;

/// <summary>
/// Exports StorageNode tree to a self-contained HTML file with collapsible subfolder rows.
/// Port of PS Export-StorageToHTML (PS lines 1621-1780).
/// Uses a toggle(i) JS pattern where each collapsible row has id="sf-{i}".
/// </summary>
public class StorageHtmlExportService
{
    private int _togIdx;

    public string BuildHtml(IReadOnlyList<StorageNode> nodes)
    {
        _togIdx = 0;
        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 Storage Metrics</title>
            <style>
              body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
              h1 { color: #0078d4; }
              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; font-weight: 600; }
              td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
              tr:hover { background: #f0f7ff; }
              .toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
                            cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
              .toggle-btn:hover { background: #e5f1fb; }
              .sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
              .sf-tbl td { background: #fafcff; font-size: 12px; }
              .num { text-align: right; font-variant-numeric: tabular-nums; }
              .generated { font-size: 11px; color: #888; margin-top: 12px; }
            </style>
            <script>
              function toggle(i) {
                var row = document.getElementById('sf-' + i);
                if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
              }
            </script>
            </head>
            <body>
            <h1>SharePoint Storage Metrics</h1>
            """);

        sb.AppendLine("""
            <table>
              <thead>
                <tr>
                  <th>Library / Folder</th>
                  <th>Site</th>
                  <th class="num">Files</th>
                  <th class="num">Total Size</th>
                  <th class="num">Version Size</th>
                  <th>Last Modified</th>
                </tr>
              </thead>
              <tbody>
            """);

        foreach (var node in nodes)
        {
            RenderNode(sb, node);
        }

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

        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<StorageNode> nodes, string filePath, CancellationToken ct)
    {
        var html = BuildHtml(nodes);
        await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
    }

    // ── Private rendering ────────────────────────────────────────────────────

    private void RenderNode(StringBuilder sb, StorageNode node)
    {
        bool hasChildren = node.Children.Count > 0;
        int myIdx = hasChildren ? ++_togIdx : 0;

        string nameCell = hasChildren
            ? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
            : $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";

        string lastMod = node.LastModified.HasValue
            ? node.LastModified.Value.ToString("yyyy-MM-dd")
            : string.Empty;

        sb.AppendLine($"""
              <tr>
                <td>{nameCell}</td>
                <td>{HtmlEncode(node.SiteTitle)}</td>
                <td class="num">{node.TotalFileCount:N0}</td>
                <td class="num">{FormatSize(node.TotalSizeBytes)}</td>
                <td class="num">{FormatSize(node.VersionSizeBytes)}</td>
                <td>{lastMod}</td>
              </tr>
            """);

        if (hasChildren)
        {
            sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
            sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
            foreach (var child in node.Children)
            {
                RenderChildNode(sb, child);
            }
            sb.AppendLine("</tbody></table>");
            sb.AppendLine("</td></tr>");
        }
    }

    private void RenderChildNode(StringBuilder sb, StorageNode node)
    {
        bool hasChildren = node.Children.Count > 0;
        int myIdx = hasChildren ? ++_togIdx : 0;

        string indent = $"margin-left:{(node.IndentLevel + 1) * 16}px";
        string nameCell = hasChildren
            ? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
            : $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";

        string lastMod = node.LastModified.HasValue
            ? node.LastModified.Value.ToString("yyyy-MM-dd")
            : string.Empty;

        sb.AppendLine($"""
              <tr>
                <td>{nameCell}</td>
                <td>{HtmlEncode(node.SiteTitle)}</td>
                <td class="num">{node.TotalFileCount:N0}</td>
                <td class="num">{FormatSize(node.TotalSizeBytes)}</td>
                <td class="num">{FormatSize(node.VersionSizeBytes)}</td>
                <td>{lastMod}</td>
              </tr>
            """);

        if (hasChildren)
        {
            sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
            sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
            foreach (var child in node.Children)
            {
                RenderChildNode(sb, child);
            }
            sb.AppendLine("</tbody></table>");
            sb.AppendLine("</td></tr>");
        }
    }

    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";
    }

    private static string HtmlEncode(string value)
        => System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
}

Verification:

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

Expected: 3 tests pass

Verification

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

Expected: 6 tests pass, 0 fail

Commit Message

feat(03-03): implement StorageCsvExportService and StorageHtmlExportService

Output

After completion, create .planning/phases/03-storage/03-03-SUMMARY.md