From 7e6d39a3dbbd561f1e2ee10b8d07a42ca79a12bc Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 15:43:22 +0200 Subject: [PATCH] feat(03-08): create SearchViewModel, SearchView XAML and code-behind - SearchViewModel: full filter props, RunOperationAsync via ISearchService - Uses TenantProfile site URL override pattern (ctx.Url is read-only) - ExportCsvCommand + ExportHtmlCommand with CanExport guard - SearchView.xaml: filter panel + DataGrid with all 8 columns - SearchView.xaml.cs: DI constructor with DataContext wiring --- .../ViewModels/Tabs/SearchViewModel.cs | 186 ++++++++++++++++++ SharepointToolbox/Views/Tabs/SearchView.xaml | 108 ++++++++++ .../Views/Tabs/SearchView.xaml.cs | 12 ++ 3 files changed, 306 insertions(+) create mode 100644 SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs create mode 100644 SharepointToolbox/Views/Tabs/SearchView.xaml create mode 100644 SharepointToolbox/Views/Tabs/SearchView.xaml.cs diff --git a/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs b/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs new file mode 100644 index 0000000..fa73283 --- /dev/null +++ b/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs @@ -0,0 +1,186 @@ +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 SearchViewModel : FeatureViewModelBase +{ + private readonly ISearchService _searchService; + private readonly ISessionManager _sessionManager; + private readonly SearchCsvExportService _csvExportService; + private readonly SearchHtmlExportService _htmlExportService; + private readonly ILogger _logger; + private TenantProfile? _currentProfile; + + // ── Filter observable properties ───────────────────────────────────────── + + [ObservableProperty] private string _siteUrl = string.Empty; + [ObservableProperty] private string _extensions = string.Empty; + [ObservableProperty] private string _regex = string.Empty; + [ObservableProperty] private bool _useCreatedAfter; + [ObservableProperty] private DateTime _createdAfter = DateTime.Today.AddMonths(-1); + [ObservableProperty] private bool _useCreatedBefore; + [ObservableProperty] private DateTime _createdBefore = DateTime.Today; + [ObservableProperty] private bool _useModifiedAfter; + [ObservableProperty] private DateTime _modifiedAfter = DateTime.Today.AddMonths(-1); + [ObservableProperty] private bool _useModifiedBefore; + [ObservableProperty] private DateTime _modifiedBefore = DateTime.Today; + [ObservableProperty] private string _createdBy = string.Empty; + [ObservableProperty] private string _modifiedBy = string.Empty; + [ObservableProperty] private string _library = string.Empty; + [ObservableProperty] private int _maxResults = 5000; + + 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 SearchViewModel( + ISearchService searchService, + ISessionManager sessionManager, + SearchCsvExportService csvExportService, + SearchHtmlExportService htmlExportService, + ILogger logger) + : base(logger) + { + _searchService = searchService; + _sessionManager = sessionManager; + _csvExportService = csvExportService; + _htmlExportService = htmlExportService; + _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; + } + + var siteProfile = new TenantProfile + { + TenantUrl = SiteUrl.TrimEnd('/'), + ClientId = _currentProfile.ClientId, + Name = _currentProfile.Name + }; + var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct); + + var opts = new SearchOptions( + Extensions: ParseExtensions(Extensions), + Regex: string.IsNullOrWhiteSpace(Regex) ? null : Regex, + CreatedAfter: UseCreatedAfter ? CreatedAfter : null, + CreatedBefore: UseCreatedBefore ? CreatedBefore : null, + ModifiedAfter: UseModifiedAfter ? ModifiedAfter : null, + ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null, + CreatedBy: string.IsNullOrWhiteSpace(CreatedBy) ? null : CreatedBy, + ModifiedBy: string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy, + Library: string.IsNullOrWhiteSpace(Library) ? null : Library, + MaxResults: Math.Clamp(MaxResults, 1, 50_000), + SiteUrl: SiteUrl.TrimEnd('/') + ); + + var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct); + + if (Application.Current?.Dispatcher is { } dispatcher) + await dispatcher.InvokeAsync(() => Results = new ObservableCollection(items)); + else + Results = new ObservableCollection(items); + } + + protected override void OnTenantSwitched(Core.Models.TenantProfile profile) + { + _currentProfile = profile; + Results = new ObservableCollection(); + SiteUrl = string.Empty; + OnPropertyChanged(nameof(CurrentProfile)); + ExportCsvCommand.NotifyCanExecuteChanged(); + ExportHtmlCommand.NotifyCanExecuteChanged(); + } + + internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; + + private bool CanExport() => Results.Count > 0; + + private async Task ExportCsvAsync() + { + if (Results.Count == 0) return; + var dialog = new SaveFileDialog + { + Title = "Export search results to CSV", + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + DefaultExt = "csv", + FileName = "search_results" + }; + 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 search results to HTML", + Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*", + DefaultExt = "html", + FileName = "search_results" + }; + 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 string[] ParseExtensions(string input) + { + if (string.IsNullOrWhiteSpace(input)) return Array.Empty(); + return input.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => e.TrimStart('.').ToLowerInvariant()) + .Where(e => e.Length > 0) + .Distinct() + .ToArray(); + } + + private static void OpenFile(string filePath) + { + try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); } + catch { } + } +} diff --git a/SharepointToolbox/Views/Tabs/SearchView.xaml b/SharepointToolbox/Views/Tabs/SearchView.xaml new file mode 100644 index 0000000..b02e2df --- /dev/null +++ b/SharepointToolbox/Views/Tabs/SearchView.xaml @@ -0,0 +1,108 @@ + + + + + +