chore: release v2.4

- 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>
This commit is contained in:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit f4cc81bb71
64 changed files with 3315 additions and 405 deletions
@@ -3,6 +3,7 @@ using System.IO;
using System.Text;
using CsvHelper;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -10,12 +11,13 @@ public class BulkResultCsvExportService
{
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
{
var TL = TranslationSource.Instance;
using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
csv.WriteHeader<T>();
csv.WriteField("Error");
csv.WriteField("Timestamp");
csv.WriteField(TL["report.col.error"]);
csv.WriteField(TL["report.col.timestamp"]);
csv.NextRecord();
foreach (var item in failedItems.Where(r => !r.IsSuccess))
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class CsvExportService
{
private const string Header =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
private static string BuildHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
}
/// <summary>
/// Builds a CSV string from the supplied permission entries.
@@ -20,7 +24,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(BuildHeader());
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
var merged = entries
@@ -61,8 +65,11 @@ public class CsvExportService
/// <summary>
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
/// </summary>
private const string SimplifiedHeader =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
private static string BuildSimplifiedHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
}
/// <summary>
/// Builds a CSV string from simplified permission entries.
@@ -72,7 +79,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(SimplifiedHeader);
sb.AppendLine(BuildSimplifiedHeader());
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
@@ -0,0 +1,74 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row;
/// the Group column ties copies together and a Copies column gives the group size.
/// Header row is built at write-time so culture switches are honoured.
/// </summary>
public class DuplicatesCsvExportService
{
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string filePath,
CancellationToken ct)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary
sb.AppendLine($"\"{T["report.title.duplicates_short"]}\"");
sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine(string.Join(",", new[]
{
Csv(T["report.col.number"]),
Csv("Group"),
Csv(T["report.text.copies"]),
Csv(T["report.col.name"]),
Csv(T["report.col.library"]),
Csv(T["report.col.path"]),
Csv(T["report.col.size_bytes"]),
Csv(T["report.col.created"]),
Csv(T["report.col.modified"]),
}));
// Rows
foreach (var g in groups)
{
int i = 0;
foreach (var item in g.Items)
{
i++;
sb.AppendLine(string.Join(",", new[]
{
Csv(i.ToString()),
Csv(g.Name),
Csv(g.Items.Count.ToString()),
Csv(item.Name),
Csv(item.Library),
Csv(item.Path),
Csv(item.SizeBytes?.ToString() ?? string.Empty),
Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty),
Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty),
}));
}
}
await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.Text;
namespace SharepointToolbox.Services.Export;
@@ -12,15 +13,16 @@ public class DuplicatesHtmlExportService
{
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.duplicates"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Duplicate Detection Report</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -54,11 +56,9 @@ public class DuplicatesHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>Duplicate Detection Report</h1>
""");
sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} {T["report.text.duplicate_groups_found"]}</p>");
for (int i = 0; i < groups.Count; i++)
{
@@ -70,19 +70,19 @@ public class DuplicatesHtmlExportService
<div class="group-card">
<div class="group-header" onclick="toggleGroup({i})">
<span class="group-name">{H(g.Name)}</span>
<span class="badge {badgeClass}">{count} copies</span>
<span class="badge {badgeClass}">{count} {T["report.text.copies"]}</span>
</div>
<div class="group-body" id="gb-{i}">
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Library</th>
<th>Path</th>
<th>Size</th>
<th>Created</th>
<th>Modified</th>
<th>{T["report.col.number"]}</th>
<th>{T["report.col.name"]}</th>
<th>{T["report.col.library"]}</th>
<th>{T["report.col.path"]}</th>
<th>{T["report.col.size"]}</th>
<th>{T["report.col.created"]}</th>
<th>{T["report.col.modified"]}</th>
</tr>
</thead>
<tbody>
@@ -116,7 +116,7 @@ public class DuplicatesHtmlExportService
""");
}
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -19,6 +20,7 @@ public class HtmlExportService
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var T = TranslationSource.Instance;
// Compute stats
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
@@ -37,7 +39,7 @@ public class HtmlExportService
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report</title>");
sb.AppendLine($"<title>{T["report.title.permissions"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -78,25 +80,25 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report</h1>");
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Granted Through</th>");
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>");
@@ -105,7 +107,7 @@ a:hover { text-decoration: underline; }
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -136,7 +138,7 @@ a:hover { text-decoration: underline; }
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
@@ -151,7 +153,7 @@ a:hover { text-decoration: underline; }
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\">Link</a></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>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
@@ -215,6 +217,7 @@ function toggleGroup(id) {
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 = entries.Count;
@@ -233,7 +236,7 @@ function toggleGroup(id) {
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report (Simplified)</title>");
sb.AppendLine($"<title>{T["report.title.permissions_simplified"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -276,13 +279,13 @@ function toggleGroup(id) {
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
@@ -300,14 +303,14 @@ function toggleGroup(id) {
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
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>");
@@ -316,7 +319,7 @@ function toggleGroup(id) {
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -347,7 +350,7 @@ function toggleGroup(id) {
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
@@ -362,7 +365,7 @@ function toggleGroup(id) {
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\">Link</a></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>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -12,10 +13,11 @@ public class SearchCsvExportService
{
public string BuildCsv(IReadOnlyList<SearchResult> results)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Header
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)");
sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}");
foreach (var r in results)
{
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -13,15 +14,16 @@ public class SearchHtmlExportService
{
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.search"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint File Search Results</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -45,27 +47,27 @@ public class SearchHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>File Search Results</h1>
sb.AppendLine($"""
<h1>{T["report.title.search_short"]}</h1>
<div class="toolbar">
<label for="filterInput">Filter:</label>
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
<label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine("""
sb.AppendLine($"""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">File Name</th>
<th onclick="sortTable(1)">Extension</th>
<th onclick="sortTable(2)">Path</th>
<th onclick="sortTable(3)">Created</th>
<th onclick="sortTable(4)">Created By</th>
<th onclick="sortTable(5)">Modified</th>
<th onclick="sortTable(6)">Modified By</th>
<th class="num" onclick="sortTable(7)">Size</th>
<th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
<th onclick="sortTable(1)">{T["report.col.extension"]}</th>
<th onclick="sortTable(2)">{T["report.col.path"]}</th>
<th onclick="sortTable(3)">{T["report.col.created"]}</th>
<th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
<th onclick="sortTable(5)">{T["report.col.modified"]}</th>
<th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
<th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
</tr>
</thead>
<tbody>
@@ -93,7 +95,7 @@ public class SearchHtmlExportService
sb.AppendLine(" </tbody>\n</table>");
int count = results.Count;
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$"""
<script>
@@ -126,10 +128,10 @@ public class SearchHtmlExportService
rows[i].className = match ? '' : 'hidden';
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' of {{count:N0}} shown') : '';
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)';
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
};
</script>
</body></html>
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO;
using System.Text;
@@ -12,10 +13,11 @@ public class StorageCsvExportService
{
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Header
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes)
{
@@ -44,10 +46,11 @@ public class StorageCsvExportService
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Library details
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes)
{
sb.AppendLine(string.Join(",",
@@ -65,10 +68,10 @@ public class StorageCsvExportService
if (fileTypeMetrics.Count > 0)
{
sb.AppendLine();
sb.AppendLine("File Type,Size (MB),File Count");
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
}
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO;
using System.Text;
@@ -15,16 +16,17 @@ public class StorageHtmlExportService
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -50,9 +52,7 @@ public class StorageHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
@@ -62,22 +62,22 @@ public class StorageHtmlExportService
sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
</div>
""");
sb.AppendLine("""
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
<th>{T["report.col.library_folder"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<tbody>
@@ -93,7 +93,7 @@ public class StorageHtmlExportService
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
@@ -104,16 +104,17 @@ public class StorageHtmlExportService
/// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -150,9 +151,7 @@ public class StorageHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
@@ -161,10 +160,10 @@ public class StorageHtmlExportService
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
sb.AppendLine("</div>");
// ── File type chart section ──
@@ -175,7 +174,7 @@ public class StorageHtmlExportService
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -185,7 +184,7 @@ public class StorageHtmlExportService
{
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
sb.AppendLine($"""
<div class="bar-row">
@@ -201,17 +200,17 @@ public class StorageHtmlExportService
}
// ── Storage table ──
sb.AppendLine("<h2>Library Details</h2>");
sb.AppendLine("""
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
<th>{T["report.col.library_folder"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<tbody>
@@ -227,7 +226,7 @@ public class StorageHtmlExportService
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -11,8 +12,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class UserAccessCsvExportService
{
private const string DataHeader =
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\"";
private static string BuildDataHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
}
/// <summary>
/// Builds a CSV string for a single user's access entries.
@@ -20,22 +24,23 @@ public class UserAccessCsvExportService
/// </summary>
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary section
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Sites\",\"{sitesCount}\"");
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); // Blank line separating summary from data
// Data rows
sb.AppendLine(DataHeader);
sb.AppendLine(BuildDataHeader());
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
@@ -99,20 +104,21 @@ public class UserAccessCsvExportService
CancellationToken ct,
bool mergePermissions = false)
{
var T = TranslationSource.Instance;
if (mergePermissions)
{
var consolidated = PermissionConsolidator.Consolidate(entries);
var sb = new StringBuilder();
// Summary section
sb.AppendLine("\"User Access Audit Report (Consolidated)\"");
sb.AppendLine($"\"Users Audited\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"Total Entries\",\"{consolidated.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
@@ -136,14 +142,14 @@ public class UserAccessCsvExportService
{
var sb = new StringBuilder();
var fullHeader = "\"User\",\"User Login\"," + DataHeader;
var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
// Summary
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"Users Audited\",\"{users.Count}\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
sb.AppendLine(fullHeader);
@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -26,6 +27,8 @@ public class UserAccessHtmlExportService
return BuildConsolidatedHtml(consolidated, entries, branding);
}
var T = TranslationSource.Instance;
// Compute stats
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -41,7 +44,7 @@ public class UserAccessHtmlExportService
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>");
sb.AppendLine($"<title>{T["report.title.user_access"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -98,15 +101,15 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>");
sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards
@@ -123,34 +126,34 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle buttons
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">By User</button>");
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── BY-USER VIEW ───────────────────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -161,10 +164,10 @@ a:hover { text-decoration: underline; }
var uName = HtmlEncode(ug.First().UserDisplayName);
var uIsExt = ug.First().IsExternalUser;
var uCount = ug.Count();
var guestBadge = uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} access(es)</td>");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in ug)
@@ -173,10 +176,14 @@ a:hover { text-decoration: underline; }
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
@@ -192,12 +199,12 @@ a:hover { text-decoration: underline; }
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
sb.AppendLine("<table id=\"tbl-site\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-site\">");
@@ -210,7 +217,7 @@ a:hover { text-decoration: underline; }
var sCount = sg.Count();
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} access(es)</td>");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in sg)
@@ -218,12 +225,16 @@ a:hover { text-decoration: underline; }
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var guestBadge = entry.IsExternalUser ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
@@ -345,6 +356,8 @@ function sortTable(view, col) {
IReadOnlyList<UserAccessEntry> entries,
ReportBranding? branding)
{
var T = TranslationSource.Instance;
// Stats computed from the original flat list for accurate counts
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -360,7 +373,7 @@ function sortTable(view, col) {
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>");
sb.AppendLine($"<title>{T["report.title.user_access_consolidated"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -417,15 +430,15 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>");
sb.AppendLine($"<h1>{T["report.title.user_access_consolidated"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards (from original flat entries)
@@ -442,32 +455,32 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle — only By User (By Site is suppressed for consolidated view)
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\">By User</button>");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\">{T["report.view.by_user"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>User</th>");
sb.AppendLine(" <th>Permission Level</th>");
sb.AppendLine(" <th>Access Type</th>");
sb.AppendLine(" <th>Granted Through</th>");
sb.AppendLine(" <th>Sites</th>");
sb.AppendLine($" <th>{T["report.col.user"]}</th>");
sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -486,10 +499,10 @@ a:hover { text-decoration: underline; }
var cuName = HtmlEncode(cug.First().UserDisplayName);
var cuIsExt = cug.First().IsExternalUser;
var cuCount = cug.Count();
var guestBadge = cuIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = cuIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} permission(s)</td>");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} {T["report.text.permissions_parens"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in cug)
@@ -508,7 +521,7 @@ a:hover { text-decoration: underline; }
{
// Single location — inline site title + object title
var loc0 = entry.Locations[0];
var locLabel = string.IsNullOrEmpty(loc0.ObjectTitle)
var locLabel = IsRedundantObjectTitle(loc0.SiteTitle, loc0.ObjectTitle)
? HtmlEncode(loc0.SiteTitle)
: $"{HtmlEncode(loc0.SiteTitle)} &rsaquo; {HtmlEncode(loc0.ObjectTitle)}";
sb.AppendLine($" <td>{locLabel}</td>");
@@ -524,7 +537,7 @@ a:hover { text-decoration: underline; }
// Hidden sub-rows — one per location
foreach (var loc in entry.Locations)
{
var subLabel = string.IsNullOrEmpty(loc.ObjectTitle)
var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle)
? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>"
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> &rsaquo; {HtmlEncode(loc.ObjectTitle)}";
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
@@ -591,13 +604,31 @@ function toggleGroup(id) {
}
/// <summary>Returns a colored badge span for the given access type.</summary>
private static string AccessTypeBadge(AccessType accessType) => accessType switch
private static string AccessTypeBadge(AccessType accessType)
{
AccessType.Direct => "<span class=\"badge access-direct\">Direct</span>",
AccessType.Group => "<span class=\"badge access-group\">Group</span>",
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>",
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
};
var T = TranslationSource.Instance;
return accessType switch
{
AccessType.Direct => $"<span class=\"badge access-direct\">{T["report.badge.direct"]}</span>",
AccessType.Group => $"<span class=\"badge access-group\">{T["report.badge.group"]}</span>",
AccessType.Inherited => $"<span class=\"badge access-inherited\">{T["report.badge.inherited"]}</span>",
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
};
}
/// <summary>
/// Returns true when the ObjectTitle adds no information beyond the SiteTitle:
/// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate
/// of the other. Used to collapse "All Company &rsaquo; All Company" to "All Company".
/// </summary>
private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle)
{
if (string.IsNullOrWhiteSpace(objectTitle)) return true;
return string.Equals(
(siteTitle ?? string.Empty).Trim(),
objectTitle.Trim(),
StringComparison.OrdinalIgnoreCase);
}
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)