diff --git a/SharepointToolbox/Services/Export/ExportFileWriter.cs b/SharepointToolbox/Services/Export/ExportFileWriter.cs index 56cef37..c91ea85 100644 --- a/SharepointToolbox/Services/Export/ExportFileWriter.cs +++ b/SharepointToolbox/Services/Export/ExportFileWriter.cs @@ -24,4 +24,41 @@ internal static class ExportFileWriter /// Writes to as UTF-8 without BOM. public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct) => File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct); + + /// + /// Streams a directly to disk as UTF-8 with + /// BOM, chunk by chunk. Avoids the full-document ToString() copy + /// and the separate UTF-8 byte buffer that + /// would otherwise allocate — meaningful for large CSV exports. + /// + public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct) + => WriteChunksAsync(filePath, builder, Utf8WithBom, ct); + + /// + /// Streams a directly to disk as UTF-8 without + /// BOM. Same rationale as — for large + /// HTML reports it halves peak memory by skipping the intermediate string. + /// + 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); + } } diff --git a/SharepointToolbox/Services/Export/StorageCsvExportService.cs b/SharepointToolbox/Services/Export/StorageCsvExportService.cs index d594cae..c15c80e 100644 --- a/SharepointToolbox/Services/Export/StorageCsvExportService.cs +++ b/SharepointToolbox/Services/Export/StorageCsvExportService.cs @@ -1,5 +1,6 @@ using SharepointToolbox.Core.Models; using SharepointToolbox.Localization; +using System.Globalization; using System.IO; using System.Text; @@ -18,34 +19,58 @@ public class StorageCsvExportService /// public string BuildCsv(IReadOnlyList nodes) { - var T = TranslationSource.Instance; - var sb = new StringBuilder(); + // Pre-size: ~110 chars/row + header avoids most StringBuilder growth. + var sb = new StringBuilder(128 + nodes.Count * 110); + WriteCsv(sb, nodes); + return sb.ToString(); + } - // Header - 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"]}"); + private static void WriteCsv(StringBuilder sb, IReadOnlyList nodes) + { + 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) { - sb.AppendLine(string.Join(",", - Csv(node.Name), - Csv(KindLabel(node.Kind)), - 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)); + AppendCsvField(sb, node.Name).Append(','); + AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(','); + AppendCsvField(sb, node.SiteTitle).Append(','); + sb.Append(node.TotalFileCount).Append(','); + AppendMb(sb, node.TotalSizeBytes).Append(','); + AppendMb(sb, node.VersionSizeBytes).Append(','); + if (node.LastModified.HasValue) + AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + sb.AppendLine(); } - - return sb.ToString(); } /// Writes the library-level CSV to with UTF-8 BOM. public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct) { - var csv = BuildCsv(nodes); - await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + // Stream straight to disk: skip the StringBuilder→string copy and the + // 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); } /// @@ -53,44 +78,68 @@ public class StorageCsvExportService /// public string BuildCsv(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics) { - var T = TranslationSource.Instance; - var sb = new StringBuilder(); + var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40); + WriteCsv(sb, nodes, fileTypeMetrics); + return sb.ToString(); + } + + private static void WriteCsv(StringBuilder sb, IReadOnlyList nodes, IReadOnlyList 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) { - 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)); + AppendCsvField(sb, node.Name).Append(','); + AppendCsvField(sb, node.SiteTitle).Append(','); + sb.Append(node.TotalFileCount).Append(','); + AppendMb(sb, node.TotalSizeBytes).Append(','); + AppendMb(sb, node.VersionSizeBytes).Append(','); + if (node.LastModified.HasValue) + AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + sb.AppendLine(); } - // File type breakdown 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($"{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) { - string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension; - sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString())); + string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension; + AppendCsvField(sb, label).Append(','); + AppendMb(sb, m.TotalSizeBytes).Append(','); + sb.Append(m.FileCount).AppendLine(); } } - - return sb.ToString(); } /// Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM. public async Task WriteAsync(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics, string filePath, CancellationToken ct) { - var csv = BuildCsv(nodes, fileTypeMetrics); - await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40); + WriteCsv(sb, nodes, fileTypeMetrics); + await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct); } /// @@ -139,11 +188,27 @@ public class StorageCsvExportService // ── Helpers ─────────────────────────────────────────────────────────────── - private static string FormatMb(long bytes) - => (bytes / (1024.0 * 1024.0)).ToString("F2"); + private static StringBuilder AppendMb(StringBuilder sb, long bytes) + => sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture)); - /// RFC 4180 CSV field quoting with formula-injection guard. - private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value); + private static StringBuilder AppendCsvField(StringBuilder sb, string value) + => sb.Append(CsvSanitizer.EscapeMinimal(value)); + + /// + /// Pre-resolves localized labels for every + /// once per export, indexed by the enum's int value. Avoids a + /// ResourceManager.GetString call per row in hot CSV 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; + } private static string KindLabel(StorageNodeKind kind) { diff --git a/SharepointToolbox/Services/Export/StorageHtmlExportService.cs b/SharepointToolbox/Services/Export/StorageHtmlExportService.cs index 4c114f4..218038c 100644 --- a/SharepointToolbox/Services/Export/StorageHtmlExportService.cs +++ b/SharepointToolbox/Services/Export/StorageHtmlExportService.cs @@ -13,6 +13,8 @@ namespace SharepointToolbox.Services.Export; 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 @@ -21,10 +23,18 @@ public class StorageHtmlExportService /// 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; - var sb = new StringBuilder(); + _kindLabels = BuildKindLabelCache(); + _kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels); sb.AppendLine(""); sb.AppendLine(""); @@ -60,11 +70,18 @@ public class StorageHtmlExportService sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.AppendLine($"

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

"); - // 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); + // 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($"""
@@ -90,10 +107,10 @@ public class StorageHtmlExportService """); - // Only iterate root-level nodes; RenderNode recurses into Children - // inline. Iterating the flat list would render every descendant a - // second time as a top-level row. - foreach (var node in nodes.Where(n => n.IndentLevel == 0)) + // 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); } @@ -105,18 +122,24 @@ public class StorageHtmlExportService sb.AppendLine($"

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

"); sb.AppendLine(""); - - return sb.ToString(); } /// /// 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; - var sb = new StringBuilder(); + _kindLabels = BuildKindLabelCache(); + _kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels); sb.AppendLine(""); sb.AppendLine(""); @@ -163,11 +186,17 @@ public class StorageHtmlExportService sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.AppendLine($"

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

"); - // ── 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); + // ── 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"]}
"); @@ -227,10 +256,10 @@ public class StorageHtmlExportService """); - // Only iterate root-level nodes; RenderNode recurses into Children - // inline. Iterating the flat list would render every descendant a - // second time as a top-level row. - foreach (var node in nodes.Where(n => n.IndentLevel == 0)) + // 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); } @@ -242,22 +271,24 @@ public class StorageHtmlExportService sb.AppendLine($"

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

"); sb.AppendLine(""); - - return sb.ToString(); } /// Writes the library-only HTML report to . public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct, ReportBranding? branding = null) { - var html = BuildHtml(nodes, branding); - await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); + // 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 html = BuildHtml(nodes, fileTypeMetrics, branding); - await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); + var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220); + BuildHtmlCore(sb, nodes, fileTypeMetrics, branding); + await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct); } /// @@ -313,21 +344,7 @@ public class StorageHtmlExportService ? $"{HtmlEncode(node.Name)}" : $"{HtmlEncode(node.Name)}"; - string lastMod = node.LastModified.HasValue - ? node.LastModified.Value.ToString("yyyy-MM-dd") - : string.Empty; - - sb.AppendLine($""" - - {nameCell} - {HtmlEncode(KindLabel(node.Kind))} - {HtmlEncode(node.SiteTitle)} - {node.TotalFileCount:N0} - {FormatSize(node.TotalSizeBytes)} - {FormatSize(node.VersionSizeBytes)} - {lastMod} - - """); + AppendRow(sb, node, nameCell); if (hasChildren) { @@ -352,21 +369,7 @@ public class StorageHtmlExportService ? $"{HtmlEncode(node.Name)}" : $"{HtmlEncode(node.Name)}"; - string lastMod = node.LastModified.HasValue - ? node.LastModified.Value.ToString("yyyy-MM-dd") - : string.Empty; - - sb.AppendLine($""" - - {nameCell} - {HtmlEncode(KindLabel(node.Kind))} - {HtmlEncode(node.SiteTitle)} - {node.TotalFileCount:N0} - {FormatSize(node.TotalSizeBytes)} - {FormatSize(node.VersionSizeBytes)} - {lastMod} - - """); + AppendRow(sb, node, nameCell); if (hasChildren) { @@ -381,6 +384,35 @@ public class StorageHtmlExportService } } + /// + /// 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"; @@ -406,4 +438,28 @@ public class StorageHtmlExportService _ => 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; + } }