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>
147 lines
6.0 KiB
C#
147 lines
6.0 KiB
C#
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using System.IO;
|
|
using System.Text;
|
|
|
|
namespace SharepointToolbox.Services.Export;
|
|
|
|
/// <summary>
|
|
/// Exports a flat list of StorageNode objects to a UTF-8 BOM CSV.
|
|
/// Compatible with Microsoft Excel (BOM signals UTF-8 encoding).
|
|
/// </summary>
|
|
public class StorageCsvExportService
|
|
{
|
|
/// <summary>
|
|
/// Builds a single-section CSV: header row plus one row per
|
|
/// <see cref="StorageNode"/> with library, site, file count, total size
|
|
/// (MB), version size (MB), and last-modified date.
|
|
/// </summary>
|
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
var sb = new StringBuilder();
|
|
|
|
// Header
|
|
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(",",
|
|
Csv(node.Name),
|
|
Csv(node.SiteTitle),
|
|
node.TotalFileCount.ToString(),
|
|
FormatMb(node.TotalSizeBytes),
|
|
FormatMb(node.VersionSizeBytes),
|
|
node.LastModified.HasValue
|
|
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
|
: string.Empty));
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
|
{
|
|
var csv = BuildCsv(nodes);
|
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a CSV with library details followed by a file-type breakdown section.
|
|
/// </summary>
|
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
var sb = new StringBuilder();
|
|
|
|
// Library details
|
|
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(",",
|
|
Csv(node.Name),
|
|
Csv(node.SiteTitle),
|
|
node.TotalFileCount.ToString(),
|
|
FormatMb(node.TotalSizeBytes),
|
|
FormatMb(node.VersionSizeBytes),
|
|
node.LastModified.HasValue
|
|
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
|
: string.Empty));
|
|
}
|
|
|
|
// File type breakdown
|
|
if (fileTypeMetrics.Count > 0)
|
|
{
|
|
sb.AppendLine();
|
|
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) ? T["report.text.no_extension"] : m.Extension;
|
|
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
|
|
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
|
{
|
|
var csv = BuildCsv(nodes, fileTypeMetrics);
|
|
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes storage metrics with optional per-site partitioning.
|
|
/// Single → one file. BySite → one file per SiteTitle. File-type metrics
|
|
/// are replicated across all partitions because the tenant-level scan
|
|
/// does not retain per-site breakdowns.
|
|
/// </summary>
|
|
public Task WriteAsync(
|
|
IReadOnlyList<StorageNode> nodes,
|
|
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
|
|
string basePath,
|
|
ReportSplitMode splitMode,
|
|
CancellationToken ct)
|
|
=> ReportSplitHelper.WritePartitionedAsync(
|
|
nodes, basePath, splitMode,
|
|
PartitionBySite,
|
|
(part, path, c) => WriteAsync(part, fileTypeMetrics, path, c),
|
|
ct);
|
|
|
|
/// <summary>
|
|
/// Splits the flat StorageNode list into per-site slices while preserving
|
|
/// the DFS hierarchy (each root library followed by its indented descendants).
|
|
/// Siblings sharing a SiteTitle roll up into the same partition.
|
|
/// </summary>
|
|
internal static IEnumerable<(string Label, IReadOnlyList<StorageNode> Partition)> PartitionBySite(
|
|
IReadOnlyList<StorageNode> nodes)
|
|
{
|
|
var buckets = new Dictionary<string, List<StorageNode>>(StringComparer.OrdinalIgnoreCase);
|
|
string currentSite = string.Empty;
|
|
foreach (var node in nodes)
|
|
{
|
|
if (node.IndentLevel == 0)
|
|
currentSite = string.IsNullOrWhiteSpace(node.SiteTitle)
|
|
? ReportSplitHelper.DeriveSiteLabel(node.Url)
|
|
: node.SiteTitle;
|
|
if (!buckets.TryGetValue(currentSite, out var list))
|
|
{
|
|
list = new List<StorageNode>();
|
|
buckets[currentSite] = list;
|
|
}
|
|
list.Add(node);
|
|
}
|
|
return buckets.Select(kv => (kv.Key, (IReadOnlyList<StorageNode>)kv.Value));
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private static string FormatMb(long bytes)
|
|
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
|
|
|
|
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
|
|
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
|
}
|