Files
Sharepoint-Toolbox/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs
T
Dev f4cc81bb71 chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:23:11 +02:00

262 lines
10 KiB
C#

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 IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSourceSiteOverride;
// 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;
[ObservableProperty] private bool _includeSourceFolder;
[ObservableProperty] private bool _copyFolderContents = true;
/// <summary>
/// Library-relative file paths the user checked in the source picker.
/// When non-empty, only these files are transferred — folder recursion is skipped.
/// </summary>
public List<string> SelectedFilePaths { get; } = new();
/// <summary>Count of per-file selections, for display in the view.</summary>
public int SelectedFileCount => SelectedFilePaths.Count;
// 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,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null)
: base(logger)
{
_transferService = transferService;
_sessionManager = sessionManager;
_exportService = exportService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_logger = logger;
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSourceSiteOverride) return;
SourceSiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
}
partial void OnSourceSiteUrlChanged(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
_hasLocalSourceSiteOverride = false;
if (GlobalSites.Count > 0)
SourceSiteUrl = GlobalSites[0].Url;
}
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
{
_hasLocalSourceSiteOverride = true;
}
}
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,
SelectedFilePaths = SelectedFilePaths.ToList(),
IncludeSourceFolder = IncludeSourceFolder,
CopyFolderContents = CopyFolderContents,
};
// 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);
var autoOwnership = await IsAutoTakeOwnershipEnabled();
try
{
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException ex)
when (_ownershipService != null && autoOwnership)
{
_logger.LogWarning(ex, "Transfer hit access denied — auto-elevating on source and destination.");
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? SourceSiteUrl);
var adminProfile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = adminUrl,
ClientId = _currentProfile.ClientId,
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, SourceSiteUrl, string.Empty, ct);
await _ownershipService.ElevateAsync(adminCtx, DestSiteUrl, string.Empty, ct);
// Retry with fresh contexts so the new admin membership is honoured.
srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
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;
_hasLocalSourceSiteOverride = false;
SourceSiteUrl = string.Empty;
SourceLibrary = string.Empty;
SourceFolderPath = string.Empty;
DestSiteUrl = string.Empty;
DestLibrary = string.Empty;
DestFolderPath = string.Empty;
ResultSummary = string.Empty;
HasFailures = false;
SelectedFilePaths.Clear();
OnPropertyChanged(nameof(SelectedFileCount));
_lastResult = null;
}
/// <summary>Replaces the current per-file selection and notifies the view.</summary>
public void SetSelectedFiles(IEnumerable<string> libraryRelativePaths)
{
SelectedFilePaths.Clear();
SelectedFilePaths.AddRange(libraryRelativePaths);
OnPropertyChanged(nameof(SelectedFileCount));
}
private async Task<bool> IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
}