This commit is contained in:
Dev
2026-04-24 10:54:47 +02:00
19 changed files with 1113 additions and 51 deletions
@@ -50,7 +50,6 @@ public partial class MainWindowViewModel : ObservableRecipient
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
: Localization.TranslationSource.Instance["toolbar.globalSites.none"];
public IAsyncRelayCommand ConnectCommand { get; }
public IAsyncRelayCommand ClearSessionCommand { get; }
public RelayCommand ManageProfilesCommand { get; }
public RelayCommand OpenGlobalSitePickerCommand { get; }
@@ -64,7 +63,6 @@ public partial class MainWindowViewModel : ObservableRecipient
_sessionManager = sessionManager;
_logger = logger;
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
@@ -96,7 +94,6 @@ public partial class MainWindowViewModel : ObservableRecipient
{
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
}
ConnectCommand.NotifyCanExecuteChanged();
ClearSessionCommand.NotifyCanExecuteChanged();
// Clear global site selection on tenant switch (sites belong to a tenant)
GlobalSelectedSites.Clear();
@@ -121,22 +118,6 @@ public partial class MainWindowViewModel : ObservableRecipient
}
}
private async Task ConnectAsync()
{
if (SelectedProfile == null) return;
try
{
ConnectionStatus = "Connecting...";
await _sessionManager.GetOrCreateContextAsync(SelectedProfile, CancellationToken.None);
ConnectionStatus = SelectedProfile.Name;
}
catch (Exception ex)
{
ConnectionStatus = "Connection failed";
_logger.LogError(ex, "Failed to connect to tenant {TenantUrl}.", SelectedProfile.TenantUrl);
}
}
private async Task ClearSessionAsync()
{
if (SelectedProfile == null) return;
@@ -346,9 +346,25 @@ public partial class ProfileManagementViewModel : ObservableObject
private bool CanRemoveApp()
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering;
/// <summary>
/// Set by the view to display the pre-registration warning. Returns true if the
/// user accepts and registration should proceed.
/// </summary>
public Func<string, bool>? ConfirmRegisterApp { get; set; }
private async Task RegisterAppAsync(CancellationToken ct)
{
if (SelectedProfile == null) return;
// Auth caching reduces this to one prompt in the common case, but a fresh
// tenant or different admin account may still trigger up to two — warn so
// the user knows another window is expected after they sign in.
if (ConfirmRegisterApp != null)
{
var msg = string.Format(TranslationSource.Instance["profile.register.warning"], 2);
if (!ConfirmRegisterApp(msg)) return;
}
IsRegistering = true;
ShowFallbackInstructions = false;
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
@@ -0,0 +1,237 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
namespace SharepointToolbox.ViewModels.Tabs;
public partial class VersionCleanupViewModel : FeatureViewModelBase
{
private readonly IVersionCleanupService _versionService;
private readonly ISessionManager _sessionManager;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
[ObservableProperty]
private int _keepLast = 5;
[ObservableProperty]
private bool _keepFirstVersion;
[ObservableProperty]
private bool _confirmDelete = true;
[ObservableProperty]
private string _selectedLibrariesLabel = string.Empty;
public ObservableCollection<string> SelectedLibraries { get; } = new();
public ObservableCollection<VersionCleanupResult> Results { get; } = new();
public long TotalBytesFreed => Results.Sum(r => r.BytesFreed);
public int TotalVersionsDeleted => Results.Sum(r => r.VersionsDeleted);
public int TotalFilesAffected => Results.Count(r => r.VersionsDeleted > 0);
public bool HasResults => Results.Count > 0;
public TenantProfile? CurrentProfile => _currentProfile;
/// <summary>Set by the view to invoke <see cref="LibraryPickerDialog"/> against the current site.</summary>
public Func<string, IReadOnlyCollection<string>, Task<IReadOnlyList<string>?>>? PickLibrariesAsync { get; set; }
/// <summary>Set by the view to display a confirm dialog before destructive run.</summary>
public Func<string, bool>? ConfirmAction { get; set; }
public IAsyncRelayCommand SelectLibrariesCommand { get; }
public IRelayCommand ClearLibrariesCommand { get; }
public IAsyncRelayCommand ExportCsvCommand { get; }
public VersionCleanupViewModel(
IVersionCleanupService versionService,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_versionService = versionService;
_sessionManager = sessionManager;
_logger = logger;
SelectLibrariesCommand = new AsyncRelayCommand(SelectLibrariesAsync, CanPickLibraries);
ClearLibrariesCommand = new RelayCommand(ClearLibraries);
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, () => Results.Count > 0);
SelectedLibraries.CollectionChanged += (_, _) => UpdateSelectedLibrariesLabel();
Results.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasResults));
OnPropertyChanged(nameof(TotalBytesFreed));
OnPropertyChanged(nameof(TotalVersionsDeleted));
OnPropertyChanged(nameof(TotalFilesAffected));
ExportCsvCommand.NotifyCanExecuteChanged();
};
UpdateSelectedLibrariesLabel();
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
SelectedLibraries.Clear();
Results.Clear();
OnPropertyChanged(nameof(CurrentProfile));
SelectLibrariesCommand.NotifyCanExecuteChanged();
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
// Site changes invalidate library list — clear so user re-picks.
SelectedLibraries.Clear();
SelectLibrariesCommand.NotifyCanExecuteChanged();
}
private bool CanPickLibraries() => _currentProfile != null && GlobalSites.Count > 0;
private async Task SelectLibrariesAsync()
{
if (PickLibrariesAsync == null || _currentProfile == null) return;
var first = GlobalSites.FirstOrDefault();
if (first == null)
{
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
var picked = await PickLibrariesAsync(first.Url, SelectedLibraries.ToArray());
if (picked == null) return;
SelectedLibraries.Clear();
foreach (var t in picked) SelectedLibraries.Add(t);
}
private void ClearLibraries() => SelectedLibraries.Clear();
private void UpdateSelectedLibrariesLabel()
{
SelectedLibrariesLabel = SelectedLibraries.Count == 0
? TranslationSource.Instance["versions.libs.all"]
: string.Format(TranslationSource.Instance["versions.libs.count"], SelectedLibraries.Count);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
if (KeepLast < 0)
{
StatusMessage = TranslationSource.Instance["versions.err.keepLast"];
return;
}
if (ConfirmDelete && ConfirmAction != null)
{
var msg = string.Format(
TranslationSource.Instance["versions.confirm"],
KeepLast,
KeepFirstVersion ? TranslationSource.Instance["versions.confirm.keepFirst"] : string.Empty);
if (!ConfirmAction(msg)) return;
}
var options = new VersionCleanupOptions(
SelectedLibraries.ToList(), KeepLast, KeepFirstVersion);
Results.Clear();
int siteIdx = 0;
foreach (var url in urls)
{
ct.ThrowIfCancellationRequested();
siteIdx++;
progress.Report(new OperationProgress(siteIdx, urls.Count, $"Cleaning {url}..."));
var siteProfile = new TenantProfile
{
TenantUrl = url.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var siteResults = await _versionService.DeleteOldVersionsAsync(ctx, options, progress, ct);
if (Application.Current?.Dispatcher is { } dispatcher)
{
await dispatcher.InvokeAsync(() =>
{
foreach (var r in siteResults) Results.Add(r);
});
}
else
{
foreach (var r in siteResults) Results.Add(r);
}
}
}
private async Task ExportCsvAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export version cleanup results",
Filter = "CSV files (*.csv)|*.csv",
DefaultExt = "csv",
FileName = "version_cleanup",
};
if (dialog.ShowDialog() != true) return;
try
{
using var w = new System.IO.StreamWriter(dialog.FileName);
await w.WriteLineAsync("Site,Library,File,Versions Before,Versions Deleted,Versions Remaining,Bytes Freed,Error");
foreach (var r in Results)
{
await w.WriteLineAsync(string.Join(",",
Csv(r.SiteUrl),
Csv(r.Library),
Csv(r.FileServerRelativeUrl),
r.VersionsBefore,
r.VersionsDeleted,
r.VersionsRemaining,
r.BytesFreed,
Csv(r.Error ?? string.Empty)));
}
try { Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); }
catch { }
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "Version cleanup CSV export failed.");
}
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
}
partial void OnKeepLastChanged(int value)
{
if (value < 0) KeepLast = 0;
}
}