f4cc81bb71
- 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>
262 lines
10 KiB
C#
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}";
|
|
}
|
|
}
|