Compare commits
1 Commits
f56e8813e5
..
v2.4.7
| Author | SHA1 | Date | |
|---|---|---|---|
| ecc7b329d4 |
@@ -24,4 +24,41 @@ internal static class ExportFileWriter
|
|||||||
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
||||||
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
||||||
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 with
|
||||||
|
/// BOM, chunk by chunk. Avoids the full-document <c>ToString()</c> copy
|
||||||
|
/// and the separate UTF-8 byte buffer that <see cref="File.WriteAllTextAsync(string, string, Encoding, CancellationToken)"/>
|
||||||
|
/// would otherwise allocate — meaningful for large CSV exports.
|
||||||
|
/// </summary>
|
||||||
|
public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
|
||||||
|
=> WriteChunksAsync(filePath, builder, Utf8WithBom, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 without
|
||||||
|
/// BOM. Same rationale as <see cref="WriteCsvChunksAsync"/> — for large
|
||||||
|
/// HTML reports it halves peak memory by skipping the intermediate string.
|
||||||
|
/// </summary>
|
||||||
|
public static Task WriteHtmlChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
|
||||||
|
=> WriteChunksAsync(filePath, builder, Utf8NoBom, ct);
|
||||||
|
|
||||||
|
private static async Task WriteChunksAsync(string filePath, StringBuilder builder, Encoding encoding, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// FileOptions.Asynchronous lets StreamWriter use true async I/O.
|
||||||
|
await using var fs = new FileStream(
|
||||||
|
filePath,
|
||||||
|
FileMode.Create,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 64 * 1024,
|
||||||
|
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
await using var sw = new StreamWriter(fs, encoding, bufferSize: 64 * 1024);
|
||||||
|
|
||||||
|
foreach (var chunk in builder.GetChunks())
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
await sw.WriteAsync(chunk, ct);
|
||||||
|
}
|
||||||
|
await sw.FlushAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Localization;
|
using SharepointToolbox.Localization;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -18,34 +19,58 @@ public class StorageCsvExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||||||
|
WriteCsv(sb, nodes);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
|
||||||
sb.AppendLine($"{T["report.col.library"]},{T["stor.col.kind"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
// Hoist resource lookups out of the row loop: ResourceManager.GetString
|
||||||
|
// is a culture-aware dictionary probe — caching once per export saves
|
||||||
|
// O(rows × columns) lookups on large tenants.
|
||||||
|
string colLibrary = T["report.col.library"];
|
||||||
|
string colKind = T["stor.col.kind"];
|
||||||
|
string colSite = T["report.col.site"];
|
||||||
|
string colFiles = T["report.stat.files"];
|
||||||
|
string colTotalMb = T["report.col.total_size_mb"];
|
||||||
|
string colVerMb = T["report.col.version_size_mb"];
|
||||||
|
string colLastMod = T["report.col.last_modified"];
|
||||||
|
|
||||||
|
sb.Append(colLibrary).Append(',')
|
||||||
|
.Append(colKind).Append(',')
|
||||||
|
.Append(colSite).Append(',')
|
||||||
|
.Append(colFiles).Append(',')
|
||||||
|
.Append(colTotalMb).Append(',')
|
||||||
|
.Append(colVerMb).Append(',')
|
||||||
|
.AppendLine(colLastMod);
|
||||||
|
|
||||||
|
var kindLabels = BuildKindLabelCache();
|
||||||
|
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
sb.AppendLine(string.Join(",",
|
AppendCsvField(sb, node.Name).Append(',');
|
||||||
Csv(node.Name),
|
AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
|
||||||
Csv(KindLabel(node.Kind)),
|
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||||||
Csv(node.SiteTitle),
|
sb.Append(node.TotalFileCount).Append(',');
|
||||||
node.TotalFileCount.ToString(),
|
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||||||
FormatMb(node.TotalSizeBytes),
|
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||||||
FormatMb(node.VersionSizeBytes),
|
if (node.LastModified.HasValue)
|
||||||
node.LastModified.HasValue
|
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
sb.AppendLine();
|
||||||
: string.Empty));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(nodes);
|
// Stream straight to disk: skip the StringBuilder→string copy and the
|
||||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
// separate UTF-8 buffer that File.WriteAllTextAsync materializes.
|
||||||
|
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||||||
|
WriteCsv(sb, nodes);
|
||||||
|
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -53,44 +78,68 @@ public class StorageCsvExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||||||
var sb = new StringBuilder();
|
WriteCsv(sb, nodes, fileTypeMetrics);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||||
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
string colLibrary = T["report.col.library"];
|
||||||
|
string colSite = T["report.col.site"];
|
||||||
|
string colFiles = T["report.stat.files"];
|
||||||
|
string colTotalMb = T["report.col.total_size_mb"];
|
||||||
|
string colVerMb = T["report.col.version_size_mb"];
|
||||||
|
string colLastMod = T["report.col.last_modified"];
|
||||||
|
|
||||||
|
sb.Append(colLibrary).Append(',')
|
||||||
|
.Append(colSite).Append(',')
|
||||||
|
.Append(colFiles).Append(',')
|
||||||
|
.Append(colTotalMb).Append(',')
|
||||||
|
.Append(colVerMb).Append(',')
|
||||||
|
.AppendLine(colLastMod);
|
||||||
|
|
||||||
// Library details
|
|
||||||
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
|
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
sb.AppendLine(string.Join(",",
|
AppendCsvField(sb, node.Name).Append(',');
|
||||||
Csv(node.Name),
|
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||||||
Csv(node.SiteTitle),
|
sb.Append(node.TotalFileCount).Append(',');
|
||||||
node.TotalFileCount.ToString(),
|
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||||||
FormatMb(node.TotalSizeBytes),
|
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||||||
FormatMb(node.VersionSizeBytes),
|
if (node.LastModified.HasValue)
|
||||||
node.LastModified.HasValue
|
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
sb.AppendLine();
|
||||||
: string.Empty));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File type breakdown
|
|
||||||
if (fileTypeMetrics.Count > 0)
|
if (fileTypeMetrics.Count > 0)
|
||||||
{
|
{
|
||||||
|
string colFileType = T["report.col.file_type"];
|
||||||
|
string colSizeMb = T["report.col.size_mb"];
|
||||||
|
string colFileCnt = T["report.col.file_count"];
|
||||||
|
string noExtLabel = T["report.text.no_extension"];
|
||||||
|
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
|
sb.Append(colFileType).Append(',')
|
||||||
|
.Append(colSizeMb).Append(',')
|
||||||
|
.AppendLine(colFileCnt);
|
||||||
|
|
||||||
foreach (var m in fileTypeMetrics)
|
foreach (var m in fileTypeMetrics)
|
||||||
{
|
{
|
||||||
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
|
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
|
||||||
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
AppendCsvField(sb, label).Append(',');
|
||||||
|
AppendMb(sb, m.TotalSizeBytes).Append(',');
|
||||||
|
sb.Append(m.FileCount).AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
|
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(nodes, fileTypeMetrics);
|
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
WriteCsv(sb, nodes, fileTypeMetrics);
|
||||||
|
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -139,11 +188,27 @@ public class StorageCsvExportService
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string FormatMb(long bytes)
|
private static StringBuilder AppendMb(StringBuilder sb, long bytes)
|
||||||
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
|
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
|
private static StringBuilder AppendCsvField(StringBuilder sb, string value)
|
||||||
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
=> sb.Append(CsvSanitizer.EscapeMinimal(value));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||||||
|
/// once per export, indexed by the enum's int value. Avoids a
|
||||||
|
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] BuildKindLabelCache()
|
||||||
|
{
|
||||||
|
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||||||
|
int max = 0;
|
||||||
|
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||||||
|
var cache = new string[max + 1];
|
||||||
|
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||||||
|
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
private static string KindLabel(StorageNodeKind kind)
|
private static string KindLabel(StorageNodeKind kind)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
public class StorageHtmlExportService
|
public class StorageHtmlExportService
|
||||||
{
|
{
|
||||||
private int _togIdx;
|
private int _togIdx;
|
||||||
|
private string[] _kindLabels = Array.Empty<string>();
|
||||||
|
private string[] _kindLabelsHtml = Array.Empty<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a self-contained HTML report with one collapsible row per
|
/// Builds a self-contained HTML report with one collapsible row per
|
||||||
@@ -21,10 +23,18 @@ public class StorageHtmlExportService
|
|||||||
/// breakdown section is desired.
|
/// breakdown section is desired.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(3072 + nodes.Count * 340);
|
||||||
|
BuildHtmlCore(sb, nodes, branding);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, ReportBranding? branding)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
_togIdx = 0;
|
_togIdx = 0;
|
||||||
var sb = new StringBuilder();
|
_kindLabels = BuildKindLabelCache();
|
||||||
|
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
|
||||||
|
|
||||||
sb.AppendLine("<!DOCTYPE html>");
|
sb.AppendLine("<!DOCTYPE html>");
|
||||||
sb.AppendLine("<html lang=\"en\">");
|
sb.AppendLine("<html lang=\"en\">");
|
||||||
@@ -60,11 +70,18 @@ public class StorageHtmlExportService
|
|||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||||
|
|
||||||
// Summary cards
|
// Single-pass root aggregation: replaces 4 separate enumerations
|
||||||
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
// (.Where().ToList() + 3× .Sum() + a final .Where() during render).
|
||||||
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
|
var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
|
||||||
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
|
long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
|
||||||
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
|
foreach (var n in nodes)
|
||||||
|
{
|
||||||
|
if (n.IndentLevel != 0) continue;
|
||||||
|
rootNodes0.Add(n);
|
||||||
|
siteTotal0 += n.TotalSizeBytes;
|
||||||
|
versionTotal0 += n.VersionSizeBytes;
|
||||||
|
fileTotal0 += n.TotalFileCount;
|
||||||
|
}
|
||||||
|
|
||||||
sb.AppendLine($"""
|
sb.AppendLine($"""
|
||||||
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
||||||
@@ -90,10 +107,10 @@ public class StorageHtmlExportService
|
|||||||
<tbody>
|
<tbody>
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// Only iterate root-level nodes; RenderNode recurses into Children
|
// Render only the pre-materialized root list — recursing into
|
||||||
// inline. Iterating the flat list would render every descendant a
|
// Children handles descendants. Iterating the flat list would render
|
||||||
// second time as a top-level row.
|
// every descendant a second time as a top-level row.
|
||||||
foreach (var node in nodes.Where(n => n.IndentLevel == 0))
|
foreach (var node in rootNodes0)
|
||||||
{
|
{
|
||||||
RenderNode(sb, node);
|
RenderNode(sb, node);
|
||||||
}
|
}
|
||||||
@@ -105,18 +122,24 @@ public class StorageHtmlExportService
|
|||||||
|
|
||||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||||
sb.AppendLine("</body></html>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds an HTML report including a file-type breakdown chart section.
|
/// Builds an HTML report including a file-type breakdown chart section.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
|
||||||
|
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding)
|
||||||
{
|
{
|
||||||
var T = TranslationSource.Instance;
|
var T = TranslationSource.Instance;
|
||||||
_togIdx = 0;
|
_togIdx = 0;
|
||||||
var sb = new StringBuilder();
|
_kindLabels = BuildKindLabelCache();
|
||||||
|
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
|
||||||
|
|
||||||
sb.AppendLine("<!DOCTYPE html>");
|
sb.AppendLine("<!DOCTYPE html>");
|
||||||
sb.AppendLine("<html lang=\"en\">");
|
sb.AppendLine("<html lang=\"en\">");
|
||||||
@@ -163,11 +186,17 @@ public class StorageHtmlExportService
|
|||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||||
|
|
||||||
// ── Summary cards ──
|
// ── Summary cards (single-pass aggregation) ──
|
||||||
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
|
||||||
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
|
long siteTotal = 0, versionTotal = 0, fileTotal = 0;
|
||||||
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
|
foreach (var n in nodes)
|
||||||
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
{
|
||||||
|
if (n.IndentLevel != 0) continue;
|
||||||
|
rootNodes.Add(n);
|
||||||
|
siteTotal += n.TotalSizeBytes;
|
||||||
|
versionTotal += n.VersionSizeBytes;
|
||||||
|
fileTotal += n.TotalFileCount;
|
||||||
|
}
|
||||||
|
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
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(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
|
||||||
@@ -227,10 +256,10 @@ public class StorageHtmlExportService
|
|||||||
<tbody>
|
<tbody>
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// Only iterate root-level nodes; RenderNode recurses into Children
|
// Render only the pre-materialized root list — recursing into
|
||||||
// inline. Iterating the flat list would render every descendant a
|
// Children handles descendants. Iterating the flat list would render
|
||||||
// second time as a top-level row.
|
// every descendant a second time as a top-level row.
|
||||||
foreach (var node in nodes.Where(n => n.IndentLevel == 0))
|
foreach (var node in rootNodes)
|
||||||
{
|
{
|
||||||
RenderNode(sb, node);
|
RenderNode(sb, node);
|
||||||
}
|
}
|
||||||
@@ -242,22 +271,24 @@ public class StorageHtmlExportService
|
|||||||
|
|
||||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||||
sb.AppendLine("</body></html>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(nodes, branding);
|
// Build into StringBuilder, stream chunks straight to disk —
|
||||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
// skips a full-document char-array copy from sb.ToString().
|
||||||
|
var sb = new StringBuilder(3072 + nodes.Count * 340);
|
||||||
|
BuildHtmlCore(sb, nodes, branding);
|
||||||
|
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
||||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
|
||||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
|
||||||
|
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -313,21 +344,7 @@ public class StorageHtmlExportService
|
|||||||
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}"
|
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}"
|
||||||
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
|
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
|
||||||
|
|
||||||
string lastMod = node.LastModified.HasValue
|
AppendRow(sb, node, nameCell);
|
||||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
sb.AppendLine($"""
|
|
||||||
<tr>
|
|
||||||
<td>{nameCell}</td>
|
|
||||||
<td>{HtmlEncode(KindLabel(node.Kind))}</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)
|
if (hasChildren)
|
||||||
{
|
{
|
||||||
@@ -352,21 +369,7 @@ public class StorageHtmlExportService
|
|||||||
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}</span>"
|
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}</span>"
|
||||||
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
|
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
|
||||||
|
|
||||||
string lastMod = node.LastModified.HasValue
|
AppendRow(sb, node, nameCell);
|
||||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
sb.AppendLine($"""
|
|
||||||
<tr>
|
|
||||||
<td>{nameCell}</td>
|
|
||||||
<td>{HtmlEncode(KindLabel(node.Kind))}</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)
|
if (hasChildren)
|
||||||
{
|
{
|
||||||
@@ -381,6 +384,35 @@ public class StorageHtmlExportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends one data row given the pre-rendered name cell. Hot path:
|
||||||
|
/// pulls localized kind labels from <see cref="_kindLabelsHtml"/> instead
|
||||||
|
/// of going through <c>ResourceManager.GetString</c> + <c>HtmlEncode</c>
|
||||||
|
/// per row.
|
||||||
|
/// </summary>
|
||||||
|
private void AppendRow(StringBuilder sb, StorageNode node, string nameCell)
|
||||||
|
{
|
||||||
|
int kindIdx = (int)node.Kind;
|
||||||
|
string kindLabel = (uint)kindIdx < (uint)_kindLabelsHtml.Length
|
||||||
|
? _kindLabelsHtml[kindIdx]
|
||||||
|
: HtmlEncode(node.Kind.ToString());
|
||||||
|
string lastMod = node.LastModified.HasValue
|
||||||
|
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<tr>
|
||||||
|
<td>{nameCell}</td>
|
||||||
|
<td>{kindLabel}</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>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
private static string FormatSize(long bytes)
|
||||||
{
|
{
|
||||||
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||||
@@ -406,4 +438,28 @@ public class StorageHtmlExportService
|
|||||||
_ => kind.ToString()
|
_ => kind.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||||||
|
/// once per export. Cached array index lookup avoids
|
||||||
|
/// <c>ResourceManager.GetString</c> per row in hot rendering loops.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] BuildKindLabelCache()
|
||||||
|
{
|
||||||
|
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||||||
|
int max = 0;
|
||||||
|
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||||||
|
var cache = new string[max + 1];
|
||||||
|
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||||||
|
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HTML-encodes each entry of <paramref name="raw"/> once.</summary>
|
||||||
|
private static string[] BuildHtmlEncodedCache(string[] raw)
|
||||||
|
{
|
||||||
|
var encoded = new string[raw.Length];
|
||||||
|
for (int i = 0; i < raw.Length; i++) encoded[i] = HtmlEncode(raw[i]);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user