This commit is contained in:
Dev
2026-04-24 10:54:47 +02:00
19 changed files with 1113 additions and 51 deletions
+5
View File
@@ -142,6 +142,11 @@ public partial class App : Application
services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>();
// Versions cleanup
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
services.AddTransient<VersionCleanupViewModel>();
services.AddTransient<VersionCleanupView>();
// Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>();
@@ -0,0 +1,9 @@
namespace SharepointToolbox.Core.Models;
public record VersionCleanupOptions(
IReadOnlyList<string> LibraryTitles,
int KeepLast,
bool KeepFirst)
{
public static VersionCleanupOptions Default => new(Array.Empty<string>(), 5, false);
}
@@ -0,0 +1,14 @@
namespace SharepointToolbox.Core.Models;
public class VersionCleanupResult
{
public string SiteUrl { get; init; } = string.Empty;
public string Library { get; init; } = string.Empty;
public string FileServerRelativeUrl { get; init; } = string.Empty;
public string FileName { get; init; } = string.Empty;
public int VersionsBefore { get; init; }
public int VersionsDeleted { get; init; }
public int VersionsRemaining { get; init; }
public long BytesFreed { get; init; }
public string? Error { get; init; }
}
@@ -45,14 +45,13 @@ public class GraphClientFactory
{
var pca = await _msalFactory.GetOrCreateAsync(clientId);
// When a tenant is specified we must NOT reuse cached accounts from /common
// (or a different tenant) — they route tokens to the wrong authority.
IAccount? account = null;
if (tenantId is null)
{
// Always reuse a cached account when one exists — `WithTenantId` on the
// silent/interactive call redirects the authority, and MSAL stores
// refresh tokens per tenant. Skipping the cached account forces an
// interactive prompt on every Graph call (the bug that produced 45
// sign-in windows during app registration).
var accounts = await pca.GetAccountsAsync();
account = accounts.FirstOrDefault();
}
var account = accounts.FirstOrDefault();
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
@@ -68,7 +67,7 @@ public class GraphClientFactory
internal class MsalTokenProvider : IAccessTokenProvider
{
private readonly IPublicClientApplication _pca;
private readonly IAccount? _account;
private IAccount? _account;
private readonly string[] _scopes;
private readonly string? _tenantId;
@@ -86,6 +85,17 @@ internal class MsalTokenProvider : IAccessTokenProvider
Uri uri,
Dictionary<string, object>? additionalAuthenticationContext = null,
CancellationToken cancellationToken = default)
{
// Refresh _account from PCA cache each call — interactive flows on a
// sibling token provider populate the cache, and we want the next
// request on this provider to use that account silently.
if (_account is null)
{
var accounts = await _pca.GetAccountsAsync();
_account = accounts.FirstOrDefault();
}
if (_account is not null)
{
try
{
@@ -96,10 +106,15 @@ internal class MsalTokenProvider : IAccessTokenProvider
}
catch (MsalUiRequiredException)
{
// fall through to interactive
}
}
var interactive = _pca.AcquireTokenInteractive(_scopes);
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
var result = await interactive.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
var interactiveResult = await interactive.ExecuteAsync(cancellationToken);
// Cache the account so subsequent calls on this provider go silent.
_account = interactiveResult.Account;
return interactiveResult.AccessToken;
}
}
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve">
<value>Doublons</value>
</data>
<data name="tab.versions" xml:space="preserve">
<value>Versions</value>
</data>
<data name="versions.tab" xml:space="preserve">
<value>Nettoyage des versions</value>
</data>
<data name="versions.grp.libs" xml:space="preserve">
<value>Bibliothèques</value>
</data>
<data name="versions.grp.policy" xml:space="preserve">
<value>Politique de conservation</value>
</data>
<data name="versions.btn.pickLibs" xml:space="preserve">
<value>Choisir des bibliothèques…</value>
</data>
<data name="versions.btn.clearLibs" xml:space="preserve">
<value>Réinitialiser (toutes les bibliothèques)</value>
</data>
<data name="versions.btn.run" xml:space="preserve">
<value>Supprimer les anciennes versions</value>
</data>
<data name="versions.lbl.keepLast" xml:space="preserve">
<value>Conserver les dernières&#160;:</value>
</data>
<data name="versions.chk.keepFirst" xml:space="preserve">
<value>Conserver aussi la toute première version</value>
</data>
<data name="versions.chk.confirm" xml:space="preserve">
<value>Demander confirmation avant l'exécution</value>
</data>
<data name="versions.note" xml:space="preserve">
<value>Seules les versions historiques sont supprimées. La version courante publiée est toujours conservée. L'action est irréversible.</value>
</data>
<data name="versions.libs.all" xml:space="preserve">
<value>Toutes les bibliothèques (aucun filtre)</value>
</data>
<data name="versions.libs.count" xml:space="preserve">
<value>{0} bibliothèque(s) sélectionnée(s)</value>
</data>
<data name="versions.confirm" xml:space="preserve">
<value>Supprimer les versions historiques en gardant les {0} dernières {1}&#160;?
Cette action est irréversible.</value>
</data>
<data name="versions.confirm.keepFirst" xml:space="preserve">
<value>(plus la première version)</value>
</data>
<data name="versions.err.keepLast" xml:space="preserve">
<value>«&#160;Conserver les dernières&#160;» doit être supérieur ou égal à 0.</value>
</data>
<data name="versions.summary.files" xml:space="preserve">
<value>Fichiers nettoyés&#160;:</value>
</data>
<data name="versions.summary.deleted" xml:space="preserve">
<value>Versions supprimées&#160;:</value>
</data>
<data name="versions.summary.freed" xml:space="preserve">
<value>Octets libérés&#160;:</value>
</data>
<data name="versions.col.library" xml:space="preserve">
<value>Bibliothèque</value>
</data>
<data name="versions.col.file" xml:space="preserve">
<value>Fichier</value>
</data>
<data name="versions.col.before" xml:space="preserve">
<value>Avant</value>
</data>
<data name="versions.col.deleted" xml:space="preserve">
<value>Supprimées</value>
</data>
<data name="versions.col.remaining" xml:space="preserve">
<value>Restantes</value>
</data>
<data name="versions.col.freed" xml:space="preserve">
<value>Libérés</value>
</data>
<data name="versions.col.path" xml:space="preserve">
<value>Chemin</value>
</data>
<data name="versions.col.error" xml:space="preserve">
<value>Erreur</value>
</data>
<data name="librarypicker.title" xml:space="preserve">
<value>Sélectionner les bibliothèques</value>
</data>
<data name="librarypicker.loading" xml:space="preserve">
<value>Chargement des bibliothèques…</value>
</data>
<data name="librarypicker.loaded" xml:space="preserve">
<value>{0} bibliothèques chargées.</value>
</data>
<data name="librarypicker.selectAll" xml:space="preserve">
<value>Tout sélectionner</value>
</data>
<data name="librarypicker.selectNone" xml:space="preserve">
<value>Tout désélectionner</value>
</data>
<data name="tab.templates" xml:space="preserve">
<value>Modèles</value>
</data>
@@ -148,6 +245,18 @@
<data name="profile.delete" xml:space="preserve">
<value>Supprimer</value>
</data>
<data name="profile.add.tooltip" xml:space="preserve">
<value>Créer un nouveau profil à partir des valeurs ci-dessus.</value>
</data>
<data name="profile.save.tooltip" xml:space="preserve">
<value>Enregistrer les modifications du profil sélectionné.</value>
</data>
<data name="profile.delete.tooltip" xml:space="preserve">
<value>Supprimer le profil sélectionné.</value>
</data>
<data name="profile.register.warning" xml:space="preserve">
<value>L'enregistrement de l'application peut nécessiter jusqu'à {0} connexions. Continuer&#160;?</value>
</data>
<data name="status.ready" xml:space="preserve">
<value>Prêt</value>
</data>
+109
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve">
<value>Duplicates</value>
</data>
<data name="tab.versions" xml:space="preserve">
<value>Versions</value>
</data>
<data name="versions.tab" xml:space="preserve">
<value>Version cleanup</value>
</data>
<data name="versions.grp.libs" xml:space="preserve">
<value>Libraries</value>
</data>
<data name="versions.grp.policy" xml:space="preserve">
<value>Retention policy</value>
</data>
<data name="versions.btn.pickLibs" xml:space="preserve">
<value>Select libraries...</value>
</data>
<data name="versions.btn.clearLibs" xml:space="preserve">
<value>Reset (all libraries)</value>
</data>
<data name="versions.btn.run" xml:space="preserve">
<value>Delete old versions</value>
</data>
<data name="versions.lbl.keepLast" xml:space="preserve">
<value>Keep last:</value>
</data>
<data name="versions.chk.keepFirst" xml:space="preserve">
<value>Also keep the very first version</value>
</data>
<data name="versions.chk.confirm" xml:space="preserve">
<value>Ask for confirmation before running</value>
</data>
<data name="versions.note" xml:space="preserve">
<value>Only historical versions are removed. The current published version is always kept. The action cannot be undone.</value>
</data>
<data name="versions.libs.all" xml:space="preserve">
<value>All libraries (no filter)</value>
</data>
<data name="versions.libs.count" xml:space="preserve">
<value>{0} library/libraries selected</value>
</data>
<data name="versions.confirm" xml:space="preserve">
<value>Delete historical file versions, keeping the last {0} {1}?
This cannot be undone.</value>
</data>
<data name="versions.confirm.keepFirst" xml:space="preserve">
<value>(plus the first version)</value>
</data>
<data name="versions.err.keepLast" xml:space="preserve">
<value>"Keep last" must be 0 or greater.</value>
</data>
<data name="versions.summary.files" xml:space="preserve">
<value>Files trimmed:</value>
</data>
<data name="versions.summary.deleted" xml:space="preserve">
<value>Versions deleted:</value>
</data>
<data name="versions.summary.freed" xml:space="preserve">
<value>Bytes freed:</value>
</data>
<data name="versions.col.library" xml:space="preserve">
<value>Library</value>
</data>
<data name="versions.col.file" xml:space="preserve">
<value>File</value>
</data>
<data name="versions.col.before" xml:space="preserve">
<value>Before</value>
</data>
<data name="versions.col.deleted" xml:space="preserve">
<value>Deleted</value>
</data>
<data name="versions.col.remaining" xml:space="preserve">
<value>Remaining</value>
</data>
<data name="versions.col.freed" xml:space="preserve">
<value>Freed</value>
</data>
<data name="versions.col.path" xml:space="preserve">
<value>Path</value>
</data>
<data name="versions.col.error" xml:space="preserve">
<value>Error</value>
</data>
<data name="librarypicker.title" xml:space="preserve">
<value>Select libraries</value>
</data>
<data name="librarypicker.loading" xml:space="preserve">
<value>Loading libraries...</value>
</data>
<data name="librarypicker.loaded" xml:space="preserve">
<value>{0} libraries loaded.</value>
</data>
<data name="librarypicker.selectAll" xml:space="preserve">
<value>Select all</value>
</data>
<data name="librarypicker.selectNone" xml:space="preserve">
<value>Select none</value>
</data>
<data name="tab.templates" xml:space="preserve">
<value>Templates</value>
</data>
@@ -148,6 +245,18 @@
<data name="profile.delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="profile.add.tooltip" xml:space="preserve">
<value>Create a new profile from the values entered above.</value>
</data>
<data name="profile.save.tooltip" xml:space="preserve">
<value>Save changes to the selected profile.</value>
</data>
<data name="profile.delete.tooltip" xml:space="preserve">
<value>Delete the selected profile.</value>
</data>
<data name="profile.register.warning" xml:space="preserve">
<value>Registering an app may prompt you to sign in up to {0} times. Continue?</value>
</data>
<data name="status.ready" xml:space="preserve">
<value>Ready</value>
</data>
+3 -2
View File
@@ -18,8 +18,6 @@
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.connect]}"
Command="{Binding ConnectCommand}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
Command="{Binding ManageProfilesCommand}" />
<Separator />
@@ -63,6 +61,9 @@
<TabItem x:Name="DuplicatesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
</TabItem>
<TabItem x:Name="VersionsTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.versions]}">
</TabItem>
<TabItem x:Name="TransferTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
</TabItem>
+3
View File
@@ -40,6 +40,9 @@ public partial class MainWindow : Window
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
// Versions cleanup tab
VersionsTabItem.Content = serviceProvider.GetRequiredService<VersionCleanupView>();
// Phase 4: Replace stub tabs with DI-resolved Views
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
@@ -0,0 +1,28 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IVersionCleanupService
{
/// <summary>
/// Enumerates document libraries (filtered by <see cref="VersionCleanupOptions.LibraryTitles"/>
/// when non-empty) and deletes historical file versions per file according to
/// <see cref="VersionCleanupOptions.KeepLast"/> and <see cref="VersionCleanupOptions.KeepFirst"/>.
/// The current published version is never touched. Returns one result row per file
/// where at least one version was inspected.
/// </summary>
Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <summary>
/// Lists non-hidden document libraries on the site. Used by the library picker
/// so callers can present a checkbox UI.
/// </summary>
Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx,
CancellationToken ct);
}
@@ -0,0 +1,189 @@
using Microsoft.Extensions.Logging;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public class VersionCleanupService : IVersionCleanupService
{
private readonly ILogger<VersionCleanupService> _logger;
public VersionCleanupService(ILogger<VersionCleanupService> logger)
{
_logger = logger;
}
public async Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.Select(l => l.Title)
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (options.KeepLast < 0)
throw new ArgumentOutOfRangeException(nameof(options), "KeepLast must be >= 0.");
ctx.Load(ctx.Web, w => w.Url, w => w.ServerRelativeUrl,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var allLibs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
var titleFilter = options.LibraryTitles?.Count > 0
? new HashSet<string>(options.LibraryTitles, StringComparer.OrdinalIgnoreCase)
: null;
var libs = titleFilter is null
? allLibs
: allLibs.Where(l => titleFilter.Contains(l.Title)).ToList();
var results = new List<VersionCleanupResult>();
var siteUrl = ctx.Web.Url;
int libIdx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
libIdx++;
progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning versions: {lib.Title} ({libIdx}/{libs.Count})"));
// Enumerate files via paginated CAML so libs > 5,000 items work.
var files = new List<string>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileRef" },
ct: ct))
{
if (item["FSObjType"]?.ToString() != "0") continue;
var fileRef = item["FileRef"]?.ToString();
if (!string.IsNullOrEmpty(fileRef))
files.Add(fileRef);
}
int fileIdx = 0;
foreach (var fileRef in files)
{
ct.ThrowIfCancellationRequested();
fileIdx++;
if (fileIdx % 25 == 0 || fileIdx == files.Count)
{
progress.Report(new OperationProgress(fileIdx, files.Count,
$"{lib.Title}: {fileIdx}/{files.Count} files"));
}
var result = await TrimFileVersionsAsync(
ctx, siteUrl, lib.Title, fileRef, options, progress, ct);
if (result is not null)
results.Add(result);
}
}
return results;
}
private async Task<VersionCleanupResult?> TrimFileVersionsAsync(
ClientContext ctx,
string siteUrl,
string libraryTitle,
string fileServerRelativeUrl,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
try
{
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
ctx.Load(file, f => f.Name);
ctx.Load(file.Versions,
vs => vs.Include(
v => v.VersionLabel,
v => v.Created,
v => v.Size));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// file.Versions contains only HISTORICAL versions; the current published
// version lives on `file` itself and is never deletable here.
var versions = file.Versions.ToList();
int before = versions.Count;
if (before == 0) return null;
// Sort by Created ascending so [0] is the oldest historical version.
var ordered = versions
.OrderBy(v => v.Created)
.ToList();
// Preserve set: the last N most recent + optionally the very first.
var keep = new HashSet<int>();
int keepLast = Math.Min(options.KeepLast, ordered.Count);
for (int i = ordered.Count - keepLast; i < ordered.Count; i++)
keep.Add(i);
if (options.KeepFirst && ordered.Count > 0)
keep.Add(0);
long bytesFreed = 0;
int deleted = 0;
for (int i = 0; i < ordered.Count; i++)
{
if (keep.Contains(i)) continue;
var v = ordered[i];
bytesFreed += v.Size;
v.DeleteObject();
deleted++;
}
if (deleted == 0) return null;
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return new VersionCleanupResult
{
SiteUrl = siteUrl,
Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
VersionsBefore = before,
VersionsDeleted = deleted,
VersionsRemaining = before - deleted,
BytesFreed = bytesFreed,
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
return new VersionCleanupResult
{
SiteUrl = siteUrl,
Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
Error = ex.Message,
};
}
}
}
@@ -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;
}
}
@@ -0,0 +1,42 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.LibraryPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.title]}"
Width="420" Height="520" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="CanResizeWithGrip">
<DockPanel Margin="10">
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,8"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.loading]}" />
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,6">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.selectAll]}"
Click="SelectAll_Click" Margin="0,0,6,0" Padding="6,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[librarypicker.selectNone]}"
Click="SelectNone_Click" Padding="6,2" />
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.cancel]}"
Width="80" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click" />
<Button x:Name="OkButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.select]}"
Width="80" IsDefault="True" IsEnabled="False" Click="Ok_Click" />
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="LibrariesList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsSelected, Mode=TwoWay}"
Margin="2,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,105 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Services;
namespace SharepointToolbox.Views.Dialogs;
public partial class LibraryPickerDialog : Window
{
private readonly ClientContext _ctx;
private readonly IVersionCleanupService _libraryLister;
private readonly ObservableCollection<LibraryItem> _items = new();
public IReadOnlyList<string> SelectedLibraryTitles { get; private set; } = Array.Empty<string>();
public LibraryPickerDialog(
ClientContext ctx,
IVersionCleanupService libraryLister,
IReadOnlyCollection<string>? preselected = null)
{
InitializeComponent();
_ctx = ctx;
_libraryLister = libraryLister;
LibrariesList.ItemsSource = _items;
Loaded += async (_, _) => await LoadAsync(preselected ?? Array.Empty<string>());
}
private async Task LoadAsync(IReadOnlyCollection<string> preselected)
{
try
{
var titles = await _libraryLister.ListLibraryTitlesAsync(_ctx, CancellationToken.None);
var preset = new HashSet<string>(preselected, StringComparer.OrdinalIgnoreCase);
foreach (var t in titles)
{
var item = new LibraryItem { Title = t, IsSelected = preset.Contains(t) };
item.PropertyChanged += OnItemChanged;
_items.Add(item);
}
StatusText.Text = string.Format(
Localization.TranslationSource.Instance["librarypicker.loaded"],
_items.Count);
UpdateOkEnabled();
}
catch (Exception ex)
{
StatusText.Text = $"Error: {ex.Message}";
}
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LibraryItem.IsSelected)) UpdateOkEnabled();
}
private void UpdateOkEnabled()
=> OkButton.IsEnabled = _items.Any(i => i.IsSelected);
private void SelectAll_Click(object sender, RoutedEventArgs e)
{
foreach (var i in _items) i.IsSelected = true;
}
private void SelectNone_Click(object sender, RoutedEventArgs e)
{
foreach (var i in _items) i.IsSelected = false;
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
SelectedLibraryTitles = _items.Where(i => i.IsSelected).Select(i => i.Title).ToList();
DialogResult = true;
Close();
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
protected override void OnClosed(EventArgs e)
{
foreach (var i in _items) i.PropertyChanged -= OnItemChanged;
base.OnClosed(e);
}
public class LibraryItem : INotifyPropertyChanged
{
private bool _isSelected;
public string Title { get; init; } = string.Empty;
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected == value) return;
_isSelected = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
}
@@ -20,6 +20,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Profile list -->
@@ -58,8 +59,31 @@
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
</Grid>
<!-- Profile CRUD buttons (placed under fields for natural flow) -->
<Grid Grid.Row="3" Margin="0,4,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}"
MinWidth="90" Padding="10,4" Margin="0,0,6,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add.tooltip]}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
Command="{Binding SaveCommand}"
MinWidth="90" Padding="10,4"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save.tooltip]}" />
</StackPanel>
<Button Grid.Column="1" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}"
MinWidth="90" Padding="10,4"
Foreground="{DynamicResource DangerBrush}"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete.tooltip]}" />
</Grid>
<!-- Client Logo -->
<StackPanel Grid.Row="3" Margin="0,8,0,8">
<StackPanel Grid.Row="4" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
@@ -96,7 +120,7 @@
</StackPanel>
<!-- App Registration -->
<StackPanel Grid.Row="4" Margin="0,8,0,8">
<StackPanel Grid.Row="5" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
FontWeight="SemiBold" Padding="0,0,0,4"
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
@@ -134,16 +158,10 @@
</Border>
</StackPanel>
<!-- Buttons -->
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
Command="{Binding SaveCommand}" MinWidth="80" Padding="6,0" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
<!-- Close button -->
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
Width="60" Margin="4,0"
MinWidth="80" Padding="10,4"
Click="CloseButton_Click" IsCancel="True" />
</StackPanel>
</Grid>
@@ -9,6 +9,15 @@ public partial class ProfileManagementDialog : Window
{
InitializeComponent();
DataContext = viewModel;
viewModel.ConfirmRegisterApp = msg =>
{
var result = MessageBox.Show(
this, msg,
Localization.TranslationSource.Instance["profile.register"],
MessageBoxButton.OKCancel,
MessageBoxImage.Information);
return result == MessageBoxResult.OK;
};
Loaded += async (_, _) => await viewModel.LoadAsync();
}
@@ -0,0 +1,118 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.VersionCleanupView"
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 LastChildFill="True">
<ScrollViewer DockPanel.Dock="Left" Width="280" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.libs]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<TextBlock Text="{Binding SelectedLibrariesLabel}" Margin="0,0,0,6"
Foreground="{DynamicResource TextMutedBrush}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.pickLibs]}"
Command="{Binding SelectLibrariesCommand}" Height="26" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.clearLibs]}"
Command="{Binding ClearLibrariesCommand}" Height="26" />
</StackPanel>
</GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.grp.policy]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,2">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.lbl.keepLast]}"
VerticalAlignment="Center" Padding="0,0,4,0" />
<TextBox Text="{Binding KeepLast, UpdateSourceTrigger=PropertyChanged}"
Width="50" Height="22" VerticalAlignment="Center" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.keepFirst]}"
IsChecked="{Binding KeepFirstVersion}" Margin="0,4,0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.chk.confirm]}"
IsChecked="{Binding ConfirmDelete}" Margin="0,4,0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
Margin="0,6,0,0" />
</StackPanel>
</GroupBox>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.btn.run]}"
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource TextMutedBrush}" Margin="0,6" />
</StackPanel>
</ScrollViewer>
<Grid Margin="4,8,8,8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{DynamicResource AccentSoftBrush}" CornerRadius="4"
Padding="12,8" Margin="0,0,0,6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasResults}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.files]}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding TotalFilesAffected, StringFormat=N0, Mode=OneWay}" Margin="4,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.deleted]}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding TotalVersionsDeleted, StringFormat=N0, Mode=OneWay}" Margin="4,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.summary.freed]}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding TotalBytesFreed, Converter={StaticResource BytesConverter}, Mode=OneWay}"
Margin="4,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
<DataGrid Grid.Row="1" ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
<DataGrid.Columns>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.library]}"
Binding="{Binding Library}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.file]}"
Binding="{Binding FileName}" Width="200" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.before]}"
Binding="{Binding VersionsBefore, StringFormat=N0}" Width="80"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.deleted]}"
Binding="{Binding VersionsDeleted, StringFormat=N0}" Width="80"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.remaining]}"
Binding="{Binding VersionsRemaining, StringFormat=N0}" Width="90"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.freed]}"
Binding="{Binding BytesFreed, Converter={StaticResource BytesConverter}}" Width="100"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.path]}"
Binding="{Binding FileServerRelativeUrl}" Width="*" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[versions.col.error]}"
Binding="{Binding Error}" Width="160" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</DockPanel>
</UserControl>
@@ -0,0 +1,54 @@
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 VersionCleanupView : UserControl
{
private readonly ViewModels.Tabs.VersionCleanupViewModel _viewModel;
private readonly ISessionManager _sessionManager;
private readonly IVersionCleanupService _versionService;
public VersionCleanupView(
ViewModels.Tabs.VersionCleanupViewModel viewModel,
ISessionManager sessionManager,
IVersionCleanupService versionService)
{
InitializeComponent();
_viewModel = viewModel;
_sessionManager = sessionManager;
_versionService = versionService;
DataContext = viewModel;
viewModel.PickLibrariesAsync = async (siteUrl, preselected) =>
{
if (viewModel.CurrentProfile == null) return null;
var profile = new TenantProfile
{
TenantUrl = siteUrl,
ClientId = viewModel.CurrentProfile.ClientId,
Name = viewModel.CurrentProfile.Name,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var dlg = new LibraryPickerDialog(ctx, _versionService, preselected)
{
Owner = Window.GetWindow(this)
};
if (dlg.ShowDialog() != true) return null;
return dlg.SelectedLibraryTitles;
};
viewModel.ConfirmAction = msg =>
{
var result = MessageBox.Show(
Window.GetWindow(this), msg,
Localization.TranslationSource.Instance["versions.tab"],
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
return result == MessageBoxResult.OK;
};
}
}