feat(04-08): create TransferViewModel and TransferView

- TransferViewModel: source/dest site selection, transfer mode, conflict policy, confirmation dialog, per-item results, failed-items CSV export
- TransferView.xaml: DockPanel layout with GroupBoxes for source/dest, mode radio buttons, conflict policy ComboBox, progress bar, cancel button, export failed items button
- TransferView.xaml.cs: code-behind wires SitePickerDialog + FolderBrowserDialog for source and dest browsing
- Added EnumBoolConverter and StringToVisibilityConverter to IndentConverter.cs
- Registered converters in App.xaml; registered TransferViewModel, TransferView, IFileTransferService, BulkResultCsvExportService in App.xaml.cs
This commit is contained in:
Dev
2026-04-03 10:19:16 +02:00
parent 509c0c6843
commit 7b78b19bf5
6 changed files with 358 additions and 3 deletions

View File

@@ -8,6 +8,9 @@
<conv:IndentConverter x:Key="IndentConverter" /> <conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" /> <conv:BytesConverter x:Key="BytesConverter" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" /> <conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="HorizontalAlignment" Value="Right" />
</Style> </Style>

View File

@@ -119,6 +119,12 @@ public partial class App : Application
services.AddTransient<Func<TenantProfile, SitePickerDialog>>(sp => services.AddTransient<Func<TenantProfile, SitePickerDialog>>(sp =>
profile => new SitePickerDialog(sp.GetRequiredService<ISiteListService>(), profile)); profile => new SitePickerDialog(sp.GetRequiredService<ISiteListService>(), profile));
// Phase 4: File Transfer
services.AddTransient<IFileTransferService, FileTransferService>();
services.AddTransient<BulkResultCsvExportService>();
services.AddTransient<TransferViewModel>();
services.AddTransient<TransferView>();
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();
} }
} }

View File

@@ -0,0 +1,165 @@
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.ViewModels.Tabs;
public partial class TransferViewModel : FeatureViewModelBase
{
private readonly IFileTransferService _transferService;
private readonly ISessionManager _sessionManager;
private readonly BulkResultCsvExportService _exportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
// Source selection
[ObservableProperty] private string _sourceSiteUrl = string.Empty;
[ObservableProperty] private string _sourceLibrary = string.Empty;
[ObservableProperty] private string _sourceFolderPath = string.Empty;
// Destination selection
[ObservableProperty] private string _destSiteUrl = string.Empty;
[ObservableProperty] private string _destLibrary = string.Empty;
[ObservableProperty] private string _destFolderPath = string.Empty;
// Transfer options
[ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
[ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip;
// Results
[ObservableProperty] private string _resultSummary = string.Empty;
[ObservableProperty] private bool _hasFailures;
private BulkOperationSummary<string>? _lastResult;
public IAsyncRelayCommand ExportFailedCommand { get; }
// Expose current profile for View code-behind (site picker, folder browser)
public TenantProfile? CurrentProfile => _currentProfile;
// Dialog factories — set by View code-behind
public Func<string, bool>? ShowConfirmDialog { get; set; }
public TransferViewModel(
IFileTransferService transferService,
ISessionManager sessionManager,
BulkResultCsvExportService exportService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_transferService = transferService;
_sessionManager = sessionManager;
_exportService = exportService;
_logger = logger;
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected.");
if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
throw new InvalidOperationException("Source site and library must be selected.");
if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
throw new InvalidOperationException("Destination site and library must be selected.");
// Confirmation dialog
var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
return;
var job = new TransferJob
{
SourceSiteUrl = SourceSiteUrl,
SourceLibrary = SourceLibrary,
SourceFolderPath = SourceFolderPath,
DestinationSiteUrl = DestSiteUrl,
DestinationLibrary = DestLibrary,
DestinationFolderPath = DestFolderPath,
Mode = TransferMode,
ConflictPolicy = ConflictPolicy,
};
// Build per-site profiles so SessionManager can resolve contexts
var srcProfile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = SourceSiteUrl,
ClientId = _currentProfile.ClientId,
};
var dstProfile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = DestSiteUrl,
ClientId = _currentProfile.ClientId,
};
var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
// Update UI on dispatcher
await Application.Current.Dispatcher.InvokeAsync(() =>
{
HasFailures = _lastResult.HasFailures;
ExportFailedCommand.NotifyCanExecuteChanged();
if (_lastResult.HasFailures)
{
ResultSummary = string.Format(
Localization.TranslationSource.Instance["bulk.result.success"],
_lastResult.SuccessCount, _lastResult.FailedCount);
}
else
{
ResultSummary = string.Format(
Localization.TranslationSource.Instance["bulk.result.allsuccess"],
_lastResult.TotalCount);
}
});
}
private async Task ExportFailedAsync()
{
if (_lastResult == null || !_lastResult.HasFailures) return;
var dlg = new SaveFileDialog
{
Filter = "CSV Files (*.csv)|*.csv",
FileName = "transfer_failed_items.csv",
};
if (dlg.ShowDialog() == true)
{
await _exportService.WriteFailedItemsCsvAsync(
_lastResult.FailedItems.ToList(),
dlg.FileName,
CancellationToken.None);
Log.Information("Exported failed transfer items to {Path}", dlg.FileName);
}
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
SourceSiteUrl = string.Empty;
SourceLibrary = string.Empty;
SourceFolderPath = string.Empty;
DestSiteUrl = string.Empty;
DestLibrary = string.Empty;
DestFolderPath = string.Empty;
ResultSummary = string.Empty;
HasFailures = false;
_lastResult = null;
}
}

View File

@@ -45,3 +45,4 @@ public class InverseBoolConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b && !b; => value is bool b && !b;
} }

View File

@@ -0,0 +1,81 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
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 Margin="10">
<!-- Options Panel (Left) -->
<StackPanel DockPanel.Dock="Left" Width="340" Margin="0,0,10,0">
<!-- Source -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
Margin="0,0,0,10">
<StackPanel Margin="5">
<TextBox Text="{Binding SourceSiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="True" Margin="0,0,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
Click="BrowseSource_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SourceFolderPath}" Foreground="Gray" />
</StackPanel>
</GroupBox>
<!-- Destination -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.destsite]}"
Margin="0,0,0,10">
<StackPanel Margin="5">
<TextBox Text="{Binding DestSiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="True" Margin="0,0,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
Click="BrowseDest_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DestFolderPath}" Foreground="Gray" />
</StackPanel>
</GroupBox>
<!-- Transfer Mode -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode]}"
Margin="0,0,0,10">
<StackPanel Margin="5">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode.copy]}"
IsChecked="{Binding TransferMode, Converter={StaticResource EnumBoolConverter}, ConverterParameter=Copy}"
Margin="0,0,0,3" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode.move]}"
IsChecked="{Binding TransferMode, Converter={StaticResource EnumBoolConverter}, ConverterParameter=Move}" />
</StackPanel>
</GroupBox>
<!-- Conflict Policy -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}"
Margin="0,0,0,10">
<ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" />
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.rename]}" />
</ComboBox>
</GroupBox>
<!-- Actions -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.start]}"
Command="{Binding RunCommand}" Margin="0,0,0,5"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Progress -->
<ProgressBar Height="20" Margin="0,10,0,5"
Value="{Binding ProgressValue}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
<!-- Results -->
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5"
Visibility="{Binding ResultSummary, Converter={StaticResource StringToVisibilityConverter}}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}"
Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
<!-- Right panel placeholder for future enhancements -->
<Border />
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,99 @@
using System.Windows;
using System.Windows.Controls;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Views.Dialogs;
namespace SharepointToolbox.Views.Tabs;
public partial class TransferView : UserControl
{
private readonly ViewModels.Tabs.TransferViewModel _viewModel;
private readonly ISessionManager _sessionManager;
private readonly Func<TenantProfile, SitePickerDialog> _sitePickerFactory;
public TransferView(
ViewModels.Tabs.TransferViewModel viewModel,
ISessionManager sessionManager,
Func<TenantProfile, SitePickerDialog> sitePickerFactory)
{
InitializeComponent();
_viewModel = viewModel;
_sessionManager = sessionManager;
_sitePickerFactory = sitePickerFactory;
DataContext = viewModel;
viewModel.ShowConfirmDialog = message =>
{
var dlg = new ConfirmBulkOperationDialog(message) { Owner = Window.GetWindow(this) };
dlg.ShowDialog();
return dlg.IsConfirmed;
};
}
private async void BrowseSource_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.CurrentProfile == null) return;
// Pick source site — SitePickerDialog returns a list; take the first selection for transfer
var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
sitePicker.Owner = Window.GetWindow(this);
if (sitePicker.ShowDialog() != true) return;
var selectedSite = sitePicker.SelectedUrls.FirstOrDefault();
if (selectedSite == null) return;
_viewModel.SourceSiteUrl = selectedSite.Url;
// Browse library/folder for the selected source site
var profile = new TenantProfile
{
Name = _viewModel.CurrentProfile.Name,
TenantUrl = selectedSite.Url,
ClientId = _viewModel.CurrentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
if (folderBrowser.ShowDialog() == true)
{
_viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
_viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
}
}
private async void BrowseDest_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.CurrentProfile == null) return;
var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
sitePicker.Owner = Window.GetWindow(this);
if (sitePicker.ShowDialog() != true) return;
var selectedSite = sitePicker.SelectedUrls.FirstOrDefault();
if (selectedSite == null) return;
_viewModel.DestSiteUrl = selectedSite.Url;
var profile = new TenantProfile
{
Name = _viewModel.CurrentProfile.Name,
TenantUrl = selectedSite.Url,
ClientId = _viewModel.CurrentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
if (folderBrowser.ShowDialog() == true)
{
_viewModel.DestLibrary = folderBrowser.SelectedLibrary;
_viewModel.DestFolderPath = folderBrowser.SelectedFolderPath;
}
}
private void ConflictCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (ConflictCombo.SelectedIndex >= 0)
{
_viewModel.ConflictPolicy = (ConflictPolicy)ConflictCombo.SelectedIndex;
}
}
}