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 _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; /// /// Library-relative file paths the user checked in the source picker. /// When non-empty, only these files are transferred — folder recursion is skipped. /// public List SelectedFilePaths { get; } = new(); /// Count of per-file selections, for display in the view. public int SelectedFileCount => SelectedFilePaths.Count; // Results [ObservableProperty] private string _resultSummary = string.Empty; [ObservableProperty] private bool _hasFailures; private BulkOperationSummary? _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? ShowConfirmDialog { get; set; } public TransferViewModel( IFileTransferService transferService, ISessionManager sessionManager, BulkResultCsvExportService exportService, ILogger 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 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 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; } /// Replaces the current per-file selection and notifies the view. public void SetSelectedFiles(IEnumerable libraryRelativePaths) { SelectedFilePaths.Clear(); SelectedFilePaths.AddRange(libraryRelativePaths); OnPropertyChanged(nameof(SelectedFileCount)); } private async Task 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}"; } }