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:
2026-06-02 15:02:52 +02:00
committed by kawa
26 changed files with 631 additions and 91 deletions
+116
View File
@@ -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;
}
}
}
+9
View File
@@ -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));
}
}
+20
View File
@@ -0,0 +1,20 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Discovers SharePoint sites in a tenant via Microsoft Graph so users can
/// pick multiple sites to scan instead of typing URLs one at a time.
/// </summary>
public interface ISiteDiscoveryService
{
/// <summary>
/// Returns sites matching <paramref name="query"/> (defaults to all sites).
/// OneDrive personal sites are excluded; results are de-duplicated by URL
/// and ordered by title.
/// </summary>
Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
TenantProfile profile,
string? query = null,
CancellationToken ct = default);
}
+3
View File
@@ -12,6 +12,9 @@ public interface IUserSessionService
bool HasProfile { get; }
AppSettings Settings { get; }
/// <summary>Branding for exported reports: MSP logo (settings) + active profile's client logo.</summary>
ReportBranding CurrentBranding { get; }
void SetProfile(TenantProfile profile);
Task ClearSessionAsync();
void UpdateSettings(AppSettings settings);
+2
View File
@@ -15,6 +15,8 @@ public class UserSessionService : IUserSessionService
public bool HasProfile => _currentProfile is not null;
public AppSettings Settings => _settings;
public ReportBranding CurrentBranding => new(_settings.MspLogo, _currentProfile?.ClientLogo);
public event Action? ProfileChanged;
public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo)
+76
View File
@@ -0,0 +1,76 @@
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions;
using SharepointToolbox.Web.Core.Models;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Delegated Graph implementation of <see cref="ISiteDiscoveryService"/>.
/// Uses the <c>/sites?search=*</c> endpoint, paging through every result.
/// Requires the delegated <c>Sites.Read.All</c> scope.
/// </summary>
public class SiteDiscoveryService : ISiteDiscoveryService
{
private readonly AppGraphClientFactory _graphClientFactory;
public SiteDiscoveryService(AppGraphClientFactory graphClientFactory)
{
_graphClientFactory = graphClientFactory;
}
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
TenantProfile profile,
string? query = null,
CancellationToken ct = default)
{
var graphClient = await _graphClientFactory.CreateClientAsync(profile);
// "*" is the Graph convention for "return all sites".
var search = string.IsNullOrWhiteSpace(query) ? "*" : query!;
// The typed Sites.GetAsync maps its Search property to OData "$search",
// which routes "*" through KQL and fails ("'*' is not valid at position 0").
// The all-sites wildcard only works via the bare, non-OData "search"
// query parameter, so build the request manually.
var requestInfo = new RequestInformation
{
HttpMethod = Method.GET,
UrlTemplate = "{+baseurl}/sites{?search,%24top}",
PathParameters = new Dictionary<string, object>
{
{ "baseurl", graphClient.RequestAdapter.BaseUrl ?? "https://graph.microsoft.com/v1.0" }
},
};
requestInfo.QueryParameters.Add("search", search);
requestInfo.QueryParameters.Add("%24top", 999);
requestInfo.Headers.Add("Accept", "application/json");
var response = await graphClient.RequestAdapter.SendAsync<SiteCollectionResponse>(
requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct);
if (response is null) return Array.Empty<SiteInfo>();
var results = new List<SiteInfo>();
var iter = PageIterator<Site, SiteCollectionResponse>.CreatePageIterator(
graphClient, response,
site =>
{
if (ct.IsCancellationRequested) return false;
var url = site.WebUrl ?? string.Empty;
if (string.IsNullOrEmpty(url)) return true;
// Skip OneDrive personal sites — not useful for these scans.
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) return true;
var title = site.DisplayName ?? site.Name ?? url;
results.Add(new SiteInfo(url, title));
return true;
});
await iter.IterateAsync(ct);
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}