From e174a18350fb1934beabb9d69a8bb504b7c4b339 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 15:36:27 +0200 Subject: [PATCH] feat(03-07): create StorageViewModel with IStorageService orchestration and export commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Services/Export/SearchCsvExportService.cs | 42 +++- .../Export/SearchHtmlExportService.cs | 144 ++++++++++- .../ViewModels/Tabs/StorageViewModel.cs | 223 ++++++++++++++++++ 3 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs diff --git a/SharepointToolbox/Services/Export/SearchCsvExportService.cs b/SharepointToolbox/Services/Export/SearchCsvExportService.cs index 95fb036..2edc5be 100644 --- a/SharepointToolbox/Services/Export/SearchCsvExportService.cs +++ b/SharepointToolbox/Services/Export/SearchCsvExportService.cs @@ -1,14 +1,52 @@ +using System.IO; +using System.Text; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; +/// +/// Exports SearchResult list to a UTF-8 BOM CSV file. +/// Header matches the column order in SearchHtmlExportService for consistency. +/// public class SearchCsvExportService { - public string BuildCsv(IReadOnlyList results) => string.Empty; // implemented in Plan 03-05 + public string BuildCsv(IReadOnlyList 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 results, string filePath, CancellationToken ct) { 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!; } diff --git a/SharepointToolbox/Services/Export/SearchHtmlExportService.cs b/SharepointToolbox/Services/Export/SearchHtmlExportService.cs index 67df080..9617d97 100644 --- a/SharepointToolbox/Services/Export/SearchHtmlExportService.cs +++ b/SharepointToolbox/Services/Export/SearchHtmlExportService.cs @@ -1,14 +1,154 @@ +using System.IO; +using System.Text; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; +/// +/// 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. +/// public class SearchHtmlExportService { - public string BuildHtml(IReadOnlyList results) => string.Empty; // implemented in Plan 03-05 + public string BuildHtml(IReadOnlyList results) + { + var sb = new StringBuilder(); + + sb.AppendLine(""" + + + + + + SharePoint File Search Results + + + +

File Search Results

+
+ + + +
+ """); + + sb.AppendLine(""" + + + + + + + + + + + + + + + """); + + foreach (var r in results) + { + string fileName = System.IO.Path.GetFileName(r.Path); + if (string.IsNullOrEmpty(fileName)) fileName = r.Title; + + sb.AppendLine($""" + + + + + + + + + + + """); + } + + sb.AppendLine(" \n
File NameExtensionPathCreatedCreated ByModifiedModified BySize
{H(fileName)}{H(r.FileExtension)}{H(r.Path)}{(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)}{H(r.Author)}{(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)}{H(r.ModifiedBy)}{FormatSize(r.SizeBytes)}
"); + + int count = results.Count; + sb.AppendLine($"

Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)

"); + + sb.AppendLine($$""" + + + """); + + return sb.ToString(); + } public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) { 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"; } } diff --git a/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs b/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs new file mode 100644 index 0000000..b4dc584 --- /dev/null +++ b/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs @@ -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 _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 _results = new(); + public ObservableCollection 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 logger) + : base(logger) + { + _storageService = storageService; + _sessionManager = sessionManager; + _csvExportService = csvExportService; + _htmlExportService = htmlExportService; + _logger = logger; + + ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); + ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); + } + + /// Test constructor — omits export services. + internal StorageViewModel( + IStorageService storageService, + ISessionManager sessionManager, + ILogger 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 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(); + foreach (var node in nodes) + FlattenNode(node, 0, flat); + + if (Application.Current?.Dispatcher is { } dispatcher) + { + await dispatcher.InvokeAsync(() => + { + Results = new ObservableCollection(flat); + }); + } + else + { + Results = new ObservableCollection(flat); + } + } + + protected override void OnTenantSwitched(TenantProfile profile) + { + _currentProfile = profile; + Results = new ObservableCollection(); + SiteUrl = string.Empty; + OnPropertyChanged(nameof(CurrentProfile)); + ExportCsvCommand.NotifyCanExecuteChanged(); + ExportHtmlCommand.NotifyCanExecuteChanged(); + } + + internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; + + internal Task TestRunOperationAsync(CancellationToken ct, IProgress 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 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 */ } + } +}