f4cc81bb71
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
335 lines
16 KiB
C#
335 lines
16 KiB
C#
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using System.IO;
|
|
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, ReportBranding? branding = null)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
_togIdx = 0;
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine("<!DOCTYPE html>");
|
|
sb.AppendLine("<html lang=\"en\">");
|
|
sb.AppendLine("<head>");
|
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
|
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
|
|
sb.AppendLine("""
|
|
<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>
|
|
""");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
|
|
|
// Summary cards
|
|
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
|
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
|
|
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
|
|
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
|
|
|
|
sb.AppendLine($"""
|
|
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
|
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
|
|
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
|
|
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
|
|
</div>
|
|
""");
|
|
|
|
sb.AppendLine($"""
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>{T["report.col.library_folder"]}</th>
|
|
<th>{T["report.col.site"]}</th>
|
|
<th class="num">{T["report.stat.files"]}</th>
|
|
<th class="num">{T["report.stat.total_size"]}</th>
|
|
<th class="num">{T["report.stat.version_size"]}</th>
|
|
<th>{T["report.col.last_modified"]}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
""");
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
RenderNode(sb, node);
|
|
}
|
|
|
|
sb.AppendLine("""
|
|
</tbody>
|
|
</table>
|
|
""");
|
|
|
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
|
sb.AppendLine("</body></html>");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an HTML report including a file-type breakdown chart section.
|
|
/// </summary>
|
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
_togIdx = 0;
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine("<!DOCTYPE html>");
|
|
sb.AppendLine("<html lang=\"en\">");
|
|
sb.AppendLine("<head>");
|
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
|
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
|
|
sb.AppendLine("""
|
|
<style>
|
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
|
h1 { color: #0078d4; }
|
|
h2 { color: #333; margin-top: 24px; }
|
|
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; }
|
|
.chart-section { margin: 20px 0; padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
|
.bar-row { display: flex; align-items: center; margin: 4px 0; }
|
|
.bar-label { width: 80px; font-size: 12px; font-weight: 600; text-align: right; padding-right: 10px; }
|
|
.bar-track { flex: 1; background: #eee; border-radius: 4px; height: 22px; position: relative; }
|
|
.bar-fill { height: 100%; border-radius: 4px; background: #0078d4; min-width: 2px; }
|
|
.bar-value { font-size: 11px; color: #555; padding-left: 8px; white-space: nowrap; min-width: 140px; }
|
|
.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
|
|
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #0078d4; }
|
|
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
|
</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>
|
|
""");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
|
|
|
// ── Summary cards ──
|
|
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
|
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
|
|
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
|
|
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
|
|
|
sb.AppendLine("<div class=\"stats\">");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// ── File type chart section ──
|
|
if (fileTypeMetrics.Count > 0)
|
|
{
|
|
var maxSize = fileTypeMetrics.Max(m => m.TotalSizeBytes);
|
|
var totalSize = fileTypeMetrics.Sum(m => m.TotalSizeBytes);
|
|
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
|
|
|
sb.AppendLine("<div class=\"chart-section\">");
|
|
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
|
|
|
|
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
|
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
|
|
|
int idx = 0;
|
|
foreach (var m in fileTypeMetrics.Take(15))
|
|
{
|
|
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
|
|
string color = colors[idx % colors.Length];
|
|
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
|
|
|
|
sb.AppendLine($"""
|
|
<div class="bar-row">
|
|
<span class="bar-label">{HtmlEncode(label)}</span>
|
|
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
|
|
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} files</span>
|
|
</div>
|
|
""");
|
|
idx++;
|
|
}
|
|
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
// ── Storage table ──
|
|
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
|
|
sb.AppendLine($"""
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>{T["report.col.library_folder"]}</th>
|
|
<th>{T["report.col.site"]}</th>
|
|
<th class="num">{T["report.stat.files"]}</th>
|
|
<th class="num">{T["report.stat.total_size"]}</th>
|
|
<th class="num">{T["report.stat.version_size"]}</th>
|
|
<th>{T["report.col.last_modified"]}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
""");
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
RenderNode(sb, node);
|
|
}
|
|
|
|
sb.AppendLine("""
|
|
</tbody>
|
|
</table>
|
|
""");
|
|
|
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {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, ReportBranding? branding = null)
|
|
{
|
|
var html = BuildHtml(nodes, branding);
|
|
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
|
}
|
|
|
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
|
{
|
|
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
|
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);
|
|
}
|