chore: complete v1.0 milestone
All checks were successful
Release zip package / release (push) Successful in 10s
All checks were successful
Release zip package / release (push) Successful in 10s
Archive 5 phases (36 plans) to milestones/v1.0-phases/. Archive roadmap, requirements, and audit to milestones/. Evolve PROJECT.md with shipped state and validated requirements. Collapse ROADMAP.md to one-line milestone summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
792
.planning/milestones/v1.0-phases/03-storage/03-08-PLAN.md
Normal file
792
.planning/milestones/v1.0-phases/03-storage/03-08-PLAN.md
Normal file
@@ -0,0 +1,792 @@
|
||||
---
|
||||
phase: 03
|
||||
plan: 08
|
||||
title: SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint
|
||||
status: pending
|
||||
wave: 4
|
||||
depends_on:
|
||||
- 03-05
|
||||
- 03-06
|
||||
- 03-07
|
||||
files_modified:
|
||||
- SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
|
||||
- SharepointToolbox/Views/Tabs/SearchView.xaml
|
||||
- SharepointToolbox/Views/Tabs/SearchView.xaml.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
|
||||
- SharepointToolbox/Views/Tabs/DuplicatesView.xaml
|
||||
- SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
|
||||
- SharepointToolbox/App.xaml.cs
|
||||
- SharepointToolbox/MainWindow.xaml
|
||||
- SharepointToolbox/MainWindow.xaml.cs
|
||||
autonomous: false
|
||||
requirements:
|
||||
- SRCH-01
|
||||
- SRCH-02
|
||||
- SRCH-03
|
||||
- SRCH-04
|
||||
- DUPL-01
|
||||
- DUPL-02
|
||||
- DUPL-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "File Search tab shows filter controls (extensions, regex, date pickers, creator, editor, library, max results, site URL)"
|
||||
- "Running a file search populates the DataGrid with file name, extension, created, modified, author, modifier, size columns"
|
||||
- "Export CSV and Export HTML buttons are enabled after a successful search, disabled when results are empty"
|
||||
- "Duplicates tab shows type selector (Files/Folders), criteria checkboxes, site URL, optional library field, and Run Scan button"
|
||||
- "Running a duplicate scan populates the DataGrid with one row per DuplicateItem across all groups"
|
||||
- "Export HTML button is enabled after scan with results"
|
||||
- "All three feature tabs (Storage, File Search, Duplicates) are visible and functional in the running application"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs"
|
||||
provides: "File Search tab ViewModel"
|
||||
exports: ["SearchViewModel"]
|
||||
- path: "SharepointToolbox/Views/Tabs/SearchView.xaml"
|
||||
provides: "File Search tab XAML"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs"
|
||||
provides: "Duplicates tab ViewModel"
|
||||
exports: ["DuplicatesViewModel"]
|
||||
- path: "SharepointToolbox/Views/Tabs/DuplicatesView.xaml"
|
||||
provides: "Duplicates tab XAML"
|
||||
key_links:
|
||||
- from: "SearchViewModel.cs"
|
||||
to: "ISearchService.SearchFilesAsync"
|
||||
via: "RunOperationAsync override"
|
||||
pattern: "SearchFilesAsync"
|
||||
- from: "DuplicatesViewModel.cs"
|
||||
to: "IDuplicatesService.ScanDuplicatesAsync"
|
||||
via: "RunOperationAsync override"
|
||||
pattern: "ScanDuplicatesAsync"
|
||||
- from: "App.xaml.cs"
|
||||
to: "ISearchService, SearchService"
|
||||
via: "DI registration"
|
||||
pattern: "ISearchService"
|
||||
- from: "App.xaml.cs"
|
||||
to: "IDuplicatesService, DuplicatesService"
|
||||
via: "DI registration"
|
||||
pattern: "IDuplicatesService"
|
||||
---
|
||||
|
||||
# Plan 03-08: SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint
|
||||
|
||||
## Goal
|
||||
|
||||
Create ViewModels and XAML Views for the File Search and Duplicates tabs, wire them into `MainWindow`, register all dependencies in `App.xaml.cs`, then pause for a visual checkpoint to verify all three Phase 3 tabs (Storage, File Search, Duplicates) are visible and functional in the running application.
|
||||
|
||||
## Context
|
||||
|
||||
Plans 03-05 (export services), 03-06 (localization), and 03-07 (StorageView + DI) must complete first. The pattern established by `StorageViewModel` and `PermissionsViewModel` applies identically: `FeatureViewModelBase`, `AsyncRelayCommand`, `Dispatcher.InvokeAsync` for `ObservableCollection` updates, no stored `ClientContext`.
|
||||
|
||||
The Duplicates DataGrid flattens `DuplicateGroup.Items` into a flat list for display. Each row shows the group name, the individual item path, library, size, dates. A `GroupName` property on a display wrapper DTO is used to identify the group.
|
||||
|
||||
`InverseBoolConverter`, `BytesConverter`, and `RightAlignStyle` are registered in `App.xaml` by Plan 03-07. Both Search and Duplicates views use `{StaticResource InverseBoolConverter}` and `{StaticResource BytesConverter}` — these will resolve from `Application.Resources`.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1a: Create SearchViewModel, SearchView XAML, and SearchView code-behind
|
||||
|
||||
**Files:**
|
||||
- `SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs`
|
||||
- `SharepointToolbox/Views/Tabs/SearchView.xaml`
|
||||
- `SharepointToolbox/Views/Tabs/SearchView.xaml.cs`
|
||||
|
||||
**Action:** Create
|
||||
|
||||
**Why:** SRCH-01 through SRCH-04 — the UI layer for file search.
|
||||
|
||||
```csharp
|
||||
// SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
|
||||
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 ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
|
||||
ctx.Url = SiteUrl.TrimEnd('/');
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- SharepointToolbox/Views/Tabs/SearchView.xaml -->
|
||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||
<DockPanel LastChildFill="True">
|
||||
<!-- Filters panel -->
|
||||
<ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
||||
<StackPanel>
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
|
||||
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
|
||||
Height="26" Margin="0,0,0,8" />
|
||||
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel Margin="4">
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" />
|
||||
<TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" />
|
||||
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0,0,0,2" />
|
||||
<TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" />
|
||||
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.created.after]}"
|
||||
IsChecked="{Binding UseCreatedAfter}" Margin="0,2" />
|
||||
<DatePicker SelectedDate="{Binding CreatedAfter}"
|
||||
IsEnabled="{Binding UseCreatedAfter}" Height="26" Margin="0,0,0,4" />
|
||||
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.created.before]}"
|
||||
IsChecked="{Binding UseCreatedBefore}" Margin="0,2" />
|
||||
<DatePicker SelectedDate="{Binding CreatedBefore}"
|
||||
IsEnabled="{Binding UseCreatedBefore}" Height="26" Margin="0,0,0,4" />
|
||||
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.modified.after]}"
|
||||
IsChecked="{Binding UseModifiedAfter}" Margin="0,2" />
|
||||
<DatePicker SelectedDate="{Binding ModifiedAfter}"
|
||||
IsEnabled="{Binding UseModifiedAfter}" Height="26" Margin="0,0,0,4" />
|
||||
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.modified.before]}"
|
||||
IsChecked="{Binding UseModifiedBefore}" Margin="0,2" />
|
||||
<DatePicker SelectedDate="{Binding ModifiedBefore}"
|
||||
IsEnabled="{Binding UseModifiedBefore}" Height="26" Margin="0,0,0,4" />
|
||||
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.created.by]}" Padding="0,0,0,2" />
|
||||
<TextBox Text="{Binding CreatedBy, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.created.by]}" Margin="0,0,0,6" />
|
||||
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.modified.by]}" Padding="0,0,0,2" />
|
||||
<TextBox Text="{Binding ModifiedBy, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.modified.by]}" Margin="0,0,0,6" />
|
||||
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.library]}" Padding="0,0,0,2" />
|
||||
<TextBox Text="{Binding Library, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.library]}" Margin="0,0,0,6" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.max.results]}"
|
||||
VerticalAlignment="Center" Padding="0,0,4,0" />
|
||||
<TextBox Text="{Binding MaxResults, UpdateSourceTrigger=PropertyChanged}"
|
||||
Width="60" Height="22" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.run.search]}"
|
||||
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
||||
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}" Margin="0,0,0,8">
|
||||
<StackPanel Margin="4">
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.rad.csv]}"
|
||||
Command="{Binding ExportCsvCommand}" Height="26" Margin="0,2" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.rad.html]}"
|
||||
Command="{Binding ExportHtmlCommand}" Height="26" Margin="0,2" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Results DataGrid -->
|
||||
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
|
||||
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.name]}"
|
||||
Binding="{Binding Title}" Width="180" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.ext]}"
|
||||
Binding="{Binding FileExtension}" Width="70" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.created]}"
|
||||
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.author]}"
|
||||
Binding="{Binding Author}" Width="130" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.modified]}"
|
||||
Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.modby]}"
|
||||
Binding="{Binding ModifiedBy}" Width="130" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.size]}"
|
||||
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
|
||||
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.path]}"
|
||||
Binding="{Binding Path}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// SharepointToolbox/Views/Tabs/SearchView.xaml.cs
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
public partial class SearchView : UserControl
|
||||
{
|
||||
public SearchView(ViewModels.Tabs.SearchViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
|
||||
```
|
||||
|
||||
Expected: 0 errors
|
||||
|
||||
### Task 1b: Create DuplicatesViewModel, DuplicatesView XAML, and DuplicatesView code-behind
|
||||
|
||||
**Files:**
|
||||
- `SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs`
|
||||
- `SharepointToolbox/Views/Tabs/DuplicatesView.xaml`
|
||||
- `SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs`
|
||||
|
||||
**Action:** Create
|
||||
|
||||
**Why:** DUPL-01 through DUPL-03 — the UI layer for duplicate detection.
|
||||
|
||||
```csharp
|
||||
// SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
|
||||
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;
|
||||
|
||||
/// <summary>Flat display row wrapping a DuplicateItem with its group name.</summary>
|
||||
public class DuplicateRow
|
||||
{
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public string Library { get; set; } = string.Empty;
|
||||
public long? SizeBytes { get; set; }
|
||||
public DateTime? Created { get; set; }
|
||||
public DateTime? Modified { get; set; }
|
||||
public int? FolderCount { get; set; }
|
||||
public int? FileCount { get; set; }
|
||||
public int GroupSize { get; set; }
|
||||
}
|
||||
|
||||
public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
{
|
||||
private readonly IDuplicatesService _duplicatesService;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly DuplicatesHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private IReadOnlyList<DuplicateGroup> _lastGroups = Array.Empty<DuplicateGroup>();
|
||||
|
||||
[ObservableProperty] private string _siteUrl = string.Empty;
|
||||
[ObservableProperty] private bool _modeFiles = true;
|
||||
[ObservableProperty] private bool _modeFolders;
|
||||
[ObservableProperty] private bool _matchSize = true;
|
||||
[ObservableProperty] private bool _matchCreated;
|
||||
[ObservableProperty] private bool _matchModified;
|
||||
[ObservableProperty] private bool _matchSubfolders;
|
||||
[ObservableProperty] private bool _matchFileCount;
|
||||
[ObservableProperty] private bool _includeSubsites;
|
||||
[ObservableProperty] private string _library = string.Empty;
|
||||
|
||||
private ObservableCollection<DuplicateRow> _results = new();
|
||||
public ObservableCollection<DuplicateRow> Results
|
||||
{
|
||||
get => _results;
|
||||
private set
|
||||
{
|
||||
_results = value;
|
||||
OnPropertyChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public TenantProfile? CurrentProfile => _currentProfile;
|
||||
|
||||
public DuplicatesViewModel(
|
||||
IDuplicatesService duplicatesService,
|
||||
ISessionManager sessionManager,
|
||||
DuplicatesHtmlExportService htmlExportService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
{
|
||||
_duplicatesService = duplicatesService;
|
||||
_sessionManager = sessionManager;
|
||||
_htmlExportService = htmlExportService;
|
||||
_logger = logger;
|
||||
|
||||
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 ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
|
||||
ctx.Url = SiteUrl.TrimEnd('/');
|
||||
|
||||
var opts = new DuplicateScanOptions(
|
||||
Mode: ModeFiles ? "Files" : "Folders",
|
||||
MatchSize: MatchSize,
|
||||
MatchCreated: MatchCreated,
|
||||
MatchModified: MatchModified,
|
||||
MatchSubfolderCount: MatchSubfolders,
|
||||
MatchFileCount: MatchFileCount,
|
||||
IncludeSubsites: IncludeSubsites,
|
||||
Library: string.IsNullOrWhiteSpace(Library) ? null : Library
|
||||
);
|
||||
|
||||
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
|
||||
_lastGroups = groups;
|
||||
|
||||
// Flatten groups to display rows
|
||||
var rows = groups
|
||||
.SelectMany(g => g.Items.Select(item => new DuplicateRow
|
||||
{
|
||||
GroupName = g.Name,
|
||||
Name = item.Name,
|
||||
Path = item.Path,
|
||||
Library = item.Library,
|
||||
SizeBytes = item.SizeBytes,
|
||||
Created = item.Created,
|
||||
Modified = item.Modified,
|
||||
FolderCount = item.FolderCount,
|
||||
FileCount = item.FileCount,
|
||||
GroupSize = g.Items.Count
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<DuplicateRow>(rows));
|
||||
else
|
||||
Results = new ObservableCollection<DuplicateRow>(rows);
|
||||
}
|
||||
|
||||
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
Results = new ObservableCollection<DuplicateRow>();
|
||||
_lastGroups = Array.Empty<DuplicateGroup>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
||||
|
||||
private bool CanExport() => _lastGroups.Count > 0;
|
||||
|
||||
private async Task ExportHtmlAsync()
|
||||
{
|
||||
if (_lastGroups.Count == 0) return;
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Export duplicates report to HTML",
|
||||
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
||||
DefaultExt = "html",
|
||||
FileName = "duplicates_report"
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
|
||||
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- SharepointToolbox/Views/Tabs/DuplicatesView.xaml -->
|
||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||
<DockPanel LastChildFill="True">
|
||||
<!-- Options panel -->
|
||||
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
|
||||
<StackPanel>
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
|
||||
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
|
||||
Height="26" Margin="0,0,0,8" />
|
||||
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.type]}" Margin="0,0,0,8">
|
||||
<StackPanel Margin="4">
|
||||
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.files]}"
|
||||
IsChecked="{Binding ModeFiles}" Margin="0,2" />
|
||||
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.folders]}"
|
||||
IsChecked="{Binding ModeFolders}" Margin="0,2" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
|
||||
<StackPanel Margin="4">
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
|
||||
TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,0,0,6" />
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}"
|
||||
IsChecked="{Binding MatchSize}" Margin="0,2" />
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}"
|
||||
IsChecked="{Binding MatchCreated}" Margin="0,2" />
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.modified]}"
|
||||
IsChecked="{Binding MatchModified}" Margin="0,2" />
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.subfolders]}"
|
||||
IsChecked="{Binding MatchSubfolders}" Margin="0,2" />
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.filecount]}"
|
||||
IsChecked="{Binding MatchFileCount}" Margin="0,2" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.library]}" />
|
||||
<TextBox Text="{Binding Library, UpdateSourceTrigger=PropertyChanged}" Height="26"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.dup.lib]}" Margin="0,0,0,6" />
|
||||
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.include.subsites]}"
|
||||
IsChecked="{Binding IncludeSubsites}" Margin="0,4,0,8" />
|
||||
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.run.scan]}"
|
||||
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
||||
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.open.results]}"
|
||||
Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Results DataGrid -->
|
||||
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
|
||||
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
|
||||
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
|
||||
ElementStyle="{StaticResource RightAlignStyle}" />
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="160" />
|
||||
<DataGridTextColumn Header="Library" Binding="{Binding Library}" Width="120" />
|
||||
<DataGridTextColumn Header="Size"
|
||||
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
|
||||
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
||||
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
public partial class DuplicatesView : UserControl
|
||||
{
|
||||
public DuplicatesView(ViewModels.Tabs.DuplicatesViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
|
||||
```
|
||||
|
||||
Expected: 0 errors
|
||||
|
||||
### Task 2: DI registration + MainWindow wiring + visual checkpoint
|
||||
|
||||
**Files:**
|
||||
- `SharepointToolbox/App.xaml.cs` (modify)
|
||||
- `SharepointToolbox/MainWindow.xaml` (modify)
|
||||
- `SharepointToolbox/MainWindow.xaml.cs` (modify)
|
||||
|
||||
**Action:** Modify
|
||||
|
||||
**Why:** Services must be registered; tabs must replace FeatureTabBase stubs; user must verify all three Phase 3 tabs are visible and functional.
|
||||
|
||||
In `App.xaml.cs` `ConfigureServices`, add after the Storage Phase 3 registrations:
|
||||
```csharp
|
||||
// Phase 3: File Search
|
||||
services.AddTransient<ISearchService, SearchService>();
|
||||
services.AddTransient<SearchCsvExportService>();
|
||||
services.AddTransient<SearchHtmlExportService>();
|
||||
services.AddTransient<SearchViewModel>();
|
||||
services.AddTransient<SearchView>();
|
||||
|
||||
// Phase 3: Duplicates
|
||||
services.AddTransient<IDuplicatesService, DuplicatesService>();
|
||||
services.AddTransient<DuplicatesHtmlExportService>();
|
||||
services.AddTransient<DuplicatesViewModel>();
|
||||
services.AddTransient<DuplicatesView>();
|
||||
```
|
||||
|
||||
In `MainWindow.xaml`, add `x:Name` to the Search and Duplicates tab items:
|
||||
```xml
|
||||
<!-- Change from FeatureTabBase stubs to named TabItems -->
|
||||
<TabItem x:Name="SearchTabItem"
|
||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.search]}">
|
||||
</TabItem>
|
||||
<TabItem x:Name="DuplicatesTabItem"
|
||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
||||
</TabItem>
|
||||
```
|
||||
|
||||
In `MainWindow.xaml.cs`, add after the StorageTabItem wiring line:
|
||||
```csharp
|
||||
SearchTabItem.Content = serviceProvider.GetRequiredService<SearchView>();
|
||||
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
|
||||
```
|
||||
|
||||
**Visual Checkpoint** — after build succeeds, launch the application and verify:
|
||||
|
||||
1. The Storage tab shows the site URL input, scan options (Per-Library, Include Subsites, Folder Depth, Max Depth), Generate Metrics button, and an empty DataGrid
|
||||
2. The File Search tab shows the filter panel (Extensions, Name/Regex, date range checkboxes, Created By, Modified By, Library, Max Results) and the Run Search button
|
||||
3. The Duplicates tab shows the type selector (Files/Folders), criteria checkboxes, and Run Scan button
|
||||
4. Language switching (EN ↔ FR) updates all Phase 3 tab labels without restart
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
|
||||
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 0 build errors; all tests pass (no regressions from Phase 1/2)
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
|
||||
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x
|
||||
```
|
||||
|
||||
Expected: 0 errors, all tests pass
|
||||
|
||||
## Checkpoint
|
||||
|
||||
**Type:** checkpoint:human-verify
|
||||
|
||||
**What was built:** All three Phase 3 tabs (Storage, File Search, Duplicates) are wired into the running application. All Phase 3 services are registered in DI. All Phase 3 test suites pass.
|
||||
|
||||
**How to verify:**
|
||||
1. `dotnet run --project C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/SharepointToolbox.csproj`
|
||||
2. Confirm the Storage tab appears with site URL input and Generate Metrics button
|
||||
3. Confirm the File Search tab appears with filter controls and Run Search button
|
||||
4. Confirm the Duplicates tab appears with type selector and Run Scan button
|
||||
5. Switch language to French (Settings tab) — confirm Phase 3 tab headers and labels update
|
||||
6. Run the full test suite: `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x`
|
||||
|
||||
**Resume signal:** Type "approved" when all six checks pass, or describe any issues found.
|
||||
|
||||
## Commit Message
|
||||
feat(03-08): create SearchViewModel, DuplicatesViewModel, XAML views, DI wiring — Phase 3 complete
|
||||
|
||||
## Output
|
||||
|
||||
After completion, create `.planning/phases/03-storage/03-08-SUMMARY.md`
|
||||
Reference in New Issue
Block a user