using SharepointToolbox.Core.Models; using SharepointToolbox.Localization; using System.Globalization; using System.IO; using System.Text; namespace SharepointToolbox.Services.Export; /// /// Exports a flat list of StorageNode objects to a UTF-8 BOM CSV. /// Compatible with Microsoft Excel (BOM signals UTF-8 encoding). /// public class StorageCsvExportService { /// /// Builds a single-section CSV: header row plus one row per /// with library, site, file count, total size /// (MB), version size (MB), and last-modified date. /// public string BuildCsv(IReadOnlyList nodes) { // Pre-size: ~110 chars/row + header avoids most StringBuilder growth. var sb = new StringBuilder(128 + nodes.Count * 110); WriteCsv(sb, nodes); return sb.ToString(); } 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) { 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(); } } /// Writes the library-level CSV to with UTF-8 BOM. public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken 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); } /// /// Builds a CSV with library details followed by a file-type breakdown section. /// public string BuildCsv(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics) { 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); foreach (var node in nodes) { 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(); } 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.Append(colFileType).Append(',') .Append(colSizeMb).Append(',') .AppendLine(colFileCnt); foreach (var m in fileTypeMetrics) { string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension; AppendCsvField(sb, label).Append(','); AppendMb(sb, m.TotalSizeBytes).Append(','); sb.Append(m.FileCount).AppendLine(); } } } /// 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 sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40); WriteCsv(sb, nodes, fileTypeMetrics); await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct); } /// /// Writes storage metrics with optional per-site partitioning. /// Single → one file. BySite → one file per SiteTitle. File-type metrics /// are replicated across all partitions because the tenant-level scan /// does not retain per-site breakdowns. /// public Task WriteAsync( IReadOnlyList nodes, IReadOnlyList fileTypeMetrics, string basePath, ReportSplitMode splitMode, CancellationToken ct) => ReportSplitHelper.WritePartitionedAsync( nodes, basePath, splitMode, PartitionBySite, (part, path, c) => WriteAsync(part, fileTypeMetrics, path, c), ct); /// /// Splits the flat StorageNode list into per-site slices while preserving /// the DFS hierarchy (each root library followed by its indented descendants). /// Siblings sharing a SiteTitle roll up into the same partition. /// internal static IEnumerable<(string Label, IReadOnlyList Partition)> PartitionBySite( IReadOnlyList nodes) { var buckets = new Dictionary>(StringComparer.OrdinalIgnoreCase); string currentSite = string.Empty; foreach (var node in nodes) { if (node.IndentLevel == 0) currentSite = string.IsNullOrWhiteSpace(node.SiteTitle) ? ReportSplitHelper.DeriveSiteLabel(node.Url) : node.SiteTitle; if (!buckets.TryGetValue(currentSite, out var list)) { list = new List(); buckets[currentSite] = list; } list.Add(node); } return buckets.Select(kv => (kv.Key, (IReadOnlyList)kv.Value)); } // ── Helpers ─────────────────────────────────────────────────────────────── private static StringBuilder AppendMb(StringBuilder sb, long bytes) => sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture)); 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) { 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() }; } }