Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox
This commit is contained in:
@@ -142,6 +142,11 @@ public partial class App : Application
|
|||||||
services.AddTransient<DuplicatesViewModel>();
|
services.AddTransient<DuplicatesViewModel>();
|
||||||
services.AddTransient<DuplicatesView>();
|
services.AddTransient<DuplicatesView>();
|
||||||
|
|
||||||
|
// Versions cleanup
|
||||||
|
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
|
||||||
|
services.AddTransient<VersionCleanupViewModel>();
|
||||||
|
services.AddTransient<VersionCleanupView>();
|
||||||
|
|
||||||
// Phase 2: Permissions
|
// Phase 2: Permissions
|
||||||
services.AddTransient<IPermissionsService, PermissionsService>();
|
services.AddTransient<IPermissionsService, PermissionsService>();
|
||||||
services.AddTransient<ISiteListService, SiteListService>();
|
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);
|
var pca = await _msalFactory.GetOrCreateAsync(clientId);
|
||||||
|
|
||||||
// When a tenant is specified we must NOT reuse cached accounts from /common
|
// Always reuse a cached account when one exists — `WithTenantId` on the
|
||||||
// (or a different tenant) — they route tokens to the wrong authority.
|
// silent/interactive call redirects the authority, and MSAL stores
|
||||||
IAccount? account = null;
|
// refresh tokens per tenant. Skipping the cached account forces an
|
||||||
if (tenantId is null)
|
// interactive prompt on every Graph call (the bug that produced 4–5
|
||||||
{
|
// sign-in windows during app registration).
|
||||||
var accounts = await pca.GetAccountsAsync();
|
var accounts = await pca.GetAccountsAsync();
|
||||||
account = accounts.FirstOrDefault();
|
var account = accounts.FirstOrDefault();
|
||||||
}
|
|
||||||
|
|
||||||
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
|
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ public class GraphClientFactory
|
|||||||
internal class MsalTokenProvider : IAccessTokenProvider
|
internal class MsalTokenProvider : IAccessTokenProvider
|
||||||
{
|
{
|
||||||
private readonly IPublicClientApplication _pca;
|
private readonly IPublicClientApplication _pca;
|
||||||
private readonly IAccount? _account;
|
private IAccount? _account;
|
||||||
private readonly string[] _scopes;
|
private readonly string[] _scopes;
|
||||||
private readonly string? _tenantId;
|
private readonly string? _tenantId;
|
||||||
|
|
||||||
@@ -86,6 +85,17 @@ internal class MsalTokenProvider : IAccessTokenProvider
|
|||||||
Uri uri,
|
Uri uri,
|
||||||
Dictionary<string, object>? additionalAuthenticationContext = null,
|
Dictionary<string, object>? additionalAuthenticationContext = null,
|
||||||
CancellationToken cancellationToken = default)
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -96,10 +106,15 @@ internal class MsalTokenProvider : IAccessTokenProvider
|
|||||||
}
|
}
|
||||||
catch (MsalUiRequiredException)
|
catch (MsalUiRequiredException)
|
||||||
{
|
{
|
||||||
|
// fall through to interactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var interactive = _pca.AcquireTokenInteractive(_scopes);
|
var interactive = _pca.AcquireTokenInteractive(_scopes);
|
||||||
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
|
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
|
||||||
var result = await interactive.ExecuteAsync(cancellationToken);
|
var interactiveResult = await interactive.ExecuteAsync(cancellationToken);
|
||||||
return result.AccessToken;
|
// 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">
|
<data name="tab.duplicates" xml:space="preserve">
|
||||||
<value>Doublons</value>
|
<value>Doublons</value>
|
||||||
</data>
|
</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 :</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} ?
|
||||||
|
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>« Conserver les dernières » doit être supérieur ou égal à 0.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.files" xml:space="preserve">
|
||||||
|
<value>Fichiers nettoyés :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.deleted" xml:space="preserve">
|
||||||
|
<value>Versions supprimées :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.freed" xml:space="preserve">
|
||||||
|
<value>Octets libérés :</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">
|
<data name="tab.templates" xml:space="preserve">
|
||||||
<value>Modèles</value>
|
<value>Modèles</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -148,6 +245,18 @@
|
|||||||
<data name="profile.delete" xml:space="preserve">
|
<data name="profile.delete" xml:space="preserve">
|
||||||
<value>Supprimer</value>
|
<value>Supprimer</value>
|
||||||
</data>
|
</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 ?</value>
|
||||||
|
</data>
|
||||||
<data name="status.ready" xml:space="preserve">
|
<data name="status.ready" xml:space="preserve">
|
||||||
<value>Prêt</value>
|
<value>Prêt</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -82,6 +82,103 @@
|
|||||||
<data name="tab.duplicates" xml:space="preserve">
|
<data name="tab.duplicates" xml:space="preserve">
|
||||||
<value>Duplicates</value>
|
<value>Duplicates</value>
|
||||||
</data>
|
</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">
|
<data name="tab.templates" xml:space="preserve">
|
||||||
<value>Templates</value>
|
<value>Templates</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -148,6 +245,18 @@
|
|||||||
<data name="profile.delete" xml:space="preserve">
|
<data name="profile.delete" xml:space="preserve">
|
||||||
<value>Delete</value>
|
<value>Delete</value>
|
||||||
</data>
|
</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">
|
<data name="status.ready" xml:space="preserve">
|
||||||
<value>Ready</value>
|
<value>Ready</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
|
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
|
||||||
SelectedItem="{Binding SelectedProfile}"
|
SelectedItem="{Binding SelectedProfile}"
|
||||||
DisplayMemberPath="Name" />
|
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]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
|
||||||
Command="{Binding ManageProfilesCommand}" />
|
Command="{Binding ManageProfilesCommand}" />
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -63,6 +61,9 @@
|
|||||||
<TabItem x:Name="DuplicatesTabItem"
|
<TabItem x:Name="DuplicatesTabItem"
|
||||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
<TabItem x:Name="VersionsTabItem"
|
||||||
|
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.versions]}">
|
||||||
|
</TabItem>
|
||||||
<TabItem x:Name="TransferTabItem"
|
<TabItem x:Name="TransferTabItem"
|
||||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
|
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public partial class MainWindow : Window
|
|||||||
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
|
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
|
||||||
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
|
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
|
||||||
|
|
||||||
|
// Versions cleanup tab
|
||||||
|
VersionsTabItem.Content = serviceProvider.GetRequiredService<VersionCleanupView>();
|
||||||
|
|
||||||
// Phase 4: Replace stub tabs with DI-resolved Views
|
// Phase 4: Replace stub tabs with DI-resolved Views
|
||||||
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
|
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
|
||||||
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
|
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)
|
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
|
||||||
: Localization.TranslationSource.Instance["toolbar.globalSites.none"];
|
: Localization.TranslationSource.Instance["toolbar.globalSites.none"];
|
||||||
|
|
||||||
public IAsyncRelayCommand ConnectCommand { get; }
|
|
||||||
public IAsyncRelayCommand ClearSessionCommand { get; }
|
public IAsyncRelayCommand ClearSessionCommand { get; }
|
||||||
public RelayCommand ManageProfilesCommand { get; }
|
public RelayCommand ManageProfilesCommand { get; }
|
||||||
public RelayCommand OpenGlobalSitePickerCommand { get; }
|
public RelayCommand OpenGlobalSitePickerCommand { get; }
|
||||||
@@ -64,7 +63,6 @@ public partial class MainWindowViewModel : ObservableRecipient
|
|||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
|
|
||||||
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
|
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
|
||||||
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
|
ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
|
||||||
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
|
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
|
||||||
@@ -96,7 +94,6 @@ public partial class MainWindowViewModel : ObservableRecipient
|
|||||||
{
|
{
|
||||||
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
|
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
|
||||||
}
|
}
|
||||||
ConnectCommand.NotifyCanExecuteChanged();
|
|
||||||
ClearSessionCommand.NotifyCanExecuteChanged();
|
ClearSessionCommand.NotifyCanExecuteChanged();
|
||||||
// Clear global site selection on tenant switch (sites belong to a tenant)
|
// Clear global site selection on tenant switch (sites belong to a tenant)
|
||||||
GlobalSelectedSites.Clear();
|
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()
|
private async Task ClearSessionAsync()
|
||||||
{
|
{
|
||||||
if (SelectedProfile == null) return;
|
if (SelectedProfile == null) return;
|
||||||
|
|||||||
@@ -346,9 +346,25 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
private bool CanRemoveApp()
|
private bool CanRemoveApp()
|
||||||
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering;
|
=> 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)
|
private async Task RegisterAppAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (SelectedProfile == null) return;
|
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;
|
IsRegistering = true;
|
||||||
ShowFallbackInstructions = false;
|
ShowFallbackInstructions = false;
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
|
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" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Profile list -->
|
<!-- Profile list -->
|
||||||
@@ -58,8 +59,31 @@
|
|||||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
|
||||||
</Grid>
|
</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 -->
|
<!-- 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" />
|
<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"
|
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
|
||||||
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
||||||
@@ -96,7 +120,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- App Registration -->
|
<!-- 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]}"
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
|
||||||
FontWeight="SemiBold" Padding="0,0,0,4"
|
FontWeight="SemiBold" Padding="0,0,0,4"
|
||||||
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
|
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
|
||||||
@@ -134,16 +158,10 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Close button -->
|
||||||
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
|
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||||
<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" />
|
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
|
<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" />
|
Click="CloseButton_Click" IsCancel="True" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ public partial class ProfileManagementDialog : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = viewModel;
|
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();
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user