228 lines
9.6 KiB
C#
228 lines
9.6 KiB
C#
using SharepointToolbox.Core.Models;
|
||
using SharepointToolbox.Localization;
|
||
using System.Globalization;
|
||
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)
|
||
{
|
||
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
|
||
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||
WriteCsv(sb, nodes);
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
|
||
{
|
||
var T = TranslationSource.Instance;
|
||
// Hoist resource lookups out of the row loop: ResourceManager.GetString
|
||
// is a culture-aware dictionary probe — caching once per export saves
|
||
// O(rows × columns) lookups on large tenants.
|
||
string colLibrary = T["report.col.library"];
|
||
string colKind = T["stor.col.kind"];
|
||
string colSite = T["report.col.site"];
|
||
string colFiles = T["report.stat.files"];
|
||
string colTotalMb = T["report.col.total_size_mb"];
|
||
string colVerMb = T["report.col.version_size_mb"];
|
||
string colLastMod = T["report.col.last_modified"];
|
||
|
||
sb.Append(colLibrary).Append(',')
|
||
.Append(colKind).Append(',')
|
||
.Append(colSite).Append(',')
|
||
.Append(colFiles).Append(',')
|
||
.Append(colTotalMb).Append(',')
|
||
.Append(colVerMb).Append(',')
|
||
.AppendLine(colLastMod);
|
||
|
||
var kindLabels = BuildKindLabelCache();
|
||
|
||
foreach (var node in nodes)
|
||
{
|
||
AppendCsvField(sb, node.Name).Append(',');
|
||
AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
|
||
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||
sb.Append(node.TotalFileCount).Append(',');
|
||
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||
if (node.LastModified.HasValue)
|
||
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||
sb.AppendLine();
|
||
}
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
// Stream straight to disk: skip the StringBuilder→string copy and the
|
||
// separate UTF-8 buffer that File.WriteAllTextAsync materializes.
|
||
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||
WriteCsv(sb, nodes);
|
||
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, 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 sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||
WriteCsv(sb, nodes, fileTypeMetrics);
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||
{
|
||
var T = TranslationSource.Instance;
|
||
string colLibrary = T["report.col.library"];
|
||
string colSite = T["report.col.site"];
|
||
string colFiles = T["report.stat.files"];
|
||
string colTotalMb = T["report.col.total_size_mb"];
|
||
string colVerMb = T["report.col.version_size_mb"];
|
||
string colLastMod = T["report.col.last_modified"];
|
||
|
||
sb.Append(colLibrary).Append(',')
|
||
.Append(colSite).Append(',')
|
||
.Append(colFiles).Append(',')
|
||
.Append(colTotalMb).Append(',')
|
||
.Append(colVerMb).Append(',')
|
||
.AppendLine(colLastMod);
|
||
|
||
foreach (var node in nodes)
|
||
{
|
||
AppendCsvField(sb, node.Name).Append(',');
|
||
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||
sb.Append(node.TotalFileCount).Append(',');
|
||
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||
if (node.LastModified.HasValue)
|
||
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||
sb.AppendLine();
|
||
}
|
||
|
||
if (fileTypeMetrics.Count > 0)
|
||
{
|
||
string colFileType = T["report.col.file_type"];
|
||
string colSizeMb = T["report.col.size_mb"];
|
||
string colFileCnt = T["report.col.file_count"];
|
||
string noExtLabel = T["report.text.no_extension"];
|
||
|
||
sb.AppendLine();
|
||
sb.Append(colFileType).Append(',')
|
||
.Append(colSizeMb).Append(',')
|
||
.AppendLine(colFileCnt);
|
||
|
||
foreach (var m in fileTypeMetrics)
|
||
{
|
||
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
|
||
AppendCsvField(sb, label).Append(',');
|
||
AppendMb(sb, m.TotalSizeBytes).Append(',');
|
||
sb.Append(m.FileCount).AppendLine();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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 sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||
WriteCsv(sb, nodes, fileTypeMetrics);
|
||
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, 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 StringBuilder AppendMb(StringBuilder sb, long bytes)
|
||
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
|
||
|
||
private static StringBuilder AppendCsvField(StringBuilder sb, string value)
|
||
=> sb.Append(CsvSanitizer.EscapeMinimal(value));
|
||
|
||
/// <summary>
|
||
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||
/// once per export, indexed by the enum's int value. Avoids a
|
||
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
|
||
/// </summary>
|
||
private static string[] BuildKindLabelCache()
|
||
{
|
||
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||
int max = 0;
|
||
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||
var cache = new string[max + 1];
|
||
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||
return cache;
|
||
}
|
||
|
||
private static string KindLabel(StorageNodeKind kind)
|
||
{
|
||
var T = TranslationSource.Instance;
|
||
return kind switch
|
||
{
|
||
StorageNodeKind.Library => T["stor.kind.library"],
|
||
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
|
||
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
|
||
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
|
||
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
|
||
StorageNodeKind.Subsite => T["stor.kind.subsite"],
|
||
_ => kind.ToString()
|
||
};
|
||
}
|
||
}
|