- 3 tasks completed, 9 files created/modified - Visual checkpoint pending: all three Phase 3 tabs wired and ready for UI verification
37 KiB
phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | title | status | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03 | 08 | SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint | pending | 4 |
|
|
false |
|
|
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.csSharepointToolbox/Views/Tabs/SearchView.xamlSharepointToolbox/Views/Tabs/SearchView.xaml.cs
Action: Create
Why: SRCH-01 through SRCH-04 — the UI layer for file search.
// 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 { }
}
}
<!-- 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>
// 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:
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.csSharepointToolbox/Views/Tabs/DuplicatesView.xamlSharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
Action: Create
Why: DUPL-01 through DUPL-03 — the UI layer for duplicate detection.
// 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."); }
}
}
<!-- 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>
// 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:
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:
// 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:
<!-- 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:
SearchTabItem.Content = serviceProvider.GetRequiredService<SearchView>();
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
Visual Checkpoint — after build succeeds, launch the application and verify:
- 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
- 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
- The Duplicates tab shows the type selector (Files/Folders), criteria checkboxes, and Run Scan button
- Language switching (EN ↔ FR) updates all Phase 3 tab labels without restart
Verification:
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
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:
dotnet run --project C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/SharepointToolbox.csproj- Confirm the Storage tab appears with site URL input and Generate Metrics button
- Confirm the File Search tab appears with filter controls and Run Search button
- Confirm the Duplicates tab appears with type selector and Run Scan button
- Switch language to French (Settings tab) — confirm Phase 3 tab headers and labels update
- 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