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
This commit is contained in:
186
SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
Normal file
186
SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
Normal file
@@ -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<FeatureViewModelBase> _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<SearchResult> _results = new();
|
||||
public ObservableCollection<SearchResult> 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<FeatureViewModelBase> 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<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;
|
||||
}
|
||||
|
||||
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<SearchResult>(items));
|
||||
else
|
||||
Results = new ObservableCollection<SearchResult>(items);
|
||||
}
|
||||
|
||||
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
Results = new ObservableCollection<SearchResult>();
|
||||
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<string>();
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user