chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete: - Global site selection (toolbar picker, all tabs consume) - User access audit (Graph people-picker, direct/group/inherited) - Simplified permissions (plain-language labels, risk levels, detail toggle) - Storage visualization (LiveCharts2 pie/donut + bar charts) Post-phase polish: centralized site selection (removed per-tab pickers), claims prefix stripping, StorageMetrics backfill, chart tooltip fix, summary stats in app + HTML exports. 205 tests passing, 10,484 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,152 @@ public class StorageService : IStorageService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task BackfillZeroNodesAsync(
|
||||
ClientContext ctx,
|
||||
IReadOnlyList<StorageNode> nodes,
|
||||
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();
|
||||
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,
|
||||
l => l.RootFolder.ServerRelativeUrl));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var libs = ctx.Web.Lists
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int idx = 0;
|
||||
foreach (var libNode in needsBackfill)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
idx++;
|
||||
|
||||
if (!libs.TryGetValue(libNode.Name, 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);
|
||||
|
||||
// Reset all nodes in this tree to zero before accumulating
|
||||
ResetNodeCounts(libNode);
|
||||
|
||||
// Enumerate all files with their folder path
|
||||
var query = new CamlQuery
|
||||
{
|
||||
ViewXml = @"<View Scope='RecursiveAll'>
|
||||
<Query><Where>
|
||||
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
|
||||
</Where></Query>
|
||||
<ViewFields>
|
||||
<FieldRef Name='FileDirRef' />
|
||||
<FieldRef Name='File_x0020_Size' />
|
||||
</ViewFields>
|
||||
<RowLimit Paged='TRUE'>500</RowLimit>
|
||||
</View>"
|
||||
};
|
||||
|
||||
ListItemCollection items;
|
||||
do
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
items = lib.GetItems(query);
|
||||
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
||||
ic => ic.Include(
|
||||
i => i["FileDirRef"],
|
||||
i => i["File_x0020_Size"]));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
long size = 0;
|
||||
if (long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long s))
|
||||
size = s;
|
||||
|
||||
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
|
||||
|
||||
// Always count toward the library root
|
||||
libNode.TotalSizeBytes += size;
|
||||
libNode.FileStreamSizeBytes += size;
|
||||
libNode.TotalFileCount++;
|
||||
|
||||
// Also count toward the most specific matching subfolder
|
||||
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
|
||||
if (matchedFolder != null && matchedFolder != libNode)
|
||||
{
|
||||
matchedFolder.TotalSizeBytes += size;
|
||||
matchedFolder.FileStreamSizeBytes += size;
|
||||
matchedFolder.TotalFileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||
}
|
||||
while (items.ListItemCollectionPosition != null);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasZeroChild(StorageNode node)
|
||||
{
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
if (child.TotalFileCount == 0) return true;
|
||||
if (HasZeroChild(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ResetNodeCounts(StorageNode node)
|
||||
{
|
||||
node.TotalSizeBytes = 0;
|
||||
node.FileStreamSizeBytes = 0;
|
||||
node.TotalFileCount = 0;
|
||||
foreach (var child in node.Children)
|
||||
ResetNodeCounts(child);
|
||||
}
|
||||
|
||||
private static void BuildFolderLookup(StorageNode node, string parentPath,
|
||||
Dictionary<string, StorageNode> lookup)
|
||||
{
|
||||
string nodePath = node.IndentLevel == 0
|
||||
? parentPath
|
||||
: parentPath + "/" + node.Name;
|
||||
lookup[nodePath] = node;
|
||||
|
||||
foreach (var child in node.Children)
|
||||
BuildFolderLookup(child, nodePath, lookup);
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
if (lookup.TryGetValue(path, out var node))
|
||||
return node;
|
||||
int lastSlash = path.LastIndexOf('/');
|
||||
if (lastSlash <= 0) break;
|
||||
path = path[..lastSlash];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Private helpers -----------------------------------------------------
|
||||
|
||||
private static async Task<StorageNode> LoadFolderNodeAsync(
|
||||
|
||||
Reference in New Issue
Block a user