chore: complete v1.0 milestone
All checks were successful
Release zip package / release (push) Successful in 10s
All checks were successful
Release zip package / release (push) Successful in 10s
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>
This commit is contained in:
340
.planning/milestones/v1.0-phases/03-storage/03-03-PLAN.md
Normal file
340
.planning/milestones/v1.0-phases/03-storage/03-03-PLAN.md
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
phase: 03
|
||||
plan: 03
|
||||
title: Storage Export Services — CSV and Collapsible-Tree HTML
|
||||
status: pending
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 03-02
|
||||
files_modified:
|
||||
- SharepointToolbox/Services/Export/StorageCsvExportService.cs
|
||||
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- STOR-04
|
||||
- STOR-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "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"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Services/Export/StorageCsvExportService.cs"
|
||||
provides: "CSV exporter for StorageNode list (STOR-04)"
|
||||
exports: ["StorageCsvExportService"]
|
||||
- path: "SharepointToolbox/Services/Export/StorageHtmlExportService.cs"
|
||||
provides: "Collapsible-tree HTML exporter for StorageNode list (STOR-05)"
|
||||
exports: ["StorageHtmlExportService"]
|
||||
key_links:
|
||||
- from: "StorageCsvExportService.cs"
|
||||
to: "StorageNode.VersionSizeBytes"
|
||||
via: "computed property"
|
||||
pattern: "VersionSizeBytes"
|
||||
- from: "StorageHtmlExportService.cs"
|
||||
to: "toggle(i) JS"
|
||||
via: "inline script"
|
||||
pattern: "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.
|
||||
|
||||
```csharp
|
||||
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:**
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```csharp
|
||||
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})\">▶</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})\">▶</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:**
|
||||
|
||||
```bash
|
||||
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageHtmlExportServiceTests" -x
|
||||
```
|
||||
|
||||
Expected: 3 tests pass
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
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`
|
||||
Reference in New Issue
Block a user