using System.IO; using System.IO.Compression; using System.Text; namespace SharepointToolbox.Web.Services.Export; /// How a multi-site report is bundled for download. public enum ReportMergeMode { /// One document containing all sites, no per-site separation. SingleMerged, /// One HTML document with one tab per site (CSV falls back to merged). SingleTabbed, /// One file per site, delivered as a single ZIP. MultipleFiles } /// Output format of a report. public enum ReportFormat { Csv, Html } /// A ready-to-download artifact: bytes plus filename and MIME type. public sealed record MergeOutput(string FileName, byte[] Bytes, string Mime); /// /// Bundles per-site report content into a single downloadable artifact /// according to a . Format-agnostic: the caller /// supplies a buildDoc delegate that renders one site's results to a /// document string; this helper handles flattening, tabbing, and zipping. /// public static class ReportMergeHelper { /// /// Builds the download artifact. /// /// Per-site results (label + result list). /// How to bundle the output. /// File stem, e.g. "permissions". /// Stamp appended to single-file names, e.g. "20260602_101500". /// CSV or HTML. /// Renders one result list to a complete document. public static MergeOutput Build( IReadOnlyList<(string Label, IReadOnlyList Results)> sites, ReportMergeMode mode, string baseName, string timestamp, ReportFormat format, Func, string> buildDoc) { var ext = format == ReportFormat.Csv ? "csv" : "html"; var mime = format == ReportFormat.Csv ? "text/csv;charset=utf-8" : "text/html;charset=utf-8"; // CSV is BOM-prefixed for Excel; HTML is not. var enc = new UTF8Encoding(encoderShouldEmitUTF8Identifier: format == ReportFormat.Csv); // Tabs are an HTML-only concept — degrade to a single merged CSV. if (mode == ReportMergeMode.SingleTabbed && format == ReportFormat.Csv) mode = ReportMergeMode.SingleMerged; switch (mode) { case ReportMergeMode.SingleTabbed: { var parts = sites .Select(s => (s.Label, buildDoc(s.Results))) .ToList(); var html = ReportSplitHelper.BuildTabbedHtml(parts, baseName); return new MergeOutput($"{baseName}_{timestamp}.html", enc.GetBytes(html), mime); } case ReportMergeMode.MultipleFiles: { using var ms = new MemoryStream(); using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { var used = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var s in sites) { var name = UniqueName(used, $"{baseName}_{ReportSplitHelper.SanitizeFileName(s.Label)}.{ext}"); var entry = zip.CreateEntry(name, CompressionLevel.Optimal); using var es = entry.Open(); var bytes = enc.GetBytes(buildDoc(s.Results)); es.Write(bytes, 0, bytes.Length); } } return new MergeOutput($"{baseName}_{timestamp}.zip", ms.ToArray(), "application/zip"); } case ReportMergeMode.SingleMerged: default: { var flat = sites.SelectMany(s => s.Results).ToList(); var bytes = enc.GetBytes(buildDoc(flat)); return new MergeOutput($"{baseName}_{timestamp}.{ext}", bytes, mime); } } } /// /// Returns or, if already taken, a suffixed /// variant ("name_2.ext", "name_3.ext", …) so ZIP entries never collide. /// private static string UniqueName(HashSet used, string candidate) { if (used.Add(candidate)) return candidate; var stem = Path.GetFileNameWithoutExtension(candidate); var ext = Path.GetExtension(candidate); for (int i = 2; ; i++) { var next = $"{stem}_{i}{ext}"; if (used.Add(next)) return next; } } }