chore: archive v1.1 Enhanced Reports milestone
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:
Dev
2026-04-08 10:21:02 +02:00
parent fa793c5489
commit fd442f3b4c
35 changed files with 1062 additions and 760 deletions

View File

@@ -48,18 +48,7 @@ public partial class MainWindow : Window
TemplatesTabItem.Content = serviceProvider.GetRequiredService<TemplatesView>();
// Phase 7: User Access Audit
var auditView = serviceProvider.GetRequiredService<UserAccessAuditView>();
UserAccessAuditTabItem.Content = auditView;
// Wire site picker dialog factory for audit tab (same pattern as PermissionsView)
if (auditView.DataContext is ViewModels.Tabs.UserAccessAuditViewModel auditVm)
{
auditVm.OpenSitePickerDialog = () =>
{
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
return factory(auditVm.CurrentProfile ?? new TenantProfile());
};
}
UserAccessAuditTabItem.Content = serviceProvider.GetRequiredService<UserAccessAuditView>();
// Replace Settings tab placeholder with the DI-resolved SettingsView
SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>();

View File

@@ -36,7 +36,49 @@ public class StorageCsvExportService
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes);
// UTF-8 with BOM for Excel compatibility
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), 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();
// Library details
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),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("File Type,Size (MB),File Count");
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
}
}
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes, fileTypeMetrics);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}

View File

@@ -51,6 +51,151 @@ public class StorageHtmlExportService
<h1>SharePoint Storage Metrics</h1>
""");
// Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
</div>
""");
sb.AppendLine("""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
</tr>
</thead>
<tbody>
""");
foreach (var node in nodes)
{
RenderNode(sb, node);
}
sb.AppendLine("""
</tbody>
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
}
/// <summary>
/// Builds an HTML report including a file-type breakdown chart section.
/// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
h2 { color: #333; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; font-weight: 600; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
tr:hover { background: #f0f7ff; }
.toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.toggle-btn:hover { background: #e5f1fb; }
.sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
.sf-tbl td { background: #fafcff; font-size: 12px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
.chart-section { margin: 20px 0; padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
.bar-row { display: flex; align-items: center; margin: 4px 0; }
.bar-label { width: 80px; font-size: 12px; font-weight: 600; text-align: right; padding-right: 10px; }
.bar-track { flex: 1; background: #eee; border-radius: 4px; height: 22px; position: relative; }
.bar-fill { height: 100%; border-radius: 4px; background: #0078d4; min-width: 2px; }
.bar-value { font-size: 11px; color: #555; padding-left: 8px; white-space: nowrap; min-width: 140px; }
.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #0078d4; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
</style>
<script>
function toggle(i) {
var row = document.getElementById('sf-' + i);
if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
</script>
</head>
<body>
<h1>SharePoint Storage Metrics</h1>
""");
// ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
sb.AppendLine("</div>");
// ── File type chart section ──
if (fileTypeMetrics.Count > 0)
{
var maxSize = fileTypeMetrics.Max(m => m.TotalSizeBytes);
var totalSize = fileTypeMetrics.Sum(m => m.TotalSizeBytes);
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
int idx = 0;
foreach (var m in fileTypeMetrics.Take(15))
{
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
sb.AppendLine($"""
<div class="bar-row">
<span class="bar-label">{HtmlEncode(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} files</span>
</div>
""");
idx++;
}
sb.AppendLine("</div>");
}
// ── Storage table ──
sb.AppendLine("<h2>Library Details</h2>");
sb.AppendLine("""
<table>
<thead>
@@ -88,6 +233,12 @@ public class StorageHtmlExportService
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{
var html = BuildHtml(nodes, fileTypeMetrics);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
// ── Private rendering ────────────────────────────────────────────────────
private void RenderNode(StringBuilder sb, StorageNode node)

View File

@@ -26,4 +26,16 @@ public interface IStorageService
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <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.
/// </summary>
Task BackfillZeroNodesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -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(

View File

@@ -114,7 +114,7 @@ public class UserAccessAuditService : IUserAccessAuditService
yield return new UserAccessEntry(
UserDisplayName: displayName,
UserLogin: login,
UserLogin: StripClaimsPrefix(login),
SiteUrl: site.Url,
SiteTitle: site.Title,
ObjectType: entry.ObjectType,
@@ -133,6 +133,16 @@ public class UserAccessAuditService : IUserAccessAuditService
/// <summary>
/// Classifies a PermissionEntry into Direct, Group, or Inherited access type.
/// </summary>
/// <summary>
/// Removes SharePoint claims prefixes like "i:0#.f|membership|" so the
/// UI displays a plain email instead of the full claim string.
/// </summary>
private static string StripClaimsPrefix(string login)
{
int pipe = login.LastIndexOf('|');
return pipe >= 0 ? login[(pipe + 1)..] : login;
}
private static AccessType ClassifyAccessType(PermissionEntry entry)
{
// Inherited: object does not have unique permissions

View File

@@ -8,6 +8,8 @@
<UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed>
<StartupObject>SharepointToolbox.App</StartupObject>
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">

View File

@@ -33,10 +33,8 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
private readonly DuplicatesHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSiteOverride;
private IReadOnlyList<DuplicateGroup> _lastGroups = Array.Empty<DuplicateGroup>();
[ObservableProperty] private string _siteUrl = string.Empty;
[ObservableProperty] private bool _modeFiles = true;
[ObservableProperty] private bool _modeFolders;
[ObservableProperty] private bool _matchSize = true;
@@ -77,26 +75,6 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
}
partial void OnSiteUrlChanged(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
_hasLocalSiteOverride = false;
if (GlobalSites.Count > 0)
SiteUrl = GlobalSites[0].Url;
}
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
{
_hasLocalSiteOverride = true;
}
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
@@ -104,36 +82,45 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Please enter a site URL.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
var siteProfile = new TenantProfile
var allGroups = new List<DuplicateGroup>();
foreach (var url in urls)
{
TenantUrl = SiteUrl.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var siteProfile = new TenantProfile
{
TenantUrl = url.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var opts = new DuplicateScanOptions(
Mode: ModeFiles ? "Files" : "Folders",
MatchSize: MatchSize,
MatchCreated: MatchCreated,
MatchModified: MatchModified,
MatchSubfolderCount: MatchSubfolders,
MatchFileCount: MatchFileCount,
IncludeSubsites: IncludeSubsites,
Library: string.IsNullOrWhiteSpace(Library) ? null : Library
);
var opts = new DuplicateScanOptions(
Mode: ModeFiles ? "Files" : "Folders",
MatchSize: MatchSize,
MatchCreated: MatchCreated,
MatchModified: MatchModified,
MatchSubfolderCount: MatchSubfolders,
MatchFileCount: MatchFileCount,
IncludeSubsites: IncludeSubsites,
Library: string.IsNullOrWhiteSpace(Library) ? null : Library
);
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
_lastGroups = groups;
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
allGroups.AddRange(groups);
}
_lastGroups = allGroups;
// Flatten groups to display rows
var rows = groups
var rows = allGroups
.SelectMany(g => g.Items.Select(item => new DuplicateRow
{
GroupName = g.Name,
@@ -158,10 +145,8 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<DuplicateRow>();
_lastGroups = Array.Empty<DuplicateGroup>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportHtmlCommand.NotifyCanExecuteChanged();
}

View File

@@ -22,11 +22,9 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
private readonly BulkResultCsvExportService _exportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSiteOverride;
private List<FolderStructureRow>? _validRows;
private BulkOperationSummary<string>? _lastResult;
[ObservableProperty] private string _siteUrl = string.Empty;
[ObservableProperty] private string _libraryTitle = string.Empty;
[ObservableProperty] private string _previewSummary = string.Empty;
[ObservableProperty] private string _resultSummary = string.Empty;
@@ -66,26 +64,6 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
}
partial void OnSiteUrlChanged(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
_hasLocalSiteOverride = false;
if (GlobalSites.Count > 0)
SiteUrl = GlobalSites[0].Url;
}
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
{
_hasLocalSiteOverride = true;
}
}
private void ImportCsv()
{
var dlg = new OpenFileDialog
@@ -128,26 +106,36 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows. Import a CSV first.");
if (string.IsNullOrWhiteSpace(SiteUrl))
throw new InvalidOperationException("Site URL is required.");
if (string.IsNullOrWhiteSpace(LibraryTitle))
throw new InvalidOperationException("Library title is required.");
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
throw new InvalidOperationException("Select at least one site from the toolbar.");
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{uniquePaths.Count} folders will be created in {LibraryTitle}");
$"{uniquePaths.Count} folders will be created in {LibraryTitle} on {urls.Count} site(s)");
if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
return;
var profile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = SiteUrl,
ClientId = _currentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
var allResults = new List<BulkItemResult<string>>();
_lastResult = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
foreach (var url in urls)
{
var profile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = url,
ClientId = _currentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
var result = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
allResults.AddRange(result.Results);
}
_lastResult = new BulkOperationSummary<string>(allResults);
await Application.Current.Dispatcher.InvokeAsync(() =>
{
@@ -175,8 +163,6 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
SiteUrl = string.Empty;
LibraryTitle = string.Empty;
PreviewRows = new();
_validRows = null;

View File

@@ -30,9 +30,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
// ── Observable properties ───────────────────────────────────────────────
[ObservableProperty]
private string _siteUrl = string.Empty;
[ObservableProperty]
private bool _includeInherited;
@@ -115,43 +112,11 @@ public partial class PermissionsViewModel : FeatureViewModelBase
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand OpenSitePickerCommand { get; }
// ── Multi-site ──────────────────────────────────────────────────────────
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
/// <summary>
/// True when the user has manually selected sites via the site picker on this tab.
/// Prevents global site changes from overwriting the user's local selection.
/// </summary>
private bool _hasLocalSiteOverride;
// ── Dialog factory (set by View layer — keeps Window out of ViewModel) ──
/// <summary>
/// Factory function set by the View layer to open the SitePickerDialog.
/// Returns the opened Window; ViewModel calls ShowDialog() on it.
/// </summary>
public Func<Window>? OpenSitePickerDialog { get; set; }
// ── Current tenant profile (received via WeakReferenceMessenger) ────────
internal TenantProfile? _currentProfile;
/// <summary>
/// Public accessor for the current tenant profile — used by View layer dialog factory.
/// </summary>
public TenantProfile? CurrentProfile => _currentProfile;
/// <summary>
/// Label shown in the UI: "3 site(s) selected" or empty when none are selected.
/// </summary>
public string SitesSelectedLabel =>
SelectedSites.Count > 0
? string.Format(Localization.TranslationSource.Instance["perm.sites.selected"], SelectedSites.Count)
: string.Empty;
// ── Constructors ────────────────────────────────────────────────────────
/// <summary>
@@ -175,8 +140,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
}
/// <summary>
@@ -198,21 +161,10 @@ public partial class PermissionsViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
}
// ── FeatureViewModelBase implementation ─────────────────────────────────
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SelectedSites.Clear();
foreach (var site in sites)
SelectedSites.Add(site);
}
partial void OnIsSimplifiedModeChanged(bool value)
{
if (value && Results.Count > 0)
@@ -237,17 +189,15 @@ public partial class PermissionsViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
var urls = SelectedSites.Count > 0
? SelectedSites.Select(s => s.Url).ToList()
: new List<string> { SiteUrl };
var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (nonEmpty.Count == 0)
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Enter a site URL or select sites.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
var nonEmpty = urls;
var allEntries = new List<PermissionEntry>();
var scanOptions = new ScanOptions(
IncludeInherited: IncludeInherited,
@@ -303,15 +253,10 @@ public partial class PermissionsViewModel : FeatureViewModelBase
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<PermissionEntry>();
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
Summaries = Array.Empty<PermissionSummary>();
OnPropertyChanged(nameof(ActiveItemsSource));
SiteUrl = string.Empty;
SelectedSites.Clear();
OnPropertyChanged(nameof(SitesSelectedLabel));
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
@@ -381,19 +326,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
}
}
private void ExecuteOpenSitePicker()
{
if (OpenSitePickerDialog == null) return;
var dialog = OpenSitePickerDialog.Invoke();
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
{
_hasLocalSiteOverride = true;
SelectedSites.Clear();
foreach (var site in picker.SelectedUrls)
SelectedSites.Add(site);
}
}
private static void OpenFile(string filePath)
{
try

View File

@@ -19,11 +19,9 @@ public partial class SearchViewModel : FeatureViewModelBase
private readonly SearchHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSiteOverride;
// ── Filter observable properties ─────────────────────────────────────────
[ObservableProperty] private string _siteUrl = string.Empty;
[ObservableProperty] private string _extensions = string.Empty;
[ObservableProperty] private string _regex = string.Empty;
[ObservableProperty] private bool _useCreatedAfter;
@@ -74,26 +72,6 @@ public partial class SearchViewModel : FeatureViewModelBase
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
}
partial void OnSiteUrlChanged(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
_hasLocalSiteOverride = false;
if (GlobalSites.Count > 0)
SiteUrl = GlobalSites[0].Url;
}
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
{
_hasLocalSiteOverride = true;
}
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
@@ -101,48 +79,54 @@ public partial class SearchViewModel : FeatureViewModelBase
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Please enter a site URL.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
var siteProfile = new TenantProfile
var allItems = new List<SearchResult>();
foreach (var url in urls)
{
TenantUrl = SiteUrl.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var siteProfile = new TenantProfile
{
TenantUrl = url.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var opts = new SearchOptions(
Extensions: ParseExtensions(Extensions),
Regex: string.IsNullOrWhiteSpace(Regex) ? null : Regex,
CreatedAfter: UseCreatedAfter ? CreatedAfter : null,
CreatedBefore: UseCreatedBefore ? CreatedBefore : null,
ModifiedAfter: UseModifiedAfter ? ModifiedAfter : null,
ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null,
CreatedBy: string.IsNullOrWhiteSpace(CreatedBy) ? null : CreatedBy,
ModifiedBy: string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy,
Library: string.IsNullOrWhiteSpace(Library) ? null : Library,
MaxResults: Math.Clamp(MaxResults, 1, 50_000),
SiteUrl: SiteUrl.TrimEnd('/')
);
var opts = new SearchOptions(
Extensions: ParseExtensions(Extensions),
Regex: string.IsNullOrWhiteSpace(Regex) ? null : Regex,
CreatedAfter: UseCreatedAfter ? CreatedAfter : null,
CreatedBefore: UseCreatedBefore ? CreatedBefore : null,
ModifiedAfter: UseModifiedAfter ? ModifiedAfter : null,
ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null,
CreatedBy: string.IsNullOrWhiteSpace(CreatedBy) ? null : CreatedBy,
ModifiedBy: string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy,
Library: string.IsNullOrWhiteSpace(Library) ? null : Library,
MaxResults: Math.Clamp(MaxResults, 1, 50_000),
SiteUrl: url.TrimEnd('/')
);
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
allItems.AddRange(items);
}
if (Application.Current?.Dispatcher is { } dispatcher)
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(items));
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(allItems));
else
Results = new ObservableCollection<SearchResult>(items);
Results = new ObservableCollection<SearchResult>(allItems);
}
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<SearchResult>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();

View File

@@ -23,10 +23,6 @@ public partial class StorageViewModel : FeatureViewModelBase
private readonly StorageHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSiteOverride;
[ObservableProperty]
private string _siteUrl = string.Empty;
[ObservableProperty]
private bool _perLibrary = true;
@@ -101,11 +97,33 @@ public partial class StorageViewModel : FeatureViewModelBase
{
_results = value;
OnPropertyChanged();
NotifySummaryProperties();
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
}
// ── Summary properties (computed from root-level library nodes) ─────────
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
public bool HasResults => Results.Count > 0;
private void NotifySummaryProperties()
{
OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults));
}
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
@@ -146,26 +164,6 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
}
partial void OnSiteUrlChanged(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
_hasLocalSiteOverride = false;
if (GlobalSites.Count > 0)
SiteUrl = GlobalSites[0].Url;
}
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
{
_hasLocalSiteOverride = true;
}
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
@@ -173,70 +171,83 @@ public partial class StorageViewModel : FeatureViewModelBase
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Please enter a site URL.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
// Build a site-specific profile: same ClientId and Name, but TenantUrl points to the
// site URL the user entered (may differ from the tenant root).
var siteProfile = new TenantProfile
{
TenantUrl = SiteUrl.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var nonEmpty = urls;
var options = new StorageScanOptions(
PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>();
// Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
int i = 0;
foreach (var url in nonEmpty)
{
ct.ThrowIfCancellationRequested();
i++;
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
var siteProfile = new TenantProfile
{
TenantUrl = url.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
// Backfill any libraries where StorageMetrics returned zeros
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
allNodes.AddRange(nodes);
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics);
}
// Flatten tree for DataGrid display
var flat = new List<StorageNode>();
foreach (var node in nodes)
foreach (var node in allNodes)
FlattenNode(node, 0, flat);
// Merge file-type metrics across sites (same extension -> sum)
var mergedMetrics = allTypeMetrics
.GroupBy(m => m.Extension, StringComparer.OrdinalIgnoreCase)
.Select(g => new FileTypeMetric(g.Key, g.Sum(m => m.TotalSizeBytes), g.Sum(m => m.FileCount)))
.OrderByDescending(m => m.TotalSizeBytes)
.ToList();
if (Application.Current?.Dispatcher is { } dispatcher)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<StorageNode>(flat);
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
});
}
else
{
Results = new ObservableCollection<StorageNode>(flat);
}
// Collect file-type metrics for chart visualization
progress.Report(OperationProgress.Indeterminate("Scanning file types for chart..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
if (Application.Current?.Dispatcher is { } chartDispatcher)
{
await chartDispatcher.InvokeAsync(() =>
{
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
});
}
else
{
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
@@ -262,7 +273,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -285,7 +296,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -323,18 +334,26 @@ public partial class StorageViewModel : FeatureViewModelBase
if (otherSize > 0)
chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount));
// Pie/Donut series
// Pie/Donut: one PieSeries per slice (LiveCharts2 requires this for per-slice colors).
// Tooltip only shows the hovered slice because each series has exactly one value.
double innerRadius = IsDonutChart ? 50 : 0;
PieChartSeries = chartItems.Select(m => new PieSeries<long>
var pieList = new List<ISeries>();
foreach (var m in chartItems)
{
Values = new[] { m.TotalSizeBytes },
Name = m.DisplayLabel,
InnerRadius = innerRadius,
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer,
DataLabelsFormatter = point => m.DisplayLabel,
ToolTipLabelFormatter = point =>
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)"
}).ToList();
pieList.Add(new PieSeries<double>
{
Values = new[] { (double)m.TotalSizeBytes },
Name = m.DisplayLabel,
InnerRadius = innerRadius,
HoverPushout = 8,
MaxRadialColumnWidth = 60,
DataLabelsFormatter = _ => m.DisplayLabel,
ToolTipLabelFormatter = _ =>
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
IsVisibleAtLegend = true,
});
}
PieChartSeries = pieList;
// Bar chart series
BarChartSeries = new ISeries[]
@@ -388,6 +407,6 @@ public partial class StorageViewModel : FeatureViewModelBase
private static void OpenFile(string filePath)
{
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
catch { /* ignore file may open but this is best-effort */ }
catch { /* ignore -- file may open but this is best-effort */ }
}
}

View File

@@ -29,7 +29,6 @@ public partial class TemplatesViewModel : FeatureViewModelBase
[ObservableProperty] private SiteTemplate? _selectedTemplate;
// Capture options
[ObservableProperty] private string _captureSiteUrl = string.Empty;
[ObservableProperty] private string _templateName = string.Empty;
[ObservableProperty] private bool _captureLibraries = true;
[ObservableProperty] private bool _captureFolders = true;
@@ -81,11 +80,13 @@ public partial class TemplatesViewModel : FeatureViewModelBase
{
if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected.");
if (string.IsNullOrWhiteSpace(CaptureSiteUrl))
throw new InvalidOperationException("Site URL is required.");
if (string.IsNullOrWhiteSpace(TemplateName))
throw new InvalidOperationException("Template name is required.");
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
if (string.IsNullOrWhiteSpace(captureSiteUrl))
throw new InvalidOperationException("Select at least one site from the toolbar.");
try
{
IsRunning = true;
@@ -94,7 +95,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
var profile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = CaptureSiteUrl,
TenantUrl = captureSiteUrl,
ClientId = _currentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
@@ -113,7 +114,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
template.Name = TemplateName;
await _templateRepo.SaveAsync(template);
Log.Information("Template captured: {Name} from {Url}", template.Name, CaptureSiteUrl);
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
await RefreshListAsync();
StatusMessage = $"Template captured successfully.";
@@ -200,7 +201,6 @@ public partial class TemplatesViewModel : FeatureViewModelBase
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
CaptureSiteUrl = string.Empty;
TemplateName = string.Empty;
NewSiteTitle = string.Empty;
NewSiteAlias = string.Empty;

View File

@@ -99,25 +99,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand OpenSitePickerCommand { get; }
public RelayCommand<GraphUserResult> AddUserCommand { get; }
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
// ── Multi-site ──────────────────────────────────────────────────────────
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
/// <summary>
/// True when the user has manually selected sites via the site picker.
/// Prevents global site changes from overwriting the local selection.
/// </summary>
private bool _hasLocalSiteOverride;
// ── Dialog factory ──────────────────────────────────────────────────────
/// <summary>Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel.</summary>
public Func<Window>? OpenSitePickerDialog { get; set; }
// ── Current tenant profile ──────────────────────────────────────────────
internal TenantProfile? _currentProfile;
@@ -125,12 +109,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
/// <summary>Public accessor for the current tenant profile — used by View layer dialog factory.</summary>
public TenantProfile? CurrentProfile => _currentProfile;
/// <summary>Label shown in the UI: "3 site(s) selected" or empty when none are selected.</summary>
public string SitesSelectedLabel =>
SelectedSites.Count > 0
? $"{SelectedSites.Count} site(s) selected"
: string.Empty;
// ── Constructors ────────────────────────────────────────────────────────
/// <summary>Full constructor — used by DI and production code.</summary>
@@ -152,11 +130,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
var cvs = new CollectionViewSource { Source = Results };
@@ -181,11 +157,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
var cvs = new CollectionViewSource { Source = Results };
@@ -195,15 +169,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
// ── FeatureViewModelBase implementation ─────────────────────────────────
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SelectedSites.Clear();
foreach (var site in sites)
SelectedSites.Add(site);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (SelectedUsers.Count == 0)
@@ -212,16 +177,14 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
return;
}
var effectiveSites = SelectedSites.Count > 0
? SelectedSites.ToList()
: GlobalSites.ToList();
if (effectiveSites.Count == 0)
if (GlobalSites.Count == 0)
{
StatusMessage = "Select at least one site to scan.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
var effectiveSites = GlobalSites.ToList();
var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList();
var scanOptions = new ScanOptions(
IncludeInherited: IncludeInherited,
@@ -244,19 +207,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
progress,
ct);
// Update Results on the UI thread
// Update Results on the UI thread — clear + repopulate (not replace)
// so the CollectionViewSource bound to ResultsView stays connected.
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<UserAccessEntry>(entries);
Results.Clear();
foreach (var entry in entries)
Results.Add(entry);
NotifySummaryProperties();
});
}
else
{
Results = new ObservableCollection<UserAccessEntry>(entries);
Results.Clear();
foreach (var entry in entries)
Results.Add(entry);
NotifySummaryProperties();
}
@@ -269,14 +237,11 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<UserAccessEntry>();
Results.Clear();
SelectedUsers.Clear();
SearchQuery = string.Empty;
SearchResults.Clear();
SelectedSites.Clear();
FilterText = string.Empty;
OnPropertyChanged(nameof(SitesSelectedLabel));
OnPropertyChanged(nameof(CurrentProfile));
NotifySummaryProperties();
ExportCsvCommand.NotifyCanExecuteChanged();
@@ -308,15 +273,10 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
partial void OnResultsChanged(ObservableCollection<UserAccessEntry> value)
{
// Rebind CollectionViewSource when the collection reference changes
if (ResultsView is CollectionView cv && cv.SourceCollection != value)
{
// CollectionViewSource.View is already live-bound in constructor;
// for a new collection reference we need to refresh grouping/filter
ApplyGrouping();
ResultsView.Filter = FilterPredicate;
ResultsView.Refresh();
}
// Safety net: if the collection reference ever changes, rebind grouping/filter
ApplyGrouping();
ResultsView.Filter = FilterPredicate;
ResultsView.Refresh();
NotifySummaryProperties();
}
@@ -379,19 +339,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
}
}
private void ExecuteOpenSitePicker()
{
if (OpenSitePickerDialog == null) return;
var dialog = OpenSitePickerDialog.Invoke();
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
{
_hasLocalSiteOverride = true;
SelectedSites.Clear();
foreach (var site in picker.SelectedUrls)
SelectedSites.Add(site);
}
}
private void ExecuteAddUser(GraphUserResult? user)
{
if (user == null) return;

View File

@@ -6,11 +6,6 @@
<!-- Options panel -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8" />
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.type]}" Margin="0,0,0,8">
<StackPanel Margin="4">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.files]}"

View File

@@ -4,11 +4,7 @@
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<DockPanel Margin="10">
<StackPanel DockPanel.Dock="Left" Width="280" Margin="0,0,10,0">
<!-- Site URL and Library inputs -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.siteurl]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
<!-- Library input -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />

View File

@@ -28,21 +28,6 @@
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
<!-- Site URL -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.site.url]}"
Margin="0,0,0,2" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,6" />
<!-- View Sites + selected label -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.or.select]}"
Margin="0,0,0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.view.sites]}"
Command="{Binding OpenSitePickerCommand}"
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,0,4" />
<TextBlock Text="{Binding SitesSelectedLabel}"
FontStyle="Italic" Foreground="Gray" Margin="0,0,0,8" />
<!-- Checkboxes -->
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />

View File

@@ -1,8 +1,6 @@
using System.Windows.Controls;
using Microsoft.Extensions.DependencyInjection;
using SharepointToolbox.Core.Models;
using SharepointToolbox.ViewModels.Tabs;
using SharepointToolbox.Views.Dialogs;
namespace SharepointToolbox.Views.Tabs;
@@ -11,12 +9,6 @@ public partial class PermissionsView : UserControl
public PermissionsView(IServiceProvider serviceProvider)
{
InitializeComponent();
var vm = serviceProvider.GetRequiredService<PermissionsViewModel>();
DataContext = vm;
vm.OpenSitePickerDialog = () =>
{
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
return factory(vm.CurrentProfile ?? new TenantProfile());
};
DataContext = serviceProvider.GetRequiredService<PermissionsViewModel>();
}
}

View File

@@ -6,11 +6,6 @@
<!-- Filters panel -->
<ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8" />
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}"
Margin="0,0,0,8">
<StackPanel Margin="4">

View File

@@ -9,12 +9,6 @@
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
Margin="8,8,4,8">
<StackPanel>
<!-- Site URL -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.site.url]}" />
<!-- Scan options group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
@@ -78,17 +72,46 @@
</StackPanel>
</ScrollViewer>
<!-- Right content area: DataGrid on top, Chart on bottom -->
<!-- Right content area: Summary + DataGrid on top, Chart on bottom -->
<Grid Margin="4,8,8,8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="150" />
<RowDefinition Height="Auto" />
<RowDefinition Height="300" MinHeight="200" />
</Grid.RowDefinitions>
<!-- Summary bar -->
<Border Grid.Row="0" Background="#F0F7FF" CornerRadius="4" Padding="12,8" Margin="0,0,0,6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasResults}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="0,0,24,0">
<Run Text="Total Size: " FontWeight="SemiBold" />
<Run Text="{Binding SummaryTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</TextBlock>
<TextBlock Margin="0,0,24,0">
<Run Text="Version Size: " FontWeight="SemiBold" />
<Run Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</TextBlock>
<TextBlock>
<Run Text="Files: " FontWeight="SemiBold" />
<Run Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</Border>
<!-- Results DataGrid -->
<DataGrid x:Name="ResultsGrid"
Grid.Row="0"
Grid.Row="1"
ItemsSource="{Binding Results}"
IsReadOnly="True"
AutoGenerateColumns="False"
@@ -123,11 +146,11 @@
</DataGrid>
<!-- Splitter between DataGrid and Chart -->
<GridSplitter Grid.Row="1" Height="5" HorizontalAlignment="Stretch"
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
Background="#DDD" ResizeDirection="Rows" />
<!-- Chart panel -->
<Border Grid.Row="2" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
Padding="8" Background="White">
<Grid>
<!-- Chart title -->
@@ -167,7 +190,8 @@
</Style.Triggers>
</Style>
</Grid.Style>
<lvc:PieChart Series="{Binding PieChartSeries}"
<lvc:PieChart x:Name="StoragePieChart"
Series="{Binding PieChartSeries}"
LegendPosition="Right" />
</Grid>

View File

@@ -1,4 +1,7 @@
using System.Windows.Controls;
using LiveChartsCore;
using LiveChartsCore.Kernel;
using LiveChartsCore.Kernel.Sketches;
namespace SharepointToolbox.Views.Tabs;
@@ -8,5 +11,43 @@ public partial class StorageView : UserControl
{
InitializeComponent();
DataContext = viewModel;
StoragePieChart.Tooltip = new SingleSliceTooltip();
}
}
/// <summary>
/// Custom tooltip that only shows the single closest/hovered pie slice
/// instead of LiveCharts2's default which shows multiple nearby slices.
/// </summary>
internal sealed class SingleSliceTooltip : IChartTooltip
{
private readonly System.Windows.Controls.ToolTip _tip = new()
{
Padding = new System.Windows.Thickness(8, 4, 8, 4),
FontSize = 13,
Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(255, 255, 255)),
BorderBrush = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(200, 200, 200)),
BorderThickness = new System.Windows.Thickness(1),
};
public void Show(IEnumerable<ChartPoint> foundPoints, Chart chart)
{
// Only show the first (closest) point
var point = foundPoints.FirstOrDefault();
if (point == null) { Hide(chart); return; }
var label = point.Context.Series.GetPrimaryToolTipText(point);
if (string.IsNullOrEmpty(label)) label = point.Context.Series.Name ?? "";
_tip.Content = label;
_tip.IsOpen = true;
}
public void Hide(Chart chart)
{
_tip.IsOpen = false;
}
}

View File

@@ -9,10 +9,6 @@
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
Margin="0,0,0,10">
<StackPanel Margin="5">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.siteurl]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding CaptureSiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,5" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding TemplateName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />

View File

@@ -77,16 +77,6 @@
</StackPanel>
</GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.sites]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.view.sites]}"
Command="{Binding OpenSitePickerCommand}"
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,0,4" />
<TextBlock Text="{Binding SitesSelectedLabel}" FontStyle="Italic" Foreground="Gray" />
</StackPanel>
</GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
@@ -244,7 +234,7 @@
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="" Foreground="#E74C3C" Margin="0,0,4,0"
<TextBlock Text="&#x26A0;" Foreground="#E74C3C" Margin="0,0,4,0"
FontSize="12" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
@@ -301,4 +291,4 @@
</StatusBar>
</Grid>
</UserControl>
</UserControl>