Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main
Reviewed-on: #2
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,13 @@ public class WebExportService
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
|
||||
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads pre-encoded bytes (e.g. a ZIP or a merged report produced by
|
||||
/// <see cref="ReportMergeHelper"/>) with an explicit MIME type.
|
||||
/// </summary>
|
||||
public async Task DownloadBytesAsync(byte[] content, string fileName, string mime)
|
||||
{
|
||||
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, mime, Convert.ToBase64String(content));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user