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>
200 lines
8.2 KiB
C#
200 lines
8.2 KiB
C#
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 <iframe srcdoc> 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
|
|
/// <iframe srcdoc="..."> 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("&", "&")
|
|
.Replace("\"", """);
|
|
}
|
|
}
|