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:
Dev
2026-04-02 15:36:27 +02:00
parent 9a55c9e7d0
commit e174a18350
3 changed files with 405 additions and 4 deletions

View 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 */ }
}
}