12dd1de9f2
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
295 lines
14 KiB
C#
295 lines
14 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
|
|
|
|
namespace SharepointToolbox.Services.Export;
|
|
|
|
/// <summary>
|
|
/// Exports permission entries to a self-contained interactive HTML report.
|
|
/// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
|
|
/// No external CSS/JS dependencies — everything is inline so the file can be
|
|
/// emailed or served from any static host. The standard and simplified
|
|
/// variants share their document shell, stats cards, CSS, pill rendering, and
|
|
/// inline script via <see cref="PermissionHtmlFragments"/>; this class only
|
|
/// owns the table column sets and the simplified risk summary.
|
|
/// </summary>
|
|
public class HtmlExportService
|
|
{
|
|
/// <summary>
|
|
/// Builds a self-contained HTML string from the supplied permission
|
|
/// entries. Standard report: columns are Object / Title / URL / Unique /
|
|
/// Users / Permission / Granted Through. When
|
|
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
|
|
/// become expandable rows listing resolved members.
|
|
/// </summary>
|
|
public string BuildHtml(
|
|
IReadOnlyList<PermissionEntry> entries,
|
|
ReportBranding? branding = null,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
|
entries.Count,
|
|
entries.Select(e => e.PermissionLevels),
|
|
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
|
|
|
|
var sb = new StringBuilder();
|
|
AppendHead(sb, T["report.title.permissions"], includeRiskCss: false);
|
|
sb.AppendLine("<body>");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
|
|
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
|
AppendFilterInput(sb);
|
|
AppendTableOpen(sb);
|
|
sb.AppendLine("<thead><tr>");
|
|
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
|
|
sb.AppendLine("</tr></thead>");
|
|
sb.AppendLine("<tbody>");
|
|
|
|
int grpMemIdx = 0;
|
|
foreach (var entry in entries)
|
|
{
|
|
var typeCss = ObjectTypeCss(entry.ObjectType);
|
|
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
|
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
|
|
|
var (pills, subRows) = BuildUserPillsCell(
|
|
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
|
|
colSpan: 7, grpMemIdx: ref grpMemIdx);
|
|
|
|
sb.AppendLine("<tr>");
|
|
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
|
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
|
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
|
sb.AppendLine($" <td>{pills}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
|
sb.AppendLine("</tr>");
|
|
if (subRows.Length > 0) sb.Append(subRows);
|
|
}
|
|
|
|
AppendTableClose(sb);
|
|
AppendInlineJs(sb);
|
|
sb.AppendLine("</body>");
|
|
sb.AppendLine("</html>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a self-contained HTML string from simplified permission entries.
|
|
/// Adds a risk-level summary card strip plus two columns (Simplified,
|
|
/// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
|
|
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
|
|
/// </summary>
|
|
public string BuildHtml(
|
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
|
ReportBranding? branding = null,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
var summaries = PermissionSummaryBuilder.Build(entries);
|
|
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
|
entries.Count,
|
|
entries.Select(e => e.PermissionLevels),
|
|
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
|
|
|
|
var sb = new StringBuilder();
|
|
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
|
|
sb.AppendLine("<body>");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
|
|
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
|
|
|
sb.AppendLine("<div class=\"risk-cards\">");
|
|
foreach (var s in summaries)
|
|
{
|
|
var (bg, text, border) = RiskLevelColors(s.RiskLevel);
|
|
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
|
|
sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
|
|
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
|
|
sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
sb.AppendLine("</div>");
|
|
|
|
AppendFilterInput(sb);
|
|
AppendTableOpen(sb);
|
|
sb.AppendLine("<thead><tr>");
|
|
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
|
sb.AppendLine("</tr></thead>");
|
|
sb.AppendLine("<tbody>");
|
|
|
|
int grpMemIdx = 0;
|
|
foreach (var entry in entries)
|
|
{
|
|
var typeCss = ObjectTypeCss(entry.ObjectType);
|
|
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
|
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
|
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
|
|
|
var (pills, subRows) = BuildUserPillsCell(
|
|
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
|
colSpan: 9, grpMemIdx: ref grpMemIdx);
|
|
|
|
sb.AppendLine("<tr>");
|
|
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
|
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
|
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
|
sb.AppendLine($" <td>{pills}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
|
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
|
sb.AppendLine("</tr>");
|
|
if (subRows.Length > 0) sb.Append(subRows);
|
|
}
|
|
|
|
AppendTableClose(sb);
|
|
AppendInlineJs(sb);
|
|
sb.AppendLine("</body>");
|
|
sb.AppendLine("</html>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
|
|
public async Task WriteAsync(
|
|
IReadOnlyList<PermissionEntry> entries,
|
|
string filePath,
|
|
CancellationToken ct,
|
|
ReportBranding? branding = null,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
{
|
|
var html = BuildHtml(entries, branding, groupMembers);
|
|
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
|
}
|
|
|
|
/// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
|
|
public async Task WriteAsync(
|
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
|
string filePath,
|
|
CancellationToken ct,
|
|
ReportBranding? branding = null,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
{
|
|
var html = BuildHtml(entries, branding, groupMembers);
|
|
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Split-aware write for permission entries.
|
|
/// Single → one file. BySite + SeparateFiles → one file per site.
|
|
/// BySite + SingleTabbed → one file with per-site iframe tabs.
|
|
/// </summary>
|
|
public async Task WriteAsync(
|
|
IReadOnlyList<PermissionEntry> entries,
|
|
string basePath,
|
|
ReportSplitMode splitMode,
|
|
HtmlSplitLayout layout,
|
|
CancellationToken ct,
|
|
ReportBranding? branding = null,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
{
|
|
if (splitMode != ReportSplitMode.BySite)
|
|
{
|
|
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
|
return;
|
|
}
|
|
|
|
var partitions = CsvExportService.PartitionBySite(entries).ToList();
|
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
|
{
|
|
var parts = partitions
|
|
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
|
.ToList();
|
|
var title = TranslationSource.Instance["report.title.permissions"];
|
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
|
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
|
return;
|
|
}
|
|
|
|
foreach (var (label, partEntries) in partitions)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
|
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
|
}
|
|
}
|
|
|
|
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
|
|
public async Task WriteAsync(
|
|
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
|
string basePath,
|
|
ReportSplitMode splitMode,
|
|
HtmlSplitLayout layout,
|
|
CancellationToken ct,
|
|
ReportBranding? branding = null,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
|
{
|
|
if (splitMode != ReportSplitMode.BySite)
|
|
{
|
|
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
|
return;
|
|
}
|
|
|
|
var partitions = CsvExportService.PartitionBySite(entries).ToList();
|
|
if (layout == HtmlSplitLayout.SingleTabbed)
|
|
{
|
|
var parts = partitions
|
|
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
|
.ToList();
|
|
var title = TranslationSource.Instance["report.title.permissions_simplified"];
|
|
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
|
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
|
|
return;
|
|
}
|
|
|
|
foreach (var (label, partEntries) in partitions)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
|
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
|
}
|
|
}
|
|
|
|
private static (int total, int uniquePerms, int distinctUsers) ComputeStats(
|
|
int totalEntries,
|
|
IEnumerable<string> permissionLevels,
|
|
IEnumerable<string> userLogins)
|
|
{
|
|
var uniquePermSets = permissionLevels.Distinct().Count();
|
|
var distinctUsers = userLogins
|
|
.Select(u => u.Trim())
|
|
.Where(u => u.Length > 0)
|
|
.Distinct()
|
|
.Count();
|
|
return (totalEntries, uniquePermSets, distinctUsers);
|
|
}
|
|
|
|
private static void AppendTableOpen(StringBuilder sb)
|
|
{
|
|
sb.AppendLine("<div class=\"table-wrap\">");
|
|
sb.AppendLine("<table id=\"permTable\">");
|
|
}
|
|
|
|
private static void AppendTableClose(StringBuilder sb)
|
|
{
|
|
sb.AppendLine("</tbody>");
|
|
sb.AppendLine("</table>");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
/// <summary>Returns inline CSS background, text, and border colors for a risk level.</summary>
|
|
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
|
|
{
|
|
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
|
|
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
|
|
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
|
|
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
|
|
_ => ("#F3F4F6", "#374151", "#E5E7EB")
|
|
};
|
|
}
|