using System.Diagnostics; using System.IO; using System.Net; using System.Text; namespace SharepointToolbox.Services.Export; /// /// Shared helpers for split report exports: filename partitioning, site label /// derivation, and bundling per-partition HTML into a single tabbed document. /// public static class ReportSplitHelper { /// /// Returns a file-safe variant of . Invalid filename /// characters are replaced with underscores; whitespace runs are collapsed. /// 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; } /// /// Given a user-selected (e.g. "C:\reports\duplicates.csv"), /// returns a partitioned path like "C:\reports\duplicates_{label}.csv". /// 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); } /// /// 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. /// 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; } /// /// Derives a short, human-friendly site label from a SharePoint site URL. /// Falls back to the raw URL (sanitized) when parsing fails. /// 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; } } /// /// Generic dispatcher for split-aware export: if /// is not BySite, writes a single file via /// ; otherwise partitions via /// and writes one file per partition, /// each at a filename derived from plus the /// partition label. /// public static async Task WritePartitionedAsync( IReadOnlyList items, string basePath, ReportSplitMode splitMode, Func, IEnumerable<(string Label, IReadOnlyList Partition)>> partitioner, Func, 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); } } /// /// 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. /// public static string BuildTabbedHtml( IReadOnlyList<(string Label, string Html)> parts, string title) { var sb = new StringBuilder(); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($"{WebUtility.HtmlEncode(title)}"); sb.AppendLine(""" """); sb.Append("
"); for (int i = 0; i < parts.Count; i++) { var cls = i == 0 ? "tab active" : "tab"; sb.Append($"
{WebUtility.HtmlEncode(parts[i].Label)}
"); } sb.AppendLine("
"); for (int i = 0; i < parts.Count; i++) { var cls = i == 0 ? "active" : string.Empty; var escaped = EscapeForSrcdoc(parts[i].Html); sb.AppendLine($""); } sb.AppendLine(""" """); return sb.ToString(); } /// /// 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. /// private static string EscapeForSrcdoc(string html) { if (string.IsNullOrEmpty(html)) return string.Empty; return html .Replace("&", "&") .Replace("\"", """); } }