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>
454 lines
19 KiB
Markdown
454 lines
19 KiB
Markdown
---
|
|
phase: 04
|
|
plan: 08
|
|
title: TransferViewModel + TransferView
|
|
status: pending
|
|
wave: 3
|
|
depends_on:
|
|
- 04-03
|
|
- 04-07
|
|
files_modified:
|
|
- SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs
|
|
- SharepointToolbox/Views/Tabs/TransferView.xaml
|
|
- SharepointToolbox/Views/Tabs/TransferView.xaml.cs
|
|
autonomous: true
|
|
requirements:
|
|
- BULK-01
|
|
- BULK-04
|
|
- BULK-05
|
|
|
|
must_haves:
|
|
truths:
|
|
- "User can select source site via SitePickerDialog and browse source library/folder"
|
|
- "User can select destination site and browse destination library/folder"
|
|
- "User can choose Copy or Move mode and select conflict policy (Skip/Overwrite/Rename)"
|
|
- "Confirmation dialog shown before transfer starts"
|
|
- "Progress bar and cancel button work during transfer"
|
|
- "After partial failure, user sees per-item results and can export failed items CSV"
|
|
artifacts:
|
|
- path: "SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs"
|
|
provides: "Transfer tab ViewModel"
|
|
exports: ["TransferViewModel"]
|
|
- path: "SharepointToolbox/Views/Tabs/TransferView.xaml"
|
|
provides: "Transfer tab XAML layout"
|
|
- path: "SharepointToolbox/Views/Tabs/TransferView.xaml.cs"
|
|
provides: "Transfer tab code-behind"
|
|
key_links:
|
|
- from: "TransferViewModel.cs"
|
|
to: "IFileTransferService.TransferAsync"
|
|
via: "RunOperationAsync override"
|
|
pattern: "TransferAsync"
|
|
- from: "TransferViewModel.cs"
|
|
to: "ISessionManager.GetOrCreateContextAsync"
|
|
via: "context acquisition for source and dest"
|
|
pattern: "GetOrCreateContextAsync"
|
|
- from: "TransferView.xaml"
|
|
to: "TransferViewModel"
|
|
via: "DataContext binding"
|
|
pattern: "TransferViewModel"
|
|
---
|
|
|
|
# Plan 04-08: TransferViewModel + TransferView
|
|
|
|
## Goal
|
|
|
|
Create the `TransferViewModel` and `TransferView` for the file transfer tab. Source/destination site pickers (reusing SitePickerDialog pattern), library/folder tree browser (FolderBrowserDialog), Copy/Move toggle, conflict policy selector, progress tracking, cancellation, per-item error reporting, and failed-items CSV export.
|
|
|
|
## Context
|
|
|
|
`IFileTransferService`, `TransferJob`, `ConflictPolicy`, `TransferMode` from Plan 04-01. `FileTransferService` implemented in Plan 04-03. `ConfirmBulkOperationDialog` and `FolderBrowserDialog` from Plan 04-07. Localization keys from Plan 04-07.
|
|
|
|
ViewModel pattern: `FeatureViewModelBase` base class (RunCommand, CancelCommand, IsRunning, StatusMessage, ProgressValue). Override `RunOperationAsync`. Export commands as `IAsyncRelayCommand` with CanExport guard. Track `_currentProfile`, reset in `OnTenantSwitched`.
|
|
|
|
View pattern: UserControl with DockPanel. Code-behind receives ViewModel from DI, sets DataContext. Wires dialog factories.
|
|
|
|
## Tasks
|
|
|
|
### Task 1: Create TransferViewModel
|
|
|
|
**Files:**
|
|
- `SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs`
|
|
|
|
**Action:**
|
|
|
|
```csharp
|
|
using System.Collections.ObjectModel;
|
|
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; }
|
|
|
|
// Dialog factories — set by View code-behind
|
|
public Func<TenantProfile, Window>? OpenSitePickerDialog { get; set; }
|
|
public Func<Microsoft.SharePoint.Client.ClientContext, Views.Dialogs.FolderBrowserDialog>? OpenFolderBrowserDialog { get; set; }
|
|
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,
|
|
};
|
|
|
|
// Get contexts for source and destination
|
|
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;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Verify:**
|
|
```bash
|
|
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
|
|
```
|
|
|
|
**Done:** TransferViewModel compiles with source/dest selection, transfer mode, conflict policy, confirmation dialog, per-item results, and failed-items export.
|
|
|
|
### Task 2: Create TransferView XAML + code-behind
|
|
|
|
**Files:**
|
|
- `SharepointToolbox/Views/Tabs/TransferView.xaml`
|
|
- `SharepointToolbox/Views/Tabs/TransferView.xaml.cs`
|
|
|
|
**Action:**
|
|
|
|
Create `TransferView.xaml`:
|
|
```xml
|
|
<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>
|
|
```
|
|
|
|
Note: The XAML uses converters (`InverseBoolConverter`, `BoolToVisibilityConverter`, `StringToVisibilityConverter`, `EnumBoolConverter`). If `EnumBoolConverter` or `StringToVisibilityConverter` don't already exist in the project, create them in `Views/Converters/` directory. The `InverseBoolConverter` and `BoolToVisibilityConverter` should already exist from Phase 1. If `BoolToVisibilityConverter` is not registered, use standard WPF `BooleanToVisibilityConverter`. If converters are not available, simplify the XAML to use code-behind visibility toggling instead.
|
|
|
|
Create `TransferView.xaml.cs`:
|
|
```csharp
|
|
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 site
|
|
var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
|
|
sitePicker.Owner = Window.GetWindow(this);
|
|
if (sitePicker.ShowDialog() != true || sitePicker.SelectedSite == null) return;
|
|
|
|
_viewModel.SourceSiteUrl = sitePicker.SelectedSite.Url;
|
|
|
|
// Browse library/folder
|
|
var profile = new TenantProfile
|
|
{
|
|
Name = _viewModel.CurrentProfile.Name,
|
|
TenantUrl = sitePicker.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 || sitePicker.SelectedSite == null) return;
|
|
|
|
_viewModel.DestSiteUrl = sitePicker.SelectedSite.Url;
|
|
|
|
var profile = new TenantProfile
|
|
{
|
|
Name = _viewModel.CurrentProfile.Name,
|
|
TenantUrl = sitePicker.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;
|
|
}
|
|
}
|
|
|
|
// Expose CurrentProfile for site picker dialog
|
|
private TenantProfile? CurrentProfile => _viewModel.CurrentProfile;
|
|
}
|
|
```
|
|
|
|
Note on `CurrentProfile`: The `TransferViewModel` needs to expose `_currentProfile` publicly (add `public TenantProfile? CurrentProfile => _currentProfile;` property to TransferViewModel, similar to StorageViewModel pattern).
|
|
|
|
**Verify:**
|
|
```bash
|
|
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
|
|
```
|
|
|
|
**Done:** TransferView compiles. Source/dest site pickers via SitePickerDialog, library/folder browsing via FolderBrowserDialog, Copy/Move radio buttons, conflict policy dropdown, confirmation dialog before start, progress tracking, failed-items export.
|
|
|
|
**Commit:** `feat(04-08): create TransferViewModel and TransferView`
|