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 12dd1de9f2
93 changed files with 8708 additions and 1159 deletions
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO;
using System.Text;
@@ -13,18 +14,25 @@ public class StorageHtmlExportService
{
private int _togIdx;
/// <summary>
/// Builds a self-contained HTML report with one collapsible row per
/// library and indented child folders. Library-only variant — use the
/// overload that accepts <see cref="FileTypeMetric"/>s when a file-type
/// breakdown section is desired.
/// </summary>
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 +58,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 +68,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 +99,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 +110,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 +157,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 +166,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 +180,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} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -185,13 +190,13 @@ 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">
<span class="bar-label">{HtmlEncode(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} files</span>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} {T["report.text.files_unit"]}</span>
</div>
""");
idx++;
@@ -201,17 +206,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,24 +232,68 @@ 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();
}
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(nodes, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(nodes, fileTypeMetrics, branding);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
/// <summary>
/// Split-aware HTML export for storage metrics.
/// Single → one file. BySite + SeparateFiles → one file per site.
/// BySite + SingleTabbed → one HTML with per-site iframe tabs. File-type
/// metrics are replicated across partitions because they are not
/// attributed per-site by the scanner.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<StorageNode> nodes,
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(nodes, fileTypeMetrics, basePath, ct, branding);
return;
}
var partitions = StorageCsvExportService.PartitionBySite(nodes).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, fileTypeMetrics, branding)))
.ToList();
var title = TranslationSource.Instance["report.title.storage"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
return;
}
foreach (var (label, partNodes) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partNodes, fileTypeMetrics, path, ct, branding);
}
}
// ── Private rendering ────────────────────────────────────────────────────
private void RenderNode(StringBuilder sb, StorageNode node)