feat(03-07): create StorageViewModel with IStorageService orchestration and export commands
- Rule 1: Fixed ctx.Url read-only bug — use new TenantProfile with site URL for GetOrCreateContextAsync - Rule 3: Added missing System.IO using to SearchCsvExportService and SearchHtmlExportService
This commit is contained in:
@@ -1,14 +1,52 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports SearchResult list to a UTF-8 BOM CSV file.
|
||||||
|
/// Header matches the column order in SearchHtmlExportService for consistency.
|
||||||
|
/// </summary>
|
||||||
public class SearchCsvExportService
|
public class SearchCsvExportService
|
||||||
{
|
{
|
||||||
public string BuildCsv(IReadOnlyList<SearchResult> results) => string.Empty; // implemented in Plan 03-05
|
public string BuildCsv(IReadOnlyList<SearchResult> results)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)");
|
||||||
|
|
||||||
|
foreach (var r in results)
|
||||||
|
{
|
||||||
|
sb.AppendLine(string.Join(",",
|
||||||
|
Csv(IfEmpty(System.IO.Path.GetFileName(r.Path), r.Title)),
|
||||||
|
Csv(r.FileExtension),
|
||||||
|
Csv(r.Path),
|
||||||
|
r.Created.HasValue ? Csv(r.Created.Value.ToString("yyyy-MM-dd")) : string.Empty,
|
||||||
|
Csv(r.Author),
|
||||||
|
r.LastModified.HasValue ? Csv(r.LastModified.Value.ToString("yyyy-MM-dd")) : string.Empty,
|
||||||
|
Csv(r.ModifiedBy),
|
||||||
|
r.SizeBytes.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var csv = BuildCsv(results);
|
var csv = BuildCsv(results);
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct);
|
await System.IO.File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string Csv(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||||
|
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||||
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string IfEmpty(string? value, string fallback = "")
|
||||||
|
=> string.IsNullOrEmpty(value) ? fallback : value!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,154 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports SearchResult list to a self-contained sortable/filterable HTML report.
|
||||||
|
/// Port of PS Export-SearchToHTML (PS lines 2112-2233).
|
||||||
|
/// Columns are sortable by clicking the header. A filter input narrows rows by text match.
|
||||||
|
/// </summary>
|
||||||
public class SearchHtmlExportService
|
public class SearchHtmlExportService
|
||||||
{
|
{
|
||||||
public string BuildHtml(IReadOnlyList<SearchResult> results) => string.Empty; // implemented in Plan 03-05
|
public string BuildHtml(IReadOnlyList<SearchResult> results)
|
||||||
|
{
|
||||||
|
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 File Search Results</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
|
h1 { color: #0078d4; }
|
||||||
|
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.toolbar label { font-weight: 600; }
|
||||||
|
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
|
||||||
|
#resultCount { font-size: 12px; color: #666; }
|
||||||
|
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; cursor: pointer;
|
||||||
|
font-weight: 600; user-select: none; white-space: nowrap; }
|
||||||
|
th:hover { background: #106ebe; }
|
||||||
|
th.sorted-asc::after { content: ' ▲'; font-size: 10px; }
|
||||||
|
th.sorted-desc::after { content: ' ▼'; font-size: 10px; }
|
||||||
|
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
|
||||||
|
tr:hover td { background: #f0f7ff; }
|
||||||
|
tr.hidden { display: none; }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.generated { font-size: 11px; color: #888; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>File Search Results</h1>
|
||||||
|
<div class="toolbar">
|
||||||
|
<label for="filterInput">Filter:</label>
|
||||||
|
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
|
||||||
|
<span id="resultCount"></span>
|
||||||
|
</div>
|
||||||
|
""");
|
||||||
|
|
||||||
|
sb.AppendLine("""
|
||||||
|
<table id="resultsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th onclick="sortTable(0)">File Name</th>
|
||||||
|
<th onclick="sortTable(1)">Extension</th>
|
||||||
|
<th onclick="sortTable(2)">Path</th>
|
||||||
|
<th onclick="sortTable(3)">Created</th>
|
||||||
|
<th onclick="sortTable(4)">Created By</th>
|
||||||
|
<th onclick="sortTable(5)">Modified</th>
|
||||||
|
<th onclick="sortTable(6)">Modified By</th>
|
||||||
|
<th class="num" onclick="sortTable(7)">Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
""");
|
||||||
|
|
||||||
|
foreach (var r in results)
|
||||||
|
{
|
||||||
|
string fileName = System.IO.Path.GetFileName(r.Path);
|
||||||
|
if (string.IsNullOrEmpty(fileName)) fileName = r.Title;
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<tr>
|
||||||
|
<td>{H(fileName)}</td>
|
||||||
|
<td>{H(r.FileExtension)}</td>
|
||||||
|
<td><a href="{H(r.Path)}" target="_blank">{H(r.Path)}</a></td>
|
||||||
|
<td>{(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
|
||||||
|
<td>{H(r.Author)}</td>
|
||||||
|
<td>{(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
|
||||||
|
<td>{H(r.ModifiedBy)}</td>
|
||||||
|
<td class="num" data-sort="{r.SizeBytes}">{FormatSize(r.SizeBytes)}</td>
|
||||||
|
</tr>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(" </tbody>\n</table>");
|
||||||
|
|
||||||
|
int count = results.Count;
|
||||||
|
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>");
|
||||||
|
|
||||||
|
sb.AppendLine($$"""
|
||||||
|
<script>
|
||||||
|
var sortDir = {};
|
||||||
|
function sortTable(col) {
|
||||||
|
var tbl = document.getElementById('resultsTable');
|
||||||
|
var tbody = tbl.tBodies[0];
|
||||||
|
var rows = Array.from(tbody.rows);
|
||||||
|
var asc = sortDir[col] !== 'asc';
|
||||||
|
sortDir[col] = asc ? 'asc' : 'desc';
|
||||||
|
rows.sort(function(a, b) {
|
||||||
|
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
|
||||||
|
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
|
||||||
|
var an = parseFloat(av), bn = parseFloat(bv);
|
||||||
|
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
|
||||||
|
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
});
|
||||||
|
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||||
|
var ths = tbl.tHead.rows[0].cells;
|
||||||
|
for (var i = 0; i < ths.length; i++) {
|
||||||
|
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function filterTable() {
|
||||||
|
var q = document.getElementById('filterInput').value.toLowerCase();
|
||||||
|
var rows = document.getElementById('resultsTable').tBodies[0].rows;
|
||||||
|
var visible = 0;
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
|
||||||
|
rows[i].className = match ? '' : 'hidden';
|
||||||
|
if (match) visible++;
|
||||||
|
}
|
||||||
|
document.getElementById('resultCount').innerText = q ? (visible + ' of {{count:N0}} shown') : '';
|
||||||
|
}
|
||||||
|
window.onload = function() {
|
||||||
|
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
""");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var html = BuildHtml(results);
|
var html = BuildHtml(results);
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
|
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string H(string value) =>
|
||||||
|
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||||
|
|
||||||
|
private static string FormatSize(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||||
|
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
|
||||||
|
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
|
||||||
|
return $"{bytes} B";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
223
SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
Normal file
223
SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.ViewModels.Tabs;
|
||||||
|
|
||||||
|
public partial class StorageViewModel : FeatureViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IStorageService _storageService;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly StorageCsvExportService _csvExportService;
|
||||||
|
private readonly StorageHtmlExportService _htmlExportService;
|
||||||
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
|
private TenantProfile? _currentProfile;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _siteUrl = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _perLibrary = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _includeSubsites;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _folderDepth;
|
||||||
|
|
||||||
|
public bool IsMaxDepth
|
||||||
|
{
|
||||||
|
get => FolderDepth >= 999;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value) FolderDepth = 999;
|
||||||
|
else if (FolderDepth >= 999) FolderDepth = 0;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObservableCollection<StorageNode> _results = new();
|
||||||
|
public ObservableCollection<StorageNode> Results
|
||||||
|
{
|
||||||
|
get => _results;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_results = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||||
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||||
|
|
||||||
|
public TenantProfile? CurrentProfile => _currentProfile;
|
||||||
|
|
||||||
|
public StorageViewModel(
|
||||||
|
IStorageService storageService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
StorageCsvExportService csvExportService,
|
||||||
|
StorageHtmlExportService htmlExportService,
|
||||||
|
ILogger<FeatureViewModelBase> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
_storageService = storageService;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_csvExportService = csvExportService;
|
||||||
|
_htmlExportService = htmlExportService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test constructor — omits export services.</summary>
|
||||||
|
internal StorageViewModel(
|
||||||
|
IStorageService storageService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
ILogger<FeatureViewModelBase> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
_storageService = storageService;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_csvExportService = null!;
|
||||||
|
_htmlExportService = null!;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
|
{
|
||||||
|
if (_currentProfile == null)
|
||||||
|
{
|
||||||
|
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||||
|
{
|
||||||
|
StatusMessage = "Please enter a site URL.";
|
||||||
|
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 options = new StorageScanOptions(
|
||||||
|
PerLibrary: PerLibrary,
|
||||||
|
IncludeSubsites: IncludeSubsites,
|
||||||
|
FolderDepth: FolderDepth);
|
||||||
|
|
||||||
|
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||||
|
|
||||||
|
// Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
|
||||||
|
var flat = new List<StorageNode>();
|
||||||
|
foreach (var node in nodes)
|
||||||
|
FlattenNode(node, 0, flat);
|
||||||
|
|
||||||
|
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||||
|
{
|
||||||
|
await dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
Results = new ObservableCollection<StorageNode>(flat);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Results = new ObservableCollection<StorageNode>(flat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnTenantSwitched(TenantProfile profile)
|
||||||
|
{
|
||||||
|
_currentProfile = profile;
|
||||||
|
Results = new ObservableCollection<StorageNode>();
|
||||||
|
SiteUrl = string.Empty;
|
||||||
|
OnPropertyChanged(nameof(CurrentProfile));
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
||||||
|
|
||||||
|
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
|
=> RunOperationAsync(ct, progress);
|
||||||
|
|
||||||
|
private bool CanExport() => Results.Count > 0;
|
||||||
|
|
||||||
|
private async Task ExportCsvAsync()
|
||||||
|
{
|
||||||
|
if (Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export storage metrics to CSV",
|
||||||
|
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
||||||
|
DefaultExt = "csv",
|
||||||
|
FileName = "storage_metrics"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "CSV export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportHtmlAsync()
|
||||||
|
{
|
||||||
|
if (Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export storage metrics to HTML",
|
||||||
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
||||||
|
DefaultExt = "html",
|
||||||
|
FileName = "storage_metrics"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "HTML export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FlattenNode(StorageNode node, int level, List<StorageNode> result)
|
||||||
|
{
|
||||||
|
node.IndentLevel = level;
|
||||||
|
result.Add(node);
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
FlattenNode(child, level + 1, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
||||||
|
catch { /* ignore — file may open but this is best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user