feat(03-08): create DuplicatesViewModel, DuplicatesView XAML and code-behind
- DuplicatesViewModel: ModeFiles/Folders, criteria checkboxes, group flattening to DuplicateRow - Uses TenantProfile site URL override pattern (ctx.Url is read-only) - ExportHtmlCommand exports DuplicateGroup list via DuplicatesHtmlExportService - DuplicatesView.xaml: type selector, criteria panel + flattened DataGrid - DuplicatesView.xaml.cs: DI constructor with DataContext wiring
This commit is contained in:
169
SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
Normal file
169
SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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 siteProfile = new TenantProfile
|
||||||
|
{
|
||||||
|
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||||
|
ClientId = _currentProfile.ClientId,
|
||||||
|
Name = _currentProfile.Name
|
||||||
|
};
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||||
|
|
||||||
|
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."); }
|
||||||
|
}
|
||||||
|
}
|
||||||
77
SharepointToolbox/Views/Tabs/DuplicatesView.xaml
Normal file
77
SharepointToolbox/Views/Tabs/DuplicatesView.xaml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<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>
|
||||||
12
SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
Normal file
12
SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Tabs;
|
||||||
|
|
||||||
|
public partial class DuplicatesView : UserControl
|
||||||
|
{
|
||||||
|
public DuplicatesView(ViewModels.Tabs.DuplicatesViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = viewModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user