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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user