Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e5cfc506 | |||
| 23a638a10a | |||
| a48df65f2e | |||
| df179be2ed | |||
| efb3d2ad11 | |||
| 2c9dbe39d3 |
@@ -142,6 +142,12 @@ public partial class App : Application
|
||||
services.AddTransient<DuplicatesViewModel>();
|
||||
services.AddTransient<DuplicatesView>();
|
||||
|
||||
// Versions cleanup
|
||||
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
|
||||
services.AddTransient<VersionCleanupHtmlExportService>();
|
||||
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)
|
||||
{
|
||||
var accounts = await pca.GetAccountsAsync();
|
||||
account = accounts.FirstOrDefault();
|
||||
}
|
||||
// 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 4–5
|
||||
// sign-in windows during app registration).
|
||||
var accounts = await pca.GetAccountsAsync();
|
||||
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;
|
||||
|
||||
@@ -87,19 +86,35 @@ internal class MsalTokenProvider : IAccessTokenProvider
|
||||
Dictionary<string, object>? additionalAuthenticationContext = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
// 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 silent = _pca.AcquireTokenSilent(_scopes, _account);
|
||||
if (_tenantId is not null) silent = silent.WithTenantId(_tenantId);
|
||||
var result = await silent.ExecuteAsync(cancellationToken);
|
||||
return result.AccessToken;
|
||||
var accounts = await _pca.GetAccountsAsync();
|
||||
_account = accounts.FirstOrDefault();
|
||||
}
|
||||
catch (MsalUiRequiredException)
|
||||
|
||||
if (_account is not null)
|
||||
{
|
||||
var interactive = _pca.AcquireTokenInteractive(_scopes);
|
||||
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
|
||||
var result = await interactive.ExecuteAsync(cancellationToken);
|
||||
return result.AccessToken;
|
||||
try
|
||||
{
|
||||
var silent = _pca.AcquireTokenSilent(_scopes, _account);
|
||||
if (_tenantId is not null) silent = silent.WithTenantId(_tenantId);
|
||||
var result = await silent.ExecuteAsync(cancellationToken);
|
||||
return result.AccessToken;
|
||||
}
|
||||
catch (MsalUiRequiredException)
|
||||
{
|
||||
// fall through to interactive
|
||||
}
|
||||
}
|
||||
|
||||
var interactive = _pca.AcquireTokenInteractive(_scopes);
|
||||
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId);
|
||||
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 :</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">
|
||||
<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 ?</value>
|
||||
</data>
|
||||
<data name="status.ready" xml:space="preserve">
|
||||
<value>Prêt</value>
|
||||
</data>
|
||||
@@ -483,6 +592,8 @@
|
||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
||||
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
||||
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
||||
<data name="report.title.versions" xml:space="preserve"><value>Rapport de nettoyage des versions SharePoint</value></data>
|
||||
<data name="report.title.versions_short" xml:space="preserve"><value>Rapport de nettoyage des versions</value></data>
|
||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
||||
@@ -536,10 +647,7 @@
|
||||
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
|
||||
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
|
||||
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
||||
<<<<<<< HEAD
|
||||
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
|
||||
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
|
||||
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
|
||||
@@ -562,7 +670,6 @@
|
||||
<data name="report.text.high_priv" xml:space="preserve"><value>priv. élevé</value></data>
|
||||
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
|
||||
<data name="report.section.library_details" xml:space="preserve"><value>Détails des bibliothèques</value></data>
|
||||
<<<<<<< HEAD
|
||||
<!-- Site picker dialog -->
|
||||
<data name="sitepicker.title" xml:space="preserve"><value>Sélectionner les sites</value></data>
|
||||
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre :</value></data>
|
||||
@@ -654,6 +761,4 @@
|
||||
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
|
||||
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
|
||||
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
</root>
|
||||
|
||||
@@ -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>
|
||||
@@ -483,6 +592,8 @@
|
||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
|
||||
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
|
||||
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
|
||||
<data name="report.title.versions" xml:space="preserve"><value>SharePoint Version Cleanup Report</value></data>
|
||||
<data name="report.title.versions_short" xml:space="preserve"><value>Version Cleanup Report</value></data>
|
||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
|
||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
|
||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
|
||||
@@ -536,10 +647,7 @@
|
||||
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
|
||||
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
|
||||
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
||||
<<<<<<< HEAD
|
||||
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
|
||||
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
|
||||
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
|
||||
@@ -562,7 +670,6 @@
|
||||
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
|
||||
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
|
||||
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
|
||||
<<<<<<< HEAD
|
||||
<!-- Site picker dialog -->
|
||||
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
|
||||
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
|
||||
@@ -654,6 +761,4 @@
|
||||
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
|
||||
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
|
||||
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
</root>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -12,16 +12,12 @@ namespace SharepointToolbox.Services.Export;
|
||||
/// </summary>
|
||||
public class DuplicatesCsvExportService
|
||||
{
|
||||
<<<<<<< HEAD
|
||||
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<DuplicateGroup> groups,
|
||||
string filePath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
<<<<<<< HEAD
|
||||
var csv = BuildCsv(groups);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
}
|
||||
@@ -63,8 +59,6 @@ public class DuplicatesCsvExportService
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
|
||||
{
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -78,14 +72,9 @@ public class DuplicatesCsvExportService
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
{
|
||||
Csv(T["report.col.number"]),
|
||||
<<<<<<< HEAD
|
||||
Csv(T["report.col.group"]),
|
||||
Csv(T["report.text.copies"]),
|
||||
Csv(T["report.col.site"]),
|
||||
=======
|
||||
Csv("Group"),
|
||||
Csv(T["report.text.copies"]),
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Csv(T["report.col.name"]),
|
||||
Csv(T["report.col.library"]),
|
||||
Csv(T["report.col.path"]),
|
||||
@@ -94,10 +83,6 @@ public class DuplicatesCsvExportService
|
||||
Csv(T["report.col.modified"]),
|
||||
}));
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
// Rows
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
foreach (var g in groups)
|
||||
{
|
||||
int i = 0;
|
||||
@@ -109,10 +94,7 @@ public class DuplicatesCsvExportService
|
||||
Csv(i.ToString()),
|
||||
Csv(g.Name),
|
||||
Csv(g.Items.Count.ToString()),
|
||||
<<<<<<< HEAD
|
||||
Csv(item.SiteTitle),
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Csv(item.Name),
|
||||
Csv(item.Library),
|
||||
Csv(item.Path),
|
||||
@@ -123,20 +105,8 @@ public class DuplicatesCsvExportService
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Csv(string value) => CsvSanitizer.Escape(value);
|
||||
=======
|
||||
await File.WriteAllTextAsync(filePath, sb.ToString(),
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
}
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "\"\"";
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
<<<<<<< HEAD
|
||||
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -33,7 +30,6 @@ public class HtmlExportService
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
<<<<<<< HEAD
|
||||
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||
entries.Count,
|
||||
entries.Select(e => e.PermissionLevels),
|
||||
@@ -47,84 +43,6 @@ public class HtmlExportService
|
||||
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
|
||||
AppendFilterInput(sb);
|
||||
AppendTableOpen(sb);
|
||||
=======
|
||||
// Compute stats
|
||||
var totalEntries = entries.Count;
|
||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
||||
var distinctUsers = entries
|
||||
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(u => u.Trim())
|
||||
.Where(u => u.Length > 0)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// ── HTML HEAD ──────────────────────────────────────────────────────────
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.permissions"]}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(@"
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
||||
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
||||
.filter-wrap { padding: 0 24px 12px; }
|
||||
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
||||
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
/* Type badges */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
||||
.badge.site-coll { background: #dbeafe; color: #1e40af; }
|
||||
.badge.site { background: #dcfce7; color: #166534; }
|
||||
.badge.list { background: #fef9c3; color: #854d0e; }
|
||||
.badge.folder { background: #f3f4f6; color: #374151; }
|
||||
/* Unique/Inherited badges */
|
||||
.badge.unique { background: #dcfce7; color: #166534; }
|
||||
.badge.inherited { background: #f3f4f6; color: #374151; }
|
||||
/* User pills */
|
||||
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
||||
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
|
||||
.group-expandable { cursor: pointer; }
|
||||
.group-expandable:hover { opacity: 0.8; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
|
||||
// ── BODY ───────────────────────────────────────────────────────────────
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
|
||||
|
||||
// Stats cards
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Filter input
|
||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
||||
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Table
|
||||
sb.AppendLine("<div class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"permTable\">");
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
@@ -137,52 +55,9 @@ a:hover { text-decoration: underline; }
|
||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||
|
||||
<<<<<<< HEAD
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
|
||||
colSpan: 7, grpMemIdx: ref grpMemIdx);
|
||||
=======
|
||||
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
|
||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var pillsBuilder = new StringBuilder();
|
||||
var memberSubRows = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < logins.Length; i++)
|
||||
{
|
||||
var login = logins[i].Trim();
|
||||
var name = i < names.Length ? names[i].Trim() : login;
|
||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isExpandableGroup = entry.PrincipalType == "SharePointGroup"
|
||||
&& groupMembers != null
|
||||
&& groupMembers.TryGetValue(name, out var members);
|
||||
|
||||
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
||||
|
||||
string memberContent;
|
||||
if (resolvedMembers.Count > 0)
|
||||
{
|
||||
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||
memberContent = string.Join(" • ", memberParts);
|
||||
}
|
||||
else
|
||||
{
|
||||
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
||||
}
|
||||
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var pillCss = isExt ? "user-pill external-user" : "user-pill";
|
||||
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
||||
}
|
||||
}
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
@@ -416,232 +291,4 @@ a:hover { text-decoration: underline; }
|
||||
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
|
||||
_ => ("#F3F4F6", "#374151", "#E5E7EB")
|
||||
};
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
/// <summary>
|
||||
/// Builds a self-contained HTML string from simplified permission entries.
|
||||
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
|
||||
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||
|
||||
var totalEntries = entries.Count;
|
||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
||||
var distinctUsers = entries
|
||||
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(u => u.Trim())
|
||||
.Where(u => u.Length > 0)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.permissions_simplified"]}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(@"
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
||||
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
||||
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
|
||||
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
|
||||
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
|
||||
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
|
||||
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
|
||||
.filter-wrap { padding: 0 24px 12px; }
|
||||
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
||||
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(0,0,0,.03); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
||||
.badge.site-coll { background: #dbeafe; color: #1e40af; }
|
||||
.badge.site { background: #dcfce7; color: #166534; }
|
||||
.badge.list { background: #fef9c3; color: #854d0e; }
|
||||
.badge.folder { background: #f3f4f6; color: #374151; }
|
||||
.badge.unique { background: #dcfce7; color: #166534; }
|
||||
.badge.inherited { background: #f3f4f6; color: #374151; }
|
||||
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
||||
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
||||
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
|
||||
.group-expandable { cursor: pointer; }
|
||||
.group-expandable:hover { opacity: 0.8; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
|
||||
sb.AppendLine("<body>");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
|
||||
|
||||
// Stats cards
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Risk-level summary cards
|
||||
sb.AppendLine("<div class=\"risk-cards\">");
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
|
||||
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
|
||||
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
|
||||
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
|
||||
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Filter input
|
||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
||||
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Table with simplified columns
|
||||
sb.AppendLine("<div class=\"table-wrap\">");
|
||||
sb.AppendLine("<table id=\"permTable\">");
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody>");
|
||||
|
||||
int grpMemIdx = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||
|
||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var pillsBuilder = new StringBuilder();
|
||||
var memberSubRows = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < logins.Length; i++)
|
||||
{
|
||||
var login = logins[i].Trim();
|
||||
var name = i < names.Length ? names[i].Trim() : login;
|
||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
|
||||
&& groupMembers != null
|
||||
&& groupMembers.TryGetValue(name, out _);
|
||||
|
||||
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
||||
|
||||
string memberContent;
|
||||
if (resolvedMembers.Count > 0)
|
||||
{
|
||||
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||
memberContent = string.Join(" • ", memberParts);
|
||||
}
|
||||
else
|
||||
{
|
||||
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
||||
}
|
||||
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var pillCss = isExt ? "user-pill external-user" : "user-pill";
|
||||
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (memberSubRows.Length > 0)
|
||||
sb.Append(memberSubRows);
|
||||
}
|
||||
|
||||
sb.AppendLine("</tbody>");
|
||||
sb.AppendLine("</table>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
sb.AppendLine("<script>");
|
||||
sb.AppendLine(@"function filterTable() {
|
||||
var input = document.getElementById('filter').value.toLowerCase();
|
||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
||||
rows.forEach(function(row) {
|
||||
if (row.hasAttribute('data-group')) return;
|
||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function toggleGroup(id) {
|
||||
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
||||
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
||||
}");
|
||||
sb.AppendLine("</script>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the simplified HTML report to the specified file path.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
|
||||
}
|
||||
|
||||
/// <summary>Returns the CSS class for the object-type badge.</summary>
|
||||
private static string ObjectTypeCss(string t) => t switch
|
||||
{
|
||||
"Site Collection" => "badge site-coll",
|
||||
"Site" => "badge site",
|
||||
"List" => "badge list",
|
||||
"Folder" => "badge folder",
|
||||
_ => "badge"
|
||||
};
|
||||
|
||||
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
|
||||
private static string HtmlEncode(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
return value
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
}
|
||||
|
||||
@@ -180,11 +180,7 @@ public class StorageHtmlExportService
|
||||
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
||||
|
||||
sb.AppendLine("<div class=\"chart-section\">");
|
||||
<<<<<<< HEAD
|
||||
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
|
||||
=======
|
||||
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
|
||||
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
||||
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports VersionCleanupResult list to a self-contained sortable/filterable HTML report.
|
||||
/// Summary header shows totals (files trimmed, versions deleted, bytes freed); a single
|
||||
/// table lists every processed file with sort/filter controls. No external assets.
|
||||
/// </summary>
|
||||
public class VersionCleanupHtmlExportService
|
||||
{
|
||||
public string BuildHtml(IReadOnlyList<VersionCleanupResult> results, ReportBranding? branding = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
long totalBytes = results.Sum(r => r.BytesFreed);
|
||||
int totalDeleted = results.Sum(r => r.VersionsDeleted);
|
||||
int totalFiles = results.Count(r => r.VersionsDeleted > 0);
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{T["report.title.versions"]}</title>");
|
||||
sb.AppendLine("""
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #0078d4; }
|
||||
.summary { display: flex; gap: 24px; margin: 12px 0 16px 0; padding: 12px 16px;
|
||||
background: #e8f1fb; border-radius: 6px; }
|
||||
.summary .item { display: flex; flex-direction: column; }
|
||||
.summary .label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.summary .value { font-size: 18px; font-weight: 600; color: #0078d4; }
|
||||
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
|
||||
.toolbar label { font-weight: 600; }
|
||||
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
|
||||
#resultCount { font-size: 12px; color: #666; }
|
||||
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
||||
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
|
||||
font-weight: 600; user-select: none; white-space: nowrap; }
|
||||
th:hover { background: #106ebe; }
|
||||
th.sorted-asc::after { content: ' ▲'; font-size: 10px; }
|
||||
th.sorted-desc::after { content: ' ▼'; font-size: 10px; }
|
||||
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
|
||||
tr:hover td { background: #f0f7ff; }
|
||||
tr.hidden { display: none; }
|
||||
tr.err td { background: #fff4f4; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.err-cell { color: #b00020; }
|
||||
.generated { font-size: 11px; color: #888; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""");
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine($"<h1>{T["report.title.versions_short"]}</h1>");
|
||||
|
||||
sb.AppendLine($"""
|
||||
<div class="summary">
|
||||
<div class="item"><span class="label">{T["versions.summary.files"]}</span><span class="value">{totalFiles:N0}</span></div>
|
||||
<div class="item"><span class="label">{T["versions.summary.deleted"]}</span><span class="value">{totalDeleted:N0}</span></div>
|
||||
<div class="item"><span class="label">{T["versions.summary.freed"]}</span><span class="value">{FormatSize(totalBytes)}</span></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<label for="filterInput">{T["report.filter.label"]}</label>
|
||||
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
|
||||
<span id="resultCount"></span>
|
||||
</div>
|
||||
""");
|
||||
|
||||
sb.AppendLine($"""
|
||||
<table id="resultsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortTable(0)">{T["report.col.site"]}</th>
|
||||
<th onclick="sortTable(1)">{T["versions.col.library"]}</th>
|
||||
<th onclick="sortTable(2)">{T["versions.col.file"]}</th>
|
||||
<th onclick="sortTable(3)">{T["versions.col.path"]}</th>
|
||||
<th class="num" onclick="sortTable(4)">{T["versions.col.before"]}</th>
|
||||
<th class="num" onclick="sortTable(5)">{T["versions.col.deleted"]}</th>
|
||||
<th class="num" onclick="sortTable(6)">{T["versions.col.remaining"]}</th>
|
||||
<th class="num" onclick="sortTable(7)">{T["versions.col.freed"]}</th>
|
||||
<th onclick="sortTable(8)">{T["versions.col.error"]}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
string rowClass = string.IsNullOrEmpty(r.Error) ? string.Empty : " class=\"err\"";
|
||||
string errCell = string.IsNullOrEmpty(r.Error)
|
||||
? string.Empty
|
||||
: $"<span class=\"err-cell\">{H(r.Error)}</span>";
|
||||
|
||||
sb.AppendLine($"""
|
||||
<tr{rowClass}>
|
||||
<td>{H(r.SiteUrl)}</td>
|
||||
<td>{H(r.Library)}</td>
|
||||
<td>{H(r.FileName)}</td>
|
||||
<td>{H(r.FileServerRelativeUrl)}</td>
|
||||
<td class="num" data-sort="{r.VersionsBefore}">{r.VersionsBefore:N0}</td>
|
||||
<td class="num" data-sort="{r.VersionsDeleted}">{r.VersionsDeleted:N0}</td>
|
||||
<td class="num" data-sort="{r.VersionsRemaining}">{r.VersionsRemaining:N0}</td>
|
||||
<td class="num" data-sort="{r.BytesFreed}">{FormatSize(r.BytesFreed)}</td>
|
||||
<td>{errCell}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
sb.AppendLine(" </tbody>\n</table>");
|
||||
|
||||
int count = results.Count;
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
|
||||
|
||||
sb.AppendLine($$"""
|
||||
<script>
|
||||
var sortDir = {};
|
||||
function sortTable(col) {
|
||||
var tbl = document.getElementById('resultsTable');
|
||||
var tbody = tbl.tBodies[0];
|
||||
var rows = Array.from(tbody.rows);
|
||||
var asc = sortDir[col] !== 'asc';
|
||||
sortDir[col] = asc ? 'asc' : 'desc';
|
||||
rows.sort(function(a, b) {
|
||||
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
|
||||
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
|
||||
var an = parseFloat(av), bn = parseFloat(bv);
|
||||
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
|
||||
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
});
|
||||
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||
var ths = tbl.tHead.rows[0].cells;
|
||||
for (var i = 0; i < ths.length; i++) {
|
||||
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
|
||||
}
|
||||
}
|
||||
function filterTable() {
|
||||
var q = document.getElementById('filterInput').value.toLowerCase();
|
||||
var rows = document.getElementById('resultsTable').tBodies[0].rows;
|
||||
var visible = 0;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
|
||||
rows[i].className = (rows[i].className.indexOf('err') >= 0 ? 'err ' : '') + (match ? '' : 'hidden');
|
||||
if (match) visible++;
|
||||
}
|
||||
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
|
||||
}
|
||||
window.onload = function() {
|
||||
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
|
||||
};
|
||||
</script>
|
||||
</body></html>
|
||||
""");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the HTML report to <paramref name="filePath"/> as UTF-8.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<VersionCleanupResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(results, branding);
|
||||
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
}
|
||||
|
||||
private static string H(string value) =>
|
||||
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
|
||||
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
|
||||
return $"{bytes} B";
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -214,11 +214,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
<<<<<<< HEAD
|
||||
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
=======
|
||||
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
|
||||
|
||||
@@ -176,10 +176,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
|
||||
<<<<<<< HEAD
|
||||
ApplyChartThemeColors();
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
if (_themeManager is not null)
|
||||
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
|
||||
}
|
||||
@@ -401,7 +398,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
|
||||
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
|
||||
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
|
||||
<<<<<<< HEAD
|
||||
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
|
||||
|
||||
private void ApplyChartThemeColors()
|
||||
@@ -411,8 +407,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
TooltipTextPaint.Color = ChartFgColor;
|
||||
TooltipBackgroundPaint.Color = ChartSurfaceColor;
|
||||
}
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
|
||||
private void UpdateChartSeries()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
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;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
namespace SharepointToolbox.ViewModels.Tabs;
|
||||
|
||||
public partial class VersionCleanupViewModel : FeatureViewModelBase
|
||||
{
|
||||
private readonly IVersionCleanupService _versionService;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly VersionCleanupHtmlExportService _htmlExportService;
|
||||
private readonly IBrandingService _brandingService;
|
||||
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 IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
|
||||
public VersionCleanupViewModel(
|
||||
IVersionCleanupService versionService,
|
||||
ISessionManager sessionManager,
|
||||
VersionCleanupHtmlExportService htmlExportService,
|
||||
IBrandingService brandingService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
{
|
||||
_versionService = versionService;
|
||||
_sessionManager = sessionManager;
|
||||
_htmlExportService = htmlExportService;
|
||||
_brandingService = brandingService;
|
||||
_logger = logger;
|
||||
|
||||
SelectLibrariesCommand = new AsyncRelayCommand(SelectLibrariesAsync, CanPickLibraries);
|
||||
ClearLibrariesCommand = new RelayCommand(ClearLibraries);
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, () => Results.Count > 0);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, () => Results.Count > 0);
|
||||
|
||||
SelectedLibraries.CollectionChanged += (_, _) => UpdateSelectedLibrariesLabel();
|
||||
Results.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(HasResults));
|
||||
OnPropertyChanged(nameof(TotalBytesFreed));
|
||||
OnPropertyChanged(nameof(TotalVersionsDeleted));
|
||||
OnPropertyChanged(nameof(TotalFilesAffected));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.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)));
|
||||
}
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Export failed: {ex.Message}";
|
||||
_logger.LogError(ex, "Version cleanup CSV export failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportHtmlAsync()
|
||||
{
|
||||
if (Results.Count == 0) return;
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Export version cleanup results to HTML",
|
||||
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
||||
DefaultExt = "html",
|
||||
FileName = "version_cleanup",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
var clientLogo = _currentProfile?.ClientLogo;
|
||||
var branding = new ReportBranding(mspLogo, clientLogo);
|
||||
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Export failed: {ex.Message}";
|
||||
_logger.LogError(ex, "Version cleanup HTML export failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,8 @@
|
||||
<!-- Action bar: new folder (destination mode only) -->
|
||||
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
|
||||
Margin="0,0,0,6" Visibility="Collapsed">
|
||||
<<<<<<< HEAD
|
||||
<Button x:Name="NewFolderButton"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
|
||||
=======
|
||||
<Button x:Name="NewFolderButton" Content="+ New Folder"
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ public partial class FolderBrowserDialog : Window
|
||||
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
|
||||
|
||||
private readonly List<CheckBox> _fileCheckboxes = new();
|
||||
<<<<<<< HEAD
|
||||
private readonly List<TreeViewItem> _expandedNodes = new();
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
|
||||
/// <summary>
|
||||
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
|
||||
@@ -84,10 +81,7 @@ public partial class FolderBrowserDialog : Window
|
||||
// Placeholder child so the expand arrow appears.
|
||||
node.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
node.Expanded += FolderNode_Expanded;
|
||||
<<<<<<< HEAD
|
||||
_expandedNodes.Add(node);
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -107,18 +101,9 @@ public partial class FolderBrowserDialog : Window
|
||||
{
|
||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
||||
_ctx.Load(folder, f => f.StorageMetrics.TotalSize,
|
||||
<<<<<<< HEAD
|
||||
f => f.StorageMetrics.TotalFileCount);
|
||||
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
|
||||
_ctx.Load(list, l => l.Title);
|
||||
=======
|
||||
f => f.StorageMetrics.TotalFileCount,
|
||||
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl,
|
||||
sf => sf.StorageMetrics.TotalSize,
|
||||
sf => sf.StorageMetrics.TotalFileCount),
|
||||
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
|
||||
fi => fi.ServerRelativeUrl));
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
|
||||
@@ -129,7 +114,6 @@ public partial class FolderBrowserDialog : Window
|
||||
folder.StorageMetrics.TotalFileCount,
|
||||
folder.StorageMetrics.TotalSize);
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Enumerate direct children via paginated CAML — Folder.Folders /
|
||||
// Folder.Files lazy loading hits the list-view threshold on libraries
|
||||
// above 5,000 items even when only a small folder is being expanded.
|
||||
@@ -207,53 +191,6 @@ public partial class FolderBrowserDialog : Window
|
||||
node.Items.Add(fileItem);
|
||||
}
|
||||
}
|
||||
=======
|
||||
// Child folders first
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
|
||||
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||
? subFolder.Name
|
||||
: $"{info.RelativePath}/{subFolder.Name}";
|
||||
|
||||
var childInfo = new FolderNodeInfo(
|
||||
info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
|
||||
|
||||
var childNode = MakeFolderNode(
|
||||
FormatFolderHeader(subFolder.Name,
|
||||
subFolder.StorageMetrics.TotalFileCount,
|
||||
subFolder.StorageMetrics.TotalSize),
|
||||
childInfo);
|
||||
node.Items.Add(childNode);
|
||||
}
|
||||
|
||||
// Files under this folder — only shown when selection is enabled.
|
||||
if (_allowFileSelection)
|
||||
{
|
||||
foreach (var file in folder.Files)
|
||||
{
|
||||
// Library-relative path for the file (used by the transfer service)
|
||||
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
||||
? file.Name
|
||||
: $"{info.RelativePath}/{file.Name}";
|
||||
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Content = $"{file.Name} ({FormatSize(file.Length)})",
|
||||
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
||||
Margin = new Thickness(4, 2, 0, 2),
|
||||
};
|
||||
cb.Checked += FileCheckbox_Toggled;
|
||||
cb.Unchecked += FileCheckbox_Toggled;
|
||||
_fileCheckboxes.Add(cb);
|
||||
|
||||
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
|
||||
node.Items.Add(fileItem);
|
||||
}
|
||||
}
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -368,7 +305,6 @@ public partial class FolderBrowserDialog : Window
|
||||
Close();
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
Loaded -= OnLoaded;
|
||||
@@ -384,8 +320,6 @@ public partial class FolderBrowserDialog : Window
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
|
||||
private record FileNodeInfo(string LibraryTitle, string RelativePath);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
<<<<<<< HEAD
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
|
||||
=======
|
||||
Title="Input"
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Width="340" Height="140"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource AppBgBrush}"
|
||||
@@ -16,12 +12,8 @@
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<<<<<<< HEAD
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||
Width="70" IsCancel="True" Margin="0,0,8,0"
|
||||
=======
|
||||
<Button Content="Cancel" Width="70" IsCancel="True" Margin="0,0,8,0"
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Click="Cancel_Click" />
|
||||
<Button Content="OK" Width="70" IsDefault="True"
|
||||
Click="Ok_Click" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -60,12 +60,8 @@
|
||||
<!-- Site list with checkboxes -->
|
||||
<ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8"
|
||||
SelectionMode="Single"
|
||||
<<<<<<< HEAD
|
||||
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
|
||||
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
|
||||
=======
|
||||
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Width="32">
|
||||
@@ -108,11 +104,7 @@
|
||||
</ListView>
|
||||
|
||||
<!-- Status text -->
|
||||
<<<<<<< HEAD
|
||||
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
|
||||
=======
|
||||
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
|
||||
|
||||
<!-- Button row -->
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
||||
|
||||
<<<<<<< HEAD
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,4,0,0" />
|
||||
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="26" Margin="0,0,0,4">
|
||||
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
|
||||
@@ -56,8 +55,6 @@
|
||||
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
|
||||
</ComboBox>
|
||||
|
||||
=======
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
|
||||
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
|
||||
@@ -86,18 +83,12 @@
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}"
|
||||
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
|
||||
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
||||
<<<<<<< HEAD
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}"
|
||||
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}"
|
||||
Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}"
|
||||
Binding="{Binding Path}" Width="400" />
|
||||
=======
|
||||
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
|
||||
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" />
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
Click="BrowseSource_Click" Margin="0,0,0,5" />
|
||||
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<<<<<<< HEAD
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
|
||||
Text="{Binding SelectedFileCount, Mode=OneWay}" />
|
||||
@@ -33,20 +32,6 @@
|
||||
IsChecked="{Binding CopyFolderContents}"
|
||||
Margin="0,4,0,0"
|
||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
|
||||
=======
|
||||
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11">
|
||||
<Run Text="{Binding SelectedFileCount, Mode=OneWay}" />
|
||||
<Run Text=" file(s) selected" />
|
||||
</TextBlock>
|
||||
<CheckBox Content="Include source folder at destination"
|
||||
IsChecked="{Binding IncludeSourceFolder}"
|
||||
Margin="0,6,0,0"
|
||||
ToolTip="When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder." />
|
||||
<CheckBox Content="Copy folder contents"
|
||||
IsChecked="{Binding CopyFolderContents}"
|
||||
Margin="0,4,0,0"
|
||||
ToolTip="When on (default), transfer files inside the folder. When off, only the folder is created at the destination." />
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
|
||||
@@ -44,11 +44,7 @@
|
||||
</GroupBox.Style>
|
||||
<StackPanel>
|
||||
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
|
||||
<<<<<<< HEAD
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
|
||||
=======
|
||||
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
|
||||
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<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" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
|
||||
Command="{Binding ExportHtmlCommand}" 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