Files

117 lines
4.6 KiB
C#

using System.IO;
using System.IO.Compression;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>How a multi-site report is bundled for download.</summary>
public enum ReportMergeMode
{
/// <summary>One document containing all sites, no per-site separation.</summary>
SingleMerged,
/// <summary>One HTML document with one tab per site (CSV falls back to merged).</summary>
SingleTabbed,
/// <summary>One file per site, delivered as a single ZIP.</summary>
MultipleFiles
}
/// <summary>Output format of a report.</summary>
public enum ReportFormat
{
Csv,
Html
}
/// <summary>A ready-to-download artifact: bytes plus filename and MIME type.</summary>
public sealed record MergeOutput(string FileName, byte[] Bytes, string Mime);
/// <summary>
/// Bundles per-site report content into a single downloadable artifact
/// according to a <see cref="ReportMergeMode"/>. Format-agnostic: the caller
/// supplies a <c>buildDoc</c> delegate that renders one site's results to a
/// document string; this helper handles flattening, tabbing, and zipping.
/// </summary>
public static class ReportMergeHelper
{
/// <summary>
/// Builds the download artifact.
/// </summary>
/// <param name="sites">Per-site results (label + result list).</param>
/// <param name="mode">How to bundle the output.</param>
/// <param name="baseName">File stem, e.g. "permissions".</param>
/// <param name="timestamp">Stamp appended to single-file names, e.g. "20260602_101500".</param>
/// <param name="format">CSV or HTML.</param>
/// <param name="buildDoc">Renders one result list to a complete document.</param>
public static MergeOutput Build<T>(
IReadOnlyList<(string Label, IReadOnlyList<T> Results)> sites,
ReportMergeMode mode,
string baseName,
string timestamp,
ReportFormat format,
Func<IReadOnlyList<T>, 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<string>(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);
}
}
}
/// <summary>
/// Returns <paramref name="candidate"/> or, if already taken, a suffixed
/// variant ("name_2.ext", "name_3.ext", …) so ZIP entries never collide.
/// </summary>
private static string UniqueName(HashSet<string> 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;
}
}
}