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:
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)} · {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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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="⚠" Foreground="#E74C3C" Margin="0,0,4,0"
|
||||
FontSize="12" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
@@ -301,4 +291,4 @@
|
||||
</StatusBar>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
||||
Reference in New Issue
Block a user