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
@@ -0,0 +1,199 @@
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Shared helpers for split report exports: filename partitioning, site label
/// derivation, and bundling per-partition HTML into a single tabbed document.
/// </summary>
public static class ReportSplitHelper
{
/// <summary>
/// Returns a file-safe variant of <paramref name="name"/>. Invalid filename
/// characters are replaced with underscores; whitespace runs are collapsed.
/// </summary>
public static string SanitizeFileName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "part";
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (var c in name)
sb.Append(invalid.Contains(c) || c == ' ' ? '_' : c);
var trimmed = sb.ToString().Trim('_');
if (trimmed.Length > 80) trimmed = trimmed.Substring(0, 80);
return trimmed.Length == 0 ? "part" : trimmed;
}
/// <summary>
/// Given a user-selected <paramref name="basePath"/> (e.g. "C:\reports\duplicates.csv"),
/// returns a partitioned path like "C:\reports\duplicates_{label}.csv".
/// </summary>
public static string BuildPartitionPath(string basePath, string partitionLabel)
{
var dir = Path.GetDirectoryName(basePath);
var stem = Path.GetFileNameWithoutExtension(basePath);
var ext = Path.GetExtension(basePath);
var safe = SanitizeFileName(partitionLabel);
var file = $"{stem}_{safe}{ext}";
return string.IsNullOrEmpty(dir) ? file : Path.Combine(dir, file);
}
/// <summary>
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
/// e.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/foo.docx →
/// https://t.sharepoint.com/sites/hr
/// Falls back to scheme+host for root site collections.
/// </summary>
public static string DeriveSiteCollectionUrl(string objectUrl)
{
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
return objectUrl.TrimEnd('/');
var baseUrl = $"{uri.Scheme}://{uri.Host}";
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return $"{baseUrl}/{segments[0]}/{segments[1]}";
}
return baseUrl;
}
/// <summary>
/// Derives a short, human-friendly site label from a SharePoint site URL.
/// Falls back to the raw URL (sanitized) when parsing fails.
/// </summary>
public static string DeriveSiteLabel(string siteUrl, string? siteTitle = null)
{
if (!string.IsNullOrWhiteSpace(siteTitle)) return siteTitle!;
if (string.IsNullOrWhiteSpace(siteUrl)) return "site";
try
{
var uri = new Uri(siteUrl);
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return segments[1];
}
return uri.Host;
}
catch (Exception ex) when (ex is UriFormatException or ArgumentException)
{
Debug.WriteLine($"[ReportSplitHelper] DeriveSiteLabel: malformed URL '{siteUrl}' ({ex.GetType().Name}: {ex.Message}) — falling back to raw value.");
return siteUrl;
}
}
/// <summary>
/// Generic dispatcher for split-aware export: if
/// <paramref name="splitMode"/> is not BySite, writes a single file via
/// <paramref name="writer"/>; otherwise partitions via
/// <paramref name="partitioner"/> and writes one file per partition,
/// each at a filename derived from <paramref name="basePath"/> plus the
/// partition label.
/// </summary>
public static async Task WritePartitionedAsync<T>(
IReadOnlyList<T> items,
string basePath,
ReportSplitMode splitMode,
Func<IReadOnlyList<T>, IEnumerable<(string Label, IReadOnlyList<T> Partition)>> partitioner,
Func<IReadOnlyList<T>, string, CancellationToken, Task> writer,
CancellationToken ct)
{
if (splitMode != ReportSplitMode.BySite)
{
await writer(items, basePath, ct);
return;
}
foreach (var (label, partition) in partitioner(items))
{
ct.ThrowIfCancellationRequested();
var path = BuildPartitionPath(basePath, label);
await writer(partition, path, ct);
}
}
/// <summary>
/// Bundles per-partition HTML documents into one self-contained tabbed
/// HTML. Each partition HTML is embedded in an &lt;iframe srcdoc&gt; so
/// their inline styles and scripts remain isolated.
/// </summary>
public static string BuildTabbedHtml(
IReadOnlyList<(string Label, string Html)> parts,
string title)
{
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>{WebUtility.HtmlEncode(title)}</title>");
sb.AppendLine("""
<style>
html, body { margin: 0; padding: 0; height: 100%; font-family: 'Segoe UI', Arial, sans-serif; background: #1a1a2e; }
.tabbar { display: flex; flex-wrap: wrap; gap: 4px; background: #1a1a2e; padding: 8px; position: sticky; top: 0; z-index: 10; }
.tab { padding: 6px 12px; background: #2d2d4e; color: #fff; cursor: pointer; border-radius: 4px;
font-size: 13px; user-select: none; white-space: nowrap; }
.tab:hover { background: #3d3d6e; }
.tab.active { background: #0078d4; }
.frame-host { background: #f5f5f5; }
iframe { width: 100%; height: calc(100vh - 52px); border: 0; display: none; background: #f5f5f5; }
iframe.active { display: block; }
</style>
</head>
<body>
""");
sb.Append("<div class=\"tabbar\">");
for (int i = 0; i < parts.Count; i++)
{
var cls = i == 0 ? "tab active" : "tab";
sb.Append($"<div class=\"{cls}\" onclick=\"showTab({i})\">{WebUtility.HtmlEncode(parts[i].Label)}</div>");
}
sb.AppendLine("</div>");
for (int i = 0; i < parts.Count; i++)
{
var cls = i == 0 ? "active" : string.Empty;
var escaped = EscapeForSrcdoc(parts[i].Html);
sb.AppendLine($"<iframe class=\"{cls}\" srcdoc=\"{escaped}\"></iframe>");
}
sb.AppendLine("""
<script>
function showTab(i) {
var frames = document.querySelectorAll('iframe');
var tabs = document.querySelectorAll('.tab');
for (var j = 0; j < frames.length; j++) {
frames[j].classList.toggle('active', i === j);
tabs[j].classList.toggle('active', i === j);
}
}
</script>
</body></html>
""");
return sb.ToString();
}
/// <summary>
/// Escapes an HTML document so it can safely appear inside an
/// &lt;iframe srcdoc="..."&gt; attribute. Only ampersands and double
/// quotes must be encoded; angle brackets are kept literal because the
/// parser treats srcdoc as CDATA-like content.
/// </summary>
private static string EscapeForSrcdoc(string html)
{
if (string.IsNullOrEmpty(html)) return string.Empty;
return html
.Replace("&", "&amp;")
.Replace("\"", "&quot;");
}
}