using SharepointToolbox.Core.Models; using SharepointToolbox.Localization; using System.IO; using System.Text; namespace SharepointToolbox.Services.Export; /// /// 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}". /// public class StorageHtmlExportService { private int _togIdx; private string[] _kindLabels = Array.Empty(); private string[] _kindLabelsHtml = Array.Empty(); /// /// Builds a self-contained HTML report with one collapsible row per /// library and indented child folders. Library-only variant — use the /// overload that accepts s when a file-type /// breakdown section is desired. /// public string BuildHtml(IReadOnlyList 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 nodes, ReportBranding? branding) { var T = TranslationSource.Instance; _togIdx = 0; _kindLabels = BuildKindLabelCache(); _kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($"{T["report.title.storage"]}"); sb.AppendLine(""" """); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.AppendLine($"

{T["report.title.storage"]}

"); // Single-pass root aggregation: replaces 4 separate enumerations // (.Where().ToList() + 3× .Sum() + a final .Where() during render). var rootNodes0 = new List(Math.Min(nodes.Count, 64)); long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0; foreach (var n in nodes) { if (n.IndentLevel != 0) continue; rootNodes0.Add(n); siteTotal0 += n.TotalSizeBytes; versionTotal0 += n.VersionSizeBytes; fileTotal0 += n.TotalFileCount; } sb.AppendLine($"""
{FormatSize(siteTotal0)}
{T["report.stat.total_size"]}
{FormatSize(versionTotal0)}
{T["report.stat.version_size"]}
{fileTotal0:N0}
{T["report.stat.files"]}
"""); sb.AppendLine($""" """); // Render only the pre-materialized root list — recursing into // Children handles descendants. Iterating the flat list would render // every descendant a second time as a top-level row. foreach (var node in rootNodes0) { RenderNode(sb, node); } sb.AppendLine("""
{T["report.col.library_folder"]} {T["stor.col.kind"]} {T["report.col.site"]} {T["report.stat.files"]} {T["report.stat.total_size"]} {T["report.stat.version_size"]} {T["report.col.last_modified"]}
"""); sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}

"); sb.AppendLine(""); } /// /// Builds an HTML report including a file-type breakdown chart section. /// public string BuildHtml(IReadOnlyList nodes, IReadOnlyList 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 nodes, IReadOnlyList fileTypeMetrics, ReportBranding? branding) { var T = TranslationSource.Instance; _togIdx = 0; _kindLabels = BuildKindLabelCache(); _kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($"{T["report.title.storage"]}"); sb.AppendLine(""" """); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.AppendLine($"

{T["report.title.storage"]}

"); // ── Summary cards (single-pass aggregation) ── var rootNodes = new List(Math.Min(nodes.Count, 64)); long siteTotal = 0, versionTotal = 0, fileTotal = 0; foreach (var n in nodes) { if (n.IndentLevel != 0) continue; rootNodes.Add(n); siteTotal += n.TotalSizeBytes; versionTotal += n.VersionSizeBytes; fileTotal += n.TotalFileCount; } sb.AppendLine("
"); sb.AppendLine($"
{FormatSize(siteTotal)}
{T["report.stat.total_size"]}
"); sb.AppendLine($"
{FormatSize(versionTotal)}
{T["report.stat.version_size"]}
"); sb.AppendLine($"
{fileTotal:N0}
{T["report.stat.files"]}
"); sb.AppendLine($"
{rootNodes.Count}
{T["report.stat.libraries"]}
"); sb.AppendLine("
"); // ── 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("
"); sb.AppendLine($"

{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})

"); 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($"""
{HtmlEncode(label)}
{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} {T["report.text.files_unit"]}
"""); idx++; } sb.AppendLine("
"); } // ── Storage table ── sb.AppendLine($"

{T["report.section.library_details"]}

"); sb.AppendLine($""" """); // Render only the pre-materialized root list — recursing into // Children handles descendants. Iterating the flat list would render // every descendant a second time as a top-level row. foreach (var node in rootNodes) { RenderNode(sb, node); } sb.AppendLine("""
{T["report.col.library_folder"]} {T["stor.col.kind"]} {T["report.col.site"]} {T["report.stat.files"]} {T["report.stat.total_size"]} {T["report.stat.version_size"]} {T["report.col.last_modified"]}
"""); sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}

"); sb.AppendLine(""); } /// Writes the library-only HTML report to . public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct, ReportBranding? branding = null) { // Build into StringBuilder, stream chunks straight to disk — // 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); } /// Writes the HTML report including the file-type breakdown chart. public async Task WriteAsync(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null) { var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220); BuildHtmlCore(sb, nodes, fileTypeMetrics, branding); await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct); } /// /// Split-aware HTML export for storage metrics. /// Single → one file. BySite + SeparateFiles → one file per site. /// BySite + SingleTabbed → one HTML with per-site iframe tabs. File-type /// metrics are replicated across partitions because they are not /// attributed per-site by the scanner. /// public async Task WriteAsync( IReadOnlyList nodes, IReadOnlyList fileTypeMetrics, string basePath, ReportSplitMode splitMode, HtmlSplitLayout layout, CancellationToken ct, ReportBranding? branding = null) { if (splitMode != ReportSplitMode.BySite) { await WriteAsync(nodes, fileTypeMetrics, basePath, ct, branding); return; } var partitions = StorageCsvExportService.PartitionBySite(nodes).ToList(); if (layout == HtmlSplitLayout.SingleTabbed) { var parts = partitions .Select(p => (p.Label, Html: BuildHtml(p.Partition, fileTypeMetrics, branding))) .ToList(); var title = TranslationSource.Instance["report.title.storage"]; var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title); await File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct); return; } foreach (var (label, partNodes) in partitions) { ct.ThrowIfCancellationRequested(); var path = ReportSplitHelper.BuildPartitionPath(basePath, label); await WriteAsync(partNodes, fileTypeMetrics, path, ct, branding); } } // ── Private rendering ──────────────────────────────────────────────────── private void RenderNode(StringBuilder sb, StorageNode node) { bool hasChildren = node.Children.Count > 0; int myIdx = hasChildren ? ++_togIdx : 0; string nameCell = hasChildren ? $"{HtmlEncode(node.Name)}" : $"{HtmlEncode(node.Name)}"; AppendRow(sb, node, nameCell); if (hasChildren) { sb.AppendLine($""); sb.AppendLine(""); foreach (var child in node.Children) { RenderChildNode(sb, child); } sb.AppendLine("
"); sb.AppendLine(""); } } 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 ? $"{HtmlEncode(node.Name)}" : $"{HtmlEncode(node.Name)}"; AppendRow(sb, node, nameCell); if (hasChildren) { sb.AppendLine($""); sb.AppendLine(""); foreach (var child in node.Children) { RenderChildNode(sb, child); } sb.AppendLine("
"); sb.AppendLine(""); } } /// /// Appends one data row given the pre-rendered name cell. Hot path: /// pulls localized kind labels from instead /// of going through ResourceManager.GetString + HtmlEncode /// per row. /// 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($""" {nameCell} {kindLabel} {HtmlEncode(node.SiteTitle)} {node.TotalFileCount:N0} {FormatSize(node.TotalSizeBytes)} {FormatSize(node.VersionSizeBytes)} {lastMod} """); } 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); private static string KindLabel(StorageNodeKind kind) { var T = TranslationSource.Instance; return kind switch { StorageNodeKind.Library => T["stor.kind.library"], StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"], StorageNodeKind.PreservationHold => T["stor.kind.preservation"], StorageNodeKind.ListAttachments => T["stor.kind.attachments"], StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"], StorageNodeKind.Subsite => T["stor.kind.subsite"], _ => kind.ToString() }; } /// /// Pre-resolves localized labels for every /// once per export. Cached array index lookup avoids /// ResourceManager.GetString per row in hot rendering loops. /// 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; } /// HTML-encodes each entry of once. 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; } }