Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main

Reviewed-on: #2
This commit is contained in:
2026-06-02 15:02:52 +02:00
committed by kawa
26 changed files with 631 additions and 91 deletions
+3 -2
View File
@@ -55,7 +55,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -94,7 +94,8 @@
_error = string.Empty; _summary = null; _running = true; _error = string.Empty; _summary = null; _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; }
var siteUrl = _siteUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
+1 -1
View File
@@ -52,7 +52,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
+38 -15
View File
@@ -14,11 +14,8 @@
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card"> <div class="card">
<div class="form-row"> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-group"> <div class="form-row mt-8">
<label class="form-label">Site URL</label>
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Mode</label> <label class="form-label">Mode</label>
<select class="form-select" @bind="_mode" style="width:120px"> <select class="form-select" @bind="_mode" style="width:120px">
@@ -47,7 +44,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -58,6 +55,7 @@
<div class="flex-row"> <div class="flex-row">
<div class="card-title">Duplicate Groups <span class="count-badge">@_results.Count</span></div> <div class="card-title">Duplicate Groups <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button> <button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
</div> </div>
@@ -81,28 +79,43 @@
} }
@code { @code {
private string _siteUrl = string.Empty, _library = string.Empty, _mode = "Files"; private List<SiteInfo> _sites = new();
private string _library = string.Empty, _mode = "Files";
private bool _matchSize = true, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount; private bool _matchSize = true, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount;
private bool _running; private string _status = string.Empty, _error = string.Empty; private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total; private int _current, _total;
private List<DuplicateGroup> _results = new(); private List<DuplicateGroup> _results = new();
private List<(string Label, IReadOnlyList<DuplicateGroup> Results)> _bySite = new();
private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private async Task RunScan() private async Task RunScan()
{ {
_error = string.Empty; _results.Clear(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull()); var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
_results = (await Elevation.RunAsync(async c => var bySite = new List<(string, IReadOnlyList<DuplicateGroup>)>();
var flat = new List<DuplicateGroup>();
int i = 0;
foreach (var site in _sites)
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); _cts.Token.ThrowIfCancellationRequested();
_status = $"Scanning {site.Title} ({++i}/{_sites.Count})…";
await InvokeAsync(StateHasChanged);
var groups = await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c);
return await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, c); return await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, c);
}, _cts.Token)).ToList(); }, _cts.Token);
_status = $"Found {_results.Count} duplicate groups."; bySite.Add((site.Title, groups));
flat.AddRange(groups);
}
_bySite = bySite; _results = flat;
_status = $"Found {_results.Count} duplicate groups across {_sites.Count} site(s).";
} }
catch (OperationCanceledException) { _status = "Cancelled."; } catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; } catch (Exception ex) { _error = ex.Message; }
@@ -110,6 +123,16 @@
} }
private void Cancel() => _cts?.Cancel(); private void Cancel() => _cts?.Cancel();
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } private async Task ExportCsv()
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } {
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var output = ReportMergeHelper.Build(_bySite, _mergeMode, "duplicates", ts, ReportFormat.Csv, rs => CsvExport.BuildCsv(rs));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
}
private async Task ExportHtml()
{
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var output = ReportMergeHelper.Build(_bySite, _mergeMode, "duplicates", ts, ReportFormat.Html, rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
}
} }
+5 -3
View File
@@ -72,7 +72,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -118,8 +118,10 @@
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim(); if (string.IsNullOrWhiteSpace(_srcSiteUrl)) { _error = "Please enter a source site URL."; return; }
var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim(); if (string.IsNullOrWhiteSpace(_dstSiteUrl)) { _error = "Please enter a destination site URL."; return; }
var srcUrl = _srcSiteUrl.Trim();
var dstUrl = _dstSiteUrl.Trim();
var job = new TransferJob var job = new TransferJob
{ {
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder, SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
+3 -2
View File
@@ -41,7 +41,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -75,7 +75,8 @@
_error = string.Empty; _summary = null; _running = true; _error = string.Empty; _summary = null; _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; }
var siteUrl = _siteUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
+34 -20
View File
@@ -15,13 +15,8 @@
<div class="card"> <div class="card">
<div class="card-title">Scan Options</div> <div class="card-title">Scan Options</div>
<div class="form-row"> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-group"> <div class="form-row mt-8">
<label class="form-label">Site URL</label>
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:0 0 auto"> <div class="form-group" style="flex:0 0 auto">
<label class="form-label">Folder Depth</label> <label class="form-label">Folder Depth</label>
<input class="form-input" type="number" @bind="_folderDepth" min="0" max="999" style="width:80px" /> <input class="form-input" type="number" @bind="_folderDepth" min="0" max="999" style="width:80px" />
@@ -34,14 +29,14 @@
</div> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running"> <button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning…" : "Scan Site") @(_running ? "Scanning…" : "Scan Sites")
</button> </button>
@if (_running) @if (_running)
{ {
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button> <button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
} }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) @if (!string.IsNullOrEmpty(_error))
@@ -55,6 +50,7 @@
<div class="flex-row"> <div class="flex-row">
<div class="card-title">Results <span class="count-badge">@_results.Count</span></div> <div class="card-title">Results <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button> <button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
</div> </div>
@@ -91,7 +87,7 @@
} }
@code { @code {
private string _siteUrl = string.Empty; private List<SiteInfo> _sites = new();
private bool _includeInherited, _includeSubsites; private bool _includeInherited, _includeSubsites;
private bool _scanFolders = true; private bool _scanFolders = true;
private int _folderDepth = 1; private int _folderDepth = 1;
@@ -100,23 +96,37 @@
private string _error = string.Empty; private string _error = string.Empty;
private int _current, _total; private int _current, _total;
private List<PermissionEntry> _results = new(); private List<PermissionEntry> _results = new();
private List<(string Label, IReadOnlyList<PermissionEntry> Results)> _bySite = new();
private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private async Task RunScan() private async Task RunScan()
{ {
_error = string.Empty; _results.Clear(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites); var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites);
_results = (await Elevation.RunAsync(async c => var bySite = new List<(string, IReadOnlyList<PermissionEntry>)>();
var flat = new List<PermissionEntry>();
int i = 0;
foreach (var site in _sites)
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); _cts.Token.ThrowIfCancellationRequested();
_status = $"Scanning {site.Title} ({++i}/{_sites.Count})…";
await InvokeAsync(StateHasChanged);
var entries = await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c);
return await PermSvc.ScanSiteAsync(ctx, opts, progress, c); return await PermSvc.ScanSiteAsync(ctx, opts, progress, c);
}, _cts.Token)).ToList(); }, _cts.Token);
_status = $"Scan complete: {_results.Count} entries found."; bySite.Add((site.Title, entries));
flat.AddRange(entries);
}
_bySite = bySite; _results = flat;
_status = $"Scan complete: {_results.Count} entries across {_sites.Count} site(s).";
} }
catch (OperationCanceledException) { _status = "Cancelled."; } catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; } catch (Exception ex) { _error = ex.Message; }
@@ -127,13 +137,17 @@
private async Task ExportCsv() private async Task ExportCsv()
{ {
var csv = CsvExport.BuildCsv(_results); var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
await WebExport.DownloadCsvAsync(csv, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); var output = ReportMergeHelper.Build(_bySite, _mergeMode, "permissions", ts, ReportFormat.Csv,
rs => CsvExport.BuildCsv(rs));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
} }
private async Task ExportHtml() private async Task ExportHtml()
{ {
var html = HtmlExport.BuildHtml(_results); var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); var output = ReportMergeHelper.Build(_bySite, _mergeMode, "permissions", ts, ReportFormat.Html,
rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
} }
} }
+7 -1
View File
@@ -146,6 +146,12 @@
} }
</div> </div>
<div class="form-group">
<label class="form-label">Client logo (optional)</label>
<small class="text-muted d-block" style="margin-bottom:6px">Shown top-right on exported reports for this client.</small>
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">Save</button> <button class="btn btn-primary" @onclick="SaveProfile">Save</button>
<button class="btn btn-secondary" @onclick="CancelForm">Cancel</button> <button class="btn btn-secondary" @onclick="CancelForm">Cancel</button>
@@ -214,7 +220,7 @@
private void EditProfile(TenantProfile p) private void EditProfile(TenantProfile p)
{ {
_editing = p; _editing = p;
_form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId }; _form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId, ClientLogo = p.ClientLogo };
_showForm = true; _showForm = true;
_formError = _pageError = string.Empty; _formError = _pageError = string.Empty;
} }
+39 -16
View File
@@ -15,11 +15,8 @@
<div class="card"> <div class="card">
<div class="card-title">Search Options</div> <div class="card-title">Search Options</div>
<div class="form-row"> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-group"> <div class="form-row mt-8">
<label class="form-label">Site URL</label>
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">File Extensions (comma-separated)</label> <label class="form-label">File Extensions (comma-separated)</label>
<input class="form-input" @bind="_extensions" placeholder="docx, xlsx, pdf" /> <input class="form-input" @bind="_extensions" placeholder="docx, xlsx, pdf" />
@@ -55,7 +52,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -66,6 +63,7 @@
<div class="flex-row"> <div class="flex-row">
<div class="card-title">Results <span class="count-badge">@_results.Count</span></div> <div class="card-title">Results <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button> <button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
</div> </div>
@@ -92,30 +90,45 @@
} }
@code { @code {
private string _siteUrl = string.Empty, _extensions = string.Empty, _regex = string.Empty; private List<SiteInfo> _sites = new();
private string _extensions = string.Empty, _regex = string.Empty;
private string _createdBy = string.Empty, _modifiedBy = string.Empty, _library = string.Empty; private string _createdBy = string.Empty, _modifiedBy = string.Empty, _library = string.Empty;
private int _maxResults = 5000; private int _maxResults = 5000;
private bool _running; private string _status = string.Empty, _error = string.Empty; private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total; private int _current, _total;
private List<SearchResult> _results = new(); private List<SearchResult> _results = new();
private List<(string Label, IReadOnlyList<SearchResult> Results)> _bySite = new();
private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private async Task RunSearch() private async Task RunSearch()
{ {
_error = string.Empty; _results.Clear(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl); var bySite = new List<(string, IReadOnlyList<SearchResult>)>();
_results = (await Elevation.RunAsync(async c => var flat = new List<SearchResult>();
int i = 0;
foreach (var site in _sites)
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); _cts.Token.ThrowIfCancellationRequested();
_status = $"Searching {site.Title} ({++i}/{_sites.Count})…";
await InvokeAsync(StateHasChanged);
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, site.Url);
var found = await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c);
return await SearchSvc.SearchFilesAsync(ctx, opts, progress, c); return await SearchSvc.SearchFilesAsync(ctx, opts, progress, c);
}, _cts.Token)).ToList(); }, _cts.Token);
_status = $"Found {_results.Count} files."; bySite.Add((site.Title, found));
flat.AddRange(found);
}
_bySite = bySite; _results = flat;
_status = $"Found {_results.Count} files across {_sites.Count} site(s).";
} }
catch (OperationCanceledException) { _status = "Cancelled."; } catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; } catch (Exception ex) { _error = ex.Message; }
@@ -123,6 +136,16 @@
} }
private void Cancel() => _cts?.Cancel(); private void Cancel() => _cts?.Cancel();
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } private async Task ExportCsv()
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } {
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var output = ReportMergeHelper.Build(_bySite, _mergeMode, "search", ts, ReportFormat.Csv, rs => CsvExport.BuildCsv(rs));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
}
private async Task ExportHtml()
{
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var output = ReportMergeHelper.Build(_bySite, _mergeMode, "search", ts, ReportFormat.Html, rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
}
} }
+18 -1
View File
@@ -35,11 +35,21 @@
</div> </div>
</div> </div>
<div class="card">
<div class="card-title">Report Branding</div>
<div class="form-group">
<label class="form-label">MSP logo</label>
<p class="text-muted" style="margin-top:0">Shown top-left on exported HTML reports. The client's logo (top-right) is set per profile.</p>
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
</div>
</div>
@if (_saved) { <div class="alert alert-success">Settings saved.</div> } @if (_saved) { <div class="alert alert-success">Settings saved.</div> }
@code { @code {
private string _lang = "en", _theme = "System"; private string _lang = "en", _theme = "System";
private bool _autoTakeOwnership, _saved; private bool _autoTakeOwnership, _saved;
private LogoData? _mspLogo;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -47,11 +57,18 @@
_lang = s.Lang; _lang = s.Lang;
_theme = s.Theme is "System" or "Light" ? s.Theme : "System"; _theme = s.Theme is "System" or "Light" ? s.Theme : "System";
_autoTakeOwnership = s.AutoTakeOwnership; _autoTakeOwnership = s.AutoTakeOwnership;
_mspLogo = s.MspLogo;
}
private async Task OnMspLogoChanged(LogoData? logo)
{
_mspLogo = logo;
await Save();
} }
private async Task Save() private async Task Save()
{ {
Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership }); Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership, MspLogo = _mspLogo });
SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang); SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang);
await JS.InvokeVoidAsync("sptb.setTheme", _theme); await JS.InvokeVoidAsync("sptb.setTheme", _theme);
_saved = true; _saved = true;
+40 -17
View File
@@ -15,10 +15,12 @@
<div class="card"> <div class="card">
<div class="card-title">Scan Options</div> <div class="card-title">Scan Options</div>
<div class="form-row"> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-group"> <div class="form-row mt-8">
<label class="form-label">Site URL</label> <div class="form-group" style="max-width:220px">
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" /> <label class="form-label">Folder scan depth</label>
<input class="form-input" type="number" min="0" max="20" @bind="_folderDepth" />
<small class="text-muted">0 = libraries only. 1+ = drill into subfolders that many levels deep.</small>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -32,9 +34,10 @@
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running"> <button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning…" : "Scan Storage") @(_running ? "Scanning…" : "Scan Storage")
</button> </button>
@if (_sites.Count > 0) { <span class="text-muted" style="align-self:center">@_sites.Count site(s) selected</span> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -45,6 +48,7 @@
<div class="flex-row"> <div class="flex-row">
<div class="card-title">Storage Report <span class="count-badge">@_results.Count libraries</span></div> <div class="card-title">Storage Report <span class="count-badge">@_results.Count libraries</span></div>
<div class="spacer"></div> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button> <button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
</div> </div>
@@ -79,28 +83,43 @@
} }
@code { @code {
private string _siteUrl = string.Empty; private List<SiteInfo> _sites = new();
private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true; private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true;
private int _folderDepth;
private bool _running; private string _status = string.Empty, _error = string.Empty; private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total; private int _current, _total;
private List<StorageNode> _results = new(); private List<StorageNode> _results = new();
private List<(string Label, IReadOnlyList<StorageNode> Results)> _bySite = new();
private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private async Task RunScan() private async Task RunScan()
{ {
_error = string.Empty; _results.Clear(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin); var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, FolderDepth: Math.Clamp(_folderDepth, 0, 20), IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin);
_results = (await Elevation.RunAsync(async c => var bySite = new List<(string, IReadOnlyList<StorageNode>)>();
var flat = new List<StorageNode>();
int i = 0;
foreach (var site in _sites)
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); _cts.Token.ThrowIfCancellationRequested();
_status = $"Scanning {site.Title} ({++i}/{_sites.Count})…";
await InvokeAsync(StateHasChanged);
var nodes = await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c);
return await StorageSvc.CollectStorageAsync(ctx, opts, progress, c); return await StorageSvc.CollectStorageAsync(ctx, opts, progress, c);
}, _cts.Token)).ToList(); }, _cts.Token);
_status = $"Complete: {_results.Count} nodes."; bySite.Add((site.Title, nodes));
flat.AddRange(nodes);
}
_bySite = bySite; _results = flat;
_status = $"Complete: {_results.Count} nodes across {_sites.Count} site(s).";
} }
catch (OperationCanceledException) { _status = "Cancelled."; } catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; } catch (Exception ex) { _error = ex.Message; }
@@ -111,12 +130,16 @@
private async Task ExportCsv() private async Task ExportCsv()
{ {
var csv = CsvExport.BuildCsv(_results); var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
await WebExport.DownloadCsvAsync(csv, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); var output = ReportMergeHelper.Build(_bySite, _mergeMode, "storage", ts, ReportFormat.Csv,
rs => CsvExport.BuildCsv(rs));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
} }
private async Task ExportHtml() private async Task ExportHtml()
{ {
var html = HtmlExport.BuildHtml(_results); var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html"); var output = ReportMergeHelper.Build(_bySite, _mergeMode, "storage", ts, ReportFormat.Html,
rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@
<button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running"> <button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running">
@(_running ? "Capturing…" : "Capture") @(_running ? "Capturing…" : "Capture")
</button> </button>
<ProgressPanel IsRunning="_running" StatusMessage="_status" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" />
</div> </div>
<div class="card"> <div class="card">
+2 -2
View File
@@ -37,7 +37,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -103,5 +103,5 @@
private void Cancel() => _cts?.Cancel(); private void Cancel() => _cts?.Cancel();
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results.FirstOrDefault()?.UserDisplayName ?? "Users", _results.FirstOrDefault()?.UserLogin ?? "", _results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results.FirstOrDefault()?.UserDisplayName ?? "Users", _results.FirstOrDefault()?.UserLogin ?? "", _results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results, branding: Session.CurrentBranding), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
} }
+1 -1
View File
@@ -16,7 +16,7 @@
@(_running ? $"Loading… ({_loadCount} users)" : "Load Users") @(_running ? $"Loading… ({_loadCount} users)" : "Load Users")
</button> </button>
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
+6 -4
View File
@@ -56,7 +56,7 @@
</button> </button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@@ -109,7 +109,8 @@
_loading = true; _error = string.Empty; _loading = true; _error = string.Empty;
try try
{ {
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; return; }
var siteUrl = _siteUrl.Trim();
_libraries = (await Elevation.RunAsync(async c => _libraries = (await Elevation.RunAsync(async c =>
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
@@ -126,7 +127,8 @@
{ {
_error = string.Empty; _results.Clear(); _running = true; _error = string.Empty; _results.Clear(); _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; }
var siteUrl = _siteUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
@@ -144,5 +146,5 @@
} }
private void Cancel() => _cts?.Cancel(); private void Cancel() => _cts?.Cancel();
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results, Session.CurrentBranding), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
} }
+64
View File
@@ -0,0 +1,64 @@
@using Microsoft.AspNetCore.Components.Forms
@using SharepointToolbox.Web.Core.Models
@* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@
<div class="logo-upload">
@if (Value is not null)
{
<div class="flex-row" style="gap:12px;align-items:center">
<img src="data:@Value.MimeType;base64,@Value.Base64" alt=""
style="max-height:60px;max-width:200px;object-fit:contain;border:1px solid var(--border);border-radius:4px;padding:4px;background:#fff" />
<button type="button" class="btn btn-secondary btn-sm" @onclick="Remove">Remove</button>
</div>
}
else
{
<InputFile OnChange="OnChange" accept="image/png,image/jpeg,image/svg+xml,image/gif" />
<small class="text-muted d-block">PNG, JPEG, SVG or GIF — max @(MaxBytes / 1024) KB.</small>
}
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
</div>
@code {
[Parameter] public LogoData? Value { get; set; }
[Parameter] public EventCallback<LogoData?> ValueChanged { get; set; }
/// <summary>Upload size cap. Logos are stored inline as base64 in JSON, so keep small.</summary>
[Parameter] public long MaxBytes { get; set; } = 512 * 1024;
private string _error = string.Empty;
private async Task OnChange(InputFileChangeEventArgs e)
{
_error = string.Empty;
var file = e.File;
if (file is null) return;
if (file.Size > MaxBytes)
{
_error = $"File too large ({file.Size / 1024} KB). Max {MaxBytes / 1024} KB.";
return;
}
try
{
using var ms = new MemoryStream();
await file.OpenReadStream(MaxBytes).CopyToAsync(ms);
var mime = string.IsNullOrWhiteSpace(file.ContentType) ? "image/png" : file.ContentType;
var logo = new LogoData { Base64 = Convert.ToBase64String(ms.ToArray()), MimeType = mime };
Value = logo;
await ValueChanged.InvokeAsync(logo);
}
catch (Exception ex)
{
_error = $"Could not read image: {ex.Message}";
}
}
private async Task Remove()
{
Value = null;
_error = string.Empty;
await ValueChanged.InvokeAsync(null);
}
}
+22
View File
@@ -0,0 +1,22 @@
@* Dropdown for choosing how multi-site reports are bundled on export. *@
@if (Visible)
{
<select class="form-select" style="width:auto;font-size:13px" value="@Value" @onchange="OnChange"
title="How to bundle reports when multiple sites are scanned">
<option value="@ReportMergeMode.SingleMerged">One document, no tabs</option>
<option value="@ReportMergeMode.SingleTabbed">One document, tabs (HTML)</option>
<option value="@ReportMergeMode.MultipleFiles">Multiple documents (ZIP)</option>
</select>
}
@code {
[Parameter] public ReportMergeMode Value { get; set; }
[Parameter] public EventCallback<ReportMergeMode> ValueChanged { get; set; }
[Parameter] public bool Visible { get; set; } = true;
private async Task OnChange(ChangeEventArgs e)
{
if (Enum.TryParse<ReportMergeMode>(e.Value?.ToString(), out var mode))
await ValueChanged.InvokeAsync(mode);
}
}
+106
View File
@@ -0,0 +1,106 @@
@inject ISiteDiscoveryService SiteDiscovery
<div class="site-picker">
<div class="flex-row" style="gap:8px;align-items:flex-end">
<div class="form-group" style="flex:1">
<label class="form-label">Sites</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="Filter loaded sites by name or URL…" />
</div>
<button class="btn btn-secondary" @onclick="LoadSites" disabled="@_loading">
@(_loading ? "Loading…" : (_all.Count > 0 ? "Reload sites" : "Load sites"))
</button>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<div class="alert alert-error mt-8">@_error</div>
}
@if (_all.Count > 0)
{
<div class="flex-row mt-8" style="gap:12px;align-items:center">
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@Filtered.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
<span class="spacer"></span>
<span class="count-badge">@SelectedSites.Count selected</span>
</div>
<div class="site-picker-list" style="max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:4px;padding:4px;margin-top:6px">
@foreach (var s in Filtered)
{
<label style="display:flex;align-items:center;gap:8px;padding:3px 6px;cursor:pointer">
<input type="checkbox" checked="@IsSelected(s)" @onchange="e => Toggle(s, (bool)(e.Value ?? false))" />
<span>@s.Title</span>
<span class="text-muted" style="font-size:11px">@s.Url</span>
</label>
}
@if (!Filtered.Any())
{
<div class="text-muted" style="padding:6px">No sites match the filter.</div>
}
</div>
}
else if (!_loading)
{
<div class="text-muted mt-8" style="font-size:12px">Click “Load sites” to list the tenants SharePoint sites, then tick the ones to scan.</div>
}
</div>
@code {
[Parameter] public TenantProfile Profile { get; set; } = default!;
[Parameter] public List<SiteInfo> SelectedSites { get; set; } = new();
[Parameter] public EventCallback<List<SiteInfo>> SelectedSitesChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
private List<SiteInfo> _all = new();
private string _filter = string.Empty;
private bool _loading;
private string _error = string.Empty;
private IEnumerable<SiteInfo> Filtered =>
string.IsNullOrWhiteSpace(_filter)
? _all
: _all.Where(s =>
s.Title.Contains(_filter, StringComparison.OrdinalIgnoreCase) ||
s.Url.Contains(_filter, StringComparison.OrdinalIgnoreCase));
private bool IsSelected(SiteInfo s) =>
SelectedSites.Any(x => string.Equals(x.Url, s.Url, StringComparison.OrdinalIgnoreCase));
private async Task LoadSites()
{
_loading = true; _error = string.Empty;
try
{
_all = (await SiteDiscovery.SearchSitesAsync(Profile)).ToList();
if (_all.Count == 0) _error = "No sites returned. The account may lack Sites.Read.All.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
}
private async Task Toggle(SiteInfo s, bool on)
{
if (on)
{
if (!IsSelected(s)) SelectedSites.Add(s);
}
else
{
SelectedSites.RemoveAll(x => string.Equals(x.Url, s.Url, StringComparison.OrdinalIgnoreCase));
}
await SelectedSitesChanged.InvokeAsync(SelectedSites);
}
private async Task SelectAllFiltered()
{
foreach (var s in Filtered)
if (!IsSelected(s)) SelectedSites.Add(s);
await SelectedSitesChanged.InvokeAsync(SelectedSites);
}
private async Task ClearSelection()
{
SelectedSites.Clear();
await SelectedSitesChanged.InvokeAsync(SelectedSites);
}
}
+3
View File
@@ -6,4 +6,7 @@ public class AppSettings
public string Lang { get; set; } = "en"; public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false; public bool AutoTakeOwnership { get; set; } = false;
public string Theme { get; set; } = "System"; public string Theme { get; set; } = "System";
/// <summary>MSP logo shown top-left on exported reports. Null = none.</summary>
public LogoData? MspLogo { get; set; }
} }
+7 -1
View File
@@ -11,7 +11,13 @@ public class TranslationSource
{ {
public static readonly TranslationSource Instance = new(); public static readonly TranslationSource Instance = new();
private ResourceManager _resourceManager = Strings.ResourceManager; // Construct the ResourceManager with the explicit manifest base name rather
// than Strings.ResourceManager: the generated designer carries a stale base
// name ("SharepointToolbox.Strings") from before the project was renamed to
// *.Web, so its lookups throw MissingManifestResourceException. The embedded
// resource is "SharepointToolbox.Web.Localization.Strings".
private ResourceManager _resourceManager =
new ResourceManager("SharepointToolbox.Web.Localization.Strings", typeof(TranslationSource).Assembly);
private CultureInfo _currentCulture = CultureInfo.CurrentUICulture; private CultureInfo _currentCulture = CultureInfo.CurrentUICulture;
private TranslationSource() { } private TranslationSource() { }
+1
View File
@@ -147,6 +147,7 @@ builder.Services.AddScoped<IBulkSiteService, BulkSiteService>();
builder.Services.AddScoped<IVersionCleanupService, VersionCleanupService>(); builder.Services.AddScoped<IVersionCleanupService, VersionCleanupService>();
builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>(); builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>();
builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>(); builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>();
builder.Services.AddScoped<ISiteDiscoveryService, SiteDiscoveryService>();
builder.Services.AddScoped<IFolderStructureService, FolderStructureService>(); builder.Services.AddScoped<IFolderStructureService, FolderStructureService>();
builder.Services.AddScoped<ITemplateService, TemplateService>(); builder.Services.AddScoped<ITemplateService, TemplateService>();
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>(); builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
+116
View File
@@ -0,0 +1,116 @@
using System.IO;
using System.IO.Compression;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>How a multi-site report is bundled for download.</summary>
public enum ReportMergeMode
{
/// <summary>One document containing all sites, no per-site separation.</summary>
SingleMerged,
/// <summary>One HTML document with one tab per site (CSV falls back to merged).</summary>
SingleTabbed,
/// <summary>One file per site, delivered as a single ZIP.</summary>
MultipleFiles
}
/// <summary>Output format of a report.</summary>
public enum ReportFormat
{
Csv,
Html
}
/// <summary>A ready-to-download artifact: bytes plus filename and MIME type.</summary>
public sealed record MergeOutput(string FileName, byte[] Bytes, string Mime);
/// <summary>
/// Bundles per-site report content into a single downloadable artifact
/// according to a <see cref="ReportMergeMode"/>. Format-agnostic: the caller
/// supplies a <c>buildDoc</c> delegate that renders one site's results to a
/// document string; this helper handles flattening, tabbing, and zipping.
/// </summary>
public static class ReportMergeHelper
{
/// <summary>
/// Builds the download artifact.
/// </summary>
/// <param name="sites">Per-site results (label + result list).</param>
/// <param name="mode">How to bundle the output.</param>
/// <param name="baseName">File stem, e.g. "permissions".</param>
/// <param name="timestamp">Stamp appended to single-file names, e.g. "20260602_101500".</param>
/// <param name="format">CSV or HTML.</param>
/// <param name="buildDoc">Renders one result list to a complete document.</param>
public static MergeOutput Build<T>(
IReadOnlyList<(string Label, IReadOnlyList<T> Results)> sites,
ReportMergeMode mode,
string baseName,
string timestamp,
ReportFormat format,
Func<IReadOnlyList<T>, string> buildDoc)
{
var ext = format == ReportFormat.Csv ? "csv" : "html";
var mime = format == ReportFormat.Csv ? "text/csv;charset=utf-8" : "text/html;charset=utf-8";
// CSV is BOM-prefixed for Excel; HTML is not.
var enc = new UTF8Encoding(encoderShouldEmitUTF8Identifier: format == ReportFormat.Csv);
// Tabs are an HTML-only concept — degrade to a single merged CSV.
if (mode == ReportMergeMode.SingleTabbed && format == ReportFormat.Csv)
mode = ReportMergeMode.SingleMerged;
switch (mode)
{
case ReportMergeMode.SingleTabbed:
{
var parts = sites
.Select(s => (s.Label, buildDoc(s.Results)))
.ToList();
var html = ReportSplitHelper.BuildTabbedHtml(parts, baseName);
return new MergeOutput($"{baseName}_{timestamp}.html", enc.GetBytes(html), mime);
}
case ReportMergeMode.MultipleFiles:
{
using var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var s in sites)
{
var name = UniqueName(used, $"{baseName}_{ReportSplitHelper.SanitizeFileName(s.Label)}.{ext}");
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var es = entry.Open();
var bytes = enc.GetBytes(buildDoc(s.Results));
es.Write(bytes, 0, bytes.Length);
}
}
return new MergeOutput($"{baseName}_{timestamp}.zip", ms.ToArray(), "application/zip");
}
case ReportMergeMode.SingleMerged:
default:
{
var flat = sites.SelectMany(s => s.Results).ToList();
var bytes = enc.GetBytes(buildDoc(flat));
return new MergeOutput($"{baseName}_{timestamp}.{ext}", bytes, mime);
}
}
}
/// <summary>
/// Returns <paramref name="candidate"/> or, if already taken, a suffixed
/// variant ("name_2.ext", "name_3.ext", …) so ZIP entries never collide.
/// </summary>
private static string UniqueName(HashSet<string> used, string candidate)
{
if (used.Add(candidate)) return candidate;
var stem = Path.GetFileNameWithoutExtension(candidate);
var ext = Path.GetExtension(candidate);
for (int i = 2; ; i++)
{
var next = $"{stem}_{i}{ext}";
if (used.Add(next)) return next;
}
}
}
+9
View File
@@ -25,4 +25,13 @@ public class WebExportService
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes)); await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes));
} }
/// <summary>
/// Downloads pre-encoded bytes (e.g. a ZIP or a merged report produced by
/// <see cref="ReportMergeHelper"/>) with an explicit MIME type.
/// </summary>
public async Task DownloadBytesAsync(byte[] content, string fileName, string mime)
{
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, mime, Convert.ToBase64String(content));
}
} }
+20
View File
@@ -0,0 +1,20 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Discovers SharePoint sites in a tenant via Microsoft Graph so users can
/// pick multiple sites to scan instead of typing URLs one at a time.
/// </summary>
public interface ISiteDiscoveryService
{
/// <summary>
/// Returns sites matching <paramref name="query"/> (defaults to all sites).
/// OneDrive personal sites are excluded; results are de-duplicated by URL
/// and ordered by title.
/// </summary>
Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
TenantProfile profile,
string? query = null,
CancellationToken ct = default);
}
+3
View File
@@ -12,6 +12,9 @@ public interface IUserSessionService
bool HasProfile { get; } bool HasProfile { get; }
AppSettings Settings { get; } AppSettings Settings { get; }
/// <summary>Branding for exported reports: MSP logo (settings) + active profile's client logo.</summary>
ReportBranding CurrentBranding { get; }
void SetProfile(TenantProfile profile); void SetProfile(TenantProfile profile);
Task ClearSessionAsync(); Task ClearSessionAsync();
void UpdateSettings(AppSettings settings); void UpdateSettings(AppSettings settings);
+2
View File
@@ -15,6 +15,8 @@ public class UserSessionService : IUserSessionService
public bool HasProfile => _currentProfile is not null; public bool HasProfile => _currentProfile is not null;
public AppSettings Settings => _settings; public AppSettings Settings => _settings;
public ReportBranding CurrentBranding => new(_settings.MspLogo, _currentProfile?.ClientLogo);
public event Action? ProfileChanged; public event Action? ProfileChanged;
public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo) public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo)
+76
View File
@@ -0,0 +1,76 @@
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions;
using SharepointToolbox.Web.Core.Models;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Delegated Graph implementation of <see cref="ISiteDiscoveryService"/>.
/// Uses the <c>/sites?search=*</c> endpoint, paging through every result.
/// Requires the delegated <c>Sites.Read.All</c> scope.
/// </summary>
public class SiteDiscoveryService : ISiteDiscoveryService
{
private readonly AppGraphClientFactory _graphClientFactory;
public SiteDiscoveryService(AppGraphClientFactory graphClientFactory)
{
_graphClientFactory = graphClientFactory;
}
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
TenantProfile profile,
string? query = null,
CancellationToken ct = default)
{
var graphClient = await _graphClientFactory.CreateClientAsync(profile);
// "*" is the Graph convention for "return all sites".
var search = string.IsNullOrWhiteSpace(query) ? "*" : query!;
// The typed Sites.GetAsync maps its Search property to OData "$search",
// which routes "*" through KQL and fails ("'*' is not valid at position 0").
// The all-sites wildcard only works via the bare, non-OData "search"
// query parameter, so build the request manually.
var requestInfo = new RequestInformation
{
HttpMethod = Method.GET,
UrlTemplate = "{+baseurl}/sites{?search,%24top}",
PathParameters = new Dictionary<string, object>
{
{ "baseurl", graphClient.RequestAdapter.BaseUrl ?? "https://graph.microsoft.com/v1.0" }
},
};
requestInfo.QueryParameters.Add("search", search);
requestInfo.QueryParameters.Add("%24top", 999);
requestInfo.Headers.Add("Accept", "application/json");
var response = await graphClient.RequestAdapter.SendAsync<SiteCollectionResponse>(
requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct);
if (response is null) return Array.Empty<SiteInfo>();
var results = new List<SiteInfo>();
var iter = PageIterator<Site, SiteCollectionResponse>.CreatePageIterator(
graphClient, response,
site =>
{
if (ct.IsCancellationRequested) return false;
var url = site.WebUrl ?? string.Empty;
if (string.IsNullOrEmpty(url)) return true;
// Skip OneDrive personal sites — not useful for these scans.
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) return true;
var title = site.DisplayName ?? site.Name ?? url;
results.Add(new SiteInfo(url, title));
return true;
});
await iter.IterateAsync(ct);
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}