Merge remote-tracking branch 'kawa/main'
This commit is contained in:
@@ -22,12 +22,13 @@ public class StorageCsvExportService
|
||||
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"]}");
|
||||
sb.AppendLine($"{T["report.col.library"]},{T["stor.col.kind"]},{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(KindLabel(node.Kind)),
|
||||
Csv(node.SiteTitle),
|
||||
node.TotalFileCount.ToString(),
|
||||
FormatMb(node.TotalSizeBytes),
|
||||
@@ -143,4 +144,19 @@ public class StorageCsvExportService
|
||||
|
||||
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
|
||||
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ public class StorageHtmlExportService
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{T["report.col.library_folder"]}</th>
|
||||
<th>{T["stor.col.kind"]}</th>
|
||||
<th>{T["report.col.site"]}</th>
|
||||
<th class="num">{T["report.stat.files"]}</th>
|
||||
<th class="num">{T["report.stat.total_size"]}</th>
|
||||
@@ -212,6 +213,7 @@ public class StorageHtmlExportService
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{T["report.col.library_folder"]}</th>
|
||||
<th>{T["stor.col.kind"]}</th>
|
||||
<th>{T["report.col.site"]}</th>
|
||||
<th class="num">{T["report.stat.files"]}</th>
|
||||
<th class="num">{T["report.stat.total_size"]}</th>
|
||||
@@ -312,6 +314,7 @@ public class StorageHtmlExportService
|
||||
sb.AppendLine($"""
|
||||
<tr>
|
||||
<td>{nameCell}</td>
|
||||
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
|
||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
||||
<td class="num">{node.TotalFileCount:N0}</td>
|
||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
||||
@@ -322,7 +325,7 @@ public class StorageHtmlExportService
|
||||
|
||||
if (hasChildren)
|
||||
{
|
||||
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
|
||||
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
|
||||
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
@@ -350,6 +353,7 @@ public class StorageHtmlExportService
|
||||
sb.AppendLine($"""
|
||||
<tr>
|
||||
<td>{nameCell}</td>
|
||||
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
|
||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
||||
<td class="num">{node.TotalFileCount:N0}</td>
|
||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
||||
@@ -360,7 +364,7 @@ public class StorageHtmlExportService
|
||||
|
||||
if (hasChildren)
|
||||
{
|
||||
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
|
||||
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
|
||||
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
@@ -381,4 +385,19 @@ public class StorageHtmlExportService
|
||||
|
||||
private static string HtmlEncode(string value)
|
||||
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ namespace SharepointToolbox.Services;
|
||||
public interface IStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API.
|
||||
/// Returns a tree of StorageNode objects with aggregate size data.
|
||||
/// Collects storage metrics for a site, capturing every storage source
|
||||
/// SharePoint reports (visible + hidden libraries, Preservation Hold,
|
||||
/// list attachments, recycle bin, and optionally subsites). Each
|
||||
/// <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/> so
|
||||
/// callers can filter what appears in the report.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
|
||||
ClientContext ctx,
|
||||
@@ -18,9 +21,6 @@ public interface IStorageService
|
||||
/// <summary>
|
||||
/// Enumerates files across all non-hidden document libraries in the site
|
||||
/// and aggregates storage consumption grouped by file extension.
|
||||
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
|
||||
/// This is a separate operation from CollectStorageAsync -- it provides
|
||||
/// file-type breakdown data for chart visualization.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
|
||||
ClientContext ctx,
|
||||
@@ -29,13 +29,24 @@ public interface IStorageService
|
||||
|
||||
/// <summary>
|
||||
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
|
||||
/// by enumerating files per library via CamlQuery.
|
||||
/// This works around the StorageMetrics API returning zeros when the
|
||||
/// caller lacks sufficient permissions or metrics haven't been calculated.
|
||||
/// by enumerating files per library via CamlQuery. Only re-runs against
|
||||
/// document-library kinds (Library, HiddenLibrary, PreservationHold).
|
||||
/// </summary>
|
||||
Task BackfillZeroNodesAsync(
|
||||
ClientContext ctx,
|
||||
IReadOnlyList<StorageNode> nodes,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SharePoint-reported total storage usage for the site
|
||||
/// (Site.Usage.Storage). This includes everything that counts toward
|
||||
/// the site quota — recycle bin, version history, hidden libraries,
|
||||
/// list attachments — and serves as the ground-truth reference total.
|
||||
/// Returns 0 if the call is denied or the property is unavailable.
|
||||
/// </summary>
|
||||
Task<long> GetSiteUsageStorageBytesAsync(
|
||||
ClientContext ctx,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -7,27 +7,39 @@ namespace SharepointToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CSOM-based storage metrics scanner.
|
||||
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern.
|
||||
/// Captures every storage source SharePoint reports for a site:
|
||||
/// document libraries (visible + hidden), the Preservation Hold Library,
|
||||
/// list attachments, the recycle bin (1st + 2nd stage), and optionally
|
||||
/// subsites. Each <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/>
|
||||
/// so the caller can filter what appears in the report.
|
||||
/// </summary>
|
||||
public class StorageService : IStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Collects per-library and per-folder storage metrics for a single
|
||||
/// SharePoint site. Depth and indentation are controlled via
|
||||
/// <paramref name="options"/>; libraries flagged <c>Hidden</c> are skipped.
|
||||
/// Traversal is breadth-first and leans on <see cref="SharePointPaginationHelper"/>
|
||||
/// so libraries above the 5,000-item threshold remain scannable.
|
||||
/// </summary>
|
||||
// PreservationHoldLibrary base template id.
|
||||
private const int PreservationHoldTemplate = 851;
|
||||
|
||||
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
|
||||
ClientContext ctx,
|
||||
StorageScanOptions options,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = new List<StorageNode>();
|
||||
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task CollectForWebAsync(
|
||||
ClientContext ctx,
|
||||
Web web,
|
||||
StorageScanOptions options,
|
||||
List<StorageNode> result,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Load web-level metadata in one round-trip
|
||||
ctx.Load(ctx.Web,
|
||||
ctx.Load(web,
|
||||
w => w.Title,
|
||||
w => w.Url,
|
||||
w => w.ServerRelativeUrl,
|
||||
@@ -35,48 +47,244 @@ public class StorageService : IStorageService
|
||||
l => l.Title,
|
||||
l => l.Hidden,
|
||||
l => l.BaseType,
|
||||
l => l.BaseTemplate,
|
||||
l => l.ItemCount,
|
||||
l => l.RootFolder.ServerRelativeUrl));
|
||||
if (options.IncludeSubsites)
|
||||
ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/');
|
||||
string siteTitle = ctx.Web.Title;
|
||||
|
||||
var result = new List<StorageNode>();
|
||||
var libs = ctx.Web.Lists
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToList();
|
||||
string siteTitle = web.Title;
|
||||
var lists = web.Lists.ToList();
|
||||
|
||||
// ── Document libraries (incl. hidden + Preservation Hold) ───────────
|
||||
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
|
||||
int idx = 0;
|
||||
foreach (var lib in libs)
|
||||
foreach (var lib in docLibs)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
idx++;
|
||||
progress.Report(new OperationProgress(idx, libs.Count,
|
||||
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})"));
|
||||
|
||||
StorageNodeKind kind = ClassifyLibrary(lib);
|
||||
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
|
||||
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
|
||||
|
||||
progress.Report(new OperationProgress(idx, docLibs.Count,
|
||||
$"Loading storage metrics: {lib.Title} ({idx}/{docLibs.Count})"));
|
||||
|
||||
var libNode = await LoadFolderNodeAsync(
|
||||
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
|
||||
siteTitle, lib.Title, 0, progress, ct);
|
||||
siteTitle, lib.Title, 0, kind, progress, ct);
|
||||
|
||||
if (options.FolderDepth > 0)
|
||||
{
|
||||
await CollectSubfoldersAsync(
|
||||
ctx, lib, lib.RootFolder.ServerRelativeUrl,
|
||||
libNode, 1, options.FolderDepth,
|
||||
siteTitle, lib.Title, progress, ct);
|
||||
siteTitle, lib.Title, kind, progress, ct);
|
||||
}
|
||||
|
||||
result.Add(libNode);
|
||||
}
|
||||
|
||||
return result;
|
||||
// ── List attachments (non-document-library lists) ───────────────────
|
||||
if (options.IncludeListAttachments)
|
||||
{
|
||||
var nonDocLists = lists
|
||||
.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0)
|
||||
.ToList();
|
||||
|
||||
int aIdx = 0;
|
||||
foreach (var list in nonDocLists)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
aIdx++;
|
||||
progress.Report(new OperationProgress(aIdx, nonDocLists.Count,
|
||||
$"Scanning list attachments: {list.Title} ({aIdx}/{nonDocLists.Count})"));
|
||||
|
||||
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
|
||||
if (attachNode != null && attachNode.TotalSizeBytes > 0)
|
||||
result.Add(attachNode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recycle bin (stage 1 + stage 2) ─────────────────────────────────
|
||||
if (options.IncludeRecycleBin)
|
||||
{
|
||||
progress.Report(OperationProgress.Indeterminate(
|
||||
$"Scanning recycle bin: {siteTitle}..."));
|
||||
|
||||
var rbNodes = await LoadRecycleBinNodesAsync(ctx, siteTitle, progress, ct);
|
||||
result.AddRange(rbNodes);
|
||||
}
|
||||
|
||||
// ── Subsites (recursive) ────────────────────────────────────────────
|
||||
if (options.IncludeSubsites)
|
||||
{
|
||||
var subwebs = web.Webs.ToList();
|
||||
foreach (var sub in subwebs)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Build a node header so subsite results are visually grouped.
|
||||
var subResult = new List<StorageNode>();
|
||||
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
|
||||
if (subResult.Count == 0) continue;
|
||||
|
||||
var subRoot = new StorageNode
|
||||
{
|
||||
Name = sub.Title,
|
||||
Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
|
||||
SiteTitle = sub.Title,
|
||||
Library = string.Empty,
|
||||
Kind = StorageNodeKind.Subsite,
|
||||
IndentLevel = 0,
|
||||
Children = subResult,
|
||||
TotalSizeBytes = subResult.Sum(n => n.TotalSizeBytes),
|
||||
FileStreamSizeBytes = subResult.Sum(n => n.FileStreamSizeBytes),
|
||||
TotalFileCount = subResult.Sum(n => n.TotalFileCount)
|
||||
};
|
||||
result.Add(subRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static StorageNodeKind ClassifyLibrary(List lib)
|
||||
{
|
||||
if (lib.BaseTemplate == PreservationHoldTemplate ||
|
||||
string.Equals(lib.Title, "Preservation Hold Library", StringComparison.OrdinalIgnoreCase))
|
||||
return StorageNodeKind.PreservationHold;
|
||||
return lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
|
||||
}
|
||||
|
||||
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(
|
||||
ClientContext ctx,
|
||||
List list,
|
||||
string siteTitle,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Per-list attachments live in <listRootFolder>/Attachments/<itemId>/<file>.
|
||||
// The Attachments folder may or may not exist depending on whether any
|
||||
// item ever had an attachment — guard with try/catch.
|
||||
string attachmentsUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
|
||||
|
||||
try
|
||||
{
|
||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(attachmentsUrl);
|
||||
ctx.Load(folder,
|
||||
f => f.Exists,
|
||||
f => f.StorageMetrics,
|
||||
f => f.TimeLastModified,
|
||||
f => f.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0)
|
||||
return null;
|
||||
|
||||
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
|
||||
? folder.StorageMetrics.LastModified
|
||||
: folder.TimeLastModified > DateTime.MinValue
|
||||
? folder.TimeLastModified
|
||||
: (DateTime?)null;
|
||||
|
||||
return new StorageNode
|
||||
{
|
||||
Name = $"[Attachments] {list.Title}",
|
||||
Url = ctx.Url.TrimEnd('/') + attachmentsUrl,
|
||||
SiteTitle = siteTitle,
|
||||
Library = list.Title,
|
||||
Kind = StorageNodeKind.ListAttachments,
|
||||
TotalSizeBytes = folder.StorageMetrics.TotalSize,
|
||||
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
|
||||
TotalFileCount = folder.StorageMetrics.TotalFileCount,
|
||||
LastModified = lastMod,
|
||||
IndentLevel = 0,
|
||||
Children = new List<StorageNode>()
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Attachments folder absent for this list — not an error.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<StorageNode>> LoadRecycleBinNodesAsync(
|
||||
ClientContext ctx,
|
||||
string siteTitle,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var nodes = new List<StorageNode>();
|
||||
|
||||
try
|
||||
{
|
||||
var bin = ctx.Site.RecycleBin;
|
||||
ctx.Load(bin, b => b.Include(
|
||||
i => i.Size,
|
||||
i => i.ItemState,
|
||||
i => i.DeletedDate));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
long stage1Size = 0, stage2Size = 0;
|
||||
int stage1Count = 0, stage2Count = 0;
|
||||
DateTime? stage1Last = null, stage2Last = null;
|
||||
|
||||
foreach (var item in bin)
|
||||
{
|
||||
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin)
|
||||
{
|
||||
stage2Size += item.Size;
|
||||
stage2Count++;
|
||||
if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate;
|
||||
}
|
||||
else
|
||||
{
|
||||
stage1Size += item.Size;
|
||||
stage1Count++;
|
||||
if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (stage1Count > 0)
|
||||
nodes.Add(new StorageNode
|
||||
{
|
||||
Name = "[Recycle Bin] First-stage",
|
||||
SiteTitle = siteTitle,
|
||||
Library = "RecycleBin",
|
||||
Kind = StorageNodeKind.RecycleBin,
|
||||
TotalSizeBytes = stage1Size,
|
||||
FileStreamSizeBytes = stage1Size,
|
||||
TotalFileCount = stage1Count,
|
||||
LastModified = stage1Last,
|
||||
IndentLevel = 0,
|
||||
Children = new List<StorageNode>()
|
||||
});
|
||||
|
||||
if (stage2Count > 0)
|
||||
nodes.Add(new StorageNode
|
||||
{
|
||||
Name = "[Recycle Bin] Second-stage",
|
||||
SiteTitle = siteTitle,
|
||||
Library = "RecycleBin",
|
||||
Kind = StorageNodeKind.RecycleBin,
|
||||
TotalSizeBytes = stage2Size,
|
||||
FileStreamSizeBytes = stage2Size,
|
||||
TotalFileCount = stage2Count,
|
||||
LastModified = stage2Last,
|
||||
IndentLevel = 0,
|
||||
Children = new List<StorageNode>()
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Insufficient permission to read recycle bin or feature unavailable.
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates file counts and total sizes by extension across every
|
||||
/// non-hidden document library on the site. Extensions are normalised to
|
||||
/// lowercase; files without an extension roll up into a single bucket.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
|
||||
ClientContext ctx,
|
||||
IProgress<OperationProgress> progress,
|
||||
@@ -84,7 +292,6 @@ public class StorageService : IStorageService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Load all non-hidden document libraries
|
||||
ctx.Load(ctx.Web,
|
||||
w => w.Lists.Include(
|
||||
l => l.Title,
|
||||
@@ -97,7 +304,6 @@ public class StorageService : IStorageService
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToList();
|
||||
|
||||
// Accumulate file sizes by extension across all libraries
|
||||
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int libIdx = 0;
|
||||
@@ -108,9 +314,6 @@ public class StorageService : IStorageService
|
||||
progress.Report(new OperationProgress(libIdx, libs.Count,
|
||||
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
|
||||
|
||||
// Paginated CAML without a WHERE clause — WHERE on non-indexed fields
|
||||
// (FSObjType) throws list-view threshold on libraries > 5,000 items.
|
||||
// Filter files client-side via FSObjType.
|
||||
var query = new CamlQuery
|
||||
{
|
||||
ViewXml = @"<View Scope='RecursiveAll'>
|
||||
@@ -138,7 +341,7 @@ public class StorageService : IStorageService
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
|
||||
if (item["FSObjType"]?.ToString() != "0") continue;
|
||||
|
||||
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0";
|
||||
@@ -159,7 +362,6 @@ public class StorageService : IStorageService
|
||||
while (items.ListItemCollectionPosition != null);
|
||||
}
|
||||
|
||||
// Convert to FileTypeMetric list, sorted by size descending
|
||||
return extensionMap
|
||||
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
|
||||
.OrderByDescending(m => m.TotalSizeBytes)
|
||||
@@ -172,13 +374,17 @@ public class StorageService : IStorageService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find root-level library nodes that have any zero-valued nodes in their tree
|
||||
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
// Only backfill nodes scanned through CSOM document-library StorageMetrics —
|
||||
// synthetic categories (recycle bin, list attachments, subsite headers)
|
||||
// cannot be re-derived from File_x0020_Size.
|
||||
var libNodes = nodes.Where(n => n.IndentLevel == 0 &&
|
||||
(n.Kind == StorageNodeKind.Library ||
|
||||
n.Kind == StorageNodeKind.HiddenLibrary ||
|
||||
n.Kind == StorageNodeKind.PreservationHold)).ToList();
|
||||
var needsBackfill = libNodes.Where(lib =>
|
||||
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
|
||||
if (needsBackfill.Count == 0) return;
|
||||
|
||||
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
|
||||
ctx.Load(ctx.Web, w => w.ServerRelativeUrl,
|
||||
w => w.Lists.Include(
|
||||
l => l.Title, l => l.Hidden, l => l.BaseType,
|
||||
@@ -186,7 +392,7 @@ public class StorageService : IStorageService
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var libs = ctx.Web.Lists
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.Where(l => l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int idx = 0;
|
||||
@@ -195,30 +401,21 @@ public class StorageService : IStorageService
|
||||
ct.ThrowIfCancellationRequested();
|
||||
idx++;
|
||||
|
||||
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
|
||||
if (!libs.TryGetValue(libNode.Library, out var lib)) continue;
|
||||
|
||||
progress.Report(new OperationProgress(idx, needsBackfill.Count,
|
||||
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
|
||||
|
||||
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
|
||||
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
|
||||
BuildFolderLookup(libNode, libRootSrl, folderLookup);
|
||||
|
||||
// Capture original TotalSizeBytes before reset — StorageMetrics.TotalSize
|
||||
// includes version overhead, which cannot be rederived from a file scan
|
||||
// (File_x0020_Size is the current stream size only).
|
||||
var originalTotals = new Dictionary<StorageNode, long>();
|
||||
CaptureTotals(libNode, originalTotals);
|
||||
|
||||
// Reset all nodes in this tree to zero before accumulating
|
||||
ResetNodeCounts(libNode);
|
||||
|
||||
// Paginated CAML without WHERE (filter folders client-side via FSObjType).
|
||||
// SMTotalSize = per-file total including all versions (version-aware).
|
||||
// SMTotalFileStreamSize = current stream only. File_x0020_Size is a fallback
|
||||
// when SMTotalSize is unavailable (older tenants / custom fields stripped).
|
||||
var query = new CamlQuery
|
||||
{
|
||||
ViewXml = @"<View Scope='RecursiveAll'>
|
||||
@@ -250,24 +447,21 @@ public class StorageService : IStorageService
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
|
||||
if (item["FSObjType"]?.ToString() != "0") continue;
|
||||
|
||||
long streamSize = ParseLong(item["File_x0020_Size"]);
|
||||
long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize"));
|
||||
long smTotal = ParseLong(SafeGet(item, "SMTotalSize"));
|
||||
|
||||
// Prefer SM fields when present; fall back to File_x0020_Size otherwise.
|
||||
if (smStream > 0) streamSize = smStream;
|
||||
long totalSize = smTotal > 0 ? smTotal : streamSize;
|
||||
|
||||
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
|
||||
|
||||
// Always count toward the library root
|
||||
libNode.TotalSizeBytes += totalSize;
|
||||
libNode.FileStreamSizeBytes += streamSize;
|
||||
libNode.TotalFileCount++;
|
||||
|
||||
// Also count toward the most specific matching subfolder
|
||||
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
|
||||
if (matchedFolder != null && matchedFolder != libNode)
|
||||
{
|
||||
@@ -281,9 +475,6 @@ public class StorageService : IStorageService
|
||||
}
|
||||
while (items.ListItemCollectionPosition != null);
|
||||
|
||||
// Restore original TotalSizeBytes where it exceeded the recomputed value.
|
||||
// Preserves StorageMetrics.TotalSize for nodes whose original metrics were
|
||||
// valid but SMTotalSize was missing on individual files.
|
||||
foreach (var kv in originalTotals)
|
||||
{
|
||||
if (kv.Value > kv.Key.TotalSizeBytes)
|
||||
@@ -292,6 +483,23 @@ public class StorageService : IStorageService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> GetSiteUsageStorageBytesAsync(
|
||||
ClientContext ctx,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
ctx.Load(ctx.Site, s => s.Usage);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
return ctx.Site.Usage.Storage;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static long ParseLong(object? value)
|
||||
{
|
||||
if (value == null) return 0;
|
||||
@@ -345,8 +553,6 @@ public class StorageService : IStorageService
|
||||
private static StorageNode? FindDeepestFolder(string fileDirRef,
|
||||
Dictionary<string, StorageNode> lookup)
|
||||
{
|
||||
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
|
||||
// Try exact match, then walk up until we find a match
|
||||
string path = fileDirRef.TrimEnd('/');
|
||||
while (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
@@ -359,7 +565,7 @@ public class StorageService : IStorageService
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Private helpers -----------------------------------------------------
|
||||
// ── Library/folder loading helpers ──────────────────────────────────────
|
||||
|
||||
private static async Task<StorageNode> LoadFolderNodeAsync(
|
||||
ClientContext ctx,
|
||||
@@ -368,6 +574,7 @@ public class StorageService : IStorageService
|
||||
string siteTitle,
|
||||
string library,
|
||||
int indentLevel,
|
||||
StorageNodeKind kind,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -393,6 +600,7 @@ public class StorageService : IStorageService
|
||||
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
|
||||
SiteTitle = siteTitle,
|
||||
Library = library,
|
||||
Kind = kind,
|
||||
TotalSizeBytes = folder.StorageMetrics.TotalSize,
|
||||
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
|
||||
TotalFileCount = folder.StorageMetrics.TotalFileCount,
|
||||
@@ -411,15 +619,13 @@ public class StorageService : IStorageService
|
||||
int maxDepth,
|
||||
string siteTitle,
|
||||
string library,
|
||||
StorageNodeKind kind,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (currentDepth > maxDepth) return;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Enumerate direct child folders via paginated CAML scoped to the parent.
|
||||
// Folder.Folders lazy loading hits the list-view threshold on libraries
|
||||
// > 5,000 items; a paged CAML query with no WHERE bypasses it.
|
||||
var subfolders = new List<(string Name, string ServerRelativeUrl)>();
|
||||
|
||||
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||
@@ -427,13 +633,12 @@ public class StorageService : IStorageService
|
||||
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
|
||||
ct: ct))
|
||||
{
|
||||
if (item["FSObjType"]?.ToString() != "1") continue; // folders only
|
||||
if (item["FSObjType"]?.ToString() != "1") continue;
|
||||
|
||||
string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
string url = item["FileRef"]?.ToString() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
|
||||
|
||||
// Skip SharePoint system folders
|
||||
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.StartsWith("_", StringComparison.Ordinal))
|
||||
continue;
|
||||
@@ -447,14 +652,14 @@ public class StorageService : IStorageService
|
||||
|
||||
var childNode = await LoadFolderNodeAsync(
|
||||
ctx, sub.ServerRelativeUrl, sub.Name,
|
||||
siteTitle, library, currentDepth, progress, ct);
|
||||
siteTitle, library, currentDepth, kind, progress, ct);
|
||||
|
||||
if (currentDepth < maxDepth)
|
||||
{
|
||||
await CollectSubfoldersAsync(
|
||||
ctx, list, sub.ServerRelativeUrl, childNode,
|
||||
currentDepth + 1, maxDepth,
|
||||
siteTitle, library, progress, ct);
|
||||
siteTitle, library, kind, progress, ct);
|
||||
}
|
||||
|
||||
parentNode.Children.Add(childNode);
|
||||
|
||||
Reference in New Issue
Block a user